Files
lanework/Kanban/Storage/BoardWriter.swift
T
rzen eb9e1e413f 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
2026-07-26 17:37:24 -04:00

904 lines
50 KiB
Swift

import Foundation
/// Turns a mutation into a filesystem operation — the single point through which every write
/// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by
/// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when
/// the rename completes**, and a failed write is a failure the caller sees, so views — which
/// render only what is on disk — can never show phantom state (02-architecture.md §
/// Write-failure surfacing).
///
/// Four rules from 01-storage-format.md live here and are not negotiable per call site:
///
/// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every
/// write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial
/// file, and a crash mid-write leaves the previous content intact.
/// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which
/// edits by line span, so unknown keys and their order, comments, blank lines, line endings,
/// and the body survive every write by construction rather than by remembering to preserve
/// them.
/// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write
/// path. Absence of `modified-by` means "the board's user, via the app"; the file is being
/// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing.
/// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict
/// UTF-8, and a file that does not decode is a loud, specific error rather than a
/// lossy best guess.
public enum BoardWriter: Sendable {
// MARK: - The uniform per-file mutation
/// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`,
/// stamp, write atomically.
///
/// The order of the four steps is the contract:
///
/// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may
/// be seconds stale — an agent or a hand-editor may have rewritten the file since — and
/// the round-trip guarantee is only worth anything against the bytes actually there.
/// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`):
/// the settled readable-but-uneditable rule. Such a file loads and renders fine, but a
/// surgical edit of it cannot be expressed, so the write fails loudly instead of
/// corrupting it. Refusing up front also means `edits` never observes a document it
/// cannot affect.
/// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the
/// caller's closure, so the stamp always wins over anything the closure did with those
/// two keys, and no call site has to remember them.
/// 4. **Atomic replace.**
///
/// The **one path that deliberately bypasses this** is the card window's raw-source Apply
/// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a
/// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the
/// clearing rule (01-storage-format.md § Frontmatter). That path goes through
/// `atomicReplace` directly; it does not belong here.
public static func updateIndex(
inItemFolder folder: URL,
operation: String,
edits: (inout FrontmatterDocument) -> Void
) throws(BoardWriteError) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
var document = try readDocument(at: indexURL, operation: operation)
try checkEditable(document, at: indexURL, operation: operation)
edits(&document)
document.set(FrontmatterKeys.modified, to: .date(Date()))
document.remove(FrontmatterKeys.modifiedBy)
try atomicReplace(text: document.serialized(), at: indexURL, operation: operation)
}
// MARK: - Atomic replace
/// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**,
/// then a rename over the destination.
///
/// Same directory because a rename is only atomic within one filesystem — a temp in
/// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed
/// (`.index.md.lanework-<uuid>`) because `BoardLoader.directoryCandidates` skips hidden
/// entries: residue from a crashed write is invisible to a load rather than a stray warning
/// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file.
///
/// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no
/// encoder to configure and no failure case to handle. On any failure the temp file is
/// removed best-effort and `.io` is thrown: the destination is either the old bytes or the
/// new ones, never a mix, and never a directory littered with half-written files.
static func atomicReplace(text: String, at fileURL: URL, operation: String) throws(BoardWriteError) {
let directory = fileURL.deletingLastPathComponent()
let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)")
do {
try Data(text.utf8).write(to: tempURL)
} catch {
try? FileManager.default.removeItem(at: tempURL)
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not write temporary file: \(error.localizedDescription)")
)
}
// POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an
// existing destination *and* handles one that does not exist yet (the create paths),
// without inventing a second temp file of its own.
let status = tempURL.withUnsafeFileSystemRepresentation { source in
fileURL.withUnsafeFileSystemRepresentation { destination in
guard let source, let destination else { return EINVAL }
return rename(source, destination) == 0 ? 0 : errno
}
}
guard status == 0 else {
try? FileManager.default.removeItem(at: tempURL)
throw BoardWriteError(
operation: operation,
path: fileURL.path,
reason: .io(message: "could not replace file: \(String(cString: strerror(status)))")
)
}
}
// MARK: - Create
/// Creates a new board: the folder (if it does not already exist) and its `index.md` — the
/// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two
/// callers: template instantiation, which passes the user-chosen document name as `title`
/// so the window title and the Finder name start out matching (§ Board naming), and a bare
/// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name.
///
/// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing
/// *empty* folder is simply filled in, since that is exactly what an interrupted create or
/// a hand-made folder looks like.
/// - **Refuses to clobber an existing board**: if `index.md` is already there, the call
/// fails `.io` naming the path and the existing file is never touched — a create must
/// never overwrite a board that already exists.
/// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board
/// naming) is a *missing* key, not an empty string, which would be a real (if blank) title.
/// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` —
/// that field belongs to lanes and cards, never the board root.
/// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one
/// (§ Document packaging, "Extension-less board folders still open") — this call never
/// looks at `rootURL`'s extension.
public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) {
let operation = "create board"
let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName)
guard !FileManager.default.fileExists(atPath: indexURL.path) else {
throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here"))
}
do {
try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true)
} catch {
throw BoardWriteError(
operation: operation,
path: rootURL.path,
reason: .io(message: "could not create board folder: \(error.localizedDescription)")
)
}
try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation)
}
/// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under
/// `rootURL`, appends it after the board's current visible lanes, and writes its
/// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for
/// the shared mechanics.
public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
try createChild(inParent: rootURL, title: title, operation: "create lane")
}
/// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under
/// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`.
/// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared
/// mechanics.
public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID {
try createChild(inParent: laneURL, title: title, operation: "create card")
}
/// The shared body of `createLane`/`createCard` — a lane under a board and a card under a
/// lane are the same operation one level apart (01-storage-format.md § Fractal layout,
/// "Level is position"): check the parent, place the new identity after the current visible
/// siblings, mint it, write its file.
///
/// - **The parent must already exist as a directory.** Anything else — missing, or a file
/// where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never
/// a silent `mkdir` into a broken location.
/// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by
/// `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren`
/// uses, so the two can never disagree about who counts as a sibling. An empty parent's
/// first child lands at `1024`, the board convention (01-storage-format.md § Ordering).
/// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created,
/// then `index.md` is written, as two separate filesystem operations — there is no atomic
/// "create a directory with content already in it" primitive to reach for. A crash between
/// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape
/// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own
/// `fileExists` check) already tolerate — see that type's doc comment.
private static func createChild(
inParent parentFolder: URL,
title: String?,
operation: String
) throws(BoardWriteError) -> ItemID {
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))
let folder = try mintUUIDFolder(in: parentFolder, operation: operation)
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation)
return ItemID(rawValue: folder.lastPathComponent)
}
/// The frontmatter text for a file the app is minting outright — never a rewrite, so this
/// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior
/// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and
/// `modified` share one `Date()` so the two stamps are identical, not merely close;
/// `modified-by` is never written, matching the engine's absence-means-app-authored
/// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order`
/// (only when supplied — `nil` for a board, always present for a lane/card), `created`,
/// `modified` — is simply the order `set` is called in, since each call appends a fresh key
/// before the closing delimiter of an otherwise-empty document.
private static func newDocumentText(title: String?, order: Double?) -> String {
var document = FrontmatterDocument(body: "")
document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema))
if let title {
document.set(FrontmatterKeys.title, to: .string(title))
}
if let order {
document.set(FrontmatterKeys.order, to: .double(order))
}
let now = Date()
document.set(FrontmatterKeys.created, to: .date(now))
document.set(FrontmatterKeys.modified, to: .date(now))
return document.serialized()
}
/// 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 {
let candidate = parentFolder.appendingPathComponent(
freshUUIDName(in: parentFolder, avoiding: []),
isDirectory: true
)
do {
try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false)
} catch {
throw BoardWriteError(
operation: operation,
path: candidate.path,
reason: .io(message: "could not create folder: \(error.localizedDescription)")
)
}
return candidate
}
/// 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: "\(role) does not exist"))
}
guard isDirectory.boolValue else {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "\(role) is not a directory"))
}
}
// MARK: - Renumber
/// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback
/// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole
/// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the
/// parent is a lane (renumbering its cards) or the board root (renumbering its lanes).
///
/// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails
/// to parse, or that lacks a usable `order`, fails the whole operation before anything is
/// written. A renumber is bookkeeping inside a user action that already succeeded in
/// principle — it must not be the thing that discovers a broken sibling halfway through
/// rewriting the lane.
/// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order`
/// ascending, folder name breaking ties) — the same rule the loader sorts by, so a
/// renumber is guaranteed to be sequence-preserving: nothing visibly moves.
///
/// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some
/// siblings renumbered and some not — every `order` still a valid float, display order
/// still deterministic, and the next renumber finishes the job. That is the accepted cost
/// noted in § Ordering, which the deterministic tie-break exists to make harmless.
public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) {
let operation = "renumber children"
let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true)
let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent })
for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) {
try updateIndex(inItemFolder: child.folder, operation: operation) { document in
document.set(FrontmatterKeys.order, to: .double(rank))
}
}
}
/// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create
/// operations' order assignment — extracted into one place so the two can never disagree
/// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an
/// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection
/// rule the loader uses), each parsed strictly:
///
/// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is
/// *present* is not counted — presence, not validity, is the test, exactly as
/// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both).
/// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an
/// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning
/// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them.
/// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming
/// that sibling's file, before anything is written — the same discover-before-you-write
/// guarantee `renumberVisibleChildren`'s batch depends on.
/// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that
/// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an
/// unwritable sibling halfway through the batch), the creates pass `false` — a create
/// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders
/// normally (01-storage-format.md § Frontmatter) must not block creating a new item
/// beside it.
private static func visibleSiblings(
of parentFolder: URL,
operation: String,
requireEditable: Bool
) throws(BoardWriteError) -> [(folder: URL, order: Double)] {
let candidates: [URL]
do {
candidates = try BoardLoader.directoryCandidates(in: parentFolder)
} catch {
throw BoardWriteError(
operation: operation,
path: parentFolder.path,
reason: .unreadable(message: error.description)
)
}
var visible: [(folder: URL, order: Double)] = []
for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) {
let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
guard FileManager.default.fileExists(atPath: indexURL.path) else { continue }
let document = try readDocument(at: indexURL, operation: operation)
guard document.deleted.isMissing else { continue }
if requireEditable {
try checkEditable(document, at: indexURL, operation: operation)
}
switch document.order {
case .missing:
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "missing required 'order' field")
)
case let .malformed(raw):
throw BoardWriteError(
operation: operation,
path: indexURL.path,
reason: .unreadable(message: "malformed 'order' field: \(raw)")
)
case let .valid(order):
visible.append((folder: folder, order: order))
}
}
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**:
/// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's
/// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a
/// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or
/// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines
/// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules).
private static func readDocument(at url: URL, operation: String) throws(BoardWriteError) -> FrontmatterDocument {
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
throw BoardWriteError(
operation: operation,
path: url.path,
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
)
}
guard let text = String(validating: data, as: UTF8.self) else {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8"))
}
do {
return try FrontmatterDocument.parse(text)
} catch {
throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description))
}
}
private static func checkEditable(
_ document: FrontmatterDocument,
at url: URL,
operation: String
) throws(BoardWriteError) {
if let shape = document.uneditableShape {
throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape))
}
}
}
// 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 —
/// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner
/// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the
/// user's back; a one-shot action fails once and waits for them to act again.
public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible {
/// An imperative human phrase for what was being attempted — "reorder card", "renumber
/// children" — supplied by the call site, because only it knows what the user asked for.
public let operation: String
/// The file or folder involved. Absolute at this layer: the writer works in URLs and has no
/// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative).
public let path: String
public let reason: Reason
public var description: String { "\(operation): \(path): \(reason.description)" }
public enum Reason: Sendable, Equatable, CustomStringConvertible {
/// The file is missing, is not UTF-8, or its frontmatter does not parse — `message`
/// carries the specifics. A rewrite the app cannot round-trip is not attempted.
case unreadable(message: String)
/// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter):
/// the file loads and renders, but its frontmatter has a shape the surgical editor
/// cannot address, so writing it would risk corruption. Names the shape.
case uneditableFrontmatter(FrontmatterDocument.UneditableShape)
/// Any I/O failure writing the temp file or renaming it into place — disk full,
/// permissions, volume error. The destination still holds its previous bytes.
case io(message: String)
public var description: String {
switch self {
case let .unreadable(message):
"unreadable: \(message)"
case let .uneditableFrontmatter(shape):
"frontmatter cannot be edited in place: \(shape.description)"
case let .io(message):
message
}
}
}
}