From abca29054c6c5e9886e1430b2578cea17e5ff9cf Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 20:03:42 -0400 Subject: [PATCH] Convert the Writer's operation vocabulary to a closed enum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/Storage/BoardWriter.swift | 185 +++++++++++++++++++++------ KanbanTests/BoardStoreTests.swift | 2 +- KanbanTests/BoardWriterTests.swift | 85 ++++++++---- KanbanTests/WriteFidelityTests.swift | 10 +- 4 files changed, 217 insertions(+), 65 deletions(-) diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 26bc01f..959a859 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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` diff --git a/KanbanTests/BoardStoreTests.swift b/KanbanTests/BoardStoreTests.swift index 8d97fad..21aeaf5 100644 --- a/KanbanTests/BoardStoreTests.swift +++ b/KanbanTests/BoardStoreTests.swift @@ -471,7 +471,7 @@ struct BoardStoreTests { // A Writer operation that fails partway has still touched disk, so the bracket has to close // on the throwing path too — an unbalanced one would suspend the watcher for the session. - let boom = BoardWriteError(operation: "probe", path: "/nowhere", reason: .io(message: "disk full")) + let boom = BoardWriteError(operation: .style(title: nil), path: "/nowhere", reason: .io(message: "disk full")) do { try store.performWrite { () throws(BoardWriteError) -> Void in throw boom } Issue.record("expected the operation's own error to propagate") diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index 24898be..108e13a 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -94,7 +94,7 @@ struct BoardWriterPreservationTests { defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.rich) - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } @@ -120,7 +120,7 @@ struct BoardWriterPreservationTests { 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 + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.order, to: .double(2048)) } @@ -134,7 +134,7 @@ struct BoardWriterPreservationTests { defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } @@ -150,7 +150,7 @@ struct BoardWriterPreservationTests { + "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 + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } @@ -173,7 +173,7 @@ struct BoardWriterStampTests { 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 + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } @@ -193,7 +193,7 @@ struct BoardWriterStampTests { defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.modified, to: .date(Date(timeIntervalSince1970: 0))) document.set(FrontmatterKeys.modifiedBy, to: .string("claude")) } @@ -210,7 +210,7 @@ struct BoardWriterStampTests { defer { fixture.tearDown() } let folder = try fixture.item("card", Fixture.minimal) - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } @@ -244,14 +244,15 @@ struct BoardWriterUneditableTests { #expect(document.order == .valid(1024)) let error = writeFailure { - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) - #expect(error?.operation == "rename card") + // No title in `Fixture.flowMapping`, so the pre-flight read leaves it nil. + #expect(error?.operation == .style(title: nil)) #expect(error?.path.hasSuffix("card/index.md") == true) - #expect(error?.description.contains("rename card") == true) + #expect(error?.description.contains("style") == true) #expect(try fixture.indexText("card") == Fixture.flowMapping) #expect(try fixture.entryNames("card") == ["index.md"]) } @@ -265,7 +266,7 @@ struct BoardWriterUneditableTests { #expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey) let error = writeFailure { - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } @@ -290,7 +291,7 @@ struct BoardWriterFailureTests { let folder = try fixture.item("card", bytes: latin1) let error = writeFailure { - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } @@ -310,7 +311,7 @@ struct BoardWriterFailureTests { try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) let error = writeFailure { - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { _ in } + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in } } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") @@ -326,7 +327,7 @@ struct BoardWriterFailureTests { let folder = try fixture.item("card", text) let error = writeFailure { - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { _ in } + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { _ in } } guard case .unreadable = error?.reason else { Issue.record("expected .unreadable, got \(String(describing: error?.reason))") @@ -347,7 +348,7 @@ struct BoardWriterFailureTests { defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) } let error = writeFailure { - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Renamed")) } } @@ -355,7 +356,10 @@ struct BoardWriterFailureTests { Issue.record("expected .io, got \(String(describing: error?.reason))") return } - #expect(error?.operation == "rename card") + // `updateIndex` read `Fixture.rich` (title: Original) successfully before the write + // itself failed — title enrichment engages even though the failure is `.io`, not a + // read/uneditable refusal. + #expect(error?.operation == .style(title: "Original")) #expect(error?.path.hasSuffix("card/index.md") == true) #expect(try fixture.indexData("card") == before) #expect(try fixture.entryNames("card") == ["index.md"]) @@ -458,7 +462,7 @@ struct BoardWriterRenumberTests { 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(error?.operation == .renumberChildren) #expect(try fixture.indexData("lane/\(Child.a)") == untouched) } @@ -525,7 +529,7 @@ struct BoardWriterLoaderIntegrationTests { 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 + try BoardWriter.updateIndex(inItemFolder: lane, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("Doing")) } @@ -734,7 +738,7 @@ struct BoardWriterCreateChildTests { } #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) #expect(error?.path.contains(Child.a) == true) - #expect(error?.operation == "create lane") + #expect(error?.operation == .createLane) // Nothing was minted: the scan fails before the new folder is ever created. #expect(try fixture.entryNames("") == before) } @@ -1122,7 +1126,9 @@ struct BoardWriterMoveTests { try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card3)", to: "A.kanban/\(Ident.lane2)") } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) - #expect(error?.operation == "move item") + // The pre-flight read the source's document (`Item.uneditable`, title: Odd) before the + // shape refusal, so the title survives into the thrown error. + #expect(error?.operation == .move(title: "Odd")) #expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card3)") == Item.uneditable) #expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == [Ident.card2, "index.md"]) } @@ -1407,7 +1413,9 @@ struct BoardWriterCopyTests { ) } #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) - #expect(error?.operation == "copy item") + // Same enrichment as the move pre-flight: the root's document (title: Odd) was read + // before the uneditable-shape refusal fired. + #expect(error?.operation == .copy(title: "Odd")) #expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before) } @@ -1661,6 +1669,39 @@ struct BoardWriterDeleteRestoreTests { #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) #expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping) } + + // MARK: Title enrichment (02-architecture.md § Write-failure surfacing) + + /// `updateIndex`'s pre-flight read succeeds — the shape is readable, only uneditable — so by + /// the time the refusal fires, `WriteOperation.withTitle` has already run: the title survives + /// into the thrown error. `Fixture.flowMapping` above has no `title` key at all, which is why + /// this test reaches for `Item.uneditable` instead — the fixture that actually carries one. + @Test func deleteOnAnUneditableItemWithAKnownTitleCarriesItInTheOperation() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let folder = try fixture.item(Ident.lane1, Item.uneditable) + + let error = writeFailure { try BoardWriter.deleteItem(at: folder) } + #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(error?.operation == .delete(title: "Odd")) + } + + /// The negative case: a file that cannot even be read (invalid UTF-8) never gets far enough + /// for `readDocument` to hand back a document, so there is no title to learn — the operation + /// stays exactly as its call site constructed it, title `nil`. + @Test func deleteOnAnUnreadableIndexLeavesTheOperationsTitleNil() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let garbage = try #require("---\nschema: 1\ntitle: café\n---\nbody\n".data(using: .isoLatin1)) + let folder = try fixture.item(Ident.lane1, bytes: garbage) + + let error = writeFailure { try BoardWriter.deleteItem(at: folder) } + guard case .unreadable = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(error?.operation == .delete(title: nil)) + } } // MARK: - Purge @@ -1896,7 +1937,7 @@ struct BoardWriterImportAttachmentsTests { return } #expect(error?.path == missing.path) - #expect(error?.operation == "import attachment") + #expect(error?.operation == .importAttachment(filename: "missing.png")) #expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"]) } diff --git a/KanbanTests/WriteFidelityTests.swift b/KanbanTests/WriteFidelityTests.swift index dcc2d2a..f8dee38 100644 --- a/KanbanTests/WriteFidelityTests.swift +++ b/KanbanTests/WriteFidelityTests.swift @@ -131,17 +131,17 @@ struct WriteFidelityMinimalTouchTests { try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) { try BoardWriter.updateIndex( - inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: "reorder card" + inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card1)"), operation: .style(title: nil) ) { $0.set(FrontmatterKeys.order, to: .double(1536)) } } try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) { try BoardWriter.updateIndex( - inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: "set background" + inItemFolder: fixture.url("\(Ident.lane1)/\(Ident.card2)"), operation: .style(title: nil) ) { $0.set(FrontmatterKeys.background, to: .string("blue")) } } try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) { try BoardWriter.updateIndex( - inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: "rename card" + inItemFolder: fixture.url("\(Ident.lane2)/\(Ident.card3)"), operation: .style(title: nil) ) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) } } try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) { @@ -308,7 +308,7 @@ struct WriteFidelityUnknownKeyOrderTests { """ let folder = try fixture.item("card", original) - try BoardWriter.updateIndex(inItemFolder: folder, operation: "rename card") { document in + try BoardWriter.updateIndex(inItemFolder: folder, operation: .style(title: nil)) { document in document.set(FrontmatterKeys.title, to: .string("After")) } @@ -349,7 +349,7 @@ struct WriteFidelityCompositeTests { let card3 = try BoardWriter.createCard(inLane: lane1Folder, title: "Third") for (id, body) in [(card1, "First body.\n"), (card2, "Second body.\n"), (card3, "Third body.\n")] { try BoardWriter.updateIndex( - inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: "edit body" + inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: .style(title: nil) ) { $0.body = body } }