Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions Sources/mcs/Templates/TemplateComposer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
}

Expand All @@ -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],
Expand Down
84 changes: 84 additions & 0 deletions Tests/MCSTests/LifecycleIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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("<!-- mcs:begin my-pack -->"))
#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("<!-- mcs:begin pack-b -->"))
#expect(!afterClaude.contains("<!-- mcs:begin pack-a -->"))
}
}

// MARK: - Scenario 7: Hook Handler Metadata

struct HookMetadataLifecycleTests {
Expand Down
48 changes: 45 additions & 3 deletions Tests/MCSTests/TemplateComposerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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)
}

Expand Down
5 changes: 5 additions & 0 deletions Tests/MCSTests/TestHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading