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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user