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.
|
/// than through a typed `FrontmatterDocument` accessor.
|
||||||
private static let templateKey = "template"
|
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")
|
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
|
/// 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
|
/// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name
|
||||||
/// (`BoardModel.swift`).
|
/// (`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)
|
let groups = name.split(separator: "-", omittingEmptySubsequences: false)
|
||||||
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
|
guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false }
|
||||||
return groups.allSatisfy { $0.allSatisfy(lowercaseHexDigits.contains) }
|
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
|
/// 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
|
/// root and for malformed `index.md` content, not transient directory-listing races below
|
||||||
/// it.
|
/// 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(
|
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||||
at: folder,
|
at: folder,
|
||||||
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
|
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
|
||||||
@@ -222,10 +231,22 @@ public enum BoardLoader: Sendable {
|
|||||||
|
|
||||||
// MARK: - Document reading + field validation
|
// 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 {
|
private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument {
|
||||||
let text: String
|
let text: String
|
||||||
do {
|
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 {
|
} catch {
|
||||||
throw BoardLoadError(
|
throw BoardLoadError(
|
||||||
path: path,
|
path: path,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,40 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
|||||||
/// Everything after the closing delimiter line, verbatim.
|
/// Everything after the closing delimiter line, verbatim.
|
||||||
public var body: String
|
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.
|
/// An empty document, for files the app is creating rather than rewriting.
|
||||||
public init(body: String = "") {
|
public init(body: String = "") {
|
||||||
openingDelimiter = "---\n"
|
openingDelimiter = "---\n"
|
||||||
@@ -38,14 +72,23 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
|||||||
spans = []
|
spans = []
|
||||||
values = []
|
values = []
|
||||||
self.body = body
|
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.openingDelimiter = openingDelimiter
|
||||||
self.closingDelimiter = closingDelimiter
|
self.closingDelimiter = closingDelimiter
|
||||||
self.spans = spans
|
self.spans = spans
|
||||||
self.values = values
|
self.values = values
|
||||||
self.body = body
|
self.body = body
|
||||||
|
self.uneditableShape = uneditableShape
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Parsing
|
// 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 keys = mapping.map { pair in pair.key.string.map { placeholders[$0] ?? $0 } }
|
||||||
|
let spans = Self.makeSpans(lines: yamlLines, keys: keys)
|
||||||
|
|
||||||
return FrontmatterDocument(
|
return FrontmatterDocument(
|
||||||
openingDelimiter: first,
|
openingDelimiter: first,
|
||||||
closingDelimiter: lines[closingIndex],
|
closingDelimiter: lines[closingIndex],
|
||||||
spans: Self.makeSpans(lines: yamlLines, keys: keys),
|
spans: spans,
|
||||||
values: Self.lastWinsValues(keys: keys, mapping: mapping),
|
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
|
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 {
|
public func serialized() -> String {
|
||||||
openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body
|
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)
|
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) }
|
for twin in (0 ..< index).reversed() where spans[twin].key == key { clearSpan(at: twin) }
|
||||||
} else {
|
} 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)
|
let parsed = KeyedValue(key: key, value: value.parsedValue)
|
||||||
@@ -229,6 +304,16 @@ public struct FrontmatterDocument: Sendable, Equatable {
|
|||||||
values.removeAll { $0.key == key }
|
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.
|
/// Drops one span's value lines, keeping its trailing comments and blank lines.
|
||||||
private mutating func clearSpan(at index: Int) {
|
private mutating func clearSpan(at index: Int) {
|
||||||
if spans[index].tail.isEmpty {
|
if spans[index].tail.isEmpty {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
/// `<relativePath>/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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -962,3 +962,37 @@ struct FrontmatterEmissionTests {
|
|||||||
== .mapping([YAMLValue.Pair(key: .string("order"), value: .int(7))]))
|
== .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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user