import Foundation import Testing @testable import Kanban // MARK: - Fixtures // `WriterFixture` lives in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`. private enum Fixture { /// Unknown keys in a deliberate order, an own-line comment above and below, an inline /// comment, a `modified-by` stamp, and a body — everything one `title` rewrite must leave /// exactly as it found it. static let rich = """ --- # hand-written header schema: 1 title: Original order: 1024 project: lanework # agent overlay sphere: work labels: [a, b, c] created: 2026-07-26T16:41:38Z modified: 2026-07-26T19:06:55Z modified-by: claude # trailing note --- Body text. More body — with *markdown*. """ static let minimal = """ --- schema: 1 order: 1024 title: Thing --- Body """ /// The whole frontmatter as one flow mapping: reads fine, has no line per key to rewrite. static let flowMapping = "---\n{schema: 1, order: 1024}\n---\nbody\n" /// YAML's explicit-key syntax — the key is a sequence, not a name the editor can match. static let nonScalarKey = "---\n? [a, b]\n: value\nschema: 1\n---\nbody\n" } /// Literal UUID-shaped folder names, so the display-order tie-break is pinned rather than /// accidental (`Ranks.isOrderedForDisplay`). private enum Child { static let a = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" static let b = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" static let c = "cccccccc-cccc-4ccc-8ccc-cccccccccccc" static let deleted = "dddddddd-dddd-4ddd-8ddd-dddddddddddd" static let indexless = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" } // `Ident` and `Item` live in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`. /// The keys a move or a copy is allowed to have touched; every other line must be byte-identical. private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy] /// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder /// it materializes, so the file's own content is the only way back to "which card is which". private func childrenByTitle(of relativePath: String, in fixture: WriterFixture) throws -> [String: String] { var byTitle: [String: String] = [:] for child in try BoardLoader.directoryCandidates(in: fixture.url(relativePath)) where BoardLoader.isUUIDShaped(child.lastPathComponent) { let name = child.lastPathComponent guard fixture.exists("\(relativePath)/\(name)/index.md") else { continue } if let title = try FrontmatterDocument.parse(fixture.indexText("\(relativePath)/\(name)")).title.value { byTitle[title] = name } } return byTitle } // `writeFailure` lives in `WriterTestSupport.swift` — shared with `WriteFidelityTests.swift`. /// The file's lines minus every line that opens one of `keys` — what a rewrite of exactly those /// keys has to leave byte-identical. private func lines(of text: String, excludingKeys keys: [String]) -> [String] { text.components(separatedBy: "\n") .filter { line in !keys.contains { line.hasPrefix("\($0):") } } } // MARK: - Preservation struct BoardWriterPreservationTests { @Test func aTitleEditTouchesOnlyTheTitleAndTheStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let after = try fixture.indexText("card") let stamped = [FrontmatterKeys.title, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy] #expect(lines(of: after, excludingKeys: stamped) == lines(of: Fixture.rich, excludingKeys: stamped)) #expect(after.contains("title: Renamed\n")) let document = try FrontmatterDocument.parse(after) #expect(document.unknownFields.map(\.key) == ["project", "sphere", "labels"]) #expect(document.keys == [ "schema", "title", "order", "project", "sphere", "labels", "created", "modified", ]) #expect(document.rawValue(for: "labels") == "[a, b, c]") #expect(after.hasPrefix("---\n# hand-written header\nschema: 1\n")) #expect(after.contains("project: lanework # agent overlay\n")) #expect(after.contains("\n# trailing note\n---\n")) } @Test func aFrontmatterEditLeavesTheBodyByteIdentical() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) let body = Data("Body text.\n\nMore body — with *markdown*.\n".utf8) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.order, to: .double(2048)) } let after = try fixture.indexData("card") #expect(after.suffix(body.count) == body) #expect(try FrontmatterDocument.parse(fixture.indexText("card")).order == .valid(2048)) } @Test func aSuccessfulWriteLeavesNoTempFileBehind() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } #expect(try fixture.entryNames("card") == ["index.md"]) } /// The engine's per-line ending guarantee (01-storage-format.md § Fractal layout ▸ Rules), /// verified end to end through the writer: a CRLF file stays CRLF. @Test func aCRLFFileKeepsCRLFOnEveryRewrittenLine() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let text = "---\r\nschema: 1\r\norder: 1024\r\ntitle: Thing\r\n" + "modified: 2026-01-01T00:00:00Z\r\nmodified-by: claude\r\n---\r\nbody\r\n" let folder = try fixture.item("card", text) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let after = try fixture.indexText("card") #expect(after.contains("title: Renamed\r\n")) #expect(after.contains("modified: ") && !after.contains("2026-01-01T00:00:00Z")) #expect(!after.contains("modified-by")) #expect(after.hasSuffix("---\r\nbody\r\n")) // No bare LF survives anywhere once the CRLF pairs are taken out. #expect(!after.replacingOccurrences(of: "\r\n", with: "").contains("\n")) } } // MARK: - Stamps struct BoardWriterStampTests { @Test func modifiedIsStampedAndModifiedByIsCleared() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) #expect(try FrontmatterDocument.parse(fixture.indexText("card")).modifiedBy == .valid("claude")) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let after = try fixture.indexText("card") #expect(!after.contains("modified-by")) let document = try FrontmatterDocument.parse(after) #expect(document.modifiedBy == .missing) let modified = try #require(document.modified.value) #expect(abs(modified.timeIntervalSinceNow) < 60) } /// The stamps run *after* the caller's closure, so they always win — a call site cannot /// leave a stale `modified` or a re-added `modified-by` behind. @Test func theStampsOutrankWhatTheEditsClosureDid() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.modified, to: .date(Date(timeIntervalSince1970: 0))) document.set(FrontmatterKeys.modifiedBy, to: .string("claude")) } let document = try FrontmatterDocument.parse(fixture.indexText("card")) #expect(document.modifiedBy == .missing) let modified = try #require(document.modified.value) #expect(abs(modified.timeIntervalSinceNow) < 60) } /// A file with no `modified` at all gains one — appended before the closing delimiter. @Test func aFileWithoutAModifiedKeyGainsOne() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } let document = try FrontmatterDocument.parse(fixture.indexText("card")) #expect(document.keys == ["schema", "order", "title", "modified"]) #expect(document.modified.value != nil) #expect(document.body == "Body\n") } } // MARK: - Readable but uneditable /// 01-storage-format.md § Frontmatter, settled: a frontmatter shape the surgical editor cannot /// key by spans loads and renders normally, and every app write to it refuses loudly. struct BoardWriterUneditableTests { @Test func ordinaryDocumentsAreEditable() throws { #expect(try FrontmatterDocument.parse(Fixture.rich).uneditableShape == nil) #expect(try FrontmatterDocument.parse(Fixture.minimal).uneditableShape == nil) #expect(try FrontmatterDocument.parse("---\nflow: {a: 1,\nb: 2}\nlast: x\n---\nbody\n").uneditableShape == nil) #expect(FrontmatterDocument(body: "new").uneditableShape == nil) } @Test func aWholeFrontmatterFlowMappingReadsButRefusesWrites() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.flowMapping) let document = try FrontmatterDocument.parse(Fixture.flowMapping) #expect(document.uneditableShape == .keyWithoutOwnLine) #expect(document.schema == .valid(1)) #expect(document.order == .valid(1024)) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) // No title in `Fixture.flowMapping`, so the pre-flight read leaves it nil. #expect(error?.operation == .style(title: nil)) #expect(error?.path.hasSuffix("card/index.md") == true) #expect(error?.description.contains("style") == true) #expect(try fixture.indexText("card") == Fixture.flowMapping) #expect(try fixture.entryNames("card") == ["index.md"]) } @Test func aNonScalarKeyReadsButRefusesWrites() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.nonScalarKey) #expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).uneditableShape == .nonScalarKey) #expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } #expect(error?.reason == .uneditableFrontmatter(.nonScalarKey)) #expect(try fixture.indexText("card") == Fixture.nonScalarKey) } /// A key whose spacing the span matcher cannot key on lands in the same refusal — the /// detection is conservative on purpose. @Test func aKeyWithSpaceBeforeItsColonIsUneditable() throws { #expect(try FrontmatterDocument.parse("---\nschema : 1\n---\nbody\n").uneditableShape == .keyWithoutOwnLine) } } // MARK: - Failure paths struct BoardWriterFailureTests { @Test func aFileThatIsNotUTF8IsALoudErrorAndIsLeftAlone() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let latin1 = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1)) let folder = try fixture.item("card", bytes: latin1) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UTF-8")) #expect(try fixture.indexData("card") == latin1) #expect(try fixture.entryNames("card") == ["index.md"]) } @Test func aMissingIndexIsALoudError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = fixture.url("card") try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in } } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(try fixture.entryNames("card") == []) } @Test func unparseableFrontmatterIsALoudError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let text = "---\nschema: 1\n bad: indent\n---\nbody\n" let folder = try fixture.item("card", text) let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in } } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(try fixture.indexText("card") == text) } /// The atomicity guarantee under an I/O failure: the temp file cannot be created, so the /// destination still holds its previous bytes and nothing is left lying around. @Test func aFailedWriteLeavesTheFileAndTheFolderExactlyAsTheyWere() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) let before = try fixture.indexData("card") try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: folder.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) } let error = writeFailure { try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } guard case .io = error?.reason else { Issue.record("expected .io, got \(String(describing: error?.reason))") return } // `updateIndex` read `Fixture.rich` (title: Original) successfully before the write // itself failed — title enrichment engages even though the failure is `.io`, not a // read/uneditable refusal. #expect(error?.operation == .style(title: "Original")) #expect(error?.path.hasSuffix("card/index.md") == true) #expect(try fixture.indexData("card") == before) #expect(try fixture.entryNames("card") == ["index.md"]) } } // MARK: - Renumber /// 01-storage-format.md § Ordering: the renumber fallback — the sole exception to /// "a reorder rewrites only the moved item's index.md". struct BoardWriterRenumberTests { /// Crowded orders whose display sequence disagrees with folder-name order, so the assignment /// is provably by `order` first and name only as tie-break. private func crowdedLane(_ fixture: WriterFixture) throws -> URL { try fixture.item("lane/\(Child.a)", child(order: "1.0000003", title: "A")) try fixture.item("lane/\(Child.b)", child(order: "1.0000001", title: "B")) try fixture.item("lane/\(Child.c)", child(order: "1.0000002", title: "C")) try fixture.item( "lane/\(Child.deleted)", "---\nschema: 1\norder: 0.5\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\nmodified-by: claude\n---\nbody\n" ) // Skipped for the same reasons BoardLoader skips them. try fixture.item("lane/notes", "---\nschema: 1\norder: 1\ntitle: Stray\n---\nbody\n") try FileManager.default.createDirectory( at: fixture.url("lane/\(Child.indexless)"), withIntermediateDirectories: true ) return fixture.url("lane") } private func child(order: String, title: String) -> String { """ --- schema: 1 order: \(order) title: \(title) project: lanework # agent overlay modified-by: claude --- \(title) body """ } @Test func visibleChildrenAreRenumberedInDisplayOrder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let lane = try crowdedLane(fixture) try BoardWriter.renumberVisibleChildren(of: lane) let orders = try [Child.b, Child.c, Child.a].map { try FrontmatterDocument.parse(fixture.indexText("lane/\($0)")).order } #expect(orders == [.valid(1024), .valid(2048), .valid(3072)]) #expect(try fixture.indexText("lane/\(Child.b)").contains("order: 1024\n")) } @Test func eachRewrittenChildIsStampedAndKeepsItsUnknownKeys() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let lane = try crowdedLane(fixture) try BoardWriter.renumberVisibleChildren(of: lane) for name in [Child.a, Child.b, Child.c] { let text = try fixture.indexText("lane/\(name)") let document = try FrontmatterDocument.parse(text) #expect(document.modifiedBy == .missing) #expect(document.modified.value != nil) #expect(document.unknownFields.map(\.key) == ["project"]) #expect(text.contains("project: lanework # agent overlay\n")) #expect(document.body.hasSuffix(" body\n")) #expect(try fixture.entryNames("lane/\(name)") == ["index.md"]) } } @Test func tombstonedAndStrayChildrenAreUntouched() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let lane = try crowdedLane(fixture) let tombstone = try fixture.indexData("lane/\(Child.deleted)") let stray = try fixture.indexData("lane/notes") try BoardWriter.renumberVisibleChildren(of: lane) #expect(try fixture.indexData("lane/\(Child.deleted)") == tombstone) #expect(try fixture.indexData("lane/notes") == stray) } /// A renumber runs over loaded, valid children: one broken sibling fails the whole /// operation, and it fails before anything has been rewritten. @Test func aChildWithAMalformedOrderFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: banana\ntitle: B\n---\nbody\n") let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) #expect(error?.path.contains(Child.b) == true) #expect(error?.operation == .renumberChildren) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @Test func aChildWithNoOrderFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) try fixture.item("lane/\(Child.b)", "---\nschema: 1\ntitle: B\n---\nbody\n") let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } #expect(error?.reason == .unreadable(message: "missing required 'order' field")) #expect(error?.path.contains(Child.b) == true) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @Test func aChildWithUneditableFrontmatterFailsTheWholeRenumber() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", child(order: "1.0000001", title: "A")) try fixture.item("lane/\(Child.b)", Fixture.flowMapping) let untouched = try fixture.indexData("lane/\(Child.a)") let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @Test func renumberingAnEmptyParentIsANoOp() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try FileManager.default.createDirectory(at: fixture.url("lane"), withIntermediateDirectories: true) try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) #expect(try fixture.entryNames("lane") == []) } } // MARK: - Loader integration struct BoardWriterLoaderIntegrationTests { /// The temp files are dot-prefixed precisely so `BoardLoader`'s `.skipsHiddenFiles` never /// sees a crashed write's residue — not as a stray warning, and not as a candidate. @Test func leftoverTempFilesAreInvisibleToTheLoader() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n") for folder in ["", Child.a] { let residue = fixture.url(folder).appendingPathComponent(".index.md.lanework-\(UUID().uuidString)") try Data("half-written\n".utf8).write(to: residue) } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings.isEmpty) #expect(result.model.lanes.map(\.id.rawValue) == [Child.a]) } /// A board written by the writer reloads to the same values it was given. @Test func aWrittenBoardReloadsCleanly() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") let lane = try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: Lane\n---\n") try fixture.item("\(Child.a)/\(Child.b)", "---\nschema: 1\norder: 1024\ntitle: Card\n---\nbody\n") try BoardWriter.updateIndex(inItemFolder: lane, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Doing")) } let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings.isEmpty) #expect(result.model.lanes.first?.title == .valid("Doing")) #expect(result.model.lanes.first?.modifiedBy == .missing) #expect(result.model.lanes.first?.modified.value != nil) #expect(result.model.lanes.first?.cards.first?.title == .valid("Card")) } } // MARK: - Create board /// `BoardWriter.createBoard`: the folder (if missing) plus a freshly minted `index.md` — never /// a rewrite, so these tests check what a brand-new file looks like rather than what a rewrite /// preserved. struct BoardWriterCreateBoardTests { @Test func createsAFolderAndAValidIndexTheLoaderOpens() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("MyBoard.kanban") try BoardWriter.createBoard(at: root, title: "My Board") let result = try BoardLoader.load(boardRoot: root) #expect(result.warnings.isEmpty) #expect(result.model.schema == 1) #expect(result.model.title == .valid("My Board")) #expect(result.model.modifiedBy == .missing) let created = try #require(result.model.created.value) let modified = try #require(result.model.modified.value) #expect(abs(created.timeIntervalSinceNow) < 60) #expect(abs(modified.timeIntervalSinceNow) < 60) } @Test func keyOrderIsSchemaTitleCreatedModified() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("MyBoard.kanban") try BoardWriter.createBoard(at: root, title: "My Board") let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban")) #expect(document.keys == ["schema", "title", "created", "modified"]) } @Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("Untitled.kanban") try BoardWriter.createBoard(at: root, title: nil) let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban")) #expect(document.keys == ["schema", "created", "modified"]) #expect(!document.contains(FrontmatterKeys.title)) let result = try BoardLoader.load(boardRoot: root) #expect(result.model.title == .missing) } @Test func refusesToClobberAnExistingBoard() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let existing = "---\nschema: 1\ntitle: Existing\n---\nbody\n" let root = try fixture.item("Existing.kanban", existing) let error = writeFailure { try BoardWriter.createBoard(at: root, title: "New") } guard case .io = error?.reason else { Issue.record("expected .io, got \(String(describing: error?.reason))") return } #expect(error?.path.hasSuffix("Existing.kanban/index.md") == true) #expect(try fixture.indexText("Existing.kanban") == existing) } @Test func anExtensionLessBoardFolderIsCreatedAndOpensSuccessfully() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("PlainBoard") try BoardWriter.createBoard(at: root, title: "Plain") let result = try BoardLoader.load(boardRoot: root) #expect(result.warnings.isEmpty) #expect(result.model.title == .valid("Plain")) } @Test func createdEqualsModifiedAtCreationTime() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("Board.kanban") try BoardWriter.createBoard(at: root, title: "Board") let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban")) #expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified)) } } // MARK: - Create lane / card /// `BoardWriter.createLane`/`createCard`: minting a fresh identity and placing it after the /// current visible siblings (01-storage-format.md § Ordering). `fixture.root` doubles as the /// board root for lane creation — `createChild` never reads the parent's own `index.md`, only /// its children, so a bare directory is as good a board root as a fully-formed one for these /// tests. struct BoardWriterCreateChildTests { @Test func mintsLowercaseUUIDShapedNamesDistinctAcrossCalls() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let first = try BoardWriter.createLane(inBoard: fixture.root, title: "Todo") let second = try BoardWriter.createLane(inBoard: fixture.root, title: "Doing") for id in [first, second] { #expect(BoardLoader.isUUIDShaped(id.rawValue)) #expect(id.rawValue == id.rawValue.lowercased()) } #expect(first != second) } @Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) #expect(document.keys == ["schema", "title", "order", "created", "modified"]) #expect(document.schema == .valid(1)) #expect(document.modifiedBy == .missing) } @Test func anEmptyParentsFirstChildLandsAt1024() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Only") let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) #expect(document.order == .valid(1024)) } @Test func appendsAfterVisibleSiblingsAtMaxPlus1024() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") try fixture.item(Child.b, "---\nschema: 1\norder: 2048\ntitle: B\n---\nbody\n") let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(3072)) } /// The highest-order sibling is tombstoned — tombstones are inert to ordering, so it must /// not factor into the append target at all (not `9999 + 1024`, not anything derived from it). @Test func aTombstonedHighestOrderSiblingIsExcludedFromTheAppendTarget() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") try fixture.item( Child.deleted, "---\nschema: 1\norder: 9999\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(2048)) } /// A UUID-shaped sibling folder with no `index.md` — an interrupted create — is skipped /// during the order scan, not fatal; the new item still lands correctly, and the board as a /// whole still loads, surfacing only the loader's ordinary `.missingIndex` warning. @Test func aSiblingFolderWithoutIndexIsSkippedNotFatal() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") try FileManager.default.createDirectory(at: fixture.url(Child.indexless), withIntermediateDirectories: true) let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(2048)) let result = try BoardLoader.load(boardRoot: fixture.root) #expect(result.warnings == [.missingIndex(path: Child.indexless)]) #expect(Set(result.model.lanes.map(\.id.rawValue)) == Set([Child.a, newID.rawValue])) } @Test func aSiblingWithAMalformedOrderFailsTheCreateNamingTheSibling() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: banana\ntitle: A\n---\nbody\n") let before = try fixture.entryNames("") let error = writeFailure { _ = try BoardWriter.createLane(inBoard: fixture.root, title: "New") } #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) #expect(error?.path.contains(Child.a) == true) #expect(error?.operation == .createLane) // Nothing was minted: the scan fails before the new folder is ever created. #expect(try fixture.entryNames("") == before) } @Test func aMissingParentFolderIsALoudUnreadableError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let missingParent = fixture.url("does-not-exist") let error = writeFailure { _ = try BoardWriter.createLane(inBoard: missingParent, title: "New") } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } } @Test func aParentThatIsAFileIsALoudUnreadableError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let filePath = fixture.root.appendingPathComponent("afile") try Data("x".utf8).write(to: filePath) let error = writeFailure { _ = try BoardWriter.createLane(inBoard: filePath, title: "New") } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } } @Test func createdEqualsModifiedAtCreationTime() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) #expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified)) } /// `createCard` is `createLane` one level down — a lane folder (need not have its own /// `index.md` for this to work; `createChild` never reads the parent's own file) is just as /// good a parent as a board root. @Test func createCardMintsUnderALaneFolderWithTheSameRules() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") let laneFolder = fixture.url(laneID.rawValue) let first = try BoardWriter.createCard(inLane: laneFolder, title: "First") let second = try BoardWriter.createCard(inLane: laneFolder, title: "Second") #expect(BoardLoader.isUUIDShaped(first.rawValue)) #expect(first != second) let firstDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(first.rawValue)")) let secondDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(second.rawValue)")) #expect(firstDocument.order == .valid(1024)) #expect(secondDocument.order == .valid(2048)) } } // MARK: - Create round-trip struct BoardWriterCreateIntegrationTests { /// `createBoard` → `createLane` → `createCard` → `BoardLoader.load`: the whole structure /// shows up, in append order, exactly as created. @Test func createBoardCreateLaneCreateCardRoundTripsThroughTheLoader() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = fixture.url("Trip.kanban") try BoardWriter.createBoard(at: root, title: "Trip") let lane1 = try BoardWriter.createLane(inBoard: root, title: "Todo") let lane2 = try BoardWriter.createLane(inBoard: root, title: "Doing") let lane1Folder = root.appendingPathComponent(lane1.rawValue, isDirectory: true) let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First") let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second") let result = try BoardLoader.load(boardRoot: root) #expect(result.warnings.isEmpty) #expect(result.model.title == .valid("Trip")) #expect(result.model.lanes.map(\.id.rawValue) == [lane1.rawValue, lane2.rawValue]) #expect(result.model.lanes[0].title == .valid("Todo")) #expect(result.model.lanes[1].title == .valid("Doing")) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card1.rawValue, card2.rawValue]) #expect(result.model.lanes[0].cards[0].title == .valid("First")) #expect(result.model.lanes[0].cards[1].title == .valid("Second")) #expect(result.model.lanes[1].cards.isEmpty) } } // MARK: - Editability scope: reads vs rewrites /// The readable-but-uneditable pre-flight belongs only to operations that will *rewrite* /// siblings. A flow-mapping sibling loads and renders normally (01-storage-format.md § /// Frontmatter), so it must not block creating a new item beside it — but renumber, which /// would have to rewrite that very file, refuses up front, before anything is written. struct BoardWriterEditabilityScopeTests { private static let flowSibling = "---\n{schema: 1, order: 5000}\n---\n" @Test func createSucceedsBesideAnUneditableSibling() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\n---\n") try fixture.item(Child.a, Self.flowSibling) let id = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let text = try fixture.indexText(id.rawValue) #expect(text.contains("order: 6024")) } @Test func renumberRefusesOnAnUneditableSibling() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("", "---\nschema: 1\n---\n") try fixture.item(Child.a, Self.flowSibling) try fixture.item(Child.b, "---\nschema: 1\norder: 1024\n---\n") let before = try fixture.indexData(Child.b) let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.root) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(error?.path.hasSuffix("\(Child.a)/index.md") == true) #expect(try fixture.indexData(Child.b) == before) #expect(try fixture.indexText(Child.a) == Self.flowSibling) } } // MARK: - Move /// 01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves keep the UUID, /// copies mint fresh ones" — the move half, including the import boundary where a colliding /// UUID is degraded to a copy. struct BoardWriterMoveTests { /// Board `A.kanban`: two lanes, each holding one card, everything rich enough that a /// byte-level assertion means something. private func boardA(_ fixture: WriterFixture) throws { try fixture.item("A.kanban", Item.board) try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) try fixture.item("A.kanban/\(Ident.lane2)", Item.rich(order: "2048", title: "Doing")) try fixture.item("A.kanban/\(Ident.lane2)/\(Ident.card2)", Item.rich(order: "1024", title: "Card Two")) } /// Board `B.kanban`: a separate identity namespace with two lanes of its own, one of them /// holding a card. private func boardB(_ fixture: WriterFixture) throws { try fixture.item("B.kanban", Item.board) try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Inbox")) try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card3)", Item.rich(order: "1024", title: "Card Three")) try fixture.item("B.kanban/\(Ident.lane4)", Item.rich(order: "2048", title: "Done")) } @discardableResult private func move( _ fixture: WriterFixture, _ source: String, to destination: String, from sourceBoard: String = "A.kanban", into destinationBoard: String = "A.kanban", order: Double? = nil ) throws -> MoveResult { try BoardWriter.moveItem( at: fixture.url(source), toParent: fixture.url(destination), sourceBoardRoot: fixture.url(sourceBoard), destinationBoardRoot: fixture.url(destinationBoard), order: order ) } /// The everyday move: a card to another lane in the same board. The folder travels whole, /// its identity travels with it, and the only lines that may differ are the ones the /// destination `order` and the app-write stamps own. @Test func aCrossLaneMoveKeepsTheIdentityAndRewritesOnlyTheOrderAndStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") let siblingBefore = try fixture.indexData("A.kanban/\(Ident.lane2)/\(Ident.card2)") let result = try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)") #expect(result.id.rawValue == Ident.card1) #expect(result.reminted.isEmpty) #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"]) let after = try fixture.indexText("A.kanban/\(Ident.lane2)/\(Ident.card1)") #expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys)) let document = try FrontmatterDocument.parse(after) let source = try FrontmatterDocument.parse(before) #expect(document.order == .valid(2048)) #expect(document.created == source.created) #expect(document.modifiedBy == .missing) #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) #expect(document.unknownFields.map(\.key) == ["project", "labels"]) #expect(after.contains("project: lanework # agent overlay\n")) // The move rewrites exactly one file: a destination sibling is not even opened. #expect(try fixture.indexData("A.kanban/\(Ident.lane2)/\(Ident.card2)") == siblingBefore) #expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty) } @Test func anExplicitOrderIsWrittenVerbatim() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)", order: 1536) let document = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane2)/\(Ident.card1)")) #expect(document.order == .valid(1536)) } /// Boards are independent identity namespaces: an import that collides with nothing is an /// ordinary move, UUID and `created` intact. @Test func aCrossBoardMoveWithoutACollisionKeepsTheIdentity() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try boardB(fixture) let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") let result = try move( fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "B.kanban/\(Ident.lane3)", into: "B.kanban" ) #expect(result.id.rawValue == Ident.card1) #expect(result.reminted.isEmpty) #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) let after = try fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)") #expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys)) #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) let result2 = try BoardLoader.load(boardRoot: fixture.url("B.kanban")) #expect(result2.warnings.isEmpty) #expect(result2.model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card3, Ident.card1]) } /// The import boundary: the arriving UUID already exists elsewhere in the destination board, /// so it is degraded to a copy — fresh identity, content untouched, source gone as for any /// move. @Test func anArrivingIdentityTheDestinationBoardAlreadyHoldsIsReminted() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try boardB(fixture) try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Stale Twin")) let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") let result = try move( fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "B.kanban/\(Ident.lane4)", into: "B.kanban" ) let minted = result.id.rawValue #expect(BoardLoader.isUUIDShaped(minted)) #expect(minted != Ident.card1) #expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: Ident.card1), to: ItemID(rawValue: minted))]) // Content arrived intact — a remint renames a folder, it does not edit a file. let after = try fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)") #expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys)) #expect(try FrontmatterDocument.parse(after).title == .valid("Card One")) #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) #expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") == twinBefore) } /// The collision sitting in the very lane being dropped into — the case a move-then-rename /// could not repair, because the plain move would fail on the existing name. @Test func aCollisionInTheDestinationParentItselfIsStillReminted() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try boardB(fixture) try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Stale Twin")) let result = try move( fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "B.kanban/\(Ident.lane3)", into: "B.kanban" ) #expect(result.id.rawValue != Ident.card1) #expect(result.reminted.count == 1) #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(result.id.rawValue)")).title == .valid("Card One")) #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")).title == .valid("Stale Twin")) } /// Degradation is per folder at the finest grain: a lane arriving with one colliding card is /// still a lane *move*, and only that card is reminted — its bytes untouched, its /// non-colliding siblings' identities intact. @Test func aLaneMoveRemintsOnlyTheCollidingCard() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try boardB(fixture) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "3072", title: "Card Four")) // Only this one already exists over in B. try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "2048", title: "B's Own")) let laneBefore = try fixture.indexText("A.kanban/\(Ident.lane1)") let collidingBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card2)") let keptBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") let result = try move(fixture, "A.kanban/\(Ident.lane1)", to: "B.kanban", into: "B.kanban") #expect(result.id.rawValue == Ident.lane1) #expect(result.reminted.map(\.from.rawValue) == [Ident.card2]) let minted = try #require(result.reminted.first?.to.rawValue) #expect(BoardLoader.isUUIDShaped(minted)) // The repair is folder-name-only: the reminted card's file is byte-identical. #expect(try fixture.indexData("B.kanban/\(Ident.lane1)/\(minted)") == collidingBefore) #expect(try fixture.indexData("B.kanban/\(Ident.lane1)/\(Ident.card1)") == keptBefore) #expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card4)")) #expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card2)")) // The lane's own index.md is the one file the move rewrote. let laneAfter = try fixture.indexText("B.kanban/\(Ident.lane1)") #expect(lines(of: laneAfter, excludingKeys: rewrittenKeys) == lines(of: laneBefore, excludingKeys: rewrittenKeys)) #expect(try FrontmatterDocument.parse(laneAfter).order == .valid(3072)) #expect(try FrontmatterDocument.parse(laneAfter).modifiedBy == .missing) #expect(!fixture.exists("A.kanban/\(Ident.lane1)")) #expect(try BoardLoader.load(boardRoot: fixture.url("B.kanban")).warnings.isEmpty) } /// Tombstones are on disk, so they are identities: arriving on top of one would be exactly /// the duplicate the import boundary exists to prevent. @Test func aCollisionWithATombstonedDestinationItemStillRemints() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try boardB(fixture) try fixture.item( "B.kanban/\(Ident.lane4)/\(Ident.card1)", "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" ) let result = try move( fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "B.kanban/\(Ident.lane3)", into: "B.kanban" ) #expect(result.id.rawValue != Ident.card1) #expect(result.reminted.map(\.from.rawValue) == [Ident.card1]) #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(Ident.card1)")).deleted.value != nil) } /// Boards are independent identity namespaces: the same UUID living in another board is not /// this move's business — only an import boundary ever looks. @Test func aSameBoardMoveNeverRemintsEvenWhenAnotherBoardSharesTheUUID() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try boardB(fixture) try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Fork")) let result = try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)") #expect(result.id.rawValue == Ident.card1) #expect(result.reminted.isEmpty) #expect(fixture.exists("A.kanban/\(Ident.lane2)/\(Ident.card1)")) #expect(fixture.exists("B.kanban/\(Ident.lane3)/\(Ident.card1)")) } /// Discover before you write: the move has to rewrite the moved item's `order`, so a file it /// cannot round-trip refuses the gesture while the folder is still where it was. @Test func anUneditableItemRefusesTheMoveAndTheFolderDoesNotTravel() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) let error = writeFailure { try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card3)", to: "A.kanban/\(Ident.lane2)") } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) // The pre-flight read the source's document (`Item.uneditable`, title: Odd) before the // shape refusal, so the title survives into the thrown error. #expect(error?.operation == .move(title: "Odd")) #expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card3)") == Item.uneditable) #expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == [Ident.card2, "index.md"]) } @Test func aMissingDestinationParentRefusesTheMove() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) let before = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") let error = writeFailure { try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/nowhere") } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == before) } @Test func aDestinationParentThatIsAFileRefusesTheMove() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try fixture.file("A.kanban/notes.txt", Data("x".utf8)) let error = writeFailure { try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/notes.txt") } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) } /// Level detection is by name shape, so a stray is not an item: moving one would invent an /// identity the loader would go on ignoring. @Test func aStrayFolderIsNotAnItemAndCannotBeMoved() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try boardA(fixture) try fixture.item("A.kanban/\(Ident.lane1)/notes", Item.rich(order: "1024", title: "Stray")) let error = writeFailure { try move(fixture, "A.kanban/\(Ident.lane1)/notes", to: "A.kanban/\(Ident.lane2)") } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UUID-shaped")) #expect(fixture.exists("A.kanban/\(Ident.lane1)/notes")) } } // MARK: - Copy /// 01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle" — the copy half: fresh /// UUIDs for every folder materialized, `created` kept (a fork) or restamped (born from a /// template), `modified-by` cleared because a copy is an app write. struct BoardWriterCopyTests { /// One board, one lane, two cards — the source of every copy below. private func board(_ fixture: WriterFixture) throws { try fixture.item("A.kanban", Item.board) try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) try fixture.item("A.kanban/\(Ident.lane2)", Item.rich(order: "2048", title: "Doing")) } /// Content that must travel verbatim because the copy never reads it: two attachments (one /// in a subfolder the app never creates but preserves) and a stray file. private func attach(_ fixture: WriterFixture, to cardPath: String) throws { try fixture.file("\(cardPath)/attachments/sketch.png", Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])) try fixture.file("\(cardPath)/attachments/sub/deep.bin", Data([0x00, 0x01, 0x02, 0xFE])) try fixture.file("\(cardPath)/notes.txt", Data("hand-written\n".utf8)) } /// The ⌥-drag duplicate: a card copied beside itself. Fresh identity, `created` kept, /// `modified` stamped, `modified-by` cleared, everything else byte-identical. @Test func aCardCopyMintsAFreshIdentityAndForksTheStamps() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) let source = "A.kanban/\(Ident.lane1)/\(Ident.card1)" let before = try fixture.indexData(source) let id = try BoardWriter.copyItem( at: fixture.url(source), toParent: fixture.url("A.kanban/\(Ident.lane1)"), order: nil, stamps: .fork ) #expect(BoardLoader.isUUIDShaped(id.rawValue)) #expect(id.rawValue != Ident.card1) let copy = try fixture.indexText("A.kanban/\(Ident.lane1)/\(id.rawValue)") let original = String(decoding: before, as: UTF8.self) #expect(lines(of: copy, excludingKeys: rewrittenKeys) == lines(of: original, excludingKeys: rewrittenKeys)) let document = try FrontmatterDocument.parse(copy) let sourceDocument = try FrontmatterDocument.parse(original) #expect(document.order == .valid(3072)) #expect(document.created == sourceDocument.created) #expect(document.modifiedBy == .missing) #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) #expect(document.unknownFields.map(\.key) == ["project", "labels"]) #expect(document.body == "Card One body — with *markdown*.\n") // The source is never touched, on any path. #expect(try fixture.indexData(source) == before) } @Test func anExplicitOrderIsWrittenVerbatim() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url("A.kanban/\(Ident.lane2)"), order: 512, stamps: .fork ) let document = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane2)/\(id.rawValue)")) #expect(document.order == .valid(512)) } /// A copied lane's cards are new cards, not the same cards seen twice: every folder the copy /// materialized has a fresh identity, at every depth. @Test func aLaneCopyMintsFreshIdentitiesAtEveryLevel() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)"), toParent: fixture.url("A.kanban"), order: nil, stamps: .fork ) #expect(id.rawValue != Ident.lane1) let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) #expect(Set(copied.keys) == ["Card One", "Card Two"]) #expect(Set(copied.values).isDisjoint(with: [Ident.card1, Ident.card2])) #expect(copied.values.allSatisfy(BoardLoader.isUUIDShaped)) // Ranks travel with the cards; only the copied *root* gets a new one. let one = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))")) let two = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card Two"]))")) #expect(one.order == .valid(1024)) #expect(two.order == .valid(2048)) #expect(one.created == .valid(try #require(FrontmatterDocument.parse(Item.rich(order: "1024", title: "x")).created.value))) #expect(one.modifiedBy == .missing) #expect(two.modifiedBy == .missing) #expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)")).order == .valid(3072)) let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) #expect(result.warnings.isEmpty) #expect(result.model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, id.rawValue]) } /// Attachments, their subfolders, and strays travel byte-for-byte — the copy never opens /// them, so there is nothing to get wrong. @Test func attachmentsSubfoldersAndStraysTravelByteIdentically() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) try attach(fixture, to: "A.kanban/\(Ident.lane1)/\(Ident.card1)") let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)"), toParent: fixture.url("A.kanban"), order: nil, stamps: .fork ) let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) let card = try #require(copied["Card One"]) for path in ["attachments/sketch.png", "attachments/sub/deep.bin", "notes.txt"] { #expect( try fixture.data("A.kanban/\(id.rawValue)/\(card)/\(path)") == fixture.data("A.kanban/\(Ident.lane1)/\(Ident.card1)/\(path)") ) } // `attachments/` is not UUID-shaped, so the remint walk never descends into it. #expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(card)").contains("attachments")) } /// Template instantiation: born today, not forked — `created` and `modified` both fresh, and /// the same `Date` for the whole tree. @Test func bornStampsCreatedAndModifiedFreshAtEveryLevel() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)"), toParent: fixture.url("A.kanban"), order: nil, stamps: .born ) let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) let paths = ["A.kanban/\(id.rawValue)"] + copied.values.map { "A.kanban/\(id.rawValue)/\($0)" } for path in paths { let document = try FrontmatterDocument.parse(fixture.indexText(path)) let created = try #require(document.created.value) let modified = try #require(document.modified.value) #expect(abs(created.timeIntervalSinceNow) < 60) #expect(abs(created.timeIntervalSince(modified)) < 2) #expect(document.modifiedBy == .missing) } } /// The leniency below the root: a nested file the surgical editor cannot key is copied /// verbatim rather than failing the gesture — stale `modified-by` and all — while its /// editable siblings are stamped normally. @Test func aNestedUneditableFileCopiesVerbatimWhileItsSiblingsAreStamped() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)"), toParent: fixture.url("A.kanban"), order: nil, stamps: .fork ) let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) #expect(try fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Odd"]))") == Item.uneditable) #expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))")) .modifiedBy == .missing) } /// A UUID-shaped folder with no `index.md` — interrupted-create residue — is reminted and /// carried like any other folder, and simply not rewritten: the same skip the loader applies. @Test func aNestedFolderWithoutAnIndexIsCopiedAndRemintedButNotRewritten() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) try FileManager.default.createDirectory( at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"), withIntermediateDirectories: true ) let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)"), toParent: fixture.url("A.kanban"), order: nil, stamps: .fork ) let names = try fixture.entryNames("A.kanban/\(id.rawValue)").filter(BoardLoader.isUUIDShaped) #expect(names.count == 3) #expect(Set(names).isDisjoint(with: [Ident.card1, Ident.card2, Ident.indexless])) let orphan = try #require(names.first { !fixture.exists("A.kanban/\(id.rawValue)/\($0)/index.md") }) #expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(orphan)") == []) } /// The root gets no leniency: it must be rewritten to carry its new `order`, so an /// uneditable one refuses before anything is materialized. @Test func anUneditableRootRefusesTheCopyAndMaterializesNothing() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) let before = try fixture.entryNames("A.kanban/\(Ident.lane2)") let error = writeFailure { _ = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card3)"), toParent: fixture.url("A.kanban/\(Ident.lane2)"), order: nil, stamps: .fork ) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) // Same enrichment as the move pre-flight: the root's document (title: Odd) was read // before the uneditable-shape refusal fired. #expect(error?.operation == .copy(title: "Odd")) #expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before) } /// All-or-nothing at the destination: a failure part-way through leaves no half-copied tree, /// because a partial copy is pure residue — nothing was there before. @Test func aFailedCopyLeavesNothingAtTheDestination() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) let unreadable = fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card2)").appendingPathComponent("index.md") try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path) defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: unreadable.path) } let before = try fixture.entryNames("A.kanban") let error = writeFailure { _ = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)"), toParent: fixture.url("A.kanban"), order: nil, stamps: .fork ) } guard case .io = error?.reason else { Issue.record("expected .io, got \(String(describing: error?.reason))") return } #expect(try fixture.entryNames("A.kanban") == before) } /// A copy always mints, so the import boundary has nothing to do here: landing in a board /// that already holds the source's UUID is not even a special case. @Test func aCopyIntoABoardHoldingTheSameUUIDMintsAnywayWithoutError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try board(fixture) try fixture.item("B.kanban", Item.board) try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Inbox")) try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "1024", title: "Twin")) let id = try BoardWriter.copyItem( at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url("B.kanban/\(Ident.lane3)"), order: nil, stamps: .fork ) #expect(id.rawValue != Ident.card1) #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(id.rawValue)")).title == .valid("Card One")) #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")).title == .valid("Twin")) #expect(try BoardLoader.load(boardRoot: fixture.url("B.kanban")).warnings.isEmpty) } } // MARK: - Delete / Restore /// `BoardWriter.deleteItem`/`restoreItem` — the tombstone half of 01-storage-format.md § /// Deletion: `deleted: ` written into the item's own `index.md` in place, and Put Back /// (`remove(deleted)`) undoing exactly that. The folder never moves; hiding a tombstoned /// subtree is the renderer's ancestor walk, not anything either call does — a deleted lane's /// cards are never touched. struct BoardWriterDeleteRestoreTests { @Test func deletingALaneWritesATombstoneInPlaceAndPreservesEverythingElse() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) let before = try fixture.indexText("A.kanban/\(Ident.lane1)") try BoardWriter.deleteItem(at: laneFolder) // Same path, same name — a tombstone never moves or renames the folder. #expect(fixture.exists("A.kanban/\(Ident.lane1)")) #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"]) let after = try fixture.indexText("A.kanban/\(Ident.lane1)") let stamped = [FrontmatterKeys.modified, FrontmatterKeys.modifiedBy, FrontmatterKeys.deleted] #expect(lines(of: after, excludingKeys: stamped) == lines(of: before, excludingKeys: stamped)) let document = try FrontmatterDocument.parse(after) #expect(document.unknownFields.map(\.key) == ["project", "labels"]) #expect(document.body.hasSuffix("body — with *markdown*.\n")) #expect(document.modifiedBy == .missing) #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) let deleted = try #require(document.deleted.value) #expect(abs(deleted.timeIntervalSinceNow) < 60) let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) #expect(result.model.lanes.first?.isDeleted == true) } /// Hiding beneath is the renderer's walk, not a stored flag: deleting a lane writes only /// the lane's own file. @Test func deletingALaneLeavesItsNestedCardUntouched() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) let cardBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") try BoardWriter.deleteItem(at: laneFolder) #expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == cardBefore) } @Test func loaderRoundTripDeletingACardShowsItDeletedStillInTheSnapshotAtItsRecordedOrder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) let cardFolder = try fixture.item( "A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One") ) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) try BoardWriter.deleteItem(at: cardFolder) let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) #expect(result.warnings.isEmpty) let cards = try #require(result.model.lanes.first?.cards) #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2]) #expect(cards[0].isDeleted == true) #expect(cards[0].order == 1024) #expect(cards[1].isDeleted == false) } /// After restore the file carries no residue of `deleted` at all, and the item reappears /// among its current siblings at the `order` it had all along. @Test func deleteThenRestoreLeavesNoResidueAndReappearsAtItsRecordedOrder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) let cardFolder = try fixture.item( "A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1536", title: "Card One") ) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) try BoardWriter.deleteItem(at: cardFolder) #expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)")) .deleted.value != nil) try BoardWriter.restoreItem(at: cardFolder) let after = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") #expect(!after.contains("deleted")) let document = try FrontmatterDocument.parse(after) #expect(document.deleted == .missing) #expect(document.order == .valid(1536)) let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) let cards = try #require(result.model.lanes.first?.cards) #expect(cards.map(\.id.rawValue) == [Ident.card1, Ident.card2]) #expect(cards[0].isDeleted == false) } /// `remove` takes every occurrence, so a hand-duplicated `deleted` line cannot resurrect /// the tombstone the instant the winning one is gone. @Test func restoreRemovesAHandDuplicatedDeletedKeyEntirely() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let text = """ --- schema: 1 order: 1024 title: Twice Gone deleted: 2026-01-01T00:00:00Z deleted: 2026-06-01T00:00:00Z --- body """ let folder = try fixture.item(Ident.lane1, text) try BoardWriter.restoreItem(at: folder) let after = try fixture.indexText(Ident.lane1) #expect(after.components(separatedBy: "\n").filter { $0.hasPrefix("deleted:") }.isEmpty) #expect(try FrontmatterDocument.parse(after).deleted == .missing) } /// Tombstones are inert to ordering: a sibling deleted via `deleteItem` must not factor into /// a subsequent create's append target. @Test func aTombstonedSiblingIsExcludedFromTheAppendRankAfterDeleteItem() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") let highOrder = try fixture.item(Child.b, "---\nschema: 1\norder: 9999\ntitle: B\n---\nbody\n") try BoardWriter.deleteItem(at: highOrder) let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) #expect(document.order == .valid(2048)) } @Test func renumberLeavesATombstonedSiblingByteIdentical() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("lane/\(Child.a)", "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") let toDelete = try fixture.item("lane/\(Child.b)", "---\nschema: 1\norder: 3000\ntitle: B\n---\nbody\n") try BoardWriter.deleteItem(at: toDelete) let tombstoneAfterDelete = try fixture.indexData("lane/\(Child.b)") try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) #expect(try fixture.indexData("lane/\(Child.b)") == tombstoneAfterDelete) } /// Board-root deletion is structurally unreachable at the writer level: a board root's /// folder name is never UUID-shaped. @Test func deleteRefusesANonUUIDShapedFolder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = try fixture.item("A.kanban", Item.board) let error = writeFailure { try BoardWriter.deleteItem(at: root) } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UUID-shaped")) #expect(try fixture.indexText("A.kanban") == Item.board) } @Test func restoreRefusesANonUUIDShapedFolder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = try fixture.item("A.kanban", Item.board) let error = writeFailure { try BoardWriter.restoreItem(at: root) } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UUID-shaped")) #expect(try fixture.indexText("A.kanban") == Item.board) } /// Comes free via `updateIndex`'s pre-flight: a readable-but-uneditable shape refuses every /// app-mediated write, delete included. @Test func deleteOnAnUneditableItemRefuses() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item(Ident.lane1, Fixture.flowMapping) let error = writeFailure { try BoardWriter.deleteItem(at: folder) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping) } // MARK: Title enrichment (02-architecture.md § Write-failure surfacing) /// `updateIndex`'s pre-flight read succeeds — the shape is readable, only uneditable — so by /// the time the refusal fires, `WriteOperation.withTitle` has already run: the title survives /// into the thrown error. `Fixture.flowMapping` above has no `title` key at all, which is why /// this test reaches for `Item.uneditable` instead — the fixture that actually carries one. @Test func deleteOnAnUneditableItemWithAKnownTitleCarriesItInTheOperation() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item(Ident.lane1, Item.uneditable) let error = writeFailure { try BoardWriter.deleteItem(at: folder) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(error?.operation == .delete(title: "Odd")) } /// The negative case: a file that cannot even be read (invalid UTF-8) never gets far enough /// for `readDocument` to hand back a document, so there is no title to learn — the operation /// stays exactly as its call site constructed it, title `nil`. @Test func deleteOnAnUnreadableIndexLeavesTheOperationsTitleNil() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let garbage = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1)) let folder = try fixture.item(Ident.lane1, bytes: garbage) let error = writeFailure { try BoardWriter.deleteItem(at: folder) } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(error?.operation == .delete(title: nil)) } } // MARK: - Purge /// `BoardWriter.purgeItem`: physical removal — Delete Immediately / Empty Trash /// (01-storage-format.md § Deletion) — irreversible, and distinct from tombstoning. Does not /// require the item to be tombstoned first: Delete Immediately skips that stage by design. struct BoardWriterPurgeTests { @Test func purgeRemovesTheFolderTreeIncludingNestedContentFromDisk() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) let laneFolder = try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) try BoardWriter.purgeItem(at: laneFolder) #expect(!fixture.exists("A.kanban/\(Ident.lane1)")) #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) } /// A folder that is already gone (a Finder deletion) is success, not an error — the same /// end state a purge would have produced. @Test func purgeOfAnAlreadyMissingFolderSucceedsSilently() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let gone = fixture.url("A.kanban/\(Ident.lane1)") #expect(!fixture.exists("A.kanban/\(Ident.lane1)")) try BoardWriter.purgeItem(at: gone) } @Test func purgeRefusesANonUUIDShapedFolder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = try fixture.item("A.kanban", Item.board) let error = writeFailure { try BoardWriter.purgeItem(at: root) } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UUID-shaped")) #expect(fixture.exists("A.kanban")) } @Test func purgeDoesNotRequirePriorTombstone() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let folder = try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Live")) #expect(try FrontmatterDocument.parse(fixture.indexText(Ident.lane1)).deleted == .missing) try BoardWriter.purgeItem(at: folder) #expect(!fixture.exists(Ident.lane1)) } } // MARK: - Same-parent move degrades to a reorder /// A move whose destination is the item's current parent is the same gesture as any other drop /// — the writer degrades it to the bare `order` rewrite instead of leaking a `FileManager` /// name collision, and the appended rank is computed excluding the item itself, which unlike /// every other move is already sitting among the siblings it would otherwise count. struct BoardWriterSameParentMoveTests { @Test func aSameParentMoveWithNilOrderAppendsAfterTheOtherSiblings() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Lane")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) let result = try BoardWriter.moveItem( at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url("A.kanban/\(Ident.lane1)"), sourceBoardRoot: fixture.url("A.kanban"), destinationBoardRoot: fixture.url("A.kanban"), order: nil ) #expect(result.id.rawValue == Ident.card1) #expect(result.reminted.isEmpty) // Appended after Second (2048) and Third (3072), not after its own stale 1024 — // and not after itself miscounted (which would give 2048... or 4096+1024). let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") #expect(text.contains("order: 4096")) #expect(!text.contains("modified-by:")) } @Test func aSameParentMoveWithAnExplicitOrderJustRewritesIt() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } try fixture.item("A.kanban", Item.board) try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Lane")) try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "3072", title: "Only")) let laneBefore = try fixture.indexData("A.kanban/\(Ident.lane1)") let result = try BoardWriter.moveItem( at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), toParent: fixture.url("A.kanban/\(Ident.lane1)"), sourceBoardRoot: fixture.url("A.kanban"), destinationBoardRoot: fixture.url("A.kanban"), order: 512 ) #expect(result.id.rawValue == Ident.card1) #expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)").contains("order: 512")) #expect(try fixture.indexData("A.kanban/\(Ident.lane1)") == laneBefore) #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").contains(Ident.card1)) } } // MARK: - Import attachments /// `BoardWriter.importAttachments`: the write side of 01-storage-format.md § Attachments — /// never refuses a drop, auto-renames Finder-style on collision, and stops a multi-file batch /// at the first failure without undoing what already landed. struct BoardWriterImportAttachmentsTests { /// A bare UUID-shaped card folder — `importAttachments` never reads its `index.md`, so a /// minimal one (like every other card these suites mint) is as good a target as a fully /// loaded one. private func card(_ fixture: WriterFixture) throws -> URL { try fixture.item(Ident.card1, Item.rich(order: "1024", title: "Card")) } @Test func firstImportCreatesAttachmentsAndLandsTheOriginalName() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) let bytes = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF]) let source = try fixture.file("sources/shot.png", bytes) let landed = try BoardWriter.importAttachments([source], intoCard: card) #expect(landed == [ImportedAttachment(sourceURL: source, fileName: "shot.png")]) #expect(try fixture.data("\(Ident.card1)/attachments/shot.png") == bytes) // The source is a copy's origin, never its casualty. #expect(try fixture.data("sources/shot.png") == bytes) } /// Three separate drops of a file named `shot.png`: never overwritten, never refused — each /// lands under the next free Finder-style name, and all three coexist afterwards. @Test func collidingImportsAutoRenameFinderStyle() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) let first = try fixture.file("sources/1/shot.png", Data([0x01])) let second = try fixture.file("sources/2/shot.png", Data([0x02])) let third = try fixture.file("sources/3/shot.png", Data([0x03])) let landed1 = try BoardWriter.importAttachments([first], intoCard: card) let landed2 = try BoardWriter.importAttachments([second], intoCard: card) let landed3 = try BoardWriter.importAttachments([third], intoCard: card) #expect(landed1.map(\.fileName) == ["shot.png"]) #expect(landed2.map(\.fileName) == ["shot 2.png"]) #expect(landed3.map(\.fileName) == ["shot 3.png"]) #expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot 2.png", "shot 3.png", "shot.png"]) #expect(try fixture.data("\(Ident.card1)/attachments/shot.png") == Data([0x01])) #expect(try fixture.data("\(Ident.card1)/attachments/shot 2.png") == Data([0x02])) #expect(try fixture.data("\(Ident.card1)/attachments/shot 3.png") == Data([0x03])) } /// A name with no extension at all suffixes directly — `"notes"` → `"notes 2"`, not /// `"notes 2."` or some other extension-shaped artifact. @Test func anExtensionLessCollisionRenamesToNotes2() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) let first = try fixture.file("sources/1/notes", Data("first".utf8)) let second = try fixture.file("sources/2/notes", Data("second".utf8)) _ = try BoardWriter.importAttachments([first], intoCard: card) let landed = try BoardWriter.importAttachments([second], intoCard: card) #expect(landed.map(\.fileName) == ["notes 2"]) #expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["notes", "notes 2"]) } /// `fileExists` is true for a directory as much as a file: a subfolder that happens to share /// the incoming name blocks it exactly like a file would, and is never touched by the import. @Test func aFileNamedLikeAnExistingSubfolderAlsoRenames() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) try fixture.file("\(Ident.card1)/attachments/shot.png/inner.txt", Data("hand-made".utf8)) let source = try fixture.file("sources/shot.png", Data([0x09])) let landed = try BoardWriter.importAttachments([source], intoCard: card) #expect(landed.map(\.fileName) == ["shot 2.png"]) #expect(try fixture.data("\(Ident.card1)/attachments/shot 2.png") == Data([0x09])) #expect(try fixture.entryNames("\(Ident.card1)/attachments/shot.png") == ["inner.txt"]) } /// One call, three sources, two of which collide with what is already there and with each /// other in turn — the returned names track input order, not landing order. @Test func multiFileImportInOneCallReturnsLandedNamesInInputOrder() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) try fixture.file("\(Ident.card1)/attachments/shot.png", Data([0x00])) let shot = try fixture.file("sources/1/shot.png", Data([0x01])) let notes = try fixture.file("sources/2/notes.txt", Data([0x02])) let shotAgain = try fixture.file("sources/3/shot.png", Data([0x03])) let landed = try BoardWriter.importAttachments([shot, notes, shotAgain], intoCard: card) #expect(landed.map(\.fileName) == ["shot 2.png", "notes.txt", "shot 3.png"]) #expect(landed.map(\.sourceURL) == [shot, notes, shotAgain]) #expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["notes.txt", "shot 2.png", "shot 3.png", "shot.png"]) } /// Refused before any copy for that file: the failing source is named, nothing of its lands, /// and — because each import is its own completed write — the file that landed *before* it /// in the same batch stays landed rather than being rolled back. @Test func anUnreadableSourceRefusesNamingItAndStopsTheBatch() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) let first = try fixture.file("sources/shot.png", Data([0x01])) let missing = fixture.url("sources").appendingPathComponent("missing.png") let third = try fixture.file("sources/third.png", Data([0x03])) let error = writeFailure { _ = try BoardWriter.importAttachments([first, missing, third], intoCard: card) } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(error?.path == missing.path) #expect(error?.operation == .importAttachment(filename: "missing.png")) #expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"]) } /// Attachments belong to cards: the shape guard `deleteItem`/`restoreItem`/`purgeItem` share /// refuses a board root (or any non-UUID-shaped folder) before `attachments/` is even /// considered. @Test func importIntoANonUUIDShapedFolderIsRefused() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let root = try fixture.item("A.kanban", Item.board) let source = try fixture.file("sources/shot.png", Data([0x01])) let error = writeFailure { _ = try BoardWriter.importAttachments([source], intoCard: root) } guard case let .unreadable(message) = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } #expect(message.contains("UUID-shaped")) #expect(!fixture.exists("A.kanban/attachments")) } /// "Never refuses the drop" is policy, not an I/O guarantee: a copy that genuinely cannot /// land — `attachments/` made unwritable here, standing in for a full disk or a permissions /// error — surfaces as `.io` naming the source file, and no partial file is left behind. @Test func aFailedCopyLeavesNoPartialAttachment() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) let existing = try fixture.file("sources/existing.png", Data([0x00])) _ = try BoardWriter.importAttachments([existing], intoCard: card) let attachmentsFolder = fixture.url(Ident.card1).appendingPathComponent("attachments") try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: attachmentsFolder.path) let source = try fixture.file("sources/shot.png", Data([0x01])) let error = writeFailure { _ = try BoardWriter.importAttachments([source], intoCard: card) } guard case .io = error?.reason else { Issue.record("expected .io, got \(String(describing: error?.reason))") return } #expect(error?.path == source.path) #expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["existing.png"]) } } // MARK: - List attachments /// `BoardWriter.listAttachments`: the flat, top-level-files-only read (01-storage-format.md § /// Attachments) that never writes anything, not even `attachments/` itself. struct BoardWriterListAttachmentsTests { private func card(_ fixture: WriterFixture) throws -> URL { try fixture.item(Ident.card1, Item.rich(order: "1024", title: "Card")) } /// Subfolders and hidden files never surface, and survive both the import and the listing /// untouched; the sort is Finder's numeric order, not plain string order (`"shot 2.png"` /// before `"shot 10.png"`, which a byte-wise sort would put the other way). @Test func listingIsFlatExcludesHiddenFilesAndSortsFinderStyle() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) let shot2 = try fixture.file("sources/1/shot 2.png", Data([0x01])) let shot10 = try fixture.file("sources/2/shot 10.png", Data([0x02])) _ = try BoardWriter.importAttachments([shot2, shot10], intoCard: card) try fixture.file("\(Ident.card1)/attachments/sub/inside.txt", Data("hand-made".utf8)) try fixture.file("\(Ident.card1)/attachments/.DS_Store", Data("hidden".utf8)) let listing = try BoardWriter.listAttachments(ofCard: card) #expect(listing == ["shot 2.png", "shot 10.png"]) #expect(try fixture.entryNames("\(Ident.card1)/attachments/sub") == ["inside.txt"]) #expect(fixture.exists("\(Ident.card1)/attachments/.DS_Store")) } /// Nothing imported yet is an ordinary state, not an error — `attachments/` need not exist /// for a card to list cleanly as empty. @Test func aMissingAttachmentsFolderListsAsEmpty() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let card = try card(fixture) #expect(try BoardWriter.listAttachments(ofCard: card) == []) #expect(!fixture.exists("\(Ident.card1)/attachments")) } /// Unlike a missing `attachments/`, a missing *card* is a loud error — the caller asked /// about something that is not there. @Test func aMissingCardFolderIsALoudUnreadableError() throws { let fixture = try WriterFixture() defer { fixture.tearDown() } let missing = fixture.url(Ident.card1) let error = writeFailure { _ = try BoardWriter.listAttachments(ofCard: missing) } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") return } } }