diff --git a/Sources/mcs/Templates/TemplateComposer.swift b/Sources/mcs/Templates/TemplateComposer.swift index 42ec80f4..6a1278ff 100644 --- a/Sources/mcs/Templates/TemplateComposer.swift +++ b/Sources/mcs/Templates/TemplateComposer.swift @@ -33,7 +33,9 @@ enum TemplateComposer { values: values, emitWarnings: emitWarnings ) - if index > 0 { parts.append("") } + if index > 0 { + parts.append("") + } parts.append(beginMarker(identifier: contribution.sectionIdentifier)) parts.append(processedContent) parts.append(endMarker(identifier: contribution.sectionIdentifier)) @@ -180,17 +182,21 @@ enum TemplateComposer { /// Pure compose-or-update decision: produces final content from contributions /// without performing any file I/O. /// - /// - If `existingContent` is nil or has no section markers, produces a fresh compose. - /// - If `existingContent` has markers, updates each section in place preserving user content. + /// - If `existingContent` is nil or empty/whitespace-only, produces a fresh compose. + /// - Otherwise (it has markers and/or hand-written content), updates each section in + /// place, preserving all content outside the managed markers. Marker-less content is + /// treated as user content and kept, with managed sections appended below it. static func composeOrUpdate( existingContent: String?, contributions: [TemplateContribution], values: [String: String], emitWarnings: Bool = true ) -> ComposeResult { - let hasMarkers = existingContent.map { !parseSections(from: $0).isEmpty } ?? false - - guard let existingContent, hasMarkers else { + // Fresh-compose only for a nil or whitespace-only file. Any non-whitespace + // content — whether mcs-managed (has markers) or hand-written (marker-less) — + // routes to updateExisting so it is preserved rather than overwritten. + guard let existingContent, + !existingContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return freshCompose(contributions: contributions, values: values, emitWarnings: emitWarnings) } @@ -216,7 +222,9 @@ enum TemplateComposer { return ComposeResult(content: composed, warnings: []) } - /// Update an existing file that has section markers, preserving user content. + /// Update an existing non-empty file, preserving user content. Handles both + /// marker'd files (sections replaced in place) and marker-less files (the whole + /// file is user content and managed sections are appended below it). private static func updateExisting( existingContent: String, contributions: [TemplateContribution], diff --git a/Tests/MCSTests/LifecycleIntegrationTests.swift b/Tests/MCSTests/LifecycleIntegrationTests.swift index 3392368e..f65a8829 100644 --- a/Tests/MCSTests/LifecycleIntegrationTests.swift +++ b/Tests/MCSTests/LifecycleIntegrationTests.swift @@ -1226,6 +1226,90 @@ struct SectionRestorationTests { } } +// MARK: - Scenario 9b: Marker-less CLAUDE File Preservation + +struct MarkerlessPreservationTests { + @Test("Sync into a pre-existing marker-less CLAUDE file preserves user content") + func preExistingMarkerlessContentPreserved() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + // Pre-seed a hand-written CLAUDE.local.md with NO mcs markers. + let userRules = "# My personal project rules\nAlways be concise and cite sources." + try userRules.write(to: bed.claudeLocalPath, atomically: true, encoding: .utf8) + + let pack = MockTechPack( + identifier: "my-pack", + displayName: "My Pack", + templates: [TemplateContribution( + sectionIdentifier: "my-pack", + templateContent: "## My Pack\nPack-provided guidance.", + placeholders: [] + )] + ) + let registry = TechPackRegistry(packs: [pack]) + let configurator = bed.makeConfigurator(registry: registry) + + try configurator.configure(packs: [pack], confirmRemovals: false) + + let content = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + // User's hand-written rules survive rather than being overwritten. + #expect(content.contains("Always be concise and cite sources.")) + // The pack section is added with markers. + #expect(content.contains("")) + #expect(content.contains("Pack-provided guidance.")) + + // Re-sync is idempotent: prose appears exactly once, single managed section. + try configurator.configure(packs: [pack], confirmRemovals: false) + let reSynced = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(occurrences(of: "Always be concise and cite sources.", in: reSynced) == 1) + #expect(TemplateComposer.parseSections(from: reSynced).count == 1) + } + + @Test("Single-pack swap preserves user content after all markers are stripped") + func singlePackSwapPreservesUserContent() throws { + let bed = try LifecycleTestBed() + defer { bed.cleanup() } + + let packA = MockTechPack( + identifier: "pack-a", + displayName: "Pack A", + templates: [TemplateContribution( + sectionIdentifier: "pack-a", + templateContent: "## Pack A\nPack A content.", + placeholders: [] + )] + ) + let packB = MockTechPack( + identifier: "pack-b", + displayName: "Pack B", + templates: [TemplateContribution( + sectionIdentifier: "pack-b", + templateContent: "## Pack B\nPack B content.", + placeholders: [] + )] + ) + let registry = TechPackRegistry(packs: [packA, packB]) + let configurator = bed.makeConfigurator(registry: registry) + + // === Step 1: Configure pack A, then append user prose outside its markers === + try configurator.configure(packs: [packA], confirmRemovals: false) + let seeded = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + + "\n\nMy own notes outside markers.\n" + try seeded.write(to: bed.claudeLocalPath, atomically: true, encoding: .utf8) + + // === Step 2: Swap to pack B only === + // Deselecting the sole marked pack strips every marker, leaving a marker-less + // file that still holds the user's notes. + try configurator.configure(packs: [packB], confirmRemovals: false) + + let afterClaude = try String(contentsOf: bed.claudeLocalPath, encoding: .utf8) + #expect(afterClaude.contains("My own notes outside markers.")) + #expect(afterClaude.contains("")) + #expect(!afterClaude.contains("")) + } +} + // MARK: - Scenario 7: Hook Handler Metadata struct HookMetadataLifecycleTests { diff --git a/Tests/MCSTests/TemplateComposerTests.swift b/Tests/MCSTests/TemplateComposerTests.swift index 83a08e08..ac865667 100644 --- a/Tests/MCSTests/TemplateComposerTests.swift +++ b/Tests/MCSTests/TemplateComposerTests.swift @@ -386,8 +386,8 @@ struct ComposeOrUpdateTests { #expect(result.warnings.isEmpty) } - @Test("v1 content without markers produces fresh compose") - func v1MigrationCompose() { + @Test("Marker-less existing content is preserved and managed sections appended") + func markerlessContentPreserved() { let result = TemplateComposer.composeOrUpdate( existingContent: "Old v1 content without any markers", contributions: [packContribution("ios", "New iOS")], @@ -398,7 +398,49 @@ struct ComposeOrUpdateTests { #expect(sections.count == 1) #expect(sections[0].identifier == "ios") #expect(sections[0].content == "New iOS") - #expect(!result.content.contains("Old v1 content")) + // Hand-written content must survive rather than being overwritten. + #expect(result.content.contains("Old v1 content without any markers")) + // The preserved prose lands outside the managed markers (as user content). + #expect(TemplateComposer.extractUserContent(from: result.content) + .contains("Old v1 content without any markers")) + #expect(result.warnings.isEmpty) + } + + @Test("Composing into marker-less content is idempotent on re-run") + func markerlessContentIdempotent() { + let first = TemplateComposer.composeOrUpdate( + existingContent: "My hand-written rules", + contributions: [packContribution("ios", "New iOS")], + values: [:] + ) + + let second = TemplateComposer.composeOrUpdate( + existingContent: first.content, + contributions: [packContribution("ios", "New iOS")], + values: [:] + ) + + // Second pass sees the markers written by the first and updates in place — + // no duplicated section, and the prose appears exactly once. + #expect(TemplateComposer.parseSections(from: second.content).count == 1) + #expect(occurrences(of: "My hand-written rules", in: second.content) == 1) + #expect(second.warnings.isEmpty) + } + + @Test("Empty existing content produces a clean fresh compose") + func emptyExistingContentFreshCompose() { + let result = TemplateComposer.composeOrUpdate( + existingContent: " \n\n ", + contributions: [packContribution("ios", "iOS rules")], + values: [:] + ) + + let sections = TemplateComposer.parseSections(from: result.content) + #expect(sections.count == 1) + #expect(sections[0].content == "iOS rules") + // Whitespace-only input carries no user content, so nothing extra is kept. + #expect(TemplateComposer.extractUserContent(from: result.content) + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) #expect(result.warnings.isEmpty) } diff --git a/Tests/MCSTests/TestHelpers.swift b/Tests/MCSTests/TestHelpers.swift index 0c4beb29..c481ce59 100644 --- a/Tests/MCSTests/TestHelpers.swift +++ b/Tests/MCSTests/TestHelpers.swift @@ -389,6 +389,11 @@ func makeSandboxProject(label: String = "project") throws -> (home: URL, project return (home, project) } +/// Count non-overlapping occurrences of `needle` within `haystack`. +func occurrences(of needle: String, in haystack: String) -> Int { + haystack.components(separatedBy: needle).count - 1 +} + /// Create a `Configurator` configured for global-scope sync. func makeGlobalSyncConfigurator( home: URL,