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,