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