Files
lanework/KanbanTests/BoardWriterTests.swift
T
rzen 4418b7f981 Align the identity predicate with the ratified shape-only rule
Accept liberally, emit conservatively (764a4d4): the gate is 8-4-4-4-12
hex in any case and any UUID version — uuidgen and UUID().uuidString
print uppercase, and a strict lowercase gate would silently stray an
agent's standard-tool card. Identity comparison is UUID-value equality
everywhere: ItemID keeps its byte-faithful rawValue but equates and
hashes on the lowercased canonical form, and the writer's
import-boundary collision check canonicalizes, so a same-UUID arrival
spelled in another case remints instead of slipping past. The app still
mints only lowercase v4 and never renames to canonicalize.

Full suite 342 tests in 63 suites green. Two findings filed.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
2026-07-26 20:53:38 -04:00

2103 lines
99 KiB
Swift

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)
}
/// **Identity comparison is UUID-value equality, never string equality** (01-storage-format.md
/// § Fractal layout ▸ Rules, settled): the destination board already holds the arriving UUID
/// spelled in *uppercase* — an agent's `uuidgen` card — so the two are one identity and the
/// import boundary must remint. A verbatim string set would sail straight past this and leave
/// a duplicate UUID in the board.
@Test func aCollisionSpelledInADifferentCaseIsStillOneIdentityAndRemints() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try boardA(fixture)
try boardB(fixture)
let arriving = "abcdef01-2345-6789-abcd-ef0123456789"
let twin = arriving.uppercased() // The same UUID, as `uuidgen` would have printed it.
try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Card One"))
try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "Stale Twin"))
let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)")
let result = try move(
fixture, "A.kanban/\(Ident.lane1)/\(arriving)",
to: "B.kanban/\(Ident.lane4)", into: "B.kanban"
)
let minted = result.id.rawValue
#expect(BoardLoader.isUUIDShaped(minted))
#expect(ItemID(rawValue: minted) != ItemID(rawValue: arriving))
#expect(ItemID(rawValue: minted) != ItemID(rawValue: twin))
#expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: arriving), to: ItemID(rawValue: minted))])
#expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)")).title
== .valid("Card One"))
// The resident twin is untouched, and the source left as for any move.
#expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(twin)") == twinBefore)
#expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(arriving)"))
}
/// The same rule one level down: a lane arrives carrying a card whose UUID the destination
/// board already holds under a different case-spelling — that card, and only that card, is
/// reminted.
@Test func aLaneMoveRemintsAChildCollidingOnlyByCaseSpelling() throws {
let fixture = try WriterFixture()
defer { fixture.tearDown() }
try boardA(fixture)
try boardB(fixture)
let arriving = "abcdef01-2345-6789-abcd-ef0123456789"
let twin = arriving.uppercased()
try fixture.item("A.kanban/\(Ident.lane1)/\(arriving)", Item.rich(order: "3072", title: "Colliding"))
try fixture.item("B.kanban/\(Ident.lane3)/\(twin)", Item.rich(order: "2048", title: "B's Own"))
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) == [arriving])
let minted = try #require(result.reminted.first?.to.rawValue)
#expect(fixture.exists("B.kanban/\(Ident.lane1)/\(minted)"))
#expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(arriving)"))
// The card that collided with nothing kept its identity.
#expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card1)"))
}
/// 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: <now>` 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
}
}
}