diff --git a/Sources/mcs/Core/CLIOutput.swift b/Sources/mcs/Core/CLIOutput.swift index f55c482..06b0371 100644 --- a/Sources/mcs/Core/CLIOutput.swift +++ b/Sources/mcs/Core/CLIOutput.swift @@ -289,8 +289,21 @@ struct CLIOutput { } /// Inline text prompt where the user types on the same line as the label. - func promptInline(_ prompt: String, default defaultValue: String? = nil) -> String { - let hint = defaultValue.map { " (\($0))" } ?? "" + /// + /// - Parameter maskDefault: When `true`, the hint shows a generic + /// `(press Enter to keep existing value)` placeholder instead of the raw default. + /// Use for defaults that originate from previously-entered user input, which may + /// be sensitive (API keys, tokens). Pack-declared defaults remain visible. + func promptInline( + _ prompt: String, + default defaultValue: String? = nil, + maskDefault: Bool = false + ) -> String { + let hint: String = if let defaultValue { + maskDefault ? " (press Enter to keep existing value)" : " (\(defaultValue))" + } else { + "" + } write(" \(bold)\(prompt)\(reset)\(hint): ") let answer = readLine()?.trimmingCharacters(in: .whitespaces) ?? "" if answer.isEmpty, let defaultValue { @@ -314,21 +327,30 @@ struct CLIOutput { /// Single-select: arrow keys to navigate, Enter to confirm. /// Returns the index of the selected item. /// Falls back to numbered list with readLine() when not a TTY. - func singleSelect(title: String, items: [(name: String, description: String)]) -> Int { + /// + /// - Parameter initialIndex: Pre-selected cursor position (clamped to `0.. Int { guard !items.isEmpty else { return 0 } + let seed = max(0, min(initialIndex, items.count - 1)) if isInteractiveTerminal { - return interactiveSingleSelect(title: title, items: items) + return interactiveSingleSelect(title: title, items: items, initialIndex: seed) } - return fallbackSingleSelect(title: title, items: items) + return fallbackSingleSelect(title: title, items: items, initialIndex: seed) } private func interactiveSingleSelect( title: String, - items: [(name: String, description: String)] + items: [(name: String, description: String)], + initialIndex: Int ) -> Int { withRawTerminal { - var cursor = 0 + var cursor = initialIndex renderSingleSelectList(title: title, items: items, cursor: cursor) @@ -414,7 +436,8 @@ struct CLIOutput { private func fallbackSingleSelect( title: String, - items: [(name: String, description: String)] + items: [(name: String, description: String)], + initialIndex: Int ) -> Int { write("\n") write(" \(bold)\(title)\(reset)\n") @@ -423,7 +446,8 @@ struct CLIOutput { for (index, item) in items.enumerated() { if index > 0 { write("\n") } let num = index + 1 - write(" [\(num)] \(bold)\(item.name)\(reset)\n") + let marker = index == initialIndex ? " (default)" : "" + write(" [\(num)] \(bold)\(item.name)\(reset)\(marker)\n") write(" \(dim)\(item.description)\(reset)\n") } @@ -432,7 +456,10 @@ struct CLIOutput { while true { write("\(bold)> \(reset)") guard let input = readLine()?.trimmingCharacters(in: .whitespaces) else { - return 0 + return initialIndex + } + if input.isEmpty { + return initialIndex } if let num = Int(input), num >= 1, num <= items.count { return num - 1 diff --git a/Sources/mcs/ExternalPack/ExternalPackAdapter.swift b/Sources/mcs/ExternalPack/ExternalPackAdapter.swift index bb693cc..0370854 100644 --- a/Sources/mcs/ExternalPack/ExternalPackAdapter.swift +++ b/Sources/mcs/ExternalPack/ExternalPackAdapter.swift @@ -82,6 +82,7 @@ struct ExternalPackAdapter: TechPack { /// Execute prompts and return resolved values, skipping keys already in `context.resolvedValues`. /// Returns only newly resolved values — callers must merge with previously resolved values. + /// `context.priorValues` are forwarded as per-prompt defaults for `input`/`select`. func templateValues(context: ProjectConfigContext) throws -> [String: String] { let prompts = declaredPrompts(context: context) guard !prompts.isEmpty else { return [:] } @@ -91,7 +92,8 @@ struct ExternalPackAdapter: TechPack { return try executor.executeAll( prompts: remaining, packPath: packPath, - projectPath: context.projectPath + projectPath: context.projectPath, + priorValues: context.priorValues ) } diff --git a/Sources/mcs/ExternalPack/PromptExecutor.swift b/Sources/mcs/ExternalPack/PromptExecutor.swift index a2b0252..2e3a37b 100644 --- a/Sources/mcs/ExternalPack/PromptExecutor.swift +++ b/Sources/mcs/ExternalPack/PromptExecutor.swift @@ -27,19 +27,23 @@ struct PromptExecutor { /// - prompt: The declarative prompt definition /// - packPath: Root directory of the external pack /// - projectPath: Current project root directory + /// - priorValue: Value resolved in a previous sync, used as the default for + /// `input`/`select` prompts. Ignored for `script` and `fileDetect` (both + /// re-compute every sync). /// - Returns: The resolved value string func execute( prompt: PromptDefinition, packPath: URL, - projectPath: URL + projectPath: URL, + priorValue: String? = nil ) throws -> String { switch prompt.type { case .fileDetect: try executeFileDetect(prompt: prompt, projectPath: projectPath) case .input: - executeInput(prompt: prompt) + executeInput(prompt: prompt, priorValue: priorValue) case .select: - executeSelect(prompt: prompt) + executeSelect(prompt: prompt, priorValue: priorValue) case .script: try executeScript(prompt: prompt, packPath: packPath, projectPath: projectPath) } @@ -51,18 +55,22 @@ struct PromptExecutor { /// - prompts: Array of prompt definitions /// - packPath: Root directory of the external pack /// - projectPath: Current project root directory + /// - priorValues: Values from the previous sync, keyed by prompt key, + /// used as defaults for `input`/`select` prompts. /// - Returns: Dictionary of prompt key to resolved value func executeAll( prompts: [PromptDefinition], packPath: URL, - projectPath: URL + projectPath: URL, + priorValues: [String: String] = [:] ) throws -> [String: String] { var resolved: [String: String] = [:] for prompt in prompts { let value = try execute( prompt: prompt, packPath: packPath, - projectPath: projectPath + projectPath: projectPath, + priorValue: priorValues[prompt.key] ) resolved[prompt.key] = value } @@ -166,24 +174,32 @@ struct PromptExecutor { // MARK: - Input /// Free-text prompt with optional default value. - private func executeInput(prompt: PromptDefinition) -> String { + /// `priorValue` (stored from a previous sync) wins over `prompt.defaultValue` as the + /// Enter-to-accept default. Prior values are masked in the prompt hint — pack-declared + /// defaults are authored publicly and remain visible. + private func executeInput(prompt: PromptDefinition, priorValue: String?) -> String { let label = prompt.label ?? "Enter value for \(prompt.key)" - return output.promptInline(label, default: prompt.defaultValue) + let effectiveDefault = priorValue ?? prompt.defaultValue + return output.promptInline(label, default: effectiveDefault, maskDefault: priorValue != nil) } // MARK: - Select /// Single choice from a fixed list of options. - private func executeSelect(prompt: PromptDefinition) -> String { + /// `priorValue` (stored from a previous sync) becomes the pre-selected option when + /// it matches an option value; otherwise the stored value is ignored (caller should + /// have already purged invalid select priors upstream). + private func executeSelect(prompt: PromptDefinition, priorValue: String?) -> String { guard let options = prompt.options, !options.isEmpty else { - return prompt.defaultValue ?? "" + return priorValue ?? prompt.defaultValue ?? "" } let items = options.map { option -> (name: String, description: String) in (name: option.label, description: option.value) } let label = prompt.label ?? "Select value for \(prompt.key)" - let selected = output.singleSelect(title: label, items: items) + let initialIndex = PromptOption.index(of: priorValue, in: options) + let selected = output.singleSelect(title: label, items: items, initialIndex: initialIndex) return options[selected].value } diff --git a/Sources/mcs/Sync/Configurator.swift b/Sources/mcs/Sync/Configurator.swift index 2014ca4..dd7126c 100644 --- a/Sources/mcs/Sync/Configurator.swift +++ b/Sources/mcs/Sync/Configurator.swift @@ -138,7 +138,12 @@ struct Configurator { try self.dryRun(packs: selectedPacks) } else { // Interactive flow already confirmed via the "Review changes" screen above. - try configure(packs: selectedPacks, confirmRemovals: false, excludedComponents: excludedComponents) + try configure( + packs: selectedPacks, + confirmRemovals: false, + excludedComponents: excludedComponents, + customize: customize + ) output.header("Done") output.info("Run 'mcs doctor' to verify configuration") @@ -176,7 +181,8 @@ struct Configurator { func configure( packs: [any TechPack], confirmRemovals: Bool = true, - excludedComponents: [String: Set] = [:] + excludedComponents: [String: Set] = [:], + customize: Bool = false ) throws { var state = try ProjectState(stateFile: scope.stateFile) let fsContext = strategy.makeCollisionContext(trackedFiles: state.allTrackedFiles) @@ -226,8 +232,13 @@ struct Configurator { } } + // Snapshot prior values before resolveAllValues overwrites them via + // state.setResolvedValues — used later by the configureProject hook so + // packs can diff prior vs current during migrations. + let priorResolvedValues = state.resolvedValues ?? [:] + // 3–4b. Resolve all template/placeholder values upfront (single pass) - let allValues = try resolveAllValues(packs: packs, state: &state) + let allValues = try resolveAllValues(packs: packs, state: &state, customize: customize) // 4c. Pre-load templates (single disk read per pack), filtering excluded dependencies let preloadedTemplates = preloadTemplates( @@ -279,7 +290,11 @@ struct Configurator { // 8. Run pack-specific configureProject hooks (project scope only) if scope.runConfigureProjectHooks { - let hookContext = strategy.makeConfigContext(output: output, resolvedValues: allValues) + let hookContext = strategy.makeConfigContext( + output: output, + resolvedValues: allValues, + priorValues: priorResolvedValues + ) let projectPath = scope.targetPath.deletingLastPathComponent() for pack in packs { try pack.configureProject(at: projectPath, context: hookContext) @@ -588,55 +603,117 @@ struct Configurator { /// Resolve all template/placeholder values upfront (single pass). /// - /// Resolves built-in values (e.g. REPO_NAME), shared cross-pack prompts, - /// per-pack prompts, and auto-prompts for undeclared placeholders. - /// Persists resolved values to state for doctor freshness checks. + /// Reuses values from the previous sync when safe: + /// - `input` priors are reused verbatim. + /// - `select` priors are reused only if the stored value is still a valid option. + /// - `script` and `fileDetect` always re-execute (computed / filesystem-dependent). private func resolveAllValues( packs: [any TechPack], - state: inout ProjectState + state: inout ProjectState, + customize: Bool ) throws -> [String: String] { - // 3a. Built-in values (REPO_NAME, PROJECT_DIR_NAME in project scope) + let priorValues = state.resolvedValues ?? [:] var allValues = strategy.resolveBuiltInValues(shell: shell, output: output) - // 3b–3c. Detect shared prompts across packs and resolve them once. - // `initialContext` uses partial resolvedValues (built-ins only). groupSharedPrompts - // filters out already-resolved keys; current TechPack implementations only use - // isGlobalScope from the context in declaredPrompts(). - let initialContext = strategy.makeConfigContext(output: output, resolvedValues: allValues) - let sharedPrompts = CrossPackPromptResolver.groupSharedPrompts( + let initialContext = strategy.makeConfigContext( + output: output, resolvedValues: allValues, priorValues: priorValues + ) + let allDeclaredPrompts = CrossPackPromptResolver.collectDeclaredPrompts( packs: packs, context: initialContext ) + let (reusableValues, newDeclaredKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + allDeclaredPrompts, priorValues: priorValues + ) + + let seedFromPriors = decideSeedStrategy( + reusableValues: reusableValues, + newDeclaredKeys: newDeclaredKeys, + customize: customize + ) + if seedFromPriors { + allValues.merge(reusableValues) { existing, _ in existing } + } + + let sharedContext = strategy.makeConfigContext( + output: output, resolvedValues: allValues, priorValues: priorValues + ) + let sharedPrompts = CrossPackPromptResolver.groupSharedPrompts( + packs: packs, context: sharedContext + ) if !sharedPrompts.isEmpty { - let sharedValues = CrossPackPromptResolver.resolveSharedPrompts(sharedPrompts, output: output) + let sharedValues = CrossPackPromptResolver.resolveSharedPrompts( + sharedPrompts, output: output, priorValues: priorValues + ) allValues.merge(sharedValues) { existing, _ in existing } } - // 3d. Execute remaining per-pack prompts. templateValues() skips prompts whose key - // already exists in context.resolvedValues (pre-resolved by shared prompt resolution). - // Merge uses "first wins" — shared values and built-ins take precedence. - let context = strategy.makeConfigContext(output: output, resolvedValues: allValues) + let context = strategy.makeConfigContext( + output: output, resolvedValues: allValues, priorValues: priorValues + ) for pack in packs { let packValues = try pack.templateValues(context: context) allValues.merge(packValues) { existing, _ in existing } } - // 4. Auto-prompt for undeclared placeholders in pack files let undeclared = ConfiguratorSupport.scanForUndeclaredPlaceholders( packs: packs, resolvedValues: allValues, includeTemplates: scope.includeTemplatesInScan, onWarning: { output.warn($0) } ) for key in undeclared { - let value = output.promptInline("Set value for \(key)", default: nil) - allValues[key] = value + if seedFromPriors, let prior = priorValues[key] { + allValues[key] = prior + continue + } + let prior = priorValues[key] + allValues[key] = output.promptInline( + "Set value for \(key)", + default: prior, + maskDefault: prior != nil + ) } - // 4b. Persist resolved values for doctor freshness checks state.setResolvedValues(allValues) - return allValues } + /// Returns `true` when reusable priors should short-circuit the prompt executors. + /// + /// `--customize` always re-asks. Non-interactive reuses with a dimmed one-line + /// acknowledgement (visible in CI logs, not loud). Interactive with new prompts + /// added since last sync reuses old values and prompts only for the new ones. + /// Interactive with only reusable prompts shows the key list (values masked — + /// prompts often hold secrets) and gates on a single Y/n. + private func decideSeedStrategy( + reusableValues: [String: String], + newDeclaredKeys: Set, + customize: Bool + ) -> Bool { + guard !reusableValues.isEmpty else { return false } + if customize { return false } + + if !output.hasInteractiveStdin { + output.dimmed("Reusing \(reusableValues.count) previously configured value(s) from last sync.") + return true + } + + if !newDeclaredKeys.isEmpty { + output.plain("") + output.info( + "Reusing \(reusableValues.count) previously configured value(s); " + + "asking for \(newDeclaredKeys.count) new." + ) + return true + } + + output.plain("") + output.info("Previously configured keys (values hidden — prompts may hold secrets):") + for key in reusableValues.keys.sorted() { + output.dimmed(" \(key)") + } + return output.askYesNo("Reuse these values?", default: true) + } + /// Pre-load templates from disk (single read per pack), filtering excluded dependencies. /// /// Templates whose `dependencies` include an excluded component are filtered out, diff --git a/Sources/mcs/Sync/CrossPackPromptResolver.swift b/Sources/mcs/Sync/CrossPackPromptResolver.swift index 0d868ae..41ed5d1 100644 --- a/Sources/mcs/Sync/CrossPackPromptResolver.swift +++ b/Sources/mcs/Sync/CrossPackPromptResolver.swift @@ -15,6 +15,70 @@ enum CrossPackPromptResolver { /// Prompt types eligible for cross-pack deduplication. static let deduplicableTypes: Set = [.input, .select] + /// Flat list of every declaration from every pack. Multiple packs can declare + /// the same key — `partitionDeclaredPrompts` groups them when merging select options. + static func collectDeclaredPrompts( + packs: [any TechPack], + context: ProjectConfigContext + ) -> [PromptDefinition] { + packs.flatMap { $0.declaredPrompts(context: context) } + } + + /// Partition declared prompts against `priorValues`. + /// + /// `script` and `fileDetect` keys are excluded from both outputs — they always + /// re-run and must not trigger the "new prompts" UX branch. + /// + /// Select priors are reusable when: + /// - no declaration constrains the value (all have nil/empty options — the executor + /// falls back to free-form input), OR + /// - at least one declaration constrains via `options` AND the prior is in the + /// merged set of constrained options. + /// + /// Conservative rule for mixed declarations: when any pack constrains the value, + /// the prior must satisfy those constraints (matches `resolveSharedPrompts` which + /// presents the merged constrained option list to the user). + /// + /// Type conflicts across packs (input vs select) fall back to input semantics. + static func partitionDeclaredPrompts( + _ prompts: [PromptDefinition], + priorValues: [String: String] + ) -> (reusableValues: [String: String], newDeclaredKeys: Set) { + var constrainedOptionsByKey: [String: Set] = [:] + var typesByKey: [String: Set] = [:] + for prompt in prompts { + typesByKey[prompt.key, default: []].insert(prompt.type) + if prompt.type == .select, let options = prompt.options, !options.isEmpty { + constrainedOptionsByKey[prompt.key, default: []].formUnion(options.map(\.value)) + } + } + + var reusable: [String: String] = [:] + var newKeys: Set = [] + for (key, types) in typesByKey { + let answerableTypes = types.intersection(deduplicableTypes) + guard !answerableTypes.isEmpty else { continue } + + guard let prior = priorValues[key] else { + newKeys.insert(key) + continue + } + + if answerableTypes == [.select] { + let constrained = constrainedOptionsByKey[key] ?? [] + // No constraints → free-form; any constraint → prior must satisfy it. + if constrained.isEmpty || constrained.contains(prior) { + reusable[key] = prior + } else { + newKeys.insert(key) + } + } else { + reusable[key] = prior + } + } + return (reusable, newKeys) + } + /// Collect prompts from all packs and group by key, skipping already-resolved keys. /// /// - Returns: A dictionary keyed by prompt key, with each value being the list @@ -42,10 +106,14 @@ enum CrossPackPromptResolver { /// Execute shared prompts once, showing a combined label from all packs. /// + /// - Parameter priorValues: Values from a previous sync; used as the default + /// when present, overriding pack-declared defaults. For `select` prompts, + /// a prior value only applies when it still matches a merged option. /// - Returns: Resolved values for all shared prompt keys. static func resolveSharedPrompts( _ shared: [String: [PackPromptInfo]], - output: CLIOutput + output: CLIOutput, + priorValues: [String: String] = [:] ) -> [String: String] { var resolved: [String: String] = [:] @@ -70,8 +138,9 @@ enum CrossPackPromptResolver { output.warn(" Type conflict across packs (\(typesByPack)) — falling back to text input") } - // Use the first non-nil default value - let defaultValue = infos.compactMap(\.prompt.defaultValue).first + // Prior value wins over pack-declared defaults; fall back to first non-nil declared default + let declaredDefault = infos.compactMap(\.prompt.defaultValue).first + let prior = priorValues[key] if !hasTypeConflict, primaryType == .select { // Merge unique options from all packs (first occurrence of each value wins) @@ -85,16 +154,23 @@ enum CrossPackPromptResolver { } guard !mergedOptions.isEmpty else { output.warn(" Shared select prompt '\(key)' has no options — using default value") - resolved[key] = defaultValue ?? "" + resolved[key] = prior ?? declaredDefault ?? "" continue } let items = mergedOptions.map { (name: $0.label, description: $0.value) } let label = "Select value for \(key)" - let selected = output.singleSelect(title: label, items: items) + let initialIndex = PromptOption.index(of: prior, in: mergedOptions) + let selected = output.singleSelect(title: label, items: items, initialIndex: initialIndex) resolved[key] = mergedOptions[selected].value } else { - // Default to text input - let value = output.promptInline(" Enter value for \(key)", default: defaultValue) + // Default to text input; prior value seeds the Enter-to-accept default. + // Mask the hint when the default came from a prior (may hold secrets). + let effectiveDefault = prior ?? declaredDefault + let value = output.promptInline( + " Enter value for \(key)", + default: effectiveDefault, + maskDefault: prior != nil + ) resolved[key] = value } } diff --git a/Sources/mcs/Sync/GlobalSyncStrategy.swift b/Sources/mcs/Sync/GlobalSyncStrategy.swift index e49ad08..93bec6d 100644 --- a/Sources/mcs/Sync/GlobalSyncStrategy.swift +++ b/Sources/mcs/Sync/GlobalSyncStrategy.swift @@ -20,12 +20,17 @@ struct GlobalSyncStrategy: SyncStrategy { [:] } - func makeConfigContext(output: CLIOutput, resolvedValues: [String: String]) -> ProjectConfigContext { + func makeConfigContext( + output: CLIOutput, + resolvedValues: [String: String], + priorValues: [String: String] + ) -> ProjectConfigContext { ProjectConfigContext( projectPath: environment.homeDirectory, repoName: "", output: output, resolvedValues: resolvedValues, + priorValues: priorValues, isGlobalScope: true ) } diff --git a/Sources/mcs/Sync/ProjectSyncStrategy.swift b/Sources/mcs/Sync/ProjectSyncStrategy.swift index ae60de8..959b844 100644 --- a/Sources/mcs/Sync/ProjectSyncStrategy.swift +++ b/Sources/mcs/Sync/ProjectSyncStrategy.swift @@ -27,12 +27,17 @@ struct ProjectSyncStrategy: SyncStrategy { ] } - func makeConfigContext(output: CLIOutput, resolvedValues: [String: String]) -> ProjectConfigContext { + func makeConfigContext( + output: CLIOutput, + resolvedValues: [String: String], + priorValues: [String: String] + ) -> ProjectConfigContext { ProjectConfigContext( projectPath: projectPath, repoName: resolvedValues["REPO_NAME"] ?? projectPath.lastPathComponent, output: output, - resolvedValues: resolvedValues + resolvedValues: resolvedValues, + priorValues: priorValues ) } diff --git a/Sources/mcs/Sync/SyncStrategy.swift b/Sources/mcs/Sync/SyncStrategy.swift index 93397a2..375b352 100644 --- a/Sources/mcs/Sync/SyncStrategy.swift +++ b/Sources/mcs/Sync/SyncStrategy.swift @@ -17,7 +17,13 @@ protocol SyncStrategy { func resolveBuiltInValues(shell: any ShellRunning, output: CLIOutput) -> [String: String] /// Build the `ProjectConfigContext` for template value resolution. - func makeConfigContext(output: CLIOutput, resolvedValues: [String: String]) -> ProjectConfigContext + /// + /// - Parameter priorValues: Values resolved during the previous sync, used as prompt defaults. + func makeConfigContext( + output: CLIOutput, + resolvedValues: [String: String], + priorValues: [String: String] + ) -> ProjectConfigContext /// Install artifacts for a single pack. /// diff --git a/Sources/mcs/TechPack/PromptDefinition.swift b/Sources/mcs/TechPack/PromptDefinition.swift index 271968b..aebbc90 100644 --- a/Sources/mcs/TechPack/PromptDefinition.swift +++ b/Sources/mcs/TechPack/PromptDefinition.swift @@ -71,4 +71,11 @@ enum PromptType: String, Codable { struct PromptOption: Codable, Equatable { let value: String let label: String + + /// Find the index of the option whose `value` matches, returning 0 when absent. + /// Used by single-select UIs to seed the cursor from a previously-stored answer. + static func index(of value: String?, in options: [PromptOption]) -> Int { + guard let value else { return 0 } + return options.firstIndex { $0.value == value } ?? 0 + } } diff --git a/Sources/mcs/TechPack/TechPack.swift b/Sources/mcs/TechPack/TechPack.swift index c010e37..6472ca5 100644 --- a/Sources/mcs/TechPack/TechPack.swift +++ b/Sources/mcs/TechPack/TechPack.swift @@ -7,6 +7,9 @@ struct ProjectConfigContext { let output: CLIOutput /// Template values resolved by `templateValues(context:)`, available in `configureProject`. let resolvedValues: [String: String] + /// Values resolved during a previous sync, used as defaults when re-prompting. + /// Empty on first sync. Source: `ProjectState.resolvedValues`. + let priorValues: [String: String] /// When `true`, project-scoped prompts (e.g. `fileDetect`) should be skipped. let isGlobalScope: Bool @@ -15,12 +18,14 @@ struct ProjectConfigContext { repoName: String, output: CLIOutput, resolvedValues: [String: String] = [:], + priorValues: [String: String] = [:], isGlobalScope: Bool = false ) { self.projectPath = projectPath self.repoName = repoName self.output = output self.resolvedValues = resolvedValues + self.priorValues = priorValues self.isGlobalScope = isGlobalScope } } diff --git a/Tests/MCSTests/CrossPackPromptResolverTests.swift b/Tests/MCSTests/CrossPackPromptResolverTests.swift index ac6c251..4ca572b 100644 --- a/Tests/MCSTests/CrossPackPromptResolverTests.swift +++ b/Tests/MCSTests/CrossPackPromptResolverTests.swift @@ -605,6 +605,254 @@ struct ScannerExtensionTests { } } +// MARK: - partitionDeclaredPrompts + +struct PartitionDeclaredPromptsTests { + @Test("partition: input prompt with prior becomes reusable") + func partitionInputPriorReusable() { + let prompt = PromptDefinition( + key: "BRANCH_PREFIX", type: .input, + label: nil, defaultValue: nil, options: nil, + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["BRANCH_PREFIX": "bruno"] + ) + #expect(reusable == ["BRANCH_PREFIX": "bruno"]) + #expect(newKeys.isEmpty) + } + + @Test("partition: input prompt without prior becomes newDeclared") + func partitionInputMissingPriorIsNew() { + let prompt = PromptDefinition( + key: "NEW_KEY", type: .input, + label: nil, defaultValue: nil, options: nil, + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: [:] + ) + #expect(reusable.isEmpty) + #expect(newKeys == ["NEW_KEY"]) + } + + @Test("partition: select prompt with valid prior is reusable") + func partitionSelectValidPriorReusable() { + let prompt = PromptDefinition( + key: "LOG_LEVEL", type: .select, + label: nil, defaultValue: nil, + options: [ + PromptOption(value: "info", label: "Info"), + PromptOption(value: "debug", label: "Debug"), + ], + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["LOG_LEVEL": "debug"] + ) + #expect(reusable == ["LOG_LEVEL": "debug"]) + #expect(newKeys.isEmpty) + } + + @Test("partition: select prompt with invalidated prior becomes newDeclared") + func partitionSelectInvalidatedPriorIsNew() { + let prompt = PromptDefinition( + key: "LOG_LEVEL", type: .select, + label: nil, defaultValue: nil, + options: [ + PromptOption(value: "info", label: "Info"), + PromptOption(value: "debug", label: "Debug"), + ], + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["LOG_LEVEL": "trace-removed"] + ) + #expect(reusable.isEmpty) + #expect(newKeys == ["LOG_LEVEL"]) + } + + @Test("partition: select with valid option from any declaring pack is reusable") + func partitionSelectAcrossPacksMergedOptions() { + let fromPackA = PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "us", label: "US")], + detectPatterns: nil, scriptCommand: nil + ) + let fromPackB = PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "eu", label: "EU")], + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [fromPackA, fromPackB], priorValues: ["REGION": "eu"] + ) + #expect(reusable == ["REGION": "eu"]) + #expect(newKeys.isEmpty) + } + + @Test("partition: select with nil options treats any prior as reusable (matches executor fallback)") + func partitionSelectNilOptionsReusable() { + let prompt = PromptDefinition( + key: "FREEFORM", type: .select, + label: nil, defaultValue: nil, + options: nil, + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["FREEFORM": "anything"] + ) + #expect(reusable == ["FREEFORM": "anything"]) + #expect(newKeys.isEmpty) + } + + @Test("partition: select with empty options array treats any prior as reusable") + func partitionSelectEmptyOptionsReusable() { + let prompt = PromptDefinition( + key: "FREEFORM", type: .select, + label: nil, defaultValue: nil, + options: [], + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, _) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["FREEFORM": "anything"] + ) + #expect(reusable == ["FREEFORM": "anything"]) + } + + @Test("partition: mixed constrained + unconstrained — prior outside constraints is not reusable") + func partitionMixedConstrainedRejectsOutOfConstraint() { + // Pack A restricts REGION to [us]; pack B declares REGION select with nil options. + // The shared resolver would present [us] to the user, so a prior of "zz" must be re-asked. + let constrained = PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "us", label: "US")], + detectPatterns: nil, scriptCommand: nil + ) + let unconstrained = PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: nil, + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [constrained, unconstrained], priorValues: ["REGION": "zz"] + ) + #expect(reusable.isEmpty) + #expect(newKeys == ["REGION"]) + } + + @Test("partition: mixed constrained + unconstrained — prior inside constraints is reusable") + func partitionMixedConstrainedAcceptsValidValue() { + let constrained = PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "us", label: "US")], + detectPatterns: nil, scriptCommand: nil + ) + let unconstrained = PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: nil, + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, _) = CrossPackPromptResolver.partitionDeclaredPrompts( + [constrained, unconstrained], priorValues: ["REGION": "us"] + ) + #expect(reusable == ["REGION": "us"]) + } + + @Test("collectDeclaredPrompts preserves duplicate-key declarations across packs") + func collectPreservesPerPackDeclarations() { + let packA = PromptMockPack( + identifier: "pack-a", displayName: "A", + prompts: [PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "us", label: "US")], + detectPatterns: nil, scriptCommand: nil + )] + ) + let packB = PromptMockPack( + identifier: "pack-b", displayName: "B", + prompts: [PromptDefinition( + key: "REGION", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "eu", label: "EU")], + detectPatterns: nil, scriptCommand: nil + )] + ) + let context = ProjectConfigContext( + projectPath: URL(fileURLWithPath: "/tmp"), + repoName: "", + output: CLIOutput(colorsEnabled: false) + ) + + let collected = CrossPackPromptResolver.collectDeclaredPrompts( + packs: [packA, packB], context: context + ) + #expect(collected.count == 2) + + // Downstream partition sees both declarations → "eu" from pack B validates. + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + collected, priorValues: ["REGION": "eu"] + ) + #expect(reusable == ["REGION": "eu"]) + #expect(newKeys.isEmpty) + } + + @Test("partition: script prompt is neither reusable nor newDeclared") + func partitionScriptExcluded() { + let prompt = PromptDefinition( + key: "VERSION", type: .script, + label: nil, defaultValue: nil, options: nil, + detectPatterns: nil, scriptCommand: "echo 1.0" + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["VERSION": "0.9"] + ) + #expect(reusable.isEmpty) + #expect(newKeys.isEmpty) + } + + @Test("partition: fileDetect prompt is neither reusable nor newDeclared") + func partitionFileDetectExcluded() { + let prompt = PromptDefinition( + key: "PROJECT", type: .fileDetect, + label: nil, defaultValue: nil, options: nil, + detectPatterns: ["*.xcodeproj"], scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [prompt], priorValues: ["PROJECT": "App.xcodeproj"] + ) + #expect(reusable.isEmpty) + #expect(newKeys.isEmpty) + } + + @Test("partition: type conflict falls back to input reuse semantics") + func partitionTypeConflictFallsBackToInput() { + let asInput = PromptDefinition( + key: "SHARED", type: .input, + label: nil, defaultValue: nil, options: nil, + detectPatterns: nil, scriptCommand: nil + ) + let asSelect = PromptDefinition( + key: "SHARED", type: .select, + label: nil, defaultValue: nil, + options: [PromptOption(value: "a", label: "A")], + detectPatterns: nil, scriptCommand: nil + ) + let (reusable, newKeys) = CrossPackPromptResolver.partitionDeclaredPrompts( + [asInput, asSelect], priorValues: ["SHARED": "anything"] + ) + #expect(reusable == ["SHARED": "anything"]) + #expect(newKeys.isEmpty) + } +} + // MARK: - PromptMockPack /// A mock TechPack that supports declaredPrompts for testing cross-pack dedup. diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index 2658701..7163484 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -1443,3 +1443,202 @@ struct HookMetadataLifecycleTests { #expect(!commands2.contains(UpdateChecker.hookCommand)) } } + +// MARK: - Prompt Value Reuse Lifecycle + +struct PromptValueReuseLifecycleTests { + /// Minimal input-style prompt helper. + private func inputPrompt(_ key: String, defaultValue: String? = nil) -> PromptDefinition { + PromptDefinition( + key: key, type: .input, + label: nil, defaultValue: defaultValue, options: nil, + detectPatterns: nil, scriptCommand: nil + ) + } + + private func selectPrompt(_ key: String, options: [String]) -> PromptDefinition { + PromptDefinition( + key: key, type: .select, + label: nil, defaultValue: nil, + options: options.map { PromptOption(value: $0, label: $0.uppercased()) }, + detectPatterns: nil, scriptCommand: nil + ) + } + + @Test("Second sync reuses persisted values instead of re-asking") + func reuseOnSecondSync() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let pack = MockPromptTechPack( + identifier: "prompt-pack", + displayName: "Prompt Pack", + prompts: [inputPrompt("BRANCH_PREFIX"), inputPrompt("LABEL_PREFIX")], + defaultAnswer: { "fresh-\($0)" } + ) + let registry = TechPackRegistry(packs: [pack]) + let configurator = bed.makeConfigurator(registry: registry) + + // First sync: no priors → mock's defaultAnswer is used + try configurator.configure(packs: [pack], confirmRemovals: false) + let state1 = try bed.projectState() + #expect(state1.resolvedValues?["BRANCH_PREFIX"] == "fresh-BRANCH_PREFIX") + #expect(state1.resolvedValues?["LABEL_PREFIX"] == "fresh-LABEL_PREFIX") + + // Pre-seed state with custom values (as if user answered them previously) + var state = state1 + state.setResolvedValues(["BRANCH_PREFIX": "bruno", "LABEL_PREFIX": "scope:"]) + try state.save() + + // Second sync (non-interactive testbed): reuse path silently seeds allValues; + // MockPromptTechPack.templateValues skips keys already in resolvedValues. + try configurator.configure(packs: [pack], confirmRemovals: false) + let state2 = try bed.projectState() + #expect(state2.resolvedValues?["BRANCH_PREFIX"] == "bruno") + #expect(state2.resolvedValues?["LABEL_PREFIX"] == "scope:") + } + + @Test("New prompt added between syncs: old values reused, new prompt asked") + func newPromptAddedSkipsGate() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + // First sync: single prompt + let packV1 = MockPromptTechPack( + identifier: "evolving-pack", + displayName: "Evolving Pack", + prompts: [inputPrompt("OLD_KEY")], + defaultAnswer: { "v1-\($0)" } + ) + let registry1 = TechPackRegistry(packs: [packV1]) + try bed.makeConfigurator(registry: registry1) + .configure(packs: [packV1], confirmRemovals: false) + + // Seed the user's answer + var state = try bed.projectState() + state.setResolvedValues(["OLD_KEY": "user-answer"]) + try state.save() + + // Second sync: pack update adds a new prompt + let packV2 = MockPromptTechPack( + identifier: "evolving-pack", + displayName: "Evolving Pack", + prompts: [inputPrompt("OLD_KEY"), inputPrompt("NEW_KEY")], + defaultAnswer: { "v2-\($0)" } + ) + let registry2 = TechPackRegistry(packs: [packV2]) + try bed.makeConfigurator(registry: registry2) + .configure(packs: [packV2], confirmRemovals: false) + + let state2 = try bed.projectState() + // Old key kept; new key resolved via mock's default (no prior for it) + #expect(state2.resolvedValues?["OLD_KEY"] == "user-answer") + #expect(state2.resolvedValues?["NEW_KEY"] == "v2-NEW_KEY") + } + + @Test("Select prior value invalidated when option is removed") + func selectInvalidationReAsks() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + // First sync: select with three options + let packV1 = MockPromptTechPack( + identifier: "select-pack", + displayName: "Select Pack", + prompts: [selectPrompt("LOG_LEVEL", options: ["info", "debug", "trace"])], + defaultAnswer: { _ in "info" } + ) + try bed.makeConfigurator(registry: TechPackRegistry(packs: [packV1])) + .configure(packs: [packV1], confirmRemovals: false) + + // User previously chose "trace" + var state = try bed.projectState() + state.setResolvedValues(["LOG_LEVEL": "trace"]) + try state.save() + + // Pack update removes "trace" from options + let packV2 = MockPromptTechPack( + identifier: "select-pack", + displayName: "Select Pack", + prompts: [selectPrompt("LOG_LEVEL", options: ["info", "debug"])], + defaultAnswer: { _ in "info" } + ) + try bed.makeConfigurator(registry: TechPackRegistry(packs: [packV2])) + .configure(packs: [packV2], confirmRemovals: false) + + let state2 = try bed.projectState() + // "trace" is no longer valid → partition treats as newDeclared → mock returns default "info" + #expect(state2.resolvedValues?["LOG_LEVEL"] == "info") + } + + @Test("Non-interactive sync reuses priors silently") + func nonInteractiveSilentReuse() throws { + // This test environment has no TTY, so hasInteractiveStdin == false; + // the reuse path applies silently without prompting. Verifies that priors + // fully short-circuit the prompt executor even when some call would have blocked. + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let pack = MockPromptTechPack( + identifier: "silent-pack", + displayName: "Silent", + prompts: [inputPrompt("KEY_A"), inputPrompt("KEY_B")], + defaultAnswer: { _ in "SHOULD_NOT_APPEAR" } + ) + try bed.makeConfigurator(registry: TechPackRegistry(packs: [pack])) + .configure(packs: [pack], confirmRemovals: false) + + var state = try bed.projectState() + state.setResolvedValues(["KEY_A": "alpha", "KEY_B": "beta"]) + try state.save() + + try bed.makeConfigurator(registry: TechPackRegistry(packs: [pack])) + .configure(packs: [pack], confirmRemovals: false) + + let final = try bed.projectState() + #expect(final.resolvedValues?["KEY_A"] == "alpha") + #expect(final.resolvedValues?["KEY_B"] == "beta") + #expect(final.resolvedValues?["KEY_A"] != "SHOULD_NOT_APPEAR") + } + + @Test("--customize forces re-ask even when priors are available") + func customizeForceReAsk() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + // Track whether templateValues saw unresolved keys (re-ask path) + // by using defaultAnswer that differs per call. + let pack = MockPromptTechPack( + identifier: "customize-pack", + displayName: "Customize", + prompts: [inputPrompt("SETTING")], + defaultAnswer: { _ in "mock-re-asked" } + ) + + try bed.makeConfigurator(registry: TechPackRegistry(packs: [pack])) + .configure(packs: [pack], confirmRemovals: false) + + var state = try bed.projectState() + state.setResolvedValues(["SETTING": "user-previous"]) + try state.save() + + // Without --customize: non-interactive reuses → state stays "user-previous" + try bed.makeConfigurator(registry: TechPackRegistry(packs: [pack])) + .configure(packs: [pack], confirmRemovals: false) + #expect(try bed.projectState().resolvedValues?["SETTING"] == "user-previous") + + // With --customize: seed bypass → mock's templateValues sees no seeded key + // and returns priorValues["SETTING"] (still "user-previous" since MockPromptTechPack + // uses context.priorValues as its answer source). This mirrors real behavior: + // prompts would run but with priors as defaults. To verify the bypass, seed a + // different prior and assert templateValues received it, not a pre-seeded resolve. + state = try bed.projectState() + state.setResolvedValues(["SETTING": "prior-updated"]) + try state.save() + + try bed.makeConfigurator(registry: TechPackRegistry(packs: [pack])) + .configure(packs: [pack], confirmRemovals: false, customize: true) + // Under --customize, templateValues runs (nothing seeded), returns priorValues["SETTING"] + #expect(try bed.projectState().resolvedValues?["SETTING"] == "prior-updated") + } +} diff --git a/Tests/MCSTests/PromptExecutorTests.swift b/Tests/MCSTests/PromptExecutorTests.swift index a106e84..360bd7a 100644 --- a/Tests/MCSTests/PromptExecutorTests.swift +++ b/Tests/MCSTests/PromptExecutorTests.swift @@ -392,4 +392,61 @@ struct PromptExecutorTests { #expect(value == "default-pick") } + + // MARK: - priorValue behavior + + @Test("Select prompt with nil options prefers priorValue over defaultValue") + func selectPromptNilOptionsHonorsPriorValue() throws { + let tmpDir = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let prompt = PromptDefinition( + key: "stored", + type: .select, + label: "Pick one", + defaultValue: "default-pick", + options: nil, + detectPatterns: nil, + scriptCommand: nil + ) + + let executor = makeExecutor() + let value = try executor.execute( + prompt: prompt, + packPath: tmpDir, + projectPath: tmpDir, + priorValue: "remembered" + ) + + #expect(value == "remembered") + } + + @Test("Script prompt ignores priorValue — always re-runs scriptCommand") + func scriptPromptIgnoresPriorValue() throws { + let tmpDir = try makeTmpDir() + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let packDir = tmpDir.appendingPathComponent("pack") + try FileManager.default.createDirectory(at: packDir, withIntermediateDirectories: true) + + let prompt = PromptDefinition( + key: "version", + type: .script, + label: "Detected version", + defaultValue: nil, + options: nil, + detectPatterns: nil, + scriptCommand: "echo 3.0.0" + ) + + let executor = makeExecutor() + let value = try executor.execute( + prompt: prompt, + packPath: packDir, + projectPath: tmpDir, + priorValue: "1.0.0-stale" + ) + + #expect(value == "3.0.0") + } } diff --git a/Tests/MCSTests/TestHelpers.swift b/Tests/MCSTests/TestHelpers.swift index 5c4c26f..939bb02 100644 --- a/Tests/MCSTests/TestHelpers.swift +++ b/Tests/MCSTests/TestHelpers.swift @@ -177,6 +177,63 @@ struct MockTechPack: TechPack { func configureProject(at _: URL, context _: ProjectConfigContext) throws {} } +/// Mock TechPack that declares prompts and resolves them using `context.priorValues` +/// (falling back to a `defaultAnswer` closure when no prior exists). Simulates the +/// adapter's "skip keys already in resolvedValues" filter so tests can verify the +/// full reuse pipeline without needing interactive stdin. +struct MockPromptTechPack: TechPack { + let identifier: String + let displayName: String + let description: String = "Mock pack with prompts" + let components: [ComponentDefinition] + let templates: [TemplateContribution] + private let prompts: [PromptDefinition] + private let defaultAnswer: @Sendable (String) -> String + + init( + identifier: String, + displayName: String, + prompts: [PromptDefinition], + components: [ComponentDefinition] = [], + templates: [TemplateContribution] = [], + defaultAnswer: @escaping @Sendable (String) -> String = { "default-\($0)" } + ) { + self.identifier = identifier + self.displayName = displayName + self.prompts = prompts + self.components = components + self.templates = templates + self.defaultAnswer = defaultAnswer + } + + func supplementaryDoctorChecks(projectRoot _: URL?) -> [any DoctorCheck] { + [] + } + + func declaredPrompts(context _: ProjectConfigContext) -> [PromptDefinition] { + prompts + } + + func templateValues(context: ProjectConfigContext) -> [String: String] { + var resolved: [String: String] = [:] + for prompt in prompts where context.resolvedValues[prompt.key] == nil { + // Mirror real executor semantics: a select prior is only a valid answer + // when it still matches one of the current options. Otherwise fall back + // to the mock's defaultAnswer (simulating the user picking fresh). + let prior = context.priorValues[prompt.key] + if prompt.type == .select, let prior, let options = prompt.options, + !options.contains(where: { $0.value == prior }) { + resolved[prompt.key] = defaultAnswer(prompt.key) + } else { + resolved[prompt.key] = prior ?? defaultAnswer(prompt.key) + } + } + return resolved + } + + func configureProject(at _: URL, context _: ProjectConfigContext) throws {} +} + /// Mock TechPack that tracks `configureProject` invocations. final class TrackingMockTechPack: TechPack, @unchecked Sendable { let identifier: String