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:
@@ -51,11 +51,16 @@ public enum BoardWriter: Sendable {
|
|||||||
/// `atomicReplace` directly; it does not belong here.
|
/// `atomicReplace` directly; it does not belong here.
|
||||||
public static func updateIndex(
|
public static func updateIndex(
|
||||||
inItemFolder folder: URL,
|
inItemFolder folder: URL,
|
||||||
operation: String,
|
operation: WriteOperation,
|
||||||
edits: (inout FrontmatterDocument) -> Void
|
edits: (inout FrontmatterDocument) -> Void
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
var document = try readDocument(at: indexURL, operation: operation)
|
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)
|
try checkEditable(document, at: indexURL, operation: operation)
|
||||||
|
|
||||||
edits(&document)
|
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
|
/// 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
|
/// 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.
|
/// 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 directory = fileURL.deletingLastPathComponent()
|
||||||
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
|
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
|
/// (§ Document packaging, "Extension-less board folders still open") — this call never
|
||||||
/// looks at `rootURL`'s extension.
|
/// looks at `rootURL`'s extension.
|
||||||
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
|
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)
|
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
|
|
||||||
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
|
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
|
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
|
||||||
/// the shared mechanics.
|
/// the shared mechanics.
|
||||||
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
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
|
/// 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
|
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
|
||||||
/// mechanics.
|
/// mechanics.
|
||||||
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
|
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
|
/// 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(
|
private static func createChild(
|
||||||
inParent parentFolder: URL,
|
inParent parentFolder: URL,
|
||||||
title: String?,
|
title: String?,
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) -> ItemID {
|
) throws(BoardWriteError) -> ItemID {
|
||||||
try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation)
|
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
|
/// 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
|
/// `freshUUIDName(in:avoiding:)`, shared with the move and copy paths so every identity the
|
||||||
/// app mints is minted one way.
|
/// 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(
|
let candidate = parentFolder.appendingPathComponent(
|
||||||
freshUUIDName(in: parentFolder, avoiding: []),
|
freshUUIDName(in: parentFolder, avoiding: []),
|
||||||
isDirectory: true
|
isDirectory: true
|
||||||
@@ -281,7 +286,7 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func checkIsDirectory(
|
private static func checkIsDirectory(
|
||||||
_ url: URL,
|
_ url: URL,
|
||||||
describedAs role: String,
|
describedAs role: String,
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
var isDirectory: ObjCBool = false
|
var isDirectory: ObjCBool = false
|
||||||
guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else {
|
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
|
/// 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.
|
/// noted in § Ordering, which the deterministic tie-break exists to make harmless.
|
||||||
public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
|
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 visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)
|
||||||
|
|
||||||
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
|
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
|
||||||
@@ -347,7 +352,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// beside it.
|
/// beside it.
|
||||||
private static func visibleSiblings(
|
private static func visibleSiblings(
|
||||||
of parentFolder: URL,
|
of parentFolder: URL,
|
||||||
operation: String,
|
operation: WriteOperation,
|
||||||
requireEditable: Bool
|
requireEditable: Bool
|
||||||
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
|
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
|
||||||
let candidates: [URL]
|
let candidates: [URL]
|
||||||
@@ -465,15 +470,23 @@ public enum BoardWriter: Sendable {
|
|||||||
destinationBoardRoot: URL,
|
destinationBoardRoot: URL,
|
||||||
order: Double?
|
order: Double?
|
||||||
) throws(BoardWriteError) -> MoveResult {
|
) 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(sourceFolder, describedAs: "item folder", operation: operation)
|
||||||
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
||||||
try checkIsUUIDShaped(sourceFolder, 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 isReorder {
|
||||||
|
|
||||||
if isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent) {
|
|
||||||
let rank: Double
|
let rank: Double
|
||||||
if let order {
|
if let order {
|
||||||
rank = order
|
rank = order
|
||||||
@@ -573,7 +586,7 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func renameFolder(
|
private static func renameFolder(
|
||||||
_ folder: URL,
|
_ folder: URL,
|
||||||
toSiblingNamed name: String,
|
toSiblingNamed name: String,
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
|
let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true)
|
||||||
do {
|
do {
|
||||||
@@ -640,11 +653,14 @@ public enum BoardWriter: Sendable {
|
|||||||
order: Double?,
|
order: Double?,
|
||||||
stamps: CopyStamps
|
stamps: CopyStamps
|
||||||
) throws(BoardWriteError) -> ItemID {
|
) throws(BoardWriteError) -> ItemID {
|
||||||
let operation = "copy item"
|
var operation: WriteOperation = .copy(title: nil)
|
||||||
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
|
try checkIsDirectory(sourceFolder, describedAs: "item folder", operation: operation)
|
||||||
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
try checkIsDirectory(destinationParent, describedAs: "destination parent folder", operation: operation)
|
||||||
try checkIsUUIDShaped(sourceFolder, 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)
|
let rank = try destinationOrder(order, inParent: destinationParent, operation: operation)
|
||||||
|
|
||||||
@@ -697,7 +713,7 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func remintDescendants(
|
private static func remintDescendants(
|
||||||
of folder: URL,
|
of folder: URL,
|
||||||
collecting copied: inout [URL],
|
collecting copied: inout [URL],
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
for child in childCandidates(of: folder) {
|
for child in childCandidates(of: folder) {
|
||||||
let fresh = freshUUIDName(in: folder, avoiding: [])
|
let fresh = freshUUIDName(in: folder, avoiding: [])
|
||||||
@@ -717,7 +733,7 @@ public enum BoardWriter: Sendable {
|
|||||||
at folder: URL,
|
at folder: URL,
|
||||||
stamps: CopyStamps,
|
stamps: CopyStamps,
|
||||||
now: Date,
|
now: Date,
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
guard FileManager.default.fileExists(atPath: indexURL.path),
|
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`
|
/// applies: fresh read, refuse an uneditable shape, `modified` stamped and `modified-by`
|
||||||
/// cleared, atomic replace.
|
/// cleared, atomic replace.
|
||||||
public static func deleteItem(at itemFolder: URL) throws(BoardWriteError) {
|
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 checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||||
try checkIsUUIDShaped(itemFolder, 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
|
/// 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).
|
/// 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) {
|
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 checkIsDirectory(itemFolder, describedAs: "item folder", operation: operation)
|
||||||
try checkIsUUIDShaped(itemFolder, 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
|
/// `deleteItem`/`restoreItem` rely on: a board root or a stray never purges through this
|
||||||
/// call, only a lane or a card.
|
/// call, only a lane or a card.
|
||||||
public static func purgeItem(at itemFolder: URL) throws(BoardWriteError) {
|
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 }
|
guard FileManager.default.fileExists(atPath: itemFolder.path) else { return }
|
||||||
|
|
||||||
try checkIsUUIDShaped(itemFolder, operation: operation)
|
try checkIsUUIDShaped(itemFolder, operation: operation)
|
||||||
@@ -858,16 +874,20 @@ public enum BoardWriter: Sendable {
|
|||||||
_ sourceURLs: [URL],
|
_ sourceURLs: [URL],
|
||||||
intoCard cardFolder: URL
|
intoCard cardFolder: URL
|
||||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||||
let operation = "import attachment"
|
// Before the per-file loop starts, no single source is implicated yet — the first
|
||||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
// source's own (original, pre-collision-rename) name stands in for the batch; an empty
|
||||||
try checkIsUUIDShaped(cardFolder, operation: operation)
|
// `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)
|
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
|
||||||
} catch {
|
} catch {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
operation: batchOperation,
|
||||||
path: cardFolder.path,
|
path: cardFolder.path,
|
||||||
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
|
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
|
||||||
)
|
)
|
||||||
@@ -875,6 +895,9 @@ public enum BoardWriter: Sendable {
|
|||||||
|
|
||||||
var landed: [ImportedAttachment] = []
|
var landed: [ImportedAttachment] = []
|
||||||
for sourceURL in sourceURLs {
|
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
|
var isDirectory: ObjCBool = false
|
||||||
guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
|
guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
@@ -952,7 +975,7 @@ public enum BoardWriter: Sendable {
|
|||||||
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
|
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
|
||||||
/// disturbs anything inside it, subfolders included.
|
/// disturbs anything inside it, subfolders included.
|
||||||
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
|
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)
|
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||||
|
|
||||||
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||||
@@ -986,7 +1009,7 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func destinationOrder(
|
private static func destinationOrder(
|
||||||
_ order: Double?,
|
_ order: Double?,
|
||||||
inParent parentFolder: URL,
|
inParent parentFolder: URL,
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) -> Double {
|
) throws(BoardWriteError) -> Double {
|
||||||
if let order { return order }
|
if let order { return order }
|
||||||
let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false)
|
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
|
/// 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
|
/// 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.
|
/// 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 {
|
guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else {
|
||||||
throw BoardWriteError(
|
throw BoardWriteError(
|
||||||
operation: operation,
|
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
|
/// 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
|
/// whole gesture while nothing has happened yet — rather than after the folder has already
|
||||||
/// travelled, or with a copy already materialized.
|
/// 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 indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
|
||||||
let document = try readDocument(at: indexURL, operation: operation)
|
let document = try readDocument(at: indexURL, operation: operation)
|
||||||
|
let operation = operation.withTitle(document.title.value)
|
||||||
try checkEditable(document, at: indexURL, operation: operation)
|
try checkEditable(document, at: indexURL, operation: operation)
|
||||||
|
return operation
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reading
|
// 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
|
/// 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
|
/// 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).
|
/// 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
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try Data(contentsOf: url)
|
data = try Data(contentsOf: url)
|
||||||
@@ -1055,7 +1089,7 @@ public enum BoardWriter: Sendable {
|
|||||||
private static func checkEditable(
|
private static func checkEditable(
|
||||||
_ document: FrontmatterDocument,
|
_ document: FrontmatterDocument,
|
||||||
at url: URL,
|
at url: URL,
|
||||||
operation: String
|
operation: WriteOperation
|
||||||
) throws(BoardWriteError) {
|
) throws(BoardWriteError) {
|
||||||
if let shape = document.uneditableShape {
|
if let shape = document.uneditableShape {
|
||||||
throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
|
throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
|
||||||
@@ -1112,6 +1146,81 @@ public struct ImportedAttachment: Sendable, Equatable {
|
|||||||
public let fileName: String
|
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
|
// MARK: - Error
|
||||||
|
|
||||||
/// A write that did not happen, said out loud: which operation, which file, and why —
|
/// 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
|
/// ("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.
|
/// user's back; a one-shot action fails once and waits for them to act again.
|
||||||
public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible {
|
public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible {
|
||||||
/// An imperative human phrase for what was being attempted — "reorder card", "renumber
|
/// What was being attempted when the write failed — the closed `WriteOperation` vocabulary,
|
||||||
/// children" — supplied by the call site, because only it knows what the user asked for.
|
/// not a string (settled, 02-architecture.md § Write-failure surfacing): the banner switches
|
||||||
public let operation: String
|
/// 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
|
/// 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).
|
/// 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 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 {
|
public enum Reason: Sendable, Equatable, CustomStringConvertible {
|
||||||
/// The file is missing, is not UTF-8, or its frontmatter does not parse — `message`
|
/// The file is missing, is not UTF-8, or its frontmatter does not parse — `message`
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ struct BoardStoreTests {
|
|||||||
|
|
||||||
// A Writer operation that fails partway has still touched disk, so the bracket has to close
|
// 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.
|
// 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 {
|
do {
|
||||||
try store.performWrite { () throws(BoardWriteError) -> Void in throw boom }
|
try store.performWrite { () throws(BoardWriteError) -> Void in throw boom }
|
||||||
Issue.record("expected the operation's own error to propagate")
|
Issue.record("expected the operation's own error to propagate")
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ struct BoardWriterPreservationTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let folder = try fixture.item("card", Fixture.rich)
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ struct BoardWriterPreservationTests {
|
|||||||
let folder = try fixture.item("card", Fixture.rich)
|
let folder = try fixture.item("card", Fixture.rich)
|
||||||
let body = Data("Body text.\n\nMore body — with *markdown*.\n".utf8)
|
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))
|
document.set(FrontmatterKeys.order, to: .double(2048))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ struct BoardWriterPreservationTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let folder = try fixture.item("card", Fixture.minimal)
|
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"))
|
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"
|
+ "modified: 2026-01-01T00:00:00Z\r\nmodified-by: claude\r\n---\r\nbody\r\n"
|
||||||
let folder = try fixture.item("card", text)
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ struct BoardWriterStampTests {
|
|||||||
let folder = try fixture.item("card", Fixture.rich)
|
let folder = try fixture.item("card", Fixture.rich)
|
||||||
#expect(try FrontmatterDocument.parse(fixture.indexText("card")).modifiedBy == .valid("claude"))
|
#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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +193,7 @@ struct BoardWriterStampTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let folder = try fixture.item("card", Fixture.minimal)
|
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.modified, to: .date(Date(timeIntervalSince1970: 0)))
|
||||||
document.set(FrontmatterKeys.modifiedBy, to: .string("claude"))
|
document.set(FrontmatterKeys.modifiedBy, to: .string("claude"))
|
||||||
}
|
}
|
||||||
@@ -210,7 +210,7 @@ struct BoardWriterStampTests {
|
|||||||
defer { fixture.tearDown() }
|
defer { fixture.tearDown() }
|
||||||
let folder = try fixture.item("card", Fixture.minimal)
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,14 +244,15 @@ struct BoardWriterUneditableTests {
|
|||||||
#expect(document.order == .valid(1024))
|
#expect(document.order == .valid(1024))
|
||||||
|
|
||||||
let error = writeFailure {
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
#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?.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.indexText("card") == Fixture.flowMapping)
|
||||||
#expect(try fixture.entryNames("card") == ["index.md"])
|
#expect(try fixture.entryNames("card") == ["index.md"])
|
||||||
}
|
}
|
||||||
@@ -265,7 +266,7 @@ struct BoardWriterUneditableTests {
|
|||||||
#expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey)
|
#expect(try FrontmatterDocument.parse(Fixture.nonScalarKey).serialized() == Fixture.nonScalarKey)
|
||||||
|
|
||||||
let error = writeFailure {
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,7 +291,7 @@ struct BoardWriterFailureTests {
|
|||||||
let folder = try fixture.item("card", bytes: latin1)
|
let folder = try fixture.item("card", bytes: latin1)
|
||||||
|
|
||||||
let error = writeFailure {
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -310,7 +311,7 @@ struct BoardWriterFailureTests {
|
|||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let error = writeFailure {
|
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 {
|
guard case .unreadable = error?.reason else {
|
||||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
||||||
@@ -326,7 +327,7 @@ struct BoardWriterFailureTests {
|
|||||||
let folder = try fixture.item("card", text)
|
let folder = try fixture.item("card", text)
|
||||||
|
|
||||||
let error = writeFailure {
|
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 {
|
guard case .unreadable = error?.reason else {
|
||||||
Issue.record("expected .unreadable, got \(String(describing: error?.reason))")
|
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) }
|
defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: folder.path) }
|
||||||
|
|
||||||
let error = writeFailure {
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("Renamed"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,7 +356,10 @@ struct BoardWriterFailureTests {
|
|||||||
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
Issue.record("expected .io, got \(String(describing: error?.reason))")
|
||||||
return
|
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(error?.path.hasSuffix("card/index.md") == true)
|
||||||
#expect(try fixture.indexData("card") == before)
|
#expect(try fixture.indexData("card") == before)
|
||||||
#expect(try fixture.entryNames("card") == ["index.md"])
|
#expect(try fixture.entryNames("card") == ["index.md"])
|
||||||
@@ -458,7 +462,7 @@ struct BoardWriterRenumberTests {
|
|||||||
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
|
let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.url("lane")) }
|
||||||
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
||||||
#expect(error?.path.contains(Child.b) == true)
|
#expect(error?.path.contains(Child.b) == true)
|
||||||
#expect(error?.operation == "renumber children")
|
#expect(error?.operation == .renumberChildren)
|
||||||
#expect(try fixture.indexData("lane/\(Child.a)") == untouched)
|
#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")
|
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 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"))
|
document.set(FrontmatterKeys.title, to: .string("Doing"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -734,7 +738,7 @@ struct BoardWriterCreateChildTests {
|
|||||||
}
|
}
|
||||||
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
#expect(error?.reason == .unreadable(message: "malformed 'order' field: banana"))
|
||||||
#expect(error?.path.contains(Child.a) == true)
|
#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.
|
// Nothing was minted: the scan fails before the new folder is ever created.
|
||||||
#expect(try fixture.entryNames("") == before)
|
#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)")
|
try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card3)", to: "A.kanban/\(Ident.lane2)")
|
||||||
}
|
}
|
||||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
#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.indexText("A.kanban/\(Ident.lane1)/\(Ident.card3)") == Item.uneditable)
|
||||||
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == [Ident.card2, "index.md"])
|
#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?.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)
|
#expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1661,6 +1669,39 @@ struct BoardWriterDeleteRestoreTests {
|
|||||||
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
#expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
|
||||||
#expect(try fixture.indexText(Ident.lane1) == Fixture.flowMapping)
|
#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
|
// MARK: - Purge
|
||||||
@@ -1896,7 +1937,7 @@ struct BoardWriterImportAttachmentsTests {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
#expect(error?.path == missing.path)
|
#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"])
|
#expect(try fixture.entryNames("\(Ident.card1)/attachments") == ["shot.png"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -131,17 +131,17 @@ struct WriteFidelityMinimalTouchTests {
|
|||||||
|
|
||||||
try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) {
|
try step("reorder", targeting: ["\(Ident.lane1)/\(Ident.card1)"]) {
|
||||||
try BoardWriter.updateIndex(
|
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)) }
|
) { $0.set(FrontmatterKeys.order, to: .double(1536)) }
|
||||||
}
|
}
|
||||||
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
try step("style write", targeting: ["\(Ident.lane1)/\(Ident.card2)"]) {
|
||||||
try BoardWriter.updateIndex(
|
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")) }
|
) { $0.set(FrontmatterKeys.background, to: .string("blue")) }
|
||||||
}
|
}
|
||||||
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
try step("rename", targeting: ["\(Ident.lane2)/\(Ident.card3)"]) {
|
||||||
try BoardWriter.updateIndex(
|
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")) }
|
) { $0.set(FrontmatterKeys.title, to: .string("Renamed Three")) }
|
||||||
}
|
}
|
||||||
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
try step("delete", targeting: ["\(Ident.lane2)/\(Ident.card4)"]) {
|
||||||
@@ -308,7 +308,7 @@ struct WriteFidelityUnknownKeyOrderTests {
|
|||||||
"""
|
"""
|
||||||
let folder = try fixture.item("card", original)
|
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"))
|
document.set(FrontmatterKeys.title, to: .string("After"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +349,7 @@ struct WriteFidelityCompositeTests {
|
|||||||
let card3 = try BoardWriter.createCard(inLane: lane1Folder, title: "Third")
|
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")] {
|
for (id, body) in [(card1, "First body.\n"), (card2, "Second body.\n"), (card3, "Third body.\n")] {
|
||||||
try BoardWriter.updateIndex(
|
try BoardWriter.updateIndex(
|
||||||
inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: "edit body"
|
inItemFolder: lane1Folder.appendingPathComponent(id.rawValue), operation: .style(title: nil)
|
||||||
) { $0.body = body }
|
) { $0.body = body }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user