From eb9e1e413fa8d7570b5c4ebaf8e5235b80d2f378 Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 17:37:24 -0400 Subject: [PATCH] Implement move and copy identity semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit moveItem: physical folder move, UUID and created travel unchanged; exactly one file rewritten (the moved root's order, stamped). Import boundary detected by resolved board-root comparison; on a cross-board move-in, arriving UUIDs colliding with any identity in the destination board (tombstones included) are reminted per folder at the finest grain — folder rename only, file bytes untouched, every repair reported in MoveResult. A colliding root moves straight to its minted name. A same-parent move degrades to a plain reorder, self excluded from the appended-rank scan. copyItem: whole-tree copy minting fresh UUIDs at every depth; .fork keeps created while stamping modified and clearing modified-by, .born (template instantiation) stamps created fresh too. Nested uneditable or unreadable files copy byte-verbatim rather than blocking the gesture; the root must be rewritable. All-or-nothing at the destination — any failure removes the partial tree. 24 new unit tests; 220 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Storage/BoardWriter.swift | 485 ++++++++++++++++++- KanbanTests/BoardWriterTests.swift | 733 +++++++++++++++++++++++++++++ 2 files changed, 1197 insertions(+), 21 deletions(-) diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index e54bddf..9d3cadf 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -195,7 +195,7 @@ public enum BoardWriter: Sendable { title: String?, operation: String ) throws(BoardWriteError) -> ItemID { - try checkIsDirectory(parentFolder, operation: operation) + try checkIsDirectory(parentFolder, describedAs: "parent folder", operation: operation) let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) let order = Ranks.append(toVisible: siblings.map(\.order)) @@ -231,19 +231,15 @@ public enum BoardWriter: Sendable { return document.serialized() } - /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the folder-naming convention - /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase - /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match - /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. A freshly minted UUID - /// already existing is astronomically unlikely — 122 bits of randomness per mint — but - /// checked for and re-minted anyway rather than assumed away; the loop body is trivial - /// precisely because the case it handles essentially never fires. + /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the create path's mint, which + /// 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 { - var candidate: URL - repeat { - candidate = parentFolder.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true) - } while FileManager.default.fileExists(atPath: candidate.path) - + let candidate = parentFolder.appendingPathComponent( + freshUUIDName(in: parentFolder, avoiding: []), + isDirectory: true + ) do { try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false) } catch { @@ -256,18 +252,43 @@ public enum BoardWriter: Sendable { return candidate } - /// The parent-exists check `createChild` runs before touching the filesystem any further — - /// shared instead of folded into `createChild` because "does this folder exist" has nothing - /// to do with siblings or minting, and inlining it would bury the one thing a caller most - /// needs to see at a glance: a missing or wrong-shaped parent is rejected before anything - /// else happens. - private static func checkIsDirectory(_ url: URL, operation: String) throws(BoardWriteError) { + /// A fresh lowercase-UUIDv4 folder *name* for `parentFolder` — the folder-naming convention + /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase + /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match + /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. + /// + /// Two exclusions, both re-minted rather than assumed away: a name already on disk in + /// `parentFolder` (so the caller's `createDirectory`/`moveItem`/`copyItem` cannot lose a + /// race with an existing entry), and any name in `taken` — the identities a collision repair + /// is minting *away* from, which are not necessarily on disk here. A freshly minted UUID + /// hitting either is astronomically unlikely — 122 bits of randomness per mint — but the + /// loop body is trivial precisely because the case it handles essentially never fires. + private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set) -> String { + var name: String + repeat { + name = UUID().uuidString.lowercased() + } while taken.contains(name) + || FileManager.default.fileExists(atPath: parentFolder.appendingPathComponent(name).path) + return name + } + + /// The folder-exists check every operation runs before touching the filesystem any further — + /// shared instead of folded into its callers because "does this folder exist" has nothing to + /// do with siblings, minting, or moving, and inlining it would bury the one thing a caller + /// most needs to see at a glance: a missing or wrong-shaped folder is rejected before + /// anything else happens. `role` names it the way the failing user action would ("parent + /// folder", "item folder") — the error goes straight into the write-failure banner. + private static func checkIsDirectory( + _ url: URL, + describedAs role: String, + operation: String + ) throws(BoardWriteError) { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { - throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent folder does not exist")) + throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) does not exist")) } guard isDirectory.boolValue else { - throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent is not a directory")) + throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory")) } } @@ -371,6 +392,391 @@ public enum BoardWriter: Sendable { return visible } + // MARK: - Move + + /// Moves a lane or card — and the whole tree beneath it — under another parent, in the same + /// board or across boards. **A move is a physical folder move and the UUID travels + /// unchanged** (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves + /// keep the UUID, copies mint fresh ones"): nothing below the moved root is read or + /// rewritten, so nested cards, `attachments/`, and strays arrive byte-identical by + /// construction rather than by copying carefully. + /// + /// Exactly one file is rewritten — the moved root's `index.md`, and only to carry its new + /// `order` (§ Ordering, "a reorder rewrites only the moved item's `index.md`"), stamped and + /// `modified-by`-cleared like every other app write. `order` is the caller's explicit rank + /// (a drop between two siblings), or `nil` to append after the destination's visible + /// siblings. + /// + /// **The import boundary is the one place within-board uniqueness is enforced** (§ Fractal + /// layout ▸ Rules). `sourceBoardRoot` and `destinationBoardRoot` are compared by resolved, + /// standardized path; when they differ, this is an import, and an arriving UUID that already + /// exists anywhere in the destination board — **tombstones included, because they are on + /// disk** — is degraded to a copy: a fresh UUID is minted for that folder and nothing else. + /// Degradation is **per folder, at the finest grain**: a lane arriving with one colliding + /// card is still a lane *move*, and only that card arrives reminted. A remint is an identity + /// repair, not an edit — the folder is renamed and its files' bytes are never touched, so + /// the reminted item's `modified`, `modified-by`, and history are exactly what the source + /// had. Every repair is reported in `MoveResult.reminted`; a same-board move never remints, + /// and the same UUID living in two boards is not an error anywhere else — boards are + /// **independent identity namespaces**, and forks only ever meet here. + /// + /// The order of operations is the contract: + /// + /// 1. **Validate before anything else**: source and destination parent must be existing + /// directories, and the source must be UUID-shaped — this writer moves lanes and cards, + /// and a stray is not one (§ Fractal layout ▸ Rules, "Name shape gates level detection"). + /// 2. **Pre-flight the moved root's editability** (`readDocument` + `checkEditable`): the + /// move must rewrite that file at the destination, so a file that cannot be round-tripped + /// refuses *before* the folder moves — discover-before-you-write, the same guarantee + /// `renumberVisibleChildren` depends on. Only the root needs it: a move never rewrites a + /// nested file, so an uneditable card inside a moved lane is none of this call's business. + /// 3. **Compute the destination `order` before the move**, off the destination's visible + /// siblings — while the moved item is still elsewhere and so cannot count itself. + /// 4. **Scan the destination board's identities** (depth 1 and 2, `directoryCandidates` + + /// `isUUIDShaped`; strays skipped, tombstones kept) — but only on an import, since a + /// same-board move cannot collide with anything but itself. + /// 5. **Move the folder** (`FileManager.moveItem`, which degrades to copy+remove across + /// volumes). A colliding *root* is renamed by moving it straight to its minted name + /// rather than moving and then renaming: one filesystem operation instead of two, and it + /// is also the only way the repair works when the collision is a sibling sitting in the + /// very destination parent — a plain move onto an existing name fails outright. + /// 6. **Repair the arrived tree's children** — the moved root's direct UUID-shaped children, + /// which is every level a compound arrival can carry (a lane's cards; a card has no + /// UUID-shaped children). + /// 7. **Rewrite the moved root's `index.md`** with the rank from step 3. + /// + /// **Atomic per filesystem operation, not per gesture** — the accepted edge. A rename that + /// fails mid-repair, or the `updateIndex` that fails once the folder has already arrived, + /// leaves the item at the destination with a stale `order` and possibly a partly repaired + /// tree. Every value on disk is still valid, the failure is surfaced verbatim through the + /// thrown error (02-architecture.md § Write-failure surfacing), and the caller's reload + /// shows the true state — nothing is silently retried or rolled back behind the user's back. + /// + /// **A move whose destination is the item's current parent degrades to a plain reorder** — + /// no folder to move, no boundary to cross, just the `order` rewrite (step 7) with the + /// appended rank computed over the siblings *excluding the item itself*, which unlike every + /// other move is already there to be miscounted. A drop that lands back in its own lane is + /// the same gesture as one that lands elsewhere; the writer doing the right degenerate + /// thing keeps the API total instead of leaking a `FileManager` name collision. + public static func moveItem( + at sourceFolder: URL, + toParent destinationParent: URL, + sourceBoardRoot: URL, + destinationBoardRoot: URL, + order: Double? + ) throws(BoardWriteError) -> MoveResult { + let operation = "move item" + 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) + + let sourceName = sourceFolder.lastPathComponent + + if isSameLocation(sourceFolder.deletingLastPathComponent(), destinationParent) { + let rank: Double + if let order { + rank = order + } else { + let siblings = try visibleSiblings(of: destinationParent, operation: operation, requireEditable: false) + rank = Ranks.append(toVisible: siblings.filter { $0.folder.lastPathComponent != sourceName }.map(\.order)) + } + try updateIndex(inItemFolder: sourceFolder, operation: operation) { document in + document.set(FrontmatterKeys.order, to: .double(rank)) + } + return MoveResult(id: ItemID(rawValue: sourceName), reminted: []) + } + + let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) + + let isImport = !isSameLocation(sourceBoardRoot, destinationBoardRoot) + let existing = isImport ? identities(inBoard: destinationBoardRoot) : [] + var reserved = existing + var reminted: [MoveResult.Remint] = [] + + var arrivedName = sourceName + if existing.contains(sourceName) { + arrivedName = freshUUIDName(in: destinationParent, avoiding: reserved) + reserved.insert(arrivedName) + reminted.append(MoveResult.Remint(from: ItemID(rawValue: sourceName), to: ItemID(rawValue: arrivedName))) + } + let arrivedRoot = destinationParent.appendingPathComponent(arrivedName, isDirectory: true) + + do { + try FileManager.default.moveItem(at: sourceFolder, to: arrivedRoot) + } catch { + throw BoardWriteError( + operation: operation, + path: sourceFolder.path, + reason: .io(message: "could not move folder: \(error.localizedDescription)") + ) + } + + if isImport { + let children = childCandidates(of: arrivedRoot) + reserved.formUnion(children.map(\.lastPathComponent)) + for child in children where existing.contains(child.lastPathComponent) { + let fresh = freshUUIDName(in: arrivedRoot, avoiding: reserved) + reserved.insert(fresh) + try renameFolder(child, toSiblingNamed: fresh, operation: operation) + reminted.append(MoveResult.Remint( + from: ItemID(rawValue: child.lastPathComponent), + to: ItemID(rawValue: fresh) + )) + } + } + + try updateIndex(inItemFolder: arrivedRoot, operation: operation) { document in + document.set(FrontmatterKeys.order, to: .double(rank)) + } + + return MoveResult(id: ItemID(rawValue: arrivedName), reminted: reminted) + } + + /// Every identity a board already holds: its UUID-shaped lane folders and their UUID-shaped + /// card folders — the two depths where identity lives (§ Fractal layout, "Level is + /// position"). **Tombstoned items count**: a tombstone is a live folder on disk carrying a + /// `deleted:` key, and arriving on top of one would be exactly the duplicate the import + /// boundary exists to prevent (a Put Back would then resurrect a twin). Strays do not count + /// — they are not identities at all — and neither does anything deeper: a card has no + /// UUID-shaped children, and a UUID-shaped folder under `attachments/` is content, not a + /// level. + /// + /// Reads through the loader's own door (`directoryCandidates`), so the writer's idea of what + /// a board contains can never drift from the loader's. An unreadable folder yields no + /// candidates rather than failing: the same degradation the loader applies below the root, + /// and the conservative direction here — a missed identity remints nothing, and a duplicate + /// UUID in one board is the unspecified-behavior case the design already names, not a + /// corruption. + private static func identities(inBoard boardRoot: URL) -> Set { + var identities: Set = [] + for lane in childCandidates(of: boardRoot) { + identities.insert(lane.lastPathComponent) + for card in childCandidates(of: lane) { + identities.insert(card.lastPathComponent) + } + } + return identities + } + + /// A folder's UUID-shaped subfolders in deterministic order — `directoryCandidates` (hidden + /// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's + /// level-detection rule and therefore the only definition of "an identity-bearing child" + /// this writer is allowed to have. + private static func childCandidates(of folder: URL) -> [URL] { + let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? [] + return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } + } + + /// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a + /// copy's remint. The folder's contents, `index.md` included, are never opened. + private static func renameFolder( + _ folder: URL, + toSiblingNamed name: String, + operation: String + ) throws(BoardWriteError) { + let destination = folder.deletingLastPathComponent().appendingPathComponent(name, isDirectory: true) + do { + try FileManager.default.moveItem(at: folder, to: destination) + } catch { + throw BoardWriteError( + operation: operation, + path: folder.path, + reason: .io(message: "could not rename folder: \(error.localizedDescription)") + ) + } + } + + /// Whether two URLs name the same place on disk — symlinks resolved, `..`/`.` standardized + /// away. The import boundary turns on this one comparison, so it is deliberately about + /// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board, + /// and treating them as two would remint every arriving item for nothing. + private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool { + lhs.resolvingSymlinksInPath().standardizedFileURL.path + == rhs.resolvingSymlinksInPath().standardizedFileURL.path + } + + // MARK: - Copy + + /// Copies a lane or card — and the whole tree beneath it — under another parent, minting + /// **fresh UUIDs for every folder it materializes** (01-storage-format.md § Fractal layout ▸ + /// Rules, "Identity lifecycle: moves keep the UUID, copies mint fresh ones"). The copy is a + /// new identity at every level: a copied lane's cards are new cards, not the same cards seen + /// twice. Returns the new root's identity. This is the item-level copy — ⌘C/⌘V, ⌥-drag + /// duplicate, the cross-board drag default; whole-board copies (Save as Template, File ▸ + /// Duplicate) keep their GUIDs and are not this call. + /// + /// Everything travels verbatim: `attachments/` and its subfolders, strays, bodies, unknown + /// keys, comments, line endings. Only the schema's provenance stamps move, per `stamps`: + /// + /// - `.fork` — an ordinary copy **keeps `created`** (it is a fork of something that really + /// was created then) while `modified` is stamped and `modified-by` cleared, because a + /// paste or a duplicate is an app write (§ Frontmatter). + /// - `.born` — template instantiation stamps `created` **and** `modified` fresh, from one + /// `Date` for the whole tree: a card made from a template is born today, not forked from + /// the template (09-templates.md). + /// + /// Two deliberate leniencies below the root, both of them "what a hand copy would do": + /// + /// - A nested `index.md` that is **readable-but-uneditable** (§ Frontmatter), or that cannot + /// be read at all, is copied byte-verbatim and simply not stamped. Refusing an entire copy + /// because one nested card is a flow mapping would be hostile, and the file arrives + /// *exactly* as it was rather than corrupted — its stale `modified-by` attribution + /// surviving is the self-reported-provenance honest limit § Frontmatter already + /// acknowledges. The **root** gets no such leniency: it must be rewritten (it needs its + /// new `order`), so an unreadable or uneditable root refuses the copy up front, before + /// anything is materialized. + /// - A nested UUID-shaped folder with **no `index.md`** — interrupted-create residue — is + /// copied and reminted like any other, and not rewritten: the same skip the loader applies + /// to it (`.missingIndex`). + /// + /// **All-or-nothing at the destination**, unlike a move: any failure once copying has begun + /// removes the partially copied tree best-effort and rethrows, because a half-copied item is + /// pure residue — nothing was there before, so there is no true state for a reload to show. + /// The source is never touched on any path. + public static func copyItem( + at sourceFolder: URL, + toParent destinationParent: URL, + order: Double?, + stamps: CopyStamps + ) throws(BoardWriteError) -> ItemID { + let operation = "copy item" + 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) + + let rank = try destinationOrder(order, inParent: destinationParent, operation: operation) + + let rootName = freshUUIDName(in: destinationParent, avoiding: []) + let root = destinationParent.appendingPathComponent(rootName, isDirectory: true) + do { + try FileManager.default.copyItem(at: sourceFolder, to: root) + } catch { + try? FileManager.default.removeItem(at: root) + throw BoardWriteError( + operation: operation, + path: sourceFolder.path, + reason: .io(message: "could not copy folder: \(error.localizedDescription)") + ) + } + + do { + var copied: [URL] = [] + try remintDescendants(of: root, collecting: &copied, operation: operation) + + let now = Date() + try updateIndex(inItemFolder: root, operation: operation) { document in + if case .born = stamps { + document.set(FrontmatterKeys.created, to: .date(now)) + } + document.set(FrontmatterKeys.order, to: .double(rank)) + } + for folder in copied { + try stampCopiedDescendant(at: folder, stamps: stamps, now: now, operation: operation) + } + } catch { + try? FileManager.default.removeItem(at: root) + throw error + } + + return ItemID(rawValue: rootName) + } + + /// Renames every UUID-shaped folder beneath `folder` to a fresh mint, depth first, and + /// collects where they ended up — "copies mint fresh UUIDs for **every** folder they + /// materialize", which for a copied lane means its cards too. Recursion rather than a + /// two-level walk because the rule is about folders, not levels; non-UUID-shaped folders + /// (`attachments/` and anything under it, strays) are neither renamed nor descended into, + /// exactly as the loader treats them. + /// + /// Each child is renamed *before* being descended into, so the collected URLs are the final + /// ones — a caller can stamp them without re-deriving any path. The mint only has to avoid + /// what is on disk in that folder: the siblings not yet renamed are still there under their + /// original names, and `freshUUIDName` checks for exactly that. + private static func remintDescendants( + of folder: URL, + collecting copied: inout [URL], + operation: String + ) throws(BoardWriteError) { + for child in childCandidates(of: folder) { + let fresh = freshUUIDName(in: folder, avoiding: []) + try renameFolder(child, toSiblingNamed: fresh, operation: operation) + + let renamed = folder.appendingPathComponent(fresh, isDirectory: true) + copied.append(renamed) + try remintDescendants(of: renamed, collecting: &copied, operation: operation) + } + } + + /// Stamps one copied folder below the root — best-effort by design (see `copyItem`): a + /// missing, unreadable, or uneditable `index.md` is left exactly as the copy found it rather + /// than failing the gesture. `order` is not touched: a nested item keeps its rank among its + /// own siblings, which travelled with it. + private static func stampCopiedDescendant( + at folder: URL, + stamps: CopyStamps, + now: Date, + operation: String + ) throws(BoardWriteError) { + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + guard FileManager.default.fileExists(atPath: indexURL.path), + let copied = try? readDocument(at: indexURL, operation: operation), + copied.uneditableShape == nil + else { return } + + try updateIndex(inItemFolder: folder, operation: operation) { document in + if case .born = stamps { + document.set(FrontmatterKeys.created, to: .date(now)) + } + } + } + + // MARK: - Move/copy pre-flight + + /// The rank a moved or copied root lands on: the caller's explicit value — a drop between + /// two siblings, whose midpoint only the caller knows — or `max + 1024` over the + /// destination's visible siblings when it has none (`Ranks.append(toVisible:)`, tombstones + /// inert). Computed *before* the item arrives, so it cannot count itself as its own sibling. + /// + /// `requireEditable: false` for the same reason the creates pass it: this only *reads* the + /// siblings' orders. A flow-mapping sibling that loads and renders normally must not block + /// dropping an item beside it. + private static func destinationOrder( + _ order: Double?, + inParent parentFolder: URL, + operation: String + ) throws(BoardWriteError) -> Double { + if let order { return order } + let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) + return Ranks.append(toVisible: siblings.map(\.order)) + } + + /// Refuses a folder that is not a lane or a card. Level detection is by name shape + /// (01-storage-format.md § Fractal layout ▸ Rules), so a stray — `notes/`, an uppercase + /// UUID, a hand-made folder — is not an item, and moving or copying one as if it were would + /// invent an identity the loader would then ignore. + private static func checkIsUUIDShaped(_ folder: URL, operation: String) throws(BoardWriteError) { + guard BoardLoader.isUUIDShaped(folder.lastPathComponent) else { + throw BoardWriteError( + operation: operation, + path: folder.path, + reason: .unreadable(message: "folder name is not UUID-shaped: only lanes and cards move and copy") + ) + } + } + + /// The discover-before-you-write pre-flight `moveItem` and `copyItem` both run on the root + /// they are about to relocate: that file *will* be rewritten at the destination (its `order`, + /// 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) { + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + let document = try readDocument(at: indexURL, operation: operation) + try checkEditable(document, at: indexURL, operation: operation) + } + // MARK: - Reading /// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**: @@ -413,6 +819,43 @@ public enum BoardWriter: Sendable { } } +// MARK: - Move and copy vocabulary + +/// What a move did to identity: where the item ended up, plus every remint the import boundary +/// performed on the way in (01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle"). +/// +/// `id` is the source identity unchanged for every ordinary move — same board or not — and the +/// minted one when the arriving root itself collided. `reminted` is empty for every move that +/// crossed no import boundary, and for every import that found no collision; when it is not, +/// each entry is a folder whose UUID the destination board already held, repaired in place. +/// Callers need both: `id` to select or scroll to the moved item afterwards, `reminted` to +/// re-point anything that was holding the old identities (a selection, an open card window). +/// +/// Content is not part of this: a remint renames a folder and touches no bytes, so nothing in +/// here implies an edit. +public struct MoveResult: Sendable, Equatable { + public let id: ItemID + public let reminted: [Remint] + + public struct Remint: Sendable, Equatable { + public let from: ItemID + public let to: ItemID + } +} + +/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy +/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter). +public enum CopyStamps: Sendable { + /// An ordinary copy — paste, duplicate, a cross-board drag: **keeps `created`**, because a + /// fork really was created when its original was, stamps `modified`, and clears + /// `modified-by` (a copy is an app write). + case fork + + /// Template instantiation (09-templates.md): stamps `created` **and** `modified` fresh and + /// strips `modified-by` — the result is born today, not forked from the template. + case born +} + // MARK: - Error /// A write that did not happen, said out loud: which operation, which file, and why — diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index f2af662..3941bd8 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -56,6 +56,24 @@ private struct WriterFixture { return folder } + /// Writes an arbitrary file — not an `index.md` — creating its folder: attachments and + /// strays, the content a copy has to carry verbatim without ever reading it. + @discardableResult + func file(_ relativePath: String, _ bytes: Data) throws -> URL { + let fileURL = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try bytes.write(to: fileURL) + return fileURL + } + + func data(_ relativePath: String) throws -> Data { + try Data(contentsOf: root.appendingPathComponent(relativePath)) + } + + func exists(_ relativePath: String) -> Bool { + FileManager.default.fileExists(atPath: url(relativePath).path) + } + func indexData(_ relativePath: String) throws -> Data { try Data(contentsOf: url(relativePath).appendingPathComponent("index.md")) } @@ -122,6 +140,67 @@ private enum Child { static let indexless = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee" } +/// Literal UUID-shaped names for the move/copy suites, which need more of them than `Child` +/// offers — an import-boundary test has the *same* identity living in two boards at once, and a +/// compound arrival needs a lane with several cards. +private enum Ident { + static let lane1 = "11111111-1111-4111-8111-111111111111" + static let lane2 = "22222222-2222-4222-8222-222222222222" + static let lane3 = "33333333-3333-4333-8333-333333333333" + static let lane4 = "44444444-4444-4444-8444-444444444444" + static let card1 = "55555555-5555-4555-8555-555555555555" + static let card2 = "66666666-6666-4666-8666-666666666666" + static let card3 = "77777777-7777-4777-8777-777777777777" + static let card4 = "99999999-9999-4999-8999-999999999999" + static let indexless = "88888888-8888-4888-8888-888888888888" +} + +/// The `index.md` texts the move/copy suites move and copy around. +private enum Item { + static let board = "---\nschema: 1\ntitle: Board\n---\nBoard description.\n" + + /// Everything a move or a copy has to leave alone: unknown keys with an inline comment, a + /// `created` stamp from before today, a foreign `modified-by`, and a body. + static func rich(order: String, title: String) -> String { + """ + --- + schema: 1 + title: \(title) + order: \(order) + project: lanework # agent overlay + labels: [a, b, c] + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + modified-by: claude + --- + \(title) body — with *markdown*. + + """ + } + + /// A whole-frontmatter flow mapping carrying a `modified-by`: readable, uneditable, and so + /// left byte-verbatim by a copy — stale attribution included. + static let uneditable = "---\n{schema: 1, order: 1024, title: Odd, modified-by: claude}\n---\nodd body\n" +} + +/// The keys a move or a copy is allowed to have touched; every other line must be byte-identical. +private let rewrittenKeys = [FrontmatterKeys.order, FrontmatterKeys.modified, FrontmatterKeys.modifiedBy] + +/// A folder's UUID-shaped children keyed by the `title` inside them. A copy remints every folder +/// it materializes, so the file's own content is the only way back to "which card is which". +private func childrenByTitle(of relativePath: String, in fixture: WriterFixture) throws -> [String: String] { + var byTitle: [String: String] = [:] + for child in try BoardLoader.directoryCandidates(in: fixture.url(relativePath)) + where BoardLoader.isUUIDShaped(child.lastPathComponent) { + let name = child.lastPathComponent + guard fixture.exists("\(relativePath)/\(name)/index.md") else { continue } + if let title = try FrontmatterDocument.parse(fixture.indexText("\(relativePath)/\(name)")).title.value { + byTitle[title] = name + } + } + return byTitle +} + private func writeFailure(_ operation: () throws -> Void) -> BoardWriteError? { do { try operation() @@ -920,3 +999,657 @@ struct BoardWriterEditabilityScopeTests { #expect(try fixture.indexText(Child.a) == Self.flowSibling) } } + +// MARK: - Move + +/// 01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle: moves keep the UUID, +/// copies mint fresh ones" — the move half, including the import boundary where a colliding +/// UUID is degraded to a copy. +struct BoardWriterMoveTests { + /// Board `A.kanban`: two lanes, each holding one card, everything rich enough that a + /// byte-level assertion means something. + private func boardA(_ fixture: WriterFixture) throws { + try fixture.item("A.kanban", Item.board) + try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) + try fixture.item("A.kanban/\(Ident.lane2)", Item.rich(order: "2048", title: "Doing")) + try fixture.item("A.kanban/\(Ident.lane2)/\(Ident.card2)", Item.rich(order: "1024", title: "Card Two")) + } + + /// Board `B.kanban`: a separate identity namespace with two lanes of its own, one of them + /// holding a card. + private func boardB(_ fixture: WriterFixture) throws { + try fixture.item("B.kanban", Item.board) + try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Inbox")) + try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card3)", Item.rich(order: "1024", title: "Card Three")) + try fixture.item("B.kanban/\(Ident.lane4)", Item.rich(order: "2048", title: "Done")) + } + + @discardableResult + private func move( + _ fixture: WriterFixture, + _ source: String, + to destination: String, + from sourceBoard: String = "A.kanban", + into destinationBoard: String = "A.kanban", + order: Double? = nil + ) throws -> MoveResult { + try BoardWriter.moveItem( + at: fixture.url(source), + toParent: fixture.url(destination), + sourceBoardRoot: fixture.url(sourceBoard), + destinationBoardRoot: fixture.url(destinationBoard), + order: order + ) + } + + /// The everyday move: a card to another lane in the same board. The folder travels whole, + /// its identity travels with it, and the only lines that may differ are the ones the + /// destination `order` and the app-write stamps own. + @Test func aCrossLaneMoveKeepsTheIdentityAndRewritesOnlyTheOrderAndStamps() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") + let siblingBefore = try fixture.indexData("A.kanban/\(Ident.lane2)/\(Ident.card2)") + + let result = try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)") + + #expect(result.id.rawValue == Ident.card1) + #expect(result.reminted.isEmpty) + #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)") == ["index.md"]) + + let after = try fixture.indexText("A.kanban/\(Ident.lane2)/\(Ident.card1)") + #expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys)) + + let document = try FrontmatterDocument.parse(after) + let source = try FrontmatterDocument.parse(before) + #expect(document.order == .valid(2048)) + #expect(document.created == source.created) + #expect(document.modifiedBy == .missing) + #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) + #expect(document.unknownFields.map(\.key) == ["project", "labels"]) + #expect(after.contains("project: lanework # agent overlay\n")) + + // The move rewrites exactly one file: a destination sibling is not even opened. + #expect(try fixture.indexData("A.kanban/\(Ident.lane2)/\(Ident.card2)") == siblingBefore) + #expect(try BoardLoader.load(boardRoot: fixture.url("A.kanban")).warnings.isEmpty) + } + + @Test func anExplicitOrderIsWrittenVerbatim() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + + try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)", order: 1536) + + let document = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane2)/\(Ident.card1)")) + #expect(document.order == .valid(1536)) + } + + /// Boards are independent identity namespaces: an import that collides with nothing is an + /// ordinary move, UUID and `created` intact. + @Test func aCrossBoardMoveWithoutACollisionKeepsTheIdentity() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") + + let result = try move( + fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", + to: "B.kanban/\(Ident.lane3)", into: "B.kanban" + ) + + #expect(result.id.rawValue == Ident.card1) + #expect(result.reminted.isEmpty) + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + + let after = try fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)") + #expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys)) + #expect(try FrontmatterDocument.parse(after).order == .valid(2048)) + + let result2 = try BoardLoader.load(boardRoot: fixture.url("B.kanban")) + #expect(result2.warnings.isEmpty) + #expect(result2.model.lanes.first?.cards.map(\.id.rawValue) == [Ident.card3, Ident.card1]) + } + + /// The import boundary: the arriving UUID already exists elsewhere in the destination board, + /// so it is degraded to a copy — fresh identity, content untouched, source gone as for any + /// move. + @Test func anArrivingIdentityTheDestinationBoardAlreadyHoldsIsReminted() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Stale Twin")) + let twinBefore = try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") + let before = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") + + let result = try move( + fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", + to: "B.kanban/\(Ident.lane4)", into: "B.kanban" + ) + + let minted = result.id.rawValue + #expect(BoardLoader.isUUIDShaped(minted)) + #expect(minted != Ident.card1) + #expect(result.reminted == [MoveResult.Remint(from: ItemID(rawValue: Ident.card1), to: ItemID(rawValue: minted))]) + + // Content arrived intact — a remint renames a folder, it does not edit a file. + let after = try fixture.indexText("B.kanban/\(Ident.lane4)/\(minted)") + #expect(lines(of: after, excludingKeys: rewrittenKeys) == lines(of: before, excludingKeys: rewrittenKeys)) + #expect(try FrontmatterDocument.parse(after).title == .valid("Card One")) + + #expect(!fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + #expect(try fixture.indexData("B.kanban/\(Ident.lane3)/\(Ident.card1)") == twinBefore) + } + + /// The collision sitting in the very lane being dropped into — the case a move-then-rename + /// could not repair, because the plain move would fail on the existing name. + @Test func aCollisionInTheDestinationParentItselfIsStillReminted() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Stale Twin")) + + let result = try move( + fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", + to: "B.kanban/\(Ident.lane3)", into: "B.kanban" + ) + + #expect(result.id.rawValue != Ident.card1) + #expect(result.reminted.count == 1) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(result.id.rawValue)")).title + == .valid("Card One")) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")).title + == .valid("Stale Twin")) + } + + /// Degradation is per folder at the finest grain: a lane arriving with one colliding card is + /// still a lane *move*, and only that card is reminted — its bytes untouched, its + /// non-colliding siblings' identities intact. + @Test func aLaneMoveRemintsOnlyTheCollidingCard() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card4)", Item.rich(order: "3072", title: "Card Four")) + // Only this one already exists over in B. + try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card2)", Item.rich(order: "2048", title: "B's Own")) + let laneBefore = try fixture.indexText("A.kanban/\(Ident.lane1)") + let collidingBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card2)") + let keptBefore = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") + + let result = try move(fixture, "A.kanban/\(Ident.lane1)", to: "B.kanban", into: "B.kanban") + + #expect(result.id.rawValue == Ident.lane1) + #expect(result.reminted.map(\.from.rawValue) == [Ident.card2]) + let minted = try #require(result.reminted.first?.to.rawValue) + #expect(BoardLoader.isUUIDShaped(minted)) + + // The repair is folder-name-only: the reminted card's file is byte-identical. + #expect(try fixture.indexData("B.kanban/\(Ident.lane1)/\(minted)") == collidingBefore) + #expect(try fixture.indexData("B.kanban/\(Ident.lane1)/\(Ident.card1)") == keptBefore) + #expect(fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card4)")) + #expect(!fixture.exists("B.kanban/\(Ident.lane1)/\(Ident.card2)")) + + // The lane's own index.md is the one file the move rewrote. + let laneAfter = try fixture.indexText("B.kanban/\(Ident.lane1)") + #expect(lines(of: laneAfter, excludingKeys: rewrittenKeys) == lines(of: laneBefore, excludingKeys: rewrittenKeys)) + #expect(try FrontmatterDocument.parse(laneAfter).order == .valid(3072)) + #expect(try FrontmatterDocument.parse(laneAfter).modifiedBy == .missing) + + #expect(!fixture.exists("A.kanban/\(Ident.lane1)")) + #expect(try BoardLoader.load(boardRoot: fixture.url("B.kanban")).warnings.isEmpty) + } + + /// Tombstones are on disk, so they are identities: arriving on top of one would be exactly + /// the duplicate the import boundary exists to prevent. + @Test func aCollisionWithATombstonedDestinationItemStillRemints() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + try fixture.item( + "B.kanban/\(Ident.lane4)/\(Ident.card1)", + "---\nschema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + + let result = try move( + fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", + to: "B.kanban/\(Ident.lane3)", into: "B.kanban" + ) + + #expect(result.id.rawValue != Ident.card1) + #expect(result.reminted.map(\.from.rawValue) == [Ident.card1]) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane4)/\(Ident.card1)")).deleted.value != nil) + } + + /// Boards are independent identity namespaces: the same UUID living in another board is not + /// this move's business — only an import boundary ever looks. + @Test func aSameBoardMoveNeverRemintsEvenWhenAnotherBoardSharesTheUUID() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try boardB(fixture) + try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "2048", title: "Fork")) + + let result = try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/\(Ident.lane2)") + + #expect(result.id.rawValue == Ident.card1) + #expect(result.reminted.isEmpty) + #expect(fixture.exists("A.kanban/\(Ident.lane2)/\(Ident.card1)")) + #expect(fixture.exists("B.kanban/\(Ident.lane3)/\(Ident.card1)")) + } + + /// Discover before you write: the move has to rewrite the moved item's `order`, so a file it + /// cannot round-trip refuses the gesture while the folder is still where it was. + @Test func anUneditableItemRefusesTheMoveAndTheFolderDoesNotTravel() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) + + let error = writeFailure { + try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card3)", to: "A.kanban/\(Ident.lane2)") + } + #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(error?.operation == "move item") + #expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card3)") == Item.uneditable) + #expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == [Ident.card2, "index.md"]) + } + + @Test func aMissingDestinationParentRefusesTheMove() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + let before = try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") + + let error = writeFailure { + try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/nowhere") + } + guard case .unreadable = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(try fixture.indexData("A.kanban/\(Ident.lane1)/\(Ident.card1)") == before) + } + + @Test func aDestinationParentThatIsAFileRefusesTheMove() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try fixture.file("A.kanban/notes.txt", Data("x".utf8)) + + let error = writeFailure { + try move(fixture, "A.kanban/\(Ident.lane1)/\(Ident.card1)", to: "A.kanban/notes.txt") + } + guard case .unreadable = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(fixture.exists("A.kanban/\(Ident.lane1)/\(Ident.card1)")) + } + + /// Level detection is by name shape, so a stray is not an item: moving one would invent an + /// identity the loader would go on ignoring. + @Test func aStrayFolderIsNotAnItemAndCannotBeMoved() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try boardA(fixture) + try fixture.item("A.kanban/\(Ident.lane1)/notes", Item.rich(order: "1024", title: "Stray")) + + let error = writeFailure { + try move(fixture, "A.kanban/\(Ident.lane1)/notes", to: "A.kanban/\(Ident.lane2)") + } + guard case let .unreadable(message) = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + #expect(message.contains("UUID-shaped")) + #expect(fixture.exists("A.kanban/\(Ident.lane1)/notes")) + } +} + +// MARK: - Copy + +/// 01-storage-format.md § Fractal layout ▸ Rules, "Identity lifecycle" — the copy half: fresh +/// UUIDs for every folder materialized, `created` kept (a fork) or restamped (born from a +/// template), `modified-by` cleared because a copy is an app write. +struct BoardWriterCopyTests { + /// One board, one lane, two cards — the source of every copy below. + private func board(_ fixture: WriterFixture) throws { + try fixture.item("A.kanban", Item.board) + try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card One")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Card Two")) + try fixture.item("A.kanban/\(Ident.lane2)", Item.rich(order: "2048", title: "Doing")) + } + + /// Content that must travel verbatim because the copy never reads it: two attachments (one + /// in a subfolder the app never creates but preserves) and a stray file. + private func attach(_ fixture: WriterFixture, to cardPath: String) throws { + try fixture.file("\(cardPath)/attachments/sketch.png", Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])) + try fixture.file("\(cardPath)/attachments/sub/deep.bin", Data([0x00, 0x01, 0x02, 0xFE])) + try fixture.file("\(cardPath)/notes.txt", Data("hand-written\n".utf8)) + } + + /// The ⌥-drag duplicate: a card copied beside itself. Fresh identity, `created` kept, + /// `modified` stamped, `modified-by` cleared, everything else byte-identical. + @Test func aCardCopyMintsAFreshIdentityAndForksTheStamps() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + let source = "A.kanban/\(Ident.lane1)/\(Ident.card1)" + let before = try fixture.indexData(source) + + let id = try BoardWriter.copyItem( + at: fixture.url(source), + toParent: fixture.url("A.kanban/\(Ident.lane1)"), + order: nil, + stamps: .fork + ) + + #expect(BoardLoader.isUUIDShaped(id.rawValue)) + #expect(id.rawValue != Ident.card1) + + let copy = try fixture.indexText("A.kanban/\(Ident.lane1)/\(id.rawValue)") + let original = String(decoding: before, as: UTF8.self) + #expect(lines(of: copy, excludingKeys: rewrittenKeys) == lines(of: original, excludingKeys: rewrittenKeys)) + + let document = try FrontmatterDocument.parse(copy) + let sourceDocument = try FrontmatterDocument.parse(original) + #expect(document.order == .valid(3072)) + #expect(document.created == sourceDocument.created) + #expect(document.modifiedBy == .missing) + #expect(abs(try #require(document.modified.value).timeIntervalSinceNow) < 60) + #expect(document.unknownFields.map(\.key) == ["project", "labels"]) + #expect(document.body == "Card One body — with *markdown*.\n") + + // The source is never touched, on any path. + #expect(try fixture.indexData(source) == before) + } + + @Test func anExplicitOrderIsWrittenVerbatim() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url("A.kanban/\(Ident.lane2)"), + order: 512, + stamps: .fork + ) + + let document = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(Ident.lane2)/\(id.rawValue)")) + #expect(document.order == .valid(512)) + } + + /// A copied lane's cards are new cards, not the same cards seen twice: every folder the copy + /// materialized has a fresh identity, at every depth. + @Test func aLaneCopyMintsFreshIdentitiesAtEveryLevel() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)"), + toParent: fixture.url("A.kanban"), + order: nil, + stamps: .fork + ) + + #expect(id.rawValue != Ident.lane1) + let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) + #expect(Set(copied.keys) == ["Card One", "Card Two"]) + #expect(Set(copied.values).isDisjoint(with: [Ident.card1, Ident.card2])) + #expect(copied.values.allSatisfy(BoardLoader.isUUIDShaped)) + + // Ranks travel with the cards; only the copied *root* gets a new one. + let one = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))")) + let two = try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card Two"]))")) + #expect(one.order == .valid(1024)) + #expect(two.order == .valid(2048)) + #expect(one.created == .valid(try #require(FrontmatterDocument.parse(Item.rich(order: "1024", title: "x")).created.value))) + #expect(one.modifiedBy == .missing) + #expect(two.modifiedBy == .missing) + #expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)")).order == .valid(3072)) + + let result = try BoardLoader.load(boardRoot: fixture.url("A.kanban")) + #expect(result.warnings.isEmpty) + #expect(result.model.lanes.map(\.id.rawValue) == [Ident.lane1, Ident.lane2, id.rawValue]) + } + + /// Attachments, their subfolders, and strays travel byte-for-byte — the copy never opens + /// them, so there is nothing to get wrong. + @Test func attachmentsSubfoldersAndStraysTravelByteIdentically() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + try attach(fixture, to: "A.kanban/\(Ident.lane1)/\(Ident.card1)") + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)"), + toParent: fixture.url("A.kanban"), + order: nil, + stamps: .fork + ) + + let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) + let card = try #require(copied["Card One"]) + for path in ["attachments/sketch.png", "attachments/sub/deep.bin", "notes.txt"] { + #expect( + try fixture.data("A.kanban/\(id.rawValue)/\(card)/\(path)") + == fixture.data("A.kanban/\(Ident.lane1)/\(Ident.card1)/\(path)") + ) + } + // `attachments/` is not UUID-shaped, so the remint walk never descends into it. + #expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(card)").contains("attachments")) + } + + /// Template instantiation: born today, not forked — `created` and `modified` both fresh, and + /// the same `Date` for the whole tree. + @Test func bornStampsCreatedAndModifiedFreshAtEveryLevel() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)"), + toParent: fixture.url("A.kanban"), + order: nil, + stamps: .born + ) + + let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) + let paths = ["A.kanban/\(id.rawValue)"] + copied.values.map { "A.kanban/\(id.rawValue)/\($0)" } + for path in paths { + let document = try FrontmatterDocument.parse(fixture.indexText(path)) + let created = try #require(document.created.value) + let modified = try #require(document.modified.value) + #expect(abs(created.timeIntervalSinceNow) < 60) + #expect(abs(created.timeIntervalSince(modified)) < 2) + #expect(document.modifiedBy == .missing) + } + } + + /// The leniency below the root: a nested file the surgical editor cannot key is copied + /// verbatim rather than failing the gesture — stale `modified-by` and all — while its + /// editable siblings are stamped normally. + @Test func aNestedUneditableFileCopiesVerbatimWhileItsSiblingsAreStamped() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)"), + toParent: fixture.url("A.kanban"), + order: nil, + stamps: .fork + ) + + let copied = try childrenByTitle(of: "A.kanban/\(id.rawValue)", in: fixture) + #expect(try fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Odd"]))") == Item.uneditable) + #expect(try FrontmatterDocument.parse(fixture.indexText("A.kanban/\(id.rawValue)/\(try #require(copied["Card One"]))")) + .modifiedBy == .missing) + } + + /// A UUID-shaped folder with no `index.md` — interrupted-create residue — is reminted and + /// carried like any other folder, and simply not rewritten: the same skip the loader applies. + @Test func aNestedFolderWithoutAnIndexIsCopiedAndRemintedButNotRewritten() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + try FileManager.default.createDirectory( + at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.indexless)"), + withIntermediateDirectories: true + ) + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)"), + toParent: fixture.url("A.kanban"), + order: nil, + stamps: .fork + ) + + let names = try fixture.entryNames("A.kanban/\(id.rawValue)").filter(BoardLoader.isUUIDShaped) + #expect(names.count == 3) + #expect(Set(names).isDisjoint(with: [Ident.card1, Ident.card2, Ident.indexless])) + let orphan = try #require(names.first { !fixture.exists("A.kanban/\(id.rawValue)/\($0)/index.md") }) + #expect(try fixture.entryNames("A.kanban/\(id.rawValue)/\(orphan)") == []) + } + + /// The root gets no leniency: it must be rewritten to carry its new `order`, so an + /// uneditable one refuses before anything is materialized. + @Test func anUneditableRootRefusesTheCopyAndMaterializesNothing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.uneditable) + let before = try fixture.entryNames("A.kanban/\(Ident.lane2)") + + let error = writeFailure { + _ = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card3)"), + toParent: fixture.url("A.kanban/\(Ident.lane2)"), + order: nil, + stamps: .fork + ) + } + #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(error?.operation == "copy item") + #expect(try fixture.entryNames("A.kanban/\(Ident.lane2)") == before) + } + + /// All-or-nothing at the destination: a failure part-way through leaves no half-copied tree, + /// because a partial copy is pure residue — nothing was there before. + @Test func aFailedCopyLeavesNothingAtTheDestination() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + let unreadable = fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card2)").appendingPathComponent("index.md") + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: unreadable.path) } + let before = try fixture.entryNames("A.kanban") + + let error = writeFailure { + _ = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)"), + toParent: fixture.url("A.kanban"), + order: nil, + stamps: .fork + ) + } + guard case .io = error?.reason else { + Issue.record("expected .io, got \(String(describing: error?.reason))") + return + } + #expect(try fixture.entryNames("A.kanban") == before) + } + + /// A copy always mints, so the import boundary has nothing to do here: landing in a board + /// that already holds the source's UUID is not even a special case. + @Test func aCopyIntoABoardHoldingTheSameUUIDMintsAnywayWithoutError() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try board(fixture) + try fixture.item("B.kanban", Item.board) + try fixture.item("B.kanban/\(Ident.lane3)", Item.rich(order: "1024", title: "Inbox")) + try fixture.item("B.kanban/\(Ident.lane3)/\(Ident.card1)", Item.rich(order: "1024", title: "Twin")) + + let id = try BoardWriter.copyItem( + at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url("B.kanban/\(Ident.lane3)"), + order: nil, + stamps: .fork + ) + + #expect(id.rawValue != Ident.card1) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(id.rawValue)")).title + == .valid("Card One")) + #expect(try FrontmatterDocument.parse(fixture.indexText("B.kanban/\(Ident.lane3)/\(Ident.card1)")).title + == .valid("Twin")) + #expect(try BoardLoader.load(boardRoot: fixture.url("B.kanban")).warnings.isEmpty) + } +} + +// MARK: - Same-parent move degrades to a reorder + +/// A move whose destination is the item's current parent is the same gesture as any other drop +/// — the writer degrades it to the bare `order` rewrite instead of leaking a `FileManager` +/// name collision, and the appended rank is computed excluding the item itself, which unlike +/// every other move is already sitting among the siblings it would otherwise count. +struct BoardWriterSameParentMoveTests { + @Test func aSameParentMoveWithNilOrderAppendsAfterTheOtherSiblings() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Lane")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third")) + + let result = try BoardWriter.moveItem( + at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url("A.kanban/\(Ident.lane1)"), + sourceBoardRoot: fixture.url("A.kanban"), + destinationBoardRoot: fixture.url("A.kanban"), + order: nil + ) + + #expect(result.id.rawValue == Ident.card1) + #expect(result.reminted.isEmpty) + // Appended after Second (2048) and Third (3072), not after its own stale 1024 — + // and not after itself miscounted (which would give 2048... or 4096+1024). + let text = try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)") + #expect(text.contains("order: 4096")) + #expect(!text.contains("modified-by:")) + } + + @Test func aSameParentMoveWithAnExplicitOrderJustRewritesIt() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("A.kanban", Item.board) + try fixture.item("A.kanban/\(Ident.lane1)", Item.rich(order: "1024", title: "Lane")) + try fixture.item("A.kanban/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "3072", title: "Only")) + let laneBefore = try fixture.indexData("A.kanban/\(Ident.lane1)") + + let result = try BoardWriter.moveItem( + at: fixture.url("A.kanban/\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url("A.kanban/\(Ident.lane1)"), + sourceBoardRoot: fixture.url("A.kanban"), + destinationBoardRoot: fixture.url("A.kanban"), + order: 512 + ) + + #expect(result.id.rawValue == Ident.card1) + #expect(try fixture.indexText("A.kanban/\(Ident.lane1)/\(Ident.card1)").contains("order: 512")) + #expect(try fixture.indexData("A.kanban/\(Ident.lane1)") == laneBefore) + #expect(try fixture.entryNames("A.kanban/\(Ident.lane1)").contains(Ident.card1)) + } +}