Implement move and copy identity semantics

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
This commit is contained in:
2026-07-26 17:37:24 -04:00
parent 8c0cec06ee
commit eb9e1e413f
2 changed files with 1197 additions and 21 deletions
+464 -21
View File
@@ -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>) -> 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<String> {
var identities: Set<String> = []
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
+733
View File
@@ -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))
}
}