Build BoardWriter — atomic, minimal-touch writes

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
This commit is contained in:
2026-07-26 16:49:42 -04:00
parent a131399c02
commit 19f67bdb78
6 changed files with 1061 additions and 8 deletions
+25 -4
View File
@@ -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,
+278
View File
@@ -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-<uuid>`) 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
}
}
}
}
+89 -4
View File
@@ -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 {