I’m trying something new here.
I wrote the following spec with a lot of help from Cursor Max in the Tuist codebase. I tried work through as many edge cases as I could think of to really flesh it out.
Posting here for discussion if anyone wants to weigh in:
Cache Profiles Spec
Overview
Cache profiles provide a flexible system for controlling binary replacement behavior in Tuist generation. This feature allows users to define named strategies for replacing targets with cached binaries that can be configured in Config
manifests and selected via CLI flags.
Motivation
Currently, Tuist treats internal and external targets differently for binary replacement:
- External targets are replaced by cached binaries by default
- Internal targets are always built from source, unless one or more targets are specified at generate time
However, some internal targets share characteristics with external dependencies: they are expensive to build and rarely change. Users want these targets treated like external dependencies for binary replacement, without actually making them external.
Cache profiles solve this by providing:
- Selective internal replacement - Choose which internal targets get replaced by cached binaries
- Multiple replacement strategies - Different profiles for development, CI, debugging
- Team-wide configuration - Shared replacement rules via Config manifests
- Flexible overrides - CLI flags to change replacement behavior per-session
Design Overview
The cache profiles system introduces:
- Built-in profiles for common scenarios
- Custom profiles with target and tag-based rules
- Priority-based resolution from CLI flags, config defaults, and system defaults
Core Types
CacheProfileType
extension Tuist {
public enum CacheProfileType: Codable, Equatable, Sendable {
/// Replace external dependencies only (system default)
case onlyExternal
/// Replace as many targets as possible with cached binaries
case allPossible
/// No binary replacement, build everything from source
case none
/// Use named custom profile from `cacheProfiles` dictionary
case custom(String)
}
}
CacheProfile
extension Tuist {
public struct CacheProfile: Codable, Equatable, Sendable {
/// Target names to replace with cached binaries
public let targets: [String]
/// Tags for targets to replace with cached binaries
public let tags: [String]
/// Creates a cache profile specifying targets to replace with cached binaries
/// - Parameters:
/// - targets: Target names to replace with cached binaries
/// - tags: Tags for targets to replace with cached binaries
/// - Returns: A configured cache profile
public static func profile(
targets: [String] = [],
tags: [String] = []
) -> Self {
CacheProfile(
targets: targets,
tags: tags
)
}
}
}
GenerationOptions Extension
extension Tuist.GenerationOptions {
/// Default cache profile to use when none is specified via CLI
public var defaultCacheProfile: CacheProfileType
/// Named cache profiles specifying targets to replace with cached binaries
public var cacheProfiles: [String: CacheProfile]
/// Creates generation options with cache profile configuration
/// - Parameters:
/// - defaultCacheProfile: Default cache profile to use when none is specified via CLI
/// - cacheProfiles: Named cache profiles specifying targets to replace with cached binaries
/// - Returns: Configured generation options
public static func options(
// ... existing parameters
defaultCacheProfile: CacheProfileType = .onlyExternal,
cacheProfiles: [String: CacheProfile] = [:]
) -> Self {
// ... implementation with new parameters
}
}
Built-in Profiles
onlyExternal
(System Default)
- Behavior: Replace external dependencies only
- Use case: Conservative replacement, current default behavior
- Internal targets: Built from source
allPossible
- Behavior: Replace as many targets as possible with cached binaries, including internal targets
- Use case: Fast iteration when focusing on specific targets
- Internal targets: Replaced by cached binaries when possible
none
- Behavior: No binary replacement, build everything from source
- Use case: Debugging build issues, clean builds
- Internal targets: Always built from source
Configuration
Basic Configuration
import ProjectDescription
let config = Config(project: .tuist(generationOptions: .options(
// Use built-in profile as default
defaultCacheProfile: .allPossible
)))
Custom Profiles Configuration
import ProjectDescription
let config = Config(project: .tuist(generationOptions: .options(
// Set custom profile as default
defaultCacheProfile: .custom("development"),
// Define custom profiles
cacheProfiles: [
"development": .profile(
targets: ["ExpensiveFramework", "SlowToCompileModule"],
tags: ["expensive", "rarely-changed"]
),
"ci": .profile(
tags: ["all"]
),
"debug": .profile(
targets: ["UtilityFramework"],
tags: ["stable"]
)
]
)))
CLI Interface
New Flag
@Option(
name: .long,
help: "Cache profile to use: 'only-external', 'all-possible', 'none', or custom profile name"
)
var cacheProfile: String?
Usage Examples
# Built-in profiles
tuist generate --cache-profile only-external
tuist generate --cache-profile all-possible
tuist generate --cache-profile none
# Custom profiles (defined in Config)
tuist generate --cache-profile development
tuist generate --cache-profile ci
tuist generate --cache-profile debug
# Use config default (no flag)
tuist generate
# Focus on specific targets (implies --cache-profile all-possible)
tuist generate MyModule AnotherTarget
# Disable binary replacement entirely (backwards compatible)
tuist generate --no-binary-cache # equivalent to --cache-profile none
Priority Resolution System
The cache profile is resolved using the following priority order (highest to lowest):
1. --no-binary-cache
Flag → none
tuist generate --no-binary-cache
# → Equivalent to --cache-profile none
2. Target Focus → allPossible
tuist generate MyModule
# → Uses allPossible (ignores any --cache-profile)
3. --cache-profile
Flag → Explicit Profile
tuist generate --cache-profile development
# → Uses "development" profile from Config
4. Config Default → defaultCacheProfile
tuist generate
# → Uses config.generationOptions.defaultCacheProfile
5. System Default → onlyExternal
tuist generate
# → Falls back to .onlyExternal if no config default
Resolution Logic
enum CacheProfileError: Error, LocalizedError {
case profileNotFound(String)
var errorDescription: String? {
switch self {
case .profileNotFound(let profile):
return "Cache profile '\(profile)' not found. Available profiles: only-external, all-possible, none, or custom profiles defined in cacheProfiles."
}
}
}
func resolveFinalCacheProfile(
binaryCache: Bool, // --no-binary-cache flag
explicitCacheProfile: String?, // --cache-profile flag
includedTargets: Set<TargetQuery>, // Target arguments
config: Tuist // Loaded configuration
) throws -> CacheProfileType {
// 1. --no-binary-cache takes highest precedence
if !binaryCache {
return .none
}
// 2. If targets specified, use allPossible (overrides any profile)
if !includedTargets.isEmpty {
return .allPossible
}
// 3. --cache-profile comes next (only when no targets specified)
if let explicitProfile = explicitCacheProfile {
return try parseCacheProfileString(explicitProfile, config: config)
}
// 4. Config default
if let configDefault = config.project.generatedProject?.generationOptions.defaultCacheProfile {
return configDefault
}
// 5. System default
return .onlyExternal
}
func parseCacheProfileString(_ profile: String, config: Tuist) throws -> CacheProfileType {
switch profile {
case "only-external": return .onlyExternal
case "all-possible": return .allPossible
case "none": return .none
default:
// Validate custom profile exists
guard config.project.generatedProject?.generationOptions.cacheProfiles[profile] != nil else {
throw CacheProfileError.profileNotFound(profile)
}
return .custom(profile)
}
}
Implementation Details
Cache Profile Resolution
When a CacheProfileType.custom(name)
is resolved:
- Lookup: Find
name
in config.cacheProfiles
dictionary
- Validation: If profile doesn’t exist, throw
CacheProfileError.profileNotFound
- Application: Apply profile’s target and tag rules to graph mapping
Graph Mapping Integration
The cache profile affects the GraphMapperFactory
pipeline:
// In GraphMapperFactory
func generation(
config: Tuist,
cacheProfile: CacheProfileType,
includedTargets: Set<TargetQuery>,
configuration: String?,
cacheStorage: CacheStoring
) -> [GraphMapping] {
var mappers: [GraphMapping] = []
// ... existing mappers
// Apply cache profile logic
switch cacheProfile {
case .none:
// Skip all binary replacement mappers
break
case .onlyExternal:
// Use conservative binary replacement (current behavior)
mappers.append(ExternalDependenciesCacheMapper(...))
case .allPossible:
// Use binary replacement for as many targets as possible
mappers.append(AllPossibleCacheMapper(...))
case .custom(let profileName):
// Profile existence already validated during resolution
if let profile = config.cacheProfiles[profileName] {
mappers.append(CustomProfileCacheMapper(profile: profile, ...))
} else {
// This should never happen due to validation, but handle gracefully
fatalError("Cache profile '\(profileName)' should have been validated during resolution")
}
}
return mappers
}
Custom Profile Target Resolution
For CacheProfile
target resolution:
- Target Names: Targets listed in
targets
array are replaced with cached binaries
- Tags: Targets with tags listed in
tags
array are replaced with cached binaries
- Fallback: Targets not explicitly listed fall back to
onlyExternal
Backwards Compatibility
Existing CLI Flags
--no-binary-cache
continues to work (maps to --cache-profile none
)
--binary-cache
continues to work (uses configured profile)
No flags continues to work (uses system default)
Existing Behavior
Default generation caches external dependencies only
Target focus replaces non-focused targets with cached binaries when possible
All existing workflows remain unchanged
Migration Path
No breaking changes to existing configurations
New features are opt-in via new configuration properties
Error Handling
Invalid Profile Names
tuist generate --cache-profile non-existent
# Error: Cache profile 'non-existent' not found. Available profiles: only-external, all-possible, none, or custom profiles defined in cacheProfiles.
# Exit code: 1
Invalid Default Profile in Config
Config(project: .tuist(generationOptions: .options(
defaultCacheProfile: .custom("non-existent"),
cacheProfiles: [
"development": .profile(targets: ["MyModule"])
// "non-existent" profile missing!
]
)))
// Error during config validation (at load time):
// Error: Cache profile 'non-existent' not found. Available profiles: only-external, all-possible, none, or custom profiles defined in cacheProfiles.
Empty Custom Profiles
cacheProfiles: [
"empty": .profile() // No targets or tags specified
]
// Behaves like 'only-external' (no internal targets replaced by cached binaries)
Conflicting Flags
# Example 1: Target focus overrides custom profile
tuist generate MyModule --cache-profile development
# Priority: Target focus (2) > --cache-profile (3)
# Result: CacheProfileType.allPossible (focus on MyModule, cache everything else)
# Example 2: --no-binary-cache overrides everything
tuist generate MyModule --cache-profile development --no-binary-cache
# Priority: --no-binary-cache (1) > Target focus (2) > --cache-profile (3)
# Result: CacheProfileType.none (no binary replacement)
# Example 3: Redundant but consistent flags
tuist generate MyModule --cache-profile none --no-binary-cache
# Priority: --no-binary-cache (1) > Target focus (2) > --cache-profile (3)
# Result: CacheProfileType.none (both flags want the same behavior)
Testing Strategy
Unit Tests
- Cache profile resolution priority
- CLI flag parsing and validation
- Custom profile target matching
- Error handling for missing profiles
- Config validation during loading
- Built-in profile validation
Integration Tests
- End-to-end generation with different profiles
- Graph mapper pipeline with cache profiles
- Backwards compatibility scenarios
- Error cases with descriptive messages
- Config validation during project loading
Fixtures
- Projects with various cache profile configurations
- Custom profiles with different target/tag combinations