createBoard fills or mints a .kanban (or extension-less) folder and refuses to clobber an existing board; createLane/createCard mint fresh lowercase-UUIDv4 folders and append at max+1024 among visible siblings via the same strict scan renumber uses (extracted, shared). New files carry schema/title?/order?/created/modified — created==modified, no modified-by — written LF through the same atomic temp+rename path. The editability pre-flight is now scoped to operations that rewrite siblings: renumber refuses on an uneditable sibling, a create beside one proceeds. Schema constant unified on BoardLoader.supportedSchema. Info.plist declares the .kanban document type (UTI conforming to com.apple.package) so Finder treats a board as one document. 20 new unit tests; 196 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
461 lines
25 KiB
Swift
461 lines
25 KiB
Swift
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: - Create
|
|
|
|
/// Creates a new board: the folder (if it does not already exist) and its `index.md` — the
|
|
/// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two
|
|
/// callers: template instantiation, which passes the user-chosen document name as `title`
|
|
/// so the window title and the Finder name start out matching (§ Board naming), and a bare
|
|
/// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name.
|
|
///
|
|
/// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing
|
|
/// *empty* folder is simply filled in, since that is exactly what an interrupted create or
|
|
/// a hand-made folder looks like.
|
|
/// - **Refuses to clobber an existing board**: if `index.md` is already there, the call
|
|
/// fails `.io` naming the path and the existing file is never touched — a create must
|
|
/// never overwrite a board that already exists.
|
|
/// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board
|
|
/// naming) is a *missing* key, not an empty string, which would be a real (if blank) title.
|
|
/// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` —
|
|
/// that field belongs to lanes and cards, never the board root.
|
|
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
|
|
/// (§ Document packaging, "Extension-less board folders still open") — this call never
|
|
/// looks at `rootURL`'s extension.
|
|
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
|
|
let operation = "create board"
|
|
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
|
|
|
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
|
|
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here"))
|
|
}
|
|
|
|
do {
|
|
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: rootURL.path,
|
|
reason: .io(message: "could not create board folder: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
|
|
try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
|
|
}
|
|
|
|
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
|
|
/// `rootURL`, appends it after the board's current visible lanes, and writes its
|
|
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
|
/// the shared mechanics.
|
|
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
|
try createChild(inParent: rootURL, title: title, operation: "create lane")
|
|
}
|
|
|
|
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
|
|
/// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`.
|
|
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
|
/// mechanics.
|
|
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
|
try createChild(inParent: laneURL, title: title, operation: "create card")
|
|
}
|
|
|
|
/// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
|
|
/// lane are the same operation one level apart (01-storage-format.md § Fractal layout,
|
|
/// "Level is position"): check the parent, place the new identity after the current visible
|
|
/// siblings, mint it, write its file.
|
|
///
|
|
/// - **The parent must already exist as a directory.** Anything else — missing, or a file
|
|
/// where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never
|
|
/// a silent `mkdir` into a broken location.
|
|
/// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by
|
|
/// `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren`
|
|
/// uses, so the two can never disagree about who counts as a sibling. An empty parent's
|
|
/// first child lands at `1024`, the board convention (01-storage-format.md § Ordering).
|
|
/// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created,
|
|
/// then `index.md` is written, as two separate filesystem operations — there is no atomic
|
|
/// "create a directory with content already in it" primitive to reach for. A crash between
|
|
/// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape
|
|
/// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own
|
|
/// `fileExists` check) already tolerate — see that type's doc comment.
|
|
private static func createChild(
|
|
inParent parentFolder: URL,
|
|
title: String?,
|
|
operation: String
|
|
) throws(BoardWriteError) -> ItemID {
|
|
try checkIsDirectory(parentFolder, operation: operation)
|
|
|
|
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
|
let order = Ranks.append(toVisible: siblings.map(\.order))
|
|
|
|
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
|
|
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
|
try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)
|
|
|
|
return ItemID(rawValue: folder.lastPathComponent)
|
|
}
|
|
|
|
/// The frontmatter text for a file the app is minting outright — never a rewrite, so this
|
|
/// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior
|
|
/// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and
|
|
/// `modified` share one `Date()` so the two stamps are identical, not merely close;
|
|
/// `modified-by` is never written, matching the engine's absence-means-app-authored
|
|
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
|
|
/// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
|
|
/// `modified` — is simply the order `set` is called in, since each call appends a fresh key
|
|
/// before the closing delimiter of an otherwise-empty document.
|
|
private static func newDocumentText(title: String?, order: Double?) -> String {
|
|
var document = FrontmatterDocument(body: "")
|
|
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
|
|
if let title {
|
|
document.set(FrontmatterKeys.title, to: .string(title))
|
|
}
|
|
if let order {
|
|
document.set(FrontmatterKeys.order, to: .double(order))
|
|
}
|
|
let now = Date()
|
|
document.set(FrontmatterKeys.created, to: .date(now))
|
|
document.set(FrontmatterKeys.modified, to: .date(now))
|
|
return document.serialized()
|
|
}
|
|
|
|
/// A fresh lowercase-UUIDv4 folder under `parentFolder` — the folder-naming convention
|
|
/// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase
|
|
/// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match
|
|
/// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. A freshly minted UUID
|
|
/// already existing is astronomically unlikely — 122 bits of randomness per mint — but
|
|
/// checked for and re-minted anyway rather than assumed away; the loop body is trivial
|
|
/// precisely because the case it handles essentially never fires.
|
|
private static func mintUUIDFolder(in parentFolder: URL, operation: String) throws(BoardWriteError) -> URL {
|
|
var candidate: URL
|
|
repeat {
|
|
candidate = parentFolder.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
|
|
} while FileManager.default.fileExists(atPath: candidate.path)
|
|
|
|
do {
|
|
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
|
|
} catch {
|
|
throw BoardWriteError(
|
|
operation: operation,
|
|
path: candidate.path,
|
|
reason: .io(message: "could not create folder: \(error.localizedDescription)")
|
|
)
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
/// The parent-exists check `createChild` runs before touching the filesystem any further —
|
|
/// shared instead of folded into `createChild` because "does this folder exist" has nothing
|
|
/// to do with siblings or minting, and inlining it would bury the one thing a caller most
|
|
/// needs to see at a glance: a missing or wrong-shaped parent is rejected before anything
|
|
/// else happens.
|
|
private static func checkIsDirectory(_ url: URL, operation: String) throws(BoardWriteError) {
|
|
var isDirectory: ObjCBool = false
|
|
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
|
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent folder does not exist"))
|
|
}
|
|
guard isDirectory.boolValue else {
|
|
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent is not a directory"))
|
|
}
|
|
}
|
|
|
|
// 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** (`visibleSiblings(of:operation:)`): 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.
|
|
/// - **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.
|
|
///
|
|
/// 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 visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)
|
|
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create
|
|
/// operations' order assignment — extracted into one place so the two can never disagree
|
|
/// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an
|
|
/// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection
|
|
/// rule the loader uses), each parsed strictly:
|
|
///
|
|
/// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
|
|
/// *present* is not counted — presence, not validity, is the test, exactly as
|
|
/// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both).
|
|
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
|
|
/// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
|
|
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
|
|
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
|
|
/// that sibling's file, before anything is written — the same discover-before-you-write
|
|
/// guarantee `renumberVisibleChildren`'s batch depends on.
|
|
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
|
|
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
|
|
/// unwritable sibling halfway through the batch), the creates pass `false` — a create
|
|
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
|
|
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
|
|
/// beside it.
|
|
private static func visibleSiblings(
|
|
of parentFolder: URL,
|
|
operation: String,
|
|
requireEditable: Bool
|
|
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
|
|
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 }
|
|
if requireEditable {
|
|
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))
|
|
}
|
|
}
|
|
return visible
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
}
|