From 19f67bdb78fd1c49ff2b1193235ffcd25c4813ad Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 16:49:42 -0400 Subject: [PATCH] =?UTF-8?q?Build=20BoardWriter=20=E2=80=94=20atomic,=20min?= =?UTF-8?q?imal-touch=20writes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The single point through which every mutation becomes a filesystem operation: read fresh from disk (strict byte-faithful UTF-8), refuse readable-but-uneditable frontmatter shapes, apply the edit through the span engine, stamp modified / clear modified-by, then temp-file+rename atomically. Renumber-visible-children is the sole minimal-touch exception, tombstones untouched. Structured BoardWriteError carries operation + path + reason for the future banner surface. Alongside, three edges the card surfaced: - The loader now decodes byte-faithfully too, so a BOM'd or non-UTF-8 index.md is rejected at load per the encoding contract, instead of loading via NSString's silent BOM strip and then refusing every write. - An appended frontmatter line adopts the file's prevailing line ending (a CRLF file stays uniformly CRLF when a stamp first lands in it). - Empty-but-not-blank frontmatter ({}, null, ~) is detected as uneditable — appending after it would be unparseable YAML. 30 new unit tests; 176 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/BoardLoader.swift | 29 +- Kanban/Storage/BoardWriter.swift | 278 +++++++++++ Kanban/Storage/FrontmatterDocument.swift | 93 +++- KanbanTests/BoardLoaderTests.swift | 40 ++ KanbanTests/BoardWriterTests.swift | 595 +++++++++++++++++++++++ KanbanTests/FrontmatterTests.swift | 34 ++ 6 files changed, 1061 insertions(+), 8 deletions(-) create mode 100644 Kanban/Storage/BoardWriter.swift create mode 100644 KanbanTests/BoardWriterTests.swift diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index c50c5ce..45c62d4 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -39,7 +39,9 @@ public enum BoardLoader: Sendable { /// than through a typed `FrontmatterDocument` accessor. private static let templateKey = "template" - private static let indexFileName = "index.md" + /// Internal rather than `private`: `BoardWriter` names the same file, and the loader and + /// the writer must never disagree about which file a folder's content lives in. + static let indexFileName = "index.md" private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader") @@ -184,7 +186,10 @@ public enum BoardLoader: Sendable { /// conformance every load. Case-sensitive — an uppercase or mixed-case UUID string is a /// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name /// (`BoardModel.swift`). - private static func isUUIDShaped(_ name: String) -> Bool { + /// + /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same + /// candidates the loader walked, and level detection has to be one rule, not two. + static func isUUIDShaped(_ name: String) -> Bool { let groups = name.split(separator: "-", omittingEmptySubsequences: false) guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false } return groups.allSatisfy { $0.allSatisfy(lowercaseHexDigits.contains) } @@ -201,7 +206,11 @@ public enum BoardLoader: Sendable { /// candidates" rather than failing the whole load — fail-fast is reserved for the board /// root and for malformed `index.md` content, not transient directory-listing races below /// it. - private static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] { + /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates + /// siblings through this same door, so the writer's idea of "the children" can never drift + /// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the + /// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load. + static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] { guard let entries = try? FileManager.default.contentsOfDirectory( at: folder, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], @@ -222,10 +231,22 @@ public enum BoardLoader: Sendable { // MARK: - Document reading + field validation + /// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same + /// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a + /// leading BOM, which would let a BOM'd file *load* here and then refuse every write over + /// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is + /// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter); + /// decoding byte-faithfully is what makes that stated rejection actually happen. private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument { let text: String do { - text = try String(contentsOf: url, encoding: .utf8) + let data = try Data(contentsOf: url) + guard let decoded = String(validating: data, as: UTF8.self) else { + throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil)) + } + text = decoded + } catch let error as BoardLoadError { + throw error } catch { throw BoardLoadError( path: path, diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift new file mode 100644 index 0000000..ad14e35 --- /dev/null +++ b/Kanban/Storage/BoardWriter.swift @@ -0,0 +1,278 @@ +import Foundation + +/// Turns a mutation into a filesystem operation — the single point through which every write +/// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by +/// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when +/// the rename completes**, and a failed write is a failure the caller sees, so views — which +/// render only what is on disk — can never show phantom state (02-architecture.md § +/// Write-failure surfacing). +/// +/// Four rules from 01-storage-format.md live here and are not negotiable per call site: +/// +/// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every +/// write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial +/// file, and a crash mid-write leaves the previous content intact. +/// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which +/// edits by line span, so unknown keys and their order, comments, blank lines, line endings, +/// and the body survive every write by construction rather than by remembering to preserve +/// them. +/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write +/// path. Absence of `modified-by` means "the board's user, via the app"; the file is being +/// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing. +/// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict +/// UTF-8, and a file that does not decode is a loud, specific error rather than a +/// lossy best guess. +public enum BoardWriter: Sendable { + + // MARK: - The uniform per-file mutation + + /// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`, + /// stamp, write atomically. + /// + /// The order of the four steps is the contract: + /// + /// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may + /// be seconds stale — an agent or a hand-editor may have rewritten the file since — and + /// the round-trip guarantee is only worth anything against the bytes actually there. + /// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`): + /// the settled readable-but-uneditable rule. Such a file loads and renders fine, but a + /// surgical edit of it cannot be expressed, so the write fails loudly instead of + /// corrupting it. Refusing up front also means `edits` never observes a document it + /// cannot affect. + /// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the + /// caller's closure, so the stamp always wins over anything the closure did with those + /// two keys, and no call site has to remember them. + /// 4. **Atomic replace.** + /// + /// The **one path that deliberately bypasses this** is the card window's raw-source Apply + /// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a + /// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the + /// clearing rule (01-storage-format.md § Frontmatter). That path goes through + /// `atomicReplace` directly; it does not belong here. + public static func updateIndex( + inItemFolder folder: URL, + operation: String, + edits: (inout FrontmatterDocument) -> Void + ) throws(BoardWriteError) { + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + var document = try readDocument(at: indexURL, operation: operation) + try checkEditable(document, at: indexURL, operation: operation) + + edits(&document) + document.set(FrontmatterKeys.modified, to: .date(Date())) + document.remove(FrontmatterKeys.modifiedBy) + + try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) + } + + // MARK: - Atomic replace + + /// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**, + /// then a rename over the destination. + /// + /// Same directory because a rename is only atomic within one filesystem — a temp in + /// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed + /// (`.index.md.lanework-`) because `BoardLoader.directoryCandidates` skips hidden + /// entries: residue from a crashed write is invisible to a load rather than a stray warning + /// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file. + /// + /// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no + /// encoder to configure and no failure case to handle. On any failure the temp file is + /// removed best-effort and `.io` is thrown: the destination is either the old bytes or the + /// new ones, never a mix, and never a directory littered with half-written files. + static func atomicReplace(text: String, at fileURL: URL, operation: String) throws(BoardWriteError) { + let directory = fileURL.deletingLastPathComponent() + let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)") + + do { + try Data(text.utf8).write(to: tempURL) + } catch { + try? FileManager.default.removeItem(at: tempURL) + throw BoardWriteError( + operation: operation, + path: fileURL.path, + reason: .io(message: "could not write temporary file: \(error.localizedDescription)") + ) + } + + // POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an + // existing destination *and* handles one that does not exist yet (the create paths), + // without inventing a second temp file of its own. + let status = tempURL.withUnsafeFileSystemRepresentation { source in + fileURL.withUnsafeFileSystemRepresentation { destination in + guard let source, let destination else { return EINVAL } + return rename(source, destination) == 0 ? 0 : errno + } + } + guard status == 0 else { + try? FileManager.default.removeItem(at: tempURL) + throw BoardWriteError( + operation: operation, + path: fileURL.path, + reason: .io(message: "could not replace file: \(String(cString: strerror(status)))") + ) + } + } + + // MARK: - Renumber + + /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback + /// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole + /// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the + /// parent is a lane (renumbering its cards) or the board root (renumbering its lanes). + /// + /// - **Runs over loaded, valid children.** Every UUID-shaped child folder holding an + /// `index.md` is parsed first; one that fails to parse, or that lacks a usable `order`, + /// fails the whole operation before anything is written. A renumber is bookkeeping inside + /// a user action that already succeeded in principle — it must not be the thing that + /// discovers a broken sibling halfway through rewriting the lane. + /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is + /// *present* is not counted, not sorted, and not rewritten — presence, not validity, is + /// the test, exactly as `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is + /// absence to both). + /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order` + /// ascending, folder name breaking ties) — the same rule the loader sorts by, so a + /// renumber is guaranteed to be sequence-preserving: nothing visibly moves. + /// - **Strays are untouched**: non-UUID-shaped folders and UUID-shaped folders without an + /// `index.md` are skipped here for the same reasons `BoardLoader` skips them. + /// + /// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some + /// siblings renumbered and some not — every `order` still a valid float, display order + /// still deterministic, and the next renumber finishes the job. That is the accepted cost + /// noted in § Ordering, which the deterministic tie-break exists to make harmless. + public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) { + let operation = "renumber children" + + let candidates: [URL] + do { + candidates = try BoardLoader.directoryCandidates(in: parentFolder) + } catch { + throw BoardWriteError( + operation: operation, + path: parentFolder.path, + reason: .unreadable(message: error.description) + ) + } + + var visible: [(folder: URL, order: Double)] = [] + for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) { + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } + + let document = try readDocument(at: indexURL, operation: operation) + guard document.deleted.isMissing else { continue } + try checkEditable(document, at: indexURL, operation: operation) + + switch document.order { + case .missing: + throw BoardWriteError( + operation: operation, + path: indexURL.path, + reason: .unreadable(message: "missing required 'order' field") + ) + case let .malformed(raw): + throw BoardWriteError( + operation: operation, + path: indexURL.path, + reason: .unreadable(message: "malformed 'order' field: \(raw)") + ) + case let .valid(order): + visible.append((folder: folder, order: order)) + } + } + + let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent }) + for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) { + try updateIndex(inItemFolder: child.folder, operation: operation) { document in + document.set(FrontmatterKeys.order, to: .double(rank)) + } + } + } + + // MARK: - Reading + + /// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**: + /// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's + /// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a + /// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or + /// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines + /// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules). + private static func readDocument(at url: URL, operation: String) throws(BoardWriteError) -> FrontmatterDocument { + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + throw BoardWriteError( + operation: operation, + path: url.path, + reason: .unreadable(message: "could not read file: \(error.localizedDescription)") + ) + } + + guard let text = String(validating: data, as: UTF8.self) else { + throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8")) + } + + do { + return try FrontmatterDocument.parse(text) + } catch { + throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description)) + } + } + + private static func checkEditable( + _ document: FrontmatterDocument, + at url: URL, + operation: String + ) throws(BoardWriteError) { + if let shape = document.uneditableShape { + throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape)) + } + } +} + +// MARK: - Error + +/// A write that did not happen, said out loud: which operation, which file, and why — +/// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner +/// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the +/// user's back; a one-shot action fails once and waits for them to act again. +public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible { + /// An imperative human phrase for what was being attempted — "reorder card", "renumber + /// children" — supplied by the call site, because only it knows what the user asked for. + public let operation: String + + /// The file or folder involved. Absolute at this layer: the writer works in URLs and has no + /// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative). + public let path: String + + public let reason: Reason + + public var description: String { "\(operation): \(path): \(reason.description)" } + + public enum Reason: Sendable, Equatable, CustomStringConvertible { + /// The file is missing, is not UTF-8, or its frontmatter does not parse — `message` + /// carries the specifics. A rewrite the app cannot round-trip is not attempted. + case unreadable(message: String) + + /// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter): + /// the file loads and renders, but its frontmatter has a shape the surgical editor + /// cannot address, so writing it would risk corruption. Names the shape. + case uneditableFrontmatter(FrontmatterDocument.UneditableShape) + + /// Any I/O failure writing the temp file or renaming it into place — disk full, + /// permissions, volume error. The destination still holds its previous bytes. + case io(message: String) + + public var description: String { + switch self { + case let .unreadable(message): + "unreadable: \(message)" + case let .uneditableFrontmatter(shape): + "frontmatter cannot be edited in place: \(shape.description)" + case let .io(message): + message + } + } + } +} diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index c90eeb0..8e52a4b 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -31,6 +31,40 @@ public struct FrontmatterDocument: Sendable, Equatable { /// Everything after the closing delimiter line, verbatim. public var body: String + /// A frontmatter shape the span editor cannot address — the reason a document reads fine + /// but refuses to be written (see `uneditableShape`). + public enum UneditableShape: Sendable, Equatable, CustomStringConvertible { + /// A top-level mapping key is not a scalar the editor can address — YAML's explicit-key + /// syntax (`? [a, b]`), whose key is a sequence or mapping rather than a name. + case nonScalarKey + /// A top-level key has no line of its own that opens it: the whole-frontmatter flow + /// mapping (`{schema: 1, order: 1024}`) and its kin (`key : value`, whose spacing the + /// span matcher cannot key on). The value reads fine; there is no line to rewrite. + /// Also covers the empty-but-not-blank block (`{}`, `null`, `~`) — zero keys, yet + /// appending one after that text would be unparseable YAML. + case keyWithoutOwnLine + + public var description: String { + switch self { + case .nonScalarKey: "a top-level key is not a scalar the editor can address" + case .keyWithoutOwnLine: "a top-level key has no line of its own" + } + } + } + + /// Why this document's frontmatter cannot be edited in place, or nil when it can be — the + /// settled **readable-but-uneditable** rule (01-storage-format.md § Frontmatter, the + /// byte-identical round-trip contract): a shape the surgical editor cannot key by spans + /// still loads, reads, and renders normally, but every app write to that file refuses + /// loudly (`BoardWriter`) rather than risk silently corrupting it. + /// + /// Computed at parse, from the same two facts `set`/`remove` depend on: that every + /// top-level key is a scalar, and that every occurrence of one found a line of its own to + /// own. Deliberately conservative — anything the span matcher could not address refuses + /// writes, even where a cleverer editor might have coped. `init(body:)` documents have no + /// text to preserve and are always editable. + public let uneditableShape: UneditableShape? + /// An empty document, for files the app is creating rather than rewriting. public init(body: String = "") { openingDelimiter = "---\n" @@ -38,14 +72,23 @@ public struct FrontmatterDocument: Sendable, Equatable { spans = [] values = [] self.body = body + uneditableShape = nil } - private init(openingDelimiter: String, closingDelimiter: String, spans: [Span], values: [KeyedValue], body: String) { + private init( + openingDelimiter: String, + closingDelimiter: String, + spans: [Span], + values: [KeyedValue], + body: String, + uneditableShape: UneditableShape? + ) { self.openingDelimiter = openingDelimiter self.closingDelimiter = closingDelimiter self.spans = spans self.values = values self.body = body + self.uneditableShape = uneditableShape } // MARK: - Parsing @@ -86,13 +129,15 @@ public struct FrontmatterDocument: Sendable, Equatable { } let keys = mapping.map { pair in pair.key.string.map { placeholders[$0] ?? $0 } } + let spans = Self.makeSpans(lines: yamlLines, keys: keys) return FrontmatterDocument( openingDelimiter: first, closingDelimiter: lines[closingIndex], - spans: Self.makeSpans(lines: yamlLines, keys: keys), + spans: spans, values: Self.lastWinsValues(keys: keys, mapping: mapping), - body: lines[(closingIndex + 1)...].joined() + body: lines[(closingIndex + 1)...].joined(), + uneditableShape: Self.uneditableShape(keys: keys, spans: spans) ) } @@ -157,6 +202,32 @@ public struct FrontmatterDocument: Sendable, Equatable { return values } + /// Whether the parse produced a document `set`/`remove` can edit by span, and if not, why + /// (see `uneditableShape`). Two questions, in the order they can be answered: + /// + /// - A `nil` entry in `keys` is a top-level mapping key YAML resolved to something other + /// than a string — the editor has no name to match a line against at all. + /// - Otherwise every occurrence must have claimed a span of its own. `makeSpans` only opens + /// a span on a line that literally starts the key it expects next, so a shortfall means + /// some occurrence lives inside a line the matcher could not key — the whole-frontmatter + /// flow mapping's keys, `key : value` spacing — and a `set` of it would append a second, + /// contradictory entry instead of rewriting the one on disk. + /// A third fact matters beyond the two above: **no unkeyed span may carry content**. A + /// block whose YAML resolves to an *empty* mapping can still have text on its lines — + /// `{}`, `null`, `~` — which lands in an unkeyed span's tail because no key ever claims + /// it. Zero keys against zero keyed spans passes both counts, yet appending a key after + /// that text (`{}` + `schema: 1`) is unparseable YAML — so any unkeyed span holding a + /// line that is not a comment or blank makes the document uneditable too. Comment-only + /// and blank preambles stay editable: appending after them is exactly what `set` is for. + private static func uneditableShape(keys: [String?], spans: [Span]) -> UneditableShape? { + if keys.contains(where: { $0 == nil }) { return .nonScalarKey } + if spans.filter({ $0.key != nil }).count != keys.count { return .keyWithoutOwnLine } + if spans.contains(where: { $0.key == nil && !lines(of: $0.text).allSatisfy(isTailLine) }) { + return .keyWithoutOwnLine + } + return nil + } + public func serialized() -> String { openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body } @@ -210,7 +281,11 @@ public struct FrontmatterDocument: Sendable, Equatable { spans[index].value = Self.rewritten(spans[index].value, key: key, to: value) for twin in (0 ..< index).reversed() where spans[twin].key == key { clearSpan(at: twin) } } else { - spans.append(Span(key: key, value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\n", tail: "")) + spans.append(Span( + key: key, + value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\(appendTerminator)", + tail: "" + )) } let parsed = KeyedValue(key: key, value: value.parsedValue) @@ -229,6 +304,16 @@ public struct FrontmatterDocument: Sendable, Equatable { values.removeAll { $0.key == key } } + /// The line ending an *appended* key adopts. Rewritten lines keep their own ending + /// (`rewritten(_:key:to:)`); an appended line has no prior ending to keep, so it follows + /// the file's prevailing one — read off the opening delimiter, the one line every parsed + /// document is guaranteed to have. A CRLF file stays uniformly CRLF when a stamp lands in + /// it for the first time; "preserved per line, never normalized" + /// (01-storage-format.md § Fractal layout ▸ Rules) extended to the line that never existed. + private var appendTerminator: String { + openingDelimiter.hasSuffix("\r\n") ? "\r\n" : "\n" + } + /// Drops one span's value lines, keeping its trailing comments and blank lines. private mutating func clearSpan(at index: Int) { if spans[index].tail.isEmpty { diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index 8d9eb51..b8f75d5 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -502,3 +502,43 @@ struct BoardLoaderFailFastTests { } } } + +// MARK: - Encoding strictness + +/// The loader decodes byte-faithfully (no NSString BOM-stripping) so the settled encoding +/// contract (01-storage-format.md § Fractal layout ▸ Rules) actually holds at load time: a +/// BOM'd file fails the frontmatter delimiter, a non-UTF-8 file is named as such — the same +/// strict decode `BoardWriter` uses, so a file can never load here and then refuse every write. +struct BoardLoaderEncodingTests { + @Test func bomPrefixedBoardIndexIsRejected() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + var bytes = Data([0xEF, 0xBB, 0xBF]) + bytes.append(Data("---\nschema: 1\n---\nbody\n".utf8)) + try bytes.write(to: fixture.root.appendingPathComponent("index.md")) + + #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in + guard let loadError = error as? BoardLoadError, + case .unparseableYAML = loadError.reason else { return false } + return loadError.path == "index.md" + } + } + + @Test func nonUTF8BoardIndexIsRejected() throws { + let fixture = try BoardFixture() + defer { fixture.tearDown() } + + // "café" in ISO-8859-1 — 0xE9 is not valid UTF-8. + var bytes = Data("---\nschema: 1\ntitle: caf".utf8) + bytes.append(0xE9) + bytes.append(Data("\n---\n".utf8)) + try bytes.write(to: fixture.root.appendingPathComponent("index.md")) + + #expect { try BoardLoader.load(boardRoot: fixture.root) } throws: { error in + guard let loadError = error as? BoardLoadError, + case let .unparseableYAML(message, _) = loadError.reason else { return false } + return message == "file is not UTF-8" + } + } +} diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift new file mode 100644 index 0000000..e892e6f --- /dev/null +++ b/KanbanTests/BoardWriterTests.swift @@ -0,0 +1,595 @@ +import Foundation +import Testing +@testable import Kanban + +// MARK: - Fixtures + +/// A temp directory holding hand-written `index.md` files, written and read back as raw bytes +/// so every assertion here is about what is actually on disk — the writer's whole contract +/// (02-architecture.md § Layering ▸ Components, "a write is done when the file is on disk"). +private struct WriterFixture { + let root: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardWriterTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + /// Restores permissions before removing: the atomicity test deliberately makes a folder + /// unwritable, and an unwritable folder is also an unremovable one. + func tearDown() { + let manager = FileManager.default + if let walker = manager.enumerator(atPath: root.path) { + for case let relative as String in walker { + try? manager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: root.appendingPathComponent(relative).path + ) + } + } + try? manager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: root.path) + try? manager.removeItem(at: root) + } + + func url(_ relativePath: String) -> URL { + relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true) + } + + /// Writes `text` verbatim (BOM-less UTF-8, line endings exactly as given) to + /// `/index.md`. + @discardableResult + func item(_ relativePath: String, _ text: String) throws -> URL { + try write(Data(text.utf8), to: relativePath) + } + + @discardableResult + func item(_ relativePath: String, bytes: Data) throws -> URL { + try write(bytes, to: relativePath) + } + + @discardableResult + private func write(_ data: Data, to relativePath: String) throws -> URL { + let folder = url(relativePath) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try data.write(to: folder.appendingPathComponent("index.md")) + return folder + } + + func indexData(_ relativePath: String) throws -> Data { + try Data(contentsOf: url(relativePath).appendingPathComponent("index.md")) + } + + func indexText(_ relativePath: String) throws -> String { + try String(decoding: indexData(relativePath), as: UTF8.self) + } + + /// Every entry in the folder, hidden ones included — the writer's temp files are hidden, so + /// only a listing that sees them can prove there is no residue. + func entryNames(_ relativePath: String) throws -> [String] { + try FileManager.default.contentsOfDirectory(atPath: url(relativePath).path).sorted() + } +} + +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" +} + +private func writeFailure(_ operation: () throws -> Void) -> BoardWriteError? { + do { + try operation() + Issue.record("expected the write to fail, but it succeeded") + return nil + } catch let error as BoardWriteError { + return error + } catch { + Issue.record("expected a BoardWriteError, got \(error)") + return nil + } +} + +/// 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: "rename card") { 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: "reorder card") { 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: "rename card") { 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: "rename card") { 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: "rename card") { 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: "rename card") { 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: "rename card") { 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: "rename card") { document in + document.set(FrontmatterKeys.title, to: .string("Renamed")) + } + } + #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(error?.operation == "rename card") + #expect(error?.path.hasSuffix("card/index.md") == true) + #expect(error?.description.contains("rename card") == 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: "rename card") { 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: "rename card") { 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: "rename card") { _ 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: "rename card") { _ 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: "rename card") { 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 + } + #expect(error?.operation == "rename card") + #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 == "renumber children") + #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: "rename lane") { 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")) + } +} diff --git a/KanbanTests/FrontmatterTests.swift b/KanbanTests/FrontmatterTests.swift index 04a6556..1bde83b 100644 --- a/KanbanTests/FrontmatterTests.swift +++ b/KanbanTests/FrontmatterTests.swift @@ -962,3 +962,37 @@ struct FrontmatterEmissionTests { == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(7))])) } } + +// MARK: - Appended lines and empty-but-not-blank blocks + +struct FrontmatterAppendedLineTests { + @Test func appendedKeyAdoptsCRLFInACRLFFile() throws { + var document = try FrontmatterDocument.parse("---\r\nschema: 1\r\n---\r\nbody\r\n") + document.set("title", to: .string("x")) + #expect(document.serialized() == "---\r\nschema: 1\r\ntitle: x\r\n---\r\nbody\r\n") + } + + @Test func appendedKeyStaysLFInAnLFFile() throws { + var document = try FrontmatterDocument.parse("---\nschema: 1\n---\nbody\n") + document.set("title", to: .string("x")) + #expect(document.serialized() == "---\nschema: 1\ntitle: x\n---\nbody\n") + } + + /// `{}`, `null`, `~` resolve to an empty mapping yet leave text no key owns — appending + /// after it would be unparseable YAML, so the shape refuses edits (readable-but-uneditable). + @Test(arguments: ["---\n{}\n---\nbody\n", "---\nnull\n---\nbody\n", "---\n~\n---\nbody\n"]) + func emptyButNotBlankFrontmatterIsUneditable(text: String) throws { + #expect(try FrontmatterDocument.parse(text).uneditableShape == .keyWithoutOwnLine) + } + + /// Truly blank or comment-only frontmatter stays editable — appending after it is exactly + /// what `set` is for, and the result must reparse. + @Test(arguments: ["---\n---\nbody\n", "---\n\n---\nbody\n", "---\n# just a comment\n---\nbody\n"]) + func blankOrCommentOnlyFrontmatterStaysEditable(text: String) throws { + var document = try FrontmatterDocument.parse(text) + #expect(document.uneditableShape == nil) + document.set("schema", to: .int(1)) + let reparsed = try FrontmatterDocument.parse(document.serialized()) + #expect(reparsed.schema == .valid(1)) + } +}