Convert the Writer's operation vocabulary to a closed enum

WriteOperation replaces free-form operation strings throughout
BoardWriter, per the settled rule in 02 § Write-failure surfacing: the
banner layer will switch exhaustively over it, so a new operation
without a banner rendering is a compile-time hole. Titles enrich at the
two points a document read makes them known (updateIndex, and the
move/copy pre-flight), so failures after the read name the item;
purge never reads and stays title-less. Free-form English survives
only in the diagnostic reason.

Full suite 300 tests in 56 suites green.

Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
2026-07-26 20:03:42 -04:00
parent 0ab0e58412
commit abca29054c
4 changed files with 217 additions and 65 deletions
+148 -37
View File
@@ -51,11 +51,16 @@ public enum BoardWriter: Sendable {
/// `atomicReplace` directly; it does not belong here.
public static func updateIndex(
inItemFolder folder: URL,
operation: String,
operation: WriteOperation,
edits: (inout FrontmatterDocument) -> Void
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
// The read just above is *the* title-enrichment point for every case that funnels
// through here (renumber, delete, restore, style, and the tail of move/copy): shadow
// the parameter so every failure from here on the uneditable-shape refusal, the
// atomic replace names the item.
let operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
edits(&document)
@@ -80,7 +85,7 @@ public enum BoardWriter: Sendable {
/// 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) {
static func atomicReplace(text: String, at fileURL: URL, operation: WriteOperation) throws(BoardWriteError) {
let directory = fileURL.deletingLastPathComponent()
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
@@ -136,7 +141,7 @@ public enum BoardWriter: Sendable {
/// (§ 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 operation = WriteOperation.createBoard
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
@@ -161,7 +166,7 @@ public enum BoardWriter: Sendable {
/// `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")
try createChild(inParent: rootURL, title: title, operation: .createLane)
}
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
@@ -169,7 +174,7 @@ public enum BoardWriter: Sendable {
/// 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")
try createChild(inParent: laneURL, title: title, operation: .createCard)
}
/// The shared body of `createLane`/`createCard` a lane under a board and a card under a
@@ -193,7 +198,7 @@ public enum BoardWriter: Sendable {
private static func createChild(
inParent parentFolder: URL,
title: String?,
operation: String
operation: WriteOperation
) throws(BoardWriteError) -> ItemID {
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
@@ -235,7 +240,7 @@ public enum BoardWriter: Sendable {
/// materializes the folder as well as naming it. The naming rule itself lives in
/// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the
/// app mints is minted one way.
private static func mintUUIDFolder(in parentFolder: URL, operation: String) throws(BoardWriteError) -> URL {
private static func mintUUIDFolder(in parentFolder: URL, operation: WriteOperation) throws(BoardWriteError) -> URL {
let candidate = parentFolder.appendingPathComponent(
freshUUIDName(in: parentFolder, avoiding: []),
isDirectory: true
@@ -281,7 +286,7 @@ public enum BoardWriter: Sendable {
private static func checkIsDirectory(
_ url: URL,
describedAs role: String,
operation: String
operation: WriteOperation
) throws(BoardWriteError) {
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
@@ -313,7 +318,7 @@ public enum BoardWriter: Sendable {
/// 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 operation = WriteOperation.renumberChildren
let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
@@ -347,7 +352,7 @@ public enum BoardWriter: Sendable {
/// beside it.
private static func visibleSiblings(
of parentFolder: URL,
operation: String,
operation: WriteOperation,
requireEditable: Bool
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
let candidates: [URL]
@@ -465,15 +470,23 @@ public enum BoardWriter: Sendable {
destinationBoardRoot: URL,
order: Double?
) throws(BoardWriteError) -> MoveResult {
let operation = "move item"
let sourceName = sourceFolder.lastPathComponent
// Which case this is an ordinary move, or the same-parent degenerate reorder is
// decided from the two URLs alone, before anything on disk is even looked at, so the
// right vocabulary word is in hand for every pre-flight check that follows rather than
// being retrofitted once the branch below is reached.
let isReorder = isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent)
var operation: WriteOperation = isReorder ? .reorder(title: nil) : .move(title: nil)
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
try checkIsUUIDShaped(sourceFolder, operation: operation)
try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
// The pre-flight's own read is where this move/reorder learns the moved root's title;
// every failure from here on in this call reuses the enriched value.
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
let sourceName = sourceFolder.lastPathComponent
if isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent) {
if isReorder {
let rank: Double
if let order {
rank = order
@@ -573,7 +586,7 @@ public enum BoardWriter: Sendable {
private static func renameFolder(
_ folder: URL,
toSiblingNamed name: String,
operation: String
operation: WriteOperation
) throws(BoardWriteError) {
let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
do {
@@ -640,11 +653,14 @@ public enum BoardWriter: Sendable {
order: Double?,
stamps: CopyStamps
) throws(BoardWriteError) -> ItemID {
let operation = "copy item"
var operation: WriteOperation = .copy(title: nil)
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
try checkIsUUIDShaped(sourceFolder, operation: operation)
try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
// The pre-flight's own read is where this copy learns the source root's title; every
// failure from here on in this call including inside the materialized-but-not-yet-
// stamped tree below reuses the enriched value.
operation = try checkIndexIsRewritable(inItemFolder: sourceFolder, operation: operation)
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
@@ -697,7 +713,7 @@ public enum BoardWriter: Sendable {
private static func remintDescendants(
of folder: URL,
collecting copied: inout [URL],
operation: String
operation: WriteOperation
) throws(BoardWriteError) {
for child in childCandidates(of: folder) {
let fresh = freshUUIDName(in: folder, avoiding: [])
@@ -717,7 +733,7 @@ public enum BoardWriter: Sendable {
at folder: URL,
stamps: CopyStamps,
now: Date,
operation: String
operation: WriteOperation
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path),
@@ -753,7 +769,7 @@ public enum BoardWriter: Sendable {
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
/// cleared, atomic replace.
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = "delete item"
let operation = WriteOperation.delete(title: nil)
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
@@ -774,7 +790,7 @@ public enum BoardWriter: Sendable {
/// police liveness (a second, independent liveness check here could only drift from the
/// store UI's own, which is what actually decides whether Put Back is offered at all).
public static func restoreItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = "restore item"
let operation = WriteOperation.restore(title: nil)
try checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
try checkIsUUIDShaped(itemFolder, operation: operation)
@@ -799,7 +815,7 @@ public enum BoardWriter: Sendable {
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
/// call, only a lane or a card.
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
let operation = "purge item"
let operation = WriteOperation.purge(title: nil)
guard FileManager.default.fileExists(atPath: itemFolder.path) else { return }
try checkIsUUIDShaped(itemFolder, operation: operation)
@@ -858,16 +874,20 @@ public enum BoardWriter: Sendable {
_ sourceURLs: [URL],
intoCard cardFolder: URL
) throws(BoardWriteError) -> [ImportedAttachment] {
let operation = "import attachment"
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
try checkIsUUIDShaped(cardFolder, operation: operation)
// Before the per-file loop starts, no single source is implicated yet the first
// source's own (original, pre-collision-rename) name stands in for the batch; an empty
// `sourceURLs` (nothing was actually dropped) falls back to the empty string rather than
// crashing on `first!`.
let batchOperation = WriteOperation.importAttachment(filename: sourceURLs.first?.lastPathComponent ?? "")
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation)
try checkIsUUIDShaped(cardFolder, operation: batchOperation)
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
do {
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
operation: batchOperation,
path: cardFolder.path,
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
)
@@ -875,6 +895,9 @@ public enum BoardWriter: Sendable {
var landed: [ImportedAttachment] = []
for sourceURL in sourceURLs {
// Each source names itself the ORIGINAL filename, not the Finder-style renamed one
// decided a few lines down, because the operation describes what the user dropped.
let operation = WriteOperation.importAttachment(filename: sourceURL.lastPathComponent)
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
throw BoardWriteError(
@@ -952,7 +975,7 @@ public enum BoardWriter: Sendable {
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
/// disturbs anything inside it, subfolders included.
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
let operation = "list attachments"
let operation = WriteOperation.listAttachments
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
@@ -986,7 +1009,7 @@ public enum BoardWriter: Sendable {
private static func destinationOrder(
_ order: Double?,
inParent parentFolder: URL,
operation: String
operation: WriteOperation
) throws(BoardWriteError) -> Double {
if let order { return order }
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
@@ -1000,7 +1023,7 @@ public enum BoardWriter: Sendable {
/// otherwise just ignore. Shared by every operation that must never reach a board root: a
/// board root's folder name is never UUID-shaped (§ Board naming), so this one check is
/// what makes board-root deletion/restore/purge structurally unreachable at this layer.
private static func checkIsUUIDShaped(_ folder: URL, operation: String) throws(BoardWriteError) {
private static func checkIsUUIDShaped(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
throw BoardWriteError(
operation: operation,
@@ -1015,10 +1038,21 @@ public enum BoardWriter: Sendable {
/// plus the stamps), so one that cannot be read or cannot be edited in place refuses the
/// whole gesture while nothing has happened yet rather than after the folder has already
/// travelled, or with a copy already materialized.
private static func checkIndexIsRewritable(inItemFolder folder: URL, operation: String) throws(BoardWriteError) {
///
/// Returns `operation` enriched with the title this same read just learned
/// (`WriteOperation.withTitle`) the caller's *only* read of the moved/copied root before it
/// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned
/// rather than discarded so every failure after the pre-flight passes (the `FileManager`
/// move/copy itself, the post-arrival `updateIndex`) also names the item.
private static func checkIndexIsRewritable(
inItemFolder folder: URL,
operation: WriteOperation
) throws(BoardWriteError) -> WriteOperation {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
let document = try readDocument(at: indexURL, operation: operation)
let operation = operation.withTitle(document.title.value)
try checkEditable(document, at: indexURL, operation: operation)
return operation
}
// MARK: - Reading
@@ -1029,7 +1063,7 @@ public enum BoardWriter: Sendable {
/// 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 {
private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument {
let data: Data
do {
data = try Data(contentsOf: url)
@@ -1055,7 +1089,7 @@ public enum BoardWriter: Sendable {
private static func checkEditable(
_ document: FrontmatterDocument,
at url: URL,
operation: String
operation: WriteOperation
) throws(BoardWriteError) {
if let shape = document.uneditableShape {
throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
@@ -1112,6 +1146,81 @@ public struct ImportedAttachment: Sendable, Equatable {
public let fileName: String
}
// MARK: - Write operation vocabulary
/// What the Writer was doing when it failed a closed vocabulary, not a string (settled,
/// 02-architecture.md § Write-failure surfacing). The banner layer switches exhaustively over
/// this to phrase user-facing text, so a new operation here is a compile-time hole there, never
/// a silent default. `title` is the affected item's title where the operation learned it before
/// failing (nil when the failure struck before the title could be read).
public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
case createBoard
case createLane
case createCard
case move(title: String?)
case reorder(title: String?)
case copy(title: String?)
case delete(title: String?) // tombstone
case restore(title: String?)
case purge(title: String?)
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
case importAttachment(filename: String)
case listAttachments
case renumberChildren // order-maintenance sweep (compaction)
/// Fills in the title once the Writer has read it off the document the operation is acting
/// on identity for the six cases with no title slot at all: `createBoard`/`createLane`/
/// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the
/// filename it already carries; `listAttachments` and `renumberChildren` name no single item.
/// Called once, right where the operation's `readDocument` succeeds `updateIndex` itself
/// (which covers every case that funnels through it: renumber, delete, restore, style, and
/// the tail end of move/copy) and the move/copy pre-flight, before the folder travels or the
/// copy materializes. Every failure past that point reuses the enriched value, because the
/// case is immutable once a caller has it in hand there is nothing to "forget" later.
public func withTitle(_ title: String?) -> WriteOperation {
switch self {
case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .renumberChildren:
self
case .move: .move(title: title)
case .reorder: .reorder(title: title)
case .copy: .copy(title: title)
case .delete: .delete(title: title)
case .restore: .restore(title: title)
case .purge: .purge(title: title)
case .style: .style(title: title)
}
}
/// A short imperative phrase `"move 'Fix login'"`, `"create card"`, `"import attachment
/// 'photo.png'"` for logs and diagnostics **only**: `BoardWriteError.description` (test
/// failures, `po error`, console output), never the banner's text. The banner owns every
/// word a user sees and switches exhaustively over the case itself to produce it
/// (02-architecture.md § Write-failure surfacing); this description exists purely so a
/// developer reading a raw error gets English without the banner layer's help.
public var description: String {
switch self {
case .createBoard: "create board"
case .createLane: "create lane"
case .createCard: "create card"
case let .move(title): Self.phrase("move", title)
case let .reorder(title): Self.phrase("reorder", title)
case let .copy(title): Self.phrase("copy", title)
case let .delete(title): Self.phrase("delete", title)
case let .restore(title): Self.phrase("restore", title)
case let .purge(title): Self.phrase("purge", title)
case let .style(title): Self.phrase("style", title)
case let .importAttachment(filename): "import attachment '\(filename)'"
case .listAttachments: "list attachments"
case .renumberChildren: "renumber children"
}
}
private static func phrase(_ verb: String, _ title: String?) -> String {
guard let title else { return verb }
return "\(verb) '\(title)'"
}
}
// MARK: - Error
/// A write that did not happen, said out loud: which operation, which file, and why
@@ -1119,9 +1228,11 @@ public struct ImportedAttachment: Sendable, Equatable {
/// ("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
/// What was being attempted when the write failed the closed `WriteOperation` vocabulary,
/// not a string (settled, 02-architecture.md § Write-failure surfacing): the banner switches
/// exhaustively over this case to produce its user-facing phrasing, so this field carries no
/// English of its own that lives only in `WriteOperation.description`, for logs.
public let operation: WriteOperation
/// 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).
@@ -1129,7 +1240,7 @@ public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertib
public let reason: Reason
public var description: String { "\(operation): \(path): \(reason.description)" }
public var description: String { "\(operation.description): \(path): \(reason.description)" }
public enum Reason: Sendable, Equatable, CustomStringConvertible {
/// The file is missing, is not UTF-8, or its frontmatter does not parse `message`