Materialize the trash — storage layer
Phase 1 of the trash pivot: the file format learns .trash/. The loader parses the reserved root container — cards only, one shared parseCard for both containers so fail-fast, attachments, and verbatim documents are literally the same code; absent means empty; symlinks and lane-shaped nestings fall out as strays by construction. BoardModel grows snapshot.trash as a plain rank-ordered card list — the container has no identity to carry. Legacy deleted: keys keep flowing through the retiring flag path so every tombstone consumer stays green, and are additionally reported through LoadResult.legacyTombstones in the loose-file idiom for phase 2's migration scheduling — nothing vanishes from view before its folder has actually moved, which is also 01's lock-deferral posture. Writer primitives land value-passing: move to trash with caller-minted rank and the deliberate modified stamp, tombstone migrations that surgically remove the key, physical lane removal, per-card and whole-container purge that leaves strays verbatim, and byte-faithful whole-subtree capture/recreate for lane undo. Board-wide identity now spans the trash, so an import colliding with a trashed UUID remints instead of colliding. The watcher already delivered .trash events — isGitInternal tests a component, not a dot — now stated and pinned rather than relied on. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -634,6 +634,12 @@ public final class AppModel {
|
|||||||
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
|
/// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere
|
||||||
/// else.
|
/// else.
|
||||||
///
|
///
|
||||||
|
/// **`.trash/` is excluded by construction** (02-architecture.md § Per-board app state,
|
||||||
|
/// re-grounded 2026-07-28 for the materialized trash): this walks `snapshot.lanes`, and the
|
||||||
|
/// trash is `snapshot.trash` — a sibling container, never a lane — so no filter is needed and
|
||||||
|
/// none could be forgotten. The flag walk above is the retiring half of the same rule, kept
|
||||||
|
/// while boards written by older versions still carry `deleted:` keys.
|
||||||
|
///
|
||||||
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
|
/// Static and pure: it is a fact about a snapshot, and the close flush is the wrong place to
|
||||||
/// discover a counting bug.
|
/// discover a counting bug.
|
||||||
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
|
public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) {
|
||||||
|
|||||||
@@ -635,6 +635,13 @@ public final class BannerCenter {
|
|||||||
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
|
||||||
case let .purge(title):
|
case let .purge(title):
|
||||||
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
|
||||||
|
case let .migrateTombstone(title):
|
||||||
|
// One sentence covering both shapes of the migration, because the user's mental model
|
||||||
|
// of either is the same non-event: a board written by an older version being brought
|
||||||
|
// up to date. It deliberately names neither "delete" nor "trash" — a card's migration
|
||||||
|
// moves it into the trash and a lane's brings it *back*, so any verb specific enough
|
||||||
|
// to describe one would be actively wrong about the other.
|
||||||
|
if let title { "Couldn't update '\(title)' to the current format" } else { "Couldn't update an item to the current format" }
|
||||||
case let .style(title):
|
case let .style(title):
|
||||||
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
|
||||||
case let .resize(title):
|
case let .resize(title):
|
||||||
|
|||||||
@@ -531,6 +531,13 @@ public final class FolderWatcher {
|
|||||||
/// so it schedules. The one thing lost is the ability to notice a pure-history change (a
|
/// so it schedules. The one thing lost is the ability to notice a pure-history change (a
|
||||||
/// commit that touched no working-tree file), which changes nothing on screen anyway.
|
/// commit that touched no working-tree file), which changes nothing on screen anyway.
|
||||||
///
|
///
|
||||||
|
/// **`.trash/` is deliberately not filtered, dot-name notwithstanding** (01-storage-format.md
|
||||||
|
/// § Deletion, resettled 2026-07-28). It is the one hidden folder in a board whose contents
|
||||||
|
/// *are* rendered — a materialized container of ordinary cards — so a foreign delete, restore
|
||||||
|
/// or purge (another device, an agent, a hand move in Finder) must reload the board exactly
|
||||||
|
/// like any other move. The filter tests for a `.git` component specifically rather than for a
|
||||||
|
/// dot prefix, which is what makes that true by construction rather than by exception.
|
||||||
|
///
|
||||||
/// A path that does not sit under the watched root at all is *not* filtered — better a
|
/// A path that does not sit under the watched root at all is *not* filtered — better a
|
||||||
/// redundant reload than a missed one.
|
/// redundant reload than a missed one.
|
||||||
private func isGitInternal(_ path: String) -> Bool {
|
private func isGitInternal(_ path: String) -> Bool {
|
||||||
|
|||||||
@@ -43,6 +43,33 @@ import os
|
|||||||
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
||||||
/// followed, whether it points to a file or a directory — this loader does not resolve
|
/// followed, whether it points to a file or a directory — this loader does not resolve
|
||||||
/// cross-volume or cyclic trees.
|
/// cross-volume or cyclic trees.
|
||||||
|
///
|
||||||
|
/// ## The trash is a container, not a level
|
||||||
|
///
|
||||||
|
/// `<root>/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly
|
||||||
|
/// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no
|
||||||
|
/// `index.md` of its own". The walk therefore treats it as a second card container beside the
|
||||||
|
/// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane
|
||||||
|
/// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands
|
||||||
|
/// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and
|
||||||
|
/// never warns; absent, the trash is simply empty.
|
||||||
|
///
|
||||||
|
/// ## The migration window
|
||||||
|
///
|
||||||
|
/// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on
|
||||||
|
/// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane
|
||||||
|
/// returns live with the key removed, a board-level key stays meaningless (ignored + logged).
|
||||||
|
/// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports
|
||||||
|
/// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated
|
||||||
|
/// fix.
|
||||||
|
///
|
||||||
|
/// **Until that write lands, such items still load through the retiring tombstone path** —
|
||||||
|
/// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a
|
||||||
|
/// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy",
|
||||||
|
/// and the safe direction while the fix is still pending (it is deferred under any read-only lock,
|
||||||
|
/// and can be pending for a whole session) is for nothing to silently vanish from view before its
|
||||||
|
/// folder has actually moved. The window closes per board on the first successful migration write,
|
||||||
|
/// after which no `deleted:` key exists to read and the flag is permanently `false`.
|
||||||
public enum BoardLoader: Sendable {
|
public enum BoardLoader: Sendable {
|
||||||
|
|
||||||
/// Schema version this app understands; anything higher fails fast
|
/// Schema version this app understands; anything higher fails fast
|
||||||
@@ -61,6 +88,31 @@ public enum BoardLoader: Sendable {
|
|||||||
/// the writer must never disagree about which file a folder's content lives in.
|
/// the writer must never disagree about which file a folder's content lives in.
|
||||||
static let indexFileName = "index.md"
|
static let indexFileName = "index.md"
|
||||||
|
|
||||||
|
/// The materialized trash container at board root (01-storage-format.md § Deletion, resettled
|
||||||
|
/// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the
|
||||||
|
/// seeded `.gitignore` on the claimed list.
|
||||||
|
///
|
||||||
|
/// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates`
|
||||||
|
/// skips hidden entries, so the container can never be mistaken for a lane candidate and can
|
||||||
|
/// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well
|
||||||
|
/// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't.
|
||||||
|
///
|
||||||
|
/// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact
|
||||||
|
/// name, and a board can have only one trash.
|
||||||
|
static let trashFolderName = ".trash"
|
||||||
|
|
||||||
|
/// Board-root names the app claims, and therefore the names the lane walk skips **without a
|
||||||
|
/// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are
|
||||||
|
/// app-claimed, not strays", plus `.trash/` from § Deletion).
|
||||||
|
///
|
||||||
|
/// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the
|
||||||
|
/// files are listed because the claim is about names, and a future check that needs the set
|
||||||
|
/// should find it complete rather than build a second one. Compared lowercased, like
|
||||||
|
/// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is.
|
||||||
|
static let reservedRootNames: Set<String> = [
|
||||||
|
trashFolderName, "claude.md", "claude.user.md", ".gitignore",
|
||||||
|
]
|
||||||
|
|
||||||
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
/// The card-level names the app claims, and therefore the three the loose-file carve-out
|
||||||
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
/// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names
|
||||||
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
|
/// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is
|
||||||
@@ -101,8 +153,15 @@ public enum BoardLoader: Sendable {
|
|||||||
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
// stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`).
|
||||||
var looseCardFiles: [LooseCardFiles] = []
|
var looseCardFiles: [LooseCardFiles] = []
|
||||||
|
|
||||||
|
// The retired tombstone model's detection channel, on the same reasoning and in the same
|
||||||
|
// idiom (see `LoadResult.legacyTombstones`).
|
||||||
|
var legacyTombstones: [LegacyTombstone] = []
|
||||||
|
|
||||||
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
// Legal per the frontmatter table, meaningless at board level — ignore and log, never
|
||||||
// tombstone (01-storage-format.md § Deletion).
|
// tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless
|
||||||
|
// — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is
|
||||||
|
// deliberately absent from `legacyTombstones`: there is no item to relocate and no key
|
||||||
|
// the app has any business removing from a file it was told to leave alone.
|
||||||
if !boardDocument.deleted.isMissing {
|
if !boardDocument.deleted.isMissing {
|
||||||
warn(.boardLevelDeletedIgnored)
|
warn(.boardLevelDeletedIgnored)
|
||||||
}
|
}
|
||||||
@@ -110,6 +169,10 @@ public enum BoardLoader: Sendable {
|
|||||||
var lanes: [Lane] = []
|
var lanes: [Lane] = []
|
||||||
for laneURL in try directoryCandidates(in: boardRoot) {
|
for laneURL in try directoryCandidates(in: boardRoot) {
|
||||||
let laneName = laneURL.lastPathComponent
|
let laneName = laneURL.lastPathComponent
|
||||||
|
// The app-claimed board-root names are not strays and must not warn as such. Hidden
|
||||||
|
// entries never reach here anyway (`.trash` included), so this is the rule stated
|
||||||
|
// rather than the mechanism relied on.
|
||||||
|
guard !reservedRootNames.contains(laneName.lowercased()) else { continue }
|
||||||
guard isUUIDShaped(laneName) else {
|
guard isUUIDShaped(laneName) else {
|
||||||
warn(.nonUUIDFolderIgnored(path: laneName))
|
warn(.nonUUIDFolderIgnored(path: laneName))
|
||||||
continue
|
continue
|
||||||
@@ -137,10 +200,7 @@ public enum BoardLoader: Sendable {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let cardPath = cardRelPath + "/" + indexFileName
|
let card = try parseCard(at: cardURL, path: cardRelPath)
|
||||||
let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
|
|
||||||
let cardSchema = try validatedSchema(in: cardDocument, path: cardPath)
|
|
||||||
let cardOrder = try validatedOrder(in: cardDocument, path: cardPath)
|
|
||||||
|
|
||||||
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
// Noticed, never acted on: the relocation is the store's, through the Writer.
|
||||||
let loose = looseFileNames(in: cardURL)
|
let loose = looseFileNames(in: cardURL)
|
||||||
@@ -148,27 +208,35 @@ public enum BoardLoader: Sendable {
|
|||||||
looseCardFiles.append(LooseCardFiles(
|
looseCardFiles.append(LooseCardFiles(
|
||||||
laneID: ItemID(rawValue: laneName),
|
laneID: ItemID(rawValue: laneName),
|
||||||
cardID: ItemID(rawValue: cardName),
|
cardID: ItemID(rawValue: cardName),
|
||||||
title: cardDocument.title.value,
|
title: card.title.value,
|
||||||
fileNames: loose
|
fileNames: loose
|
||||||
))
|
))
|
||||||
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/")
|
||||||
}
|
}
|
||||||
|
|
||||||
cards.append(Card(
|
// Detection only, the loose-file precedent exactly: the relocation into `.trash/`
|
||||||
id: ItemID(rawValue: cardName),
|
// and the key's removal are the store's, through the Writer.
|
||||||
schema: cardSchema,
|
if card.isDeleted {
|
||||||
title: cardDocument.title,
|
legacyTombstones.append(LegacyTombstone(
|
||||||
created: cardDocument.created,
|
kind: .card,
|
||||||
modified: cardDocument.modified,
|
laneID: ItemID(rawValue: laneName),
|
||||||
modifiedBy: cardDocument.modifiedBy,
|
cardID: ItemID(rawValue: cardName),
|
||||||
deleted: cardDocument.deleted,
|
title: card.title.value
|
||||||
background: cardDocument.background,
|
|
||||||
icon: cardDocument.icon,
|
|
||||||
iconColor: cardDocument.iconColor,
|
|
||||||
order: cardOrder,
|
|
||||||
attachments: attachmentNames(in: cardURL),
|
|
||||||
document: cardDocument
|
|
||||||
))
|
))
|
||||||
|
logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/")
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.append(card)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !laneDocument.deleted.isMissing {
|
||||||
|
legacyTombstones.append(LegacyTombstone(
|
||||||
|
kind: .lane,
|
||||||
|
laneID: ItemID(rawValue: laneName),
|
||||||
|
cardID: nil,
|
||||||
|
title: laneDocument.title.value
|
||||||
|
))
|
||||||
|
logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed")
|
||||||
}
|
}
|
||||||
|
|
||||||
lanes.append(Lane(
|
lanes.append(Lane(
|
||||||
@@ -189,6 +257,21 @@ public enum BoardLoader: Sendable {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var trash: [Card] = []
|
||||||
|
for cardURL in trashCandidates(in: boardRoot) {
|
||||||
|
let cardName = cardURL.lastPathComponent
|
||||||
|
let cardRelPath = trashFolderName + "/" + cardName
|
||||||
|
guard isUUIDShaped(cardName) else {
|
||||||
|
warn(.nonUUIDFolderIgnored(path: cardRelPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard hasIndex(cardURL) else {
|
||||||
|
warn(.missingIndex(path: cardRelPath))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trash.append(try parseCard(at: cardURL, path: cardRelPath))
|
||||||
|
}
|
||||||
|
|
||||||
let model = BoardModel(
|
let model = BoardModel(
|
||||||
rootURL: boardRoot,
|
rootURL: boardRoot,
|
||||||
schema: boardSchema,
|
schema: boardSchema,
|
||||||
@@ -202,10 +285,80 @@ public enum BoardLoader: Sendable {
|
|||||||
iconColor: boardDocument.iconColor,
|
iconColor: boardDocument.iconColor,
|
||||||
template: boardDocument.value(for: templateKey),
|
template: boardDocument.value(for: templateKey),
|
||||||
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
|
lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }),
|
||||||
|
trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }),
|
||||||
document: boardDocument
|
document: boardDocument
|
||||||
)
|
)
|
||||||
|
|
||||||
return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles)
|
return LoadResult(
|
||||||
|
model: model,
|
||||||
|
warnings: warnings,
|
||||||
|
looseCardFiles: looseCardFiles,
|
||||||
|
legacyTombstones: legacyTombstones
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One card folder read into a `Card` — **the card parse, shared by both containers**.
|
||||||
|
///
|
||||||
|
/// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this
|
||||||
|
/// function is what makes that literally true rather than a claim two code paths have to keep
|
||||||
|
/// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the
|
||||||
|
/// same verbatim document. Its callers keep what genuinely differs by container — the
|
||||||
|
/// lane-keyed loose-file and legacy-tombstone channels — outside it.
|
||||||
|
///
|
||||||
|
/// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`.
|
||||||
|
/// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has.
|
||||||
|
private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card {
|
||||||
|
let cardPath = path + "/" + indexFileName
|
||||||
|
let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath)
|
||||||
|
let schema = try validatedSchema(in: document, path: cardPath)
|
||||||
|
let order = try validatedOrder(in: document, path: cardPath)
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
id: ItemID(rawValue: cardURL.lastPathComponent),
|
||||||
|
schema: schema,
|
||||||
|
title: document.title,
|
||||||
|
created: document.created,
|
||||||
|
modified: document.modified,
|
||||||
|
modifiedBy: document.modifiedBy,
|
||||||
|
deleted: document.deleted,
|
||||||
|
background: document.background,
|
||||||
|
icon: document.icon,
|
||||||
|
iconColor: document.iconColor,
|
||||||
|
order: order,
|
||||||
|
attachments: attachmentNames(in: cardURL),
|
||||||
|
document: document
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candidate card folders inside `<root>/.trash/`, or `[]` when there is no trash.
|
||||||
|
///
|
||||||
|
/// **Absent is empty, not an error** — the container is minted by the first delete, so most
|
||||||
|
/// boards never have one, and a board without a trash is a board with an empty trash.
|
||||||
|
///
|
||||||
|
/// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a
|
||||||
|
/// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules),
|
||||||
|
/// and a symlinked trash would render bytes living outside the board that FSEvents never
|
||||||
|
/// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed
|
||||||
|
/// name is not a stray, so there is no case here that fits and nothing for a user to do about
|
||||||
|
/// a name the app claims.
|
||||||
|
///
|
||||||
|
/// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in
|
||||||
|
/// folder-name order — so the trash gets the same stray tolerance every other container gets,
|
||||||
|
/// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a
|
||||||
|
/// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are
|
||||||
|
/// never enumerated, because the walk stops at a card exactly as it does under a lane; the
|
||||||
|
/// outer folder either parses as a card (it has an `index.md`) or is skipped as
|
||||||
|
/// `.missingIndex`, and either way nothing below it renders.
|
||||||
|
private static func trashCandidates(in boardRoot: URL) -> [URL] {
|
||||||
|
let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true)
|
||||||
|
guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
guard values.isDirectory == true, values.isSymbolicLink != true else {
|
||||||
|
logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return (try? directoryCandidates(in: trashURL)) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Filesystem helpers
|
// MARK: - Filesystem helpers
|
||||||
@@ -364,6 +517,11 @@ public enum BoardLoader: Sendable {
|
|||||||
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
|
/// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing
|
||||||
/// anything else with them.
|
/// anything else with them.
|
||||||
///
|
///
|
||||||
|
/// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is
|
||||||
|
/// exactly right: it is a container, not a level, and the walk reaches it by name instead
|
||||||
|
/// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own
|
||||||
|
/// children get identical stray tolerance.
|
||||||
|
///
|
||||||
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
|
/// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no
|
||||||
/// candidates" rather than failing the whole load — fail-fast is reserved for the board
|
/// candidates" rather than failing the whole load — fail-fast is reserved for the board
|
||||||
/// root and for malformed `index.md` content, not transient directory-listing races below
|
/// root and for malformed `index.md` content, not transient directory-listing races below
|
||||||
@@ -512,7 +670,73 @@ public struct LoadResult: Sendable {
|
|||||||
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
/// belongs on disk is a question about the *tree*, not about what the board is currently
|
||||||
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
/// rendering — the same reason the loader flags a tombstoned card at all rather than dropping
|
||||||
/// it.
|
/// it.
|
||||||
|
///
|
||||||
|
/// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The
|
||||||
|
/// channel is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a trashed card
|
||||||
|
/// has no lane; widening the key is the store-side change that belongs with the store-side
|
||||||
|
/// scheduling. Loose files beside a trashed card's `index.md` therefore keep the ordinary
|
||||||
|
/// stray posture — tolerated, preserved verbatim — and are tidied the moment the card is
|
||||||
|
/// restored into a lane, which is the only state in which they matter.
|
||||||
public var looseCardFiles: [LooseCardFiles] = []
|
public var looseCardFiles: [LooseCardFiles] = []
|
||||||
|
|
||||||
|
/// The legacy `deleted:` keys this walk found — the retired tombstone model's **migration
|
||||||
|
/// input** (01-storage-format.md § Deletion, resettled 2026-07-28: "Legacy `deleted:` keys
|
||||||
|
/// migrate on load-and-write, never destroy").
|
||||||
|
///
|
||||||
|
/// **The `looseCardFiles` idiom, for the same reason it exists**: `warnings` is the
|
||||||
|
/// stray-*tolerance* vocabulary — "this was ignored, it is staying exactly where it is, there
|
||||||
|
/// is nothing to do" — and a legacy tombstone is the opposite, pending work the store acts on.
|
||||||
|
/// Folding it into the warning channel would also throw away everything the act needs (which
|
||||||
|
/// lane, which card, which title) and force it to be re-derived from a display string.
|
||||||
|
///
|
||||||
|
/// Nothing renders this. Its consumer is the store, which relocates each `.card` into
|
||||||
|
/// `.trash/` with the key removed, strips each `.lane`'s key in place (a lane returns **live**
|
||||||
|
/// — resurrection is the safe direction), and posts the warning-tone notice. Like the
|
||||||
|
/// relocation it mirrors, the write is deferred under any read-only lock; the items stay
|
||||||
|
/// rendered through the retiring tombstone path until it lands (see this type's `BoardLoader`
|
||||||
|
/// note on the migration window).
|
||||||
|
///
|
||||||
|
/// Board-level `deleted:` never appears here — it is meaningless, ignored and logged
|
||||||
|
/// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite.
|
||||||
|
///
|
||||||
|
/// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane.
|
||||||
|
public var legacyTombstones: [LegacyTombstone] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One item found carrying a legacy `deleted:` key — everything its migration and notice need,
|
||||||
|
/// and nothing more.
|
||||||
|
///
|
||||||
|
/// The path is carried as its identity components rather than as a URL — `LooseCardFiles`'
|
||||||
|
/// convention, for its reason: the write derives its path from the store's *current* root, which
|
||||||
|
/// may have been re-resolved since the load. `title` is the item's as written, `nil` for an
|
||||||
|
/// untitled one, because "Untitled" is a rendering and never a value (03-board-ui.md § Card face).
|
||||||
|
public struct LegacyTombstone: Sendable, Equatable {
|
||||||
|
/// Which migration this item takes — the two are genuinely different acts, not one act at two
|
||||||
|
/// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays
|
||||||
|
/// exactly where it is (the key is stripped and it returns live).
|
||||||
|
public enum Kind: Sendable, Equatable {
|
||||||
|
case card
|
||||||
|
case lane
|
||||||
|
}
|
||||||
|
|
||||||
|
public let kind: Kind
|
||||||
|
|
||||||
|
/// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the
|
||||||
|
/// context the relocation needs to find the folder at all.
|
||||||
|
public let laneID: ItemID
|
||||||
|
|
||||||
|
/// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload
|
||||||
|
/// so the common "which folder is this" question is one path join at every call site.
|
||||||
|
public let cardID: ItemID?
|
||||||
|
|
||||||
|
public let title: String?
|
||||||
|
|
||||||
|
public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) {
|
||||||
|
self.kind = kind
|
||||||
|
self.laneID = laneID
|
||||||
|
self.cardID = cardID
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
/// One card found holding files that belong in its `attachments/` — everything the relocation and
|
||||||
|
|||||||
@@ -92,11 +92,39 @@ public struct BoardModel: Sendable, Equatable {
|
|||||||
/// parsed into a dedicated Swift shape, so future subkeys need no model change.
|
/// parsed into a dedicated Swift shape, so future subkeys need no model change.
|
||||||
public let template: YAMLValue?
|
public let template: YAMLValue?
|
||||||
|
|
||||||
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break) — **including
|
/// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
||||||
/// tombstoned lanes**, which stay in the snapshot flagged (`Lane.isDeleted`) for the trash
|
///
|
||||||
/// view (01-storage-format.md § Deletion).
|
/// **Legacy tombstones ride along flagged during the migration window.** A lane still
|
||||||
|
/// carrying a `deleted:` key loads here with `Lane.isDeleted` set, exactly as it did under
|
||||||
|
/// the retired tombstone model, *and* is reported through `LoadResult.legacyTombstones` — see
|
||||||
|
/// `BoardLoader`'s "The migration window" note for why both happen at once and what ends it.
|
||||||
public let lanes: [Lane]
|
public let lanes: [Lane]
|
||||||
|
|
||||||
|
/// The board's **materialized trash**: the card folders sitting directly in
|
||||||
|
/// `<root>/.trash/`, in display order (01-storage-format.md § Deletion, resettled
|
||||||
|
/// 2026-07-28; 03-board-ui.md § Trash).
|
||||||
|
///
|
||||||
|
/// **A sibling container of `lanes`, not a lane.** `.trash/` is a reserved, app-claimed name
|
||||||
|
/// at board root that "holds card folders directly — same shape as a lane's children, no
|
||||||
|
/// `index.md` of its own", so it has no identity, no title, no `order`, and no frontmatter to
|
||||||
|
/// model: the container *is* the list. That is why this is `[Card]` rather than a `Lane` or a
|
||||||
|
/// `Trash` struct — there is nothing for either to carry that this array does not.
|
||||||
|
///
|
||||||
|
/// **Display order is `order` ascending, like any lane's cards** — `Ranks.sortedForDisplay`,
|
||||||
|
/// same folder-name tie-break. Newest-first falls out of ordinary ranks rather than a
|
||||||
|
/// timestamp sort: every arrival mints a rank *above* the current topmost
|
||||||
|
/// (`Ranks.insertAtHead`), so the trash needs no sort rule of its own. There is deliberately
|
||||||
|
/// no `deleted:` key on anything in here — a trashed card is an ordinary card in a special
|
||||||
|
/// place.
|
||||||
|
///
|
||||||
|
/// Empty when `.trash/` is absent (the overwhelmingly common case — the folder is minted by
|
||||||
|
/// the first delete), and empty when it holds nothing the loader recognizes as a card.
|
||||||
|
///
|
||||||
|
/// **Defaulted so a snapshot can be built without one.** The one construction site is
|
||||||
|
/// `BoardLoader.load`, which always supplies it; the default keeps the memberwise initializer
|
||||||
|
/// usable from tests and future fixtures that have no trash to describe.
|
||||||
|
public var trash: [Card] = []
|
||||||
|
|
||||||
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
|
/// The full parsed `index.md`. Unknown/reserved keys (`labels`, `assignees`, `due`,
|
||||||
/// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
|
/// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer
|
||||||
/// can round-trip them without this model knowing what they mean.
|
/// can round-trip them without this model knowing what they mean.
|
||||||
@@ -132,9 +160,9 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
|||||||
/// failing the load (01-storage-format.md § Frontmatter).
|
/// failing the load (01-storage-format.md § Frontmatter).
|
||||||
public let width: FieldValue<Int>
|
public let width: FieldValue<Int>
|
||||||
|
|
||||||
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break)
|
/// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break).
|
||||||
/// — **including tombstoned cards**, which stay in the snapshot flagged (`Card.isDeleted`)
|
/// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until
|
||||||
/// for the trash view.
|
/// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`.
|
||||||
public let cards: [Card]
|
public let cards: [Card]
|
||||||
|
|
||||||
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
||||||
@@ -148,6 +176,13 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
|||||||
/// key's presence is what encodes deletion intent, a missing key is the only thing that
|
/// key's presence is what encodes deletion intent, a missing key is the only thing that
|
||||||
/// means "not deleted" (`FieldValue.isMissing` already treats an explicit `deleted: null`
|
/// means "not deleted" (`FieldValue.isMissing` already treats an explicit `deleted: null`
|
||||||
/// as absent, matching the engine's own null-is-missing rule).
|
/// as absent, matching the engine's own null-is-missing rule).
|
||||||
|
///
|
||||||
|
/// **Retiring.** The tombstone model is retired (01-storage-format.md § Deletion, resettled
|
||||||
|
/// 2026-07-28): the app never writes a `deleted:` key again, and one found on load is
|
||||||
|
/// migration input reported through `LoadResult.legacyTombstones` (a lane's migration removes
|
||||||
|
/// the key and returns the lane live). This accessor survives only for the migration window,
|
||||||
|
/// during which the retiring tombstone renderers still read it; it becomes permanently
|
||||||
|
/// `false` for every board once the migration has run, and goes away with its last consumer.
|
||||||
public var isDeleted: Bool { !deleted.isMissing }
|
public var isDeleted: Bool { !deleted.isMissing }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +234,8 @@ public struct Card: Identifiable, Sendable, Equatable {
|
|||||||
public var body: String { document.body }
|
public var body: String { document.body }
|
||||||
|
|
||||||
/// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not
|
/// A tombstoned card. See `Lane.isDeleted`'s doc comment — the same "presence, not
|
||||||
/// validity" rule applies here.
|
/// validity" rule applies here, and the same retirement: a trashed card lives in
|
||||||
|
/// `BoardModel.trash` and carries no `deleted:` key at all, so this reads `false` for every
|
||||||
|
/// card in the container that replaced the flag.
|
||||||
public var isDeleted: Bool { !deleted.isMissing }
|
public var isDeleted: Bool { !deleted.isMissing }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -589,6 +589,16 @@ public enum BoardWriter: Sendable {
|
|||||||
identities.insert(canonicalIdentity(card.lastPathComponent))
|
identities.insert(canonicalIdentity(card.lastPathComponent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// **The trash counts.** Board-wide uniqueness spans both containers (01-storage-format.md
|
||||||
|
// § Fractal layout ▸ Rules — "Duplicate ids within a board are never tolerated"), and a
|
||||||
|
// trashed card is an ordinary card in a special place: arriving on top of one would put
|
||||||
|
// two folders with one identity in the board, and the moment the user dragged the trashed
|
||||||
|
// one back out the snapshot would carry the duplicate the loader is forbidden to hold.
|
||||||
|
// This is also what makes `deleteCardToTrash`'s "collision is impossible" true rather than
|
||||||
|
// hopeful: an import that would have produced the twin was reminted before it landed.
|
||||||
|
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||||
|
identities.insert(canonicalIdentity(card.lastPathComponent))
|
||||||
|
}
|
||||||
return identities
|
return identities
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -897,7 +907,496 @@ public enum BoardWriter: Sendable {
|
|||||||
return ItemID(rawValue: root.lastPathComponent)
|
return ItemID(rawValue: root.lastPathComponent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Tombstone
|
// MARK: - The materialized trash
|
||||||
|
|
||||||
|
/// `<boardRoot>/.trash/` — the board's trash container, named but not created.
|
||||||
|
/// One place, so the loader's walk and every write below can never disagree about where the
|
||||||
|
/// trash is (the name itself is `BoardLoader.trashFolderName`, which is where the reserved-name
|
||||||
|
/// rule lives).
|
||||||
|
static func trashFolder(inBoard boardRoot: URL) -> URL {
|
||||||
|
boardRoot.appendingPathComponent(BoardLoader.trashFolderName, isDirectory: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Deleting a card: a physical move into `<board-root>/.trash/`** (01-storage-format.md
|
||||||
|
/// § Deletion, resettled 2026-07-28; 03-board-ui.md § Trash). The tombstone is retired — no
|
||||||
|
/// key is written, nothing is flagged, and the card becomes "an ordinary card in a special
|
||||||
|
/// place".
|
||||||
|
///
|
||||||
|
/// The sequence, which is the contract:
|
||||||
|
///
|
||||||
|
/// 1. **`cardFolder` must be a card** (`checkIsCardFolder`, the stricter guard: UUID-shaped
|
||||||
|
/// *under* a UUID-shaped parent). This is what makes "lanes are never trashed" structural
|
||||||
|
/// rather than a policy the caller has to remember — a lane, a board root and a stray are
|
||||||
|
/// all refused here, and lane deletion has its own call (`removeLane`).
|
||||||
|
/// 2. **Pre-flight the card's `index.md`** (`checkIndexIsRewritable`) — the move rewrites it
|
||||||
|
/// at the destination, so a file that cannot be round-tripped refuses *before* the folder
|
||||||
|
/// travels. `moveItem`'s discover-before-you-write rule, for its reason.
|
||||||
|
/// 3. **`.trash/` is created if absent** — it is minted by the first delete, so most boards
|
||||||
|
/// meet it here.
|
||||||
|
/// 4. **Move the folder.** Nothing beneath it is read or rewritten, so `attachments/`, strays
|
||||||
|
/// and every byte arrive unchanged, exactly as in an ordinary move.
|
||||||
|
/// 5. **Rewrite `order` to `order`, and stamp.**
|
||||||
|
///
|
||||||
|
/// **`order` is the caller's, always** — deliberately not defaulted and deliberately not
|
||||||
|
/// computed here. Entry is at the *top* ("every arrival … lands at the trash's topmost
|
||||||
|
/// position, minting an `order` rank above the current top"), which is
|
||||||
|
/// `Ranks.insertAtHead(ofVisible:)` over the trash's current ranks — a question about the
|
||||||
|
/// *snapshot*, which the store holds and this stateless layer does not. Value-passing keeps
|
||||||
|
/// the seam: the Writer takes a rank, the store computes it.
|
||||||
|
///
|
||||||
|
/// **The `modified` stamp is the point, not a side effect.** Deletion is the one exception to
|
||||||
|
/// moves-don't-stamp — "deletion is an edit to the card's story" — and the stamp is what a
|
||||||
|
/// future age-based auto-purge reads. It falls out of `updateIndex` here rather than being
|
||||||
|
/// asked for, which is why there is nothing extra in step 5.
|
||||||
|
///
|
||||||
|
/// **Collision inside `.trash/` is impossible by construction**, and it is checked anyway. The
|
||||||
|
/// card is a resident of this very board, and board-wide uniqueness now spans lanes *and* the
|
||||||
|
/// trash (`identities(inBoard:)`), so no folder of that name can already be in there — the
|
||||||
|
/// import boundary reminted any arriving twin before it ever landed. Should one exist regardless
|
||||||
|
/// (a hand copy, an interrupted move), the move fails loudly through `FileManager` rather than
|
||||||
|
/// clobbering it: this call never remints, because the identity is exactly what a later restore
|
||||||
|
/// and the undo stack are holding on to.
|
||||||
|
///
|
||||||
|
/// - Returns: the card's identity, unchanged — a delete moves a folder, it does not rename one.
|
||||||
|
@discardableResult
|
||||||
|
public static func deleteCardToTrash(
|
||||||
|
at cardFolder: URL,
|
||||||
|
inBoard boardRoot: URL,
|
||||||
|
order: Double
|
||||||
|
) throws(BoardWriteError) -> ItemID {
|
||||||
|
try moveCardIntoTrash(
|
||||||
|
at: cardFolder,
|
||||||
|
inBoard: boardRoot,
|
||||||
|
order: order,
|
||||||
|
operation: .delete(title: nil),
|
||||||
|
removingLegacyKey: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Migrating a legacy tombstoned card**: the same physical move into `.trash/`, plus the
|
||||||
|
/// surgical removal of the `deleted:` key that put it there (01-storage-format.md § Deletion:
|
||||||
|
/// "a card carrying `deleted:` is relocated into `.trash/` (key removed)").
|
||||||
|
///
|
||||||
|
/// `deleteCardToTrash` with one extra edit, and written as such rather than as a parameter on
|
||||||
|
/// the public delete: the two are different events with different vocabulary — one is the user
|
||||||
|
/// pressing ⌫, the other is the app tidying a board written by an older version — and a
|
||||||
|
/// failure must say which (`WriteOperation.migrateTombstone`).
|
||||||
|
///
|
||||||
|
/// The removal is `FrontmatterDocument.remove`, so it takes **every** occurrence of the key:
|
||||||
|
/// a hand-duplicated `deleted:` line cannot leave a twin behind that would re-migrate the card
|
||||||
|
/// on the next load. Nothing else in the file is touched — unknown keys, comments, blank lines,
|
||||||
|
/// line endings and the body are the same bytes they were, and `order` and the stamps are the
|
||||||
|
/// only writes, exactly as for an ordinary delete.
|
||||||
|
@discardableResult
|
||||||
|
public static func migrateTombstonedCard(
|
||||||
|
at cardFolder: URL,
|
||||||
|
inBoard boardRoot: URL,
|
||||||
|
order: Double
|
||||||
|
) throws(BoardWriteError) -> ItemID {
|
||||||
|
try moveCardIntoTrash(
|
||||||
|
at: cardFolder,
|
||||||
|
inBoard: boardRoot,
|
||||||
|
order: order,
|
||||||
|
operation: .migrateTombstone(title: nil),
|
||||||
|
removingLegacyKey: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared body of `deleteCardToTrash` and `migrateTombstonedCard` — see the former for the
|
||||||
|
/// sequence and the latter for what `removingLegacyKey` adds.
|
||||||
|
private static func moveCardIntoTrash(
|
||||||
|
at cardFolder: URL,
|
||||||
|
inBoard boardRoot: URL,
|
||||||
|
order: Double,
|
||||||
|
operation initialOperation: WriteOperation,
|
||||||
|
removingLegacyKey: Bool
|
||||||
|
) throws(BoardWriteError) -> ItemID {
|
||||||
|
var operation = initialOperation
|
||||||
|
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||||
|
try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation)
|
||||||
|
try checkIsCardFolder(cardFolder, operation: operation)
|
||||||
|
operation = try checkIndexIsRewritable(inItemFolder: cardFolder, operation: operation)
|
||||||
|
|
||||||
|
let trash = trashFolder(inBoard: boardRoot)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: trash.path,
|
||||||
|
reason: .io(message: "could not create the trash folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = cardFolder.lastPathComponent
|
||||||
|
let arrived = trash.appendingPathComponent(name, isDirectory: true)
|
||||||
|
do {
|
||||||
|
try FileManager.default.moveItem(at: cardFolder, to: arrived)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: cardFolder.path,
|
||||||
|
reason: .io(message: "could not move folder into the trash: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
try updateIndex(inItemFolder: arrived, operation: operation) { document in
|
||||||
|
document.set(FrontmatterKeys.order, to: .double(order))
|
||||||
|
if removingLegacyKey {
|
||||||
|
document.remove(FrontmatterKeys.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ItemID(rawValue: name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Migrating a legacy tombstoned lane**: the `deleted:` key is removed and the lane returns
|
||||||
|
/// **live**, exactly where it always was (01-storage-format.md § Deletion: "a lane carrying
|
||||||
|
/// `deleted:` returns live with the key removed and a notice — resurrection is the safe
|
||||||
|
/// direction, nothing is destroyed by migration").
|
||||||
|
///
|
||||||
|
/// **Nothing moves and nothing is removed.** There is no lane trash to move it into, and
|
||||||
|
/// destroying a lane the user may never have meant to lose is the one direction migration is
|
||||||
|
/// forbidden to take. Its cards come back with it; any of *them* carrying their own
|
||||||
|
/// `deleted:` key migrate on their own account, as ordinary tombstoned cards.
|
||||||
|
///
|
||||||
|
/// The lane's rank is untouched, so it returns to its own position among its siblings —
|
||||||
|
/// position-perfect for the same reason the retired Put Back was: the folder never moved.
|
||||||
|
///
|
||||||
|
/// Refuses anything that is not a lane (`checkIsLaneFolder`): a card's migration is a move and
|
||||||
|
/// has its own call, and pointing this at one would strip the key while leaving the card
|
||||||
|
/// exactly where the tombstone had hidden it.
|
||||||
|
public static func migrateTombstonedLane(at laneFolder: URL) throws(BoardWriteError) {
|
||||||
|
let operation = WriteOperation.migrateTombstone(title: nil)
|
||||||
|
try checkIsDirectory(laneFolder, describedAs: "lane folder", operation: operation)
|
||||||
|
try checkIsLaneFolder(laneFolder, operation: operation)
|
||||||
|
|
||||||
|
try updateIndex(inItemFolder: laneFolder, operation: operation) { document in
|
||||||
|
document.remove(FrontmatterKeys.deleted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Deleting a lane is physical** — the folder and everything under it are removed
|
||||||
|
/// (01-storage-format.md § Deletion; 03-board-ui.md § Trash: "Cards only. Lanes are never
|
||||||
|
/// trashed"). There is no lane trash and no tombstone; the recovery net is native undo
|
||||||
|
/// in-session and git history on git boards.
|
||||||
|
///
|
||||||
|
/// **Capture before you remove.** Undo restores a lane by replaying its bytes, which only
|
||||||
|
/// works if someone is holding them — `captureSubtree(at:operation:)` is that primitive, and
|
||||||
|
/// the pairing is the caller's (the undo step captures, then calls this). Deliberately not
|
||||||
|
/// folded in here: a purge that always paid for a full tree read would make Empty Trash on a
|
||||||
|
/// large board slow for a recovery nothing was going to use.
|
||||||
|
///
|
||||||
|
/// **A folder that is already gone is success**, `purgeItem`'s rule and for its reason: a
|
||||||
|
/// Finder deletion converges on exactly the end state this produces, so there is nothing left
|
||||||
|
/// to distinguish.
|
||||||
|
///
|
||||||
|
/// Refuses anything that is not a lane (`checkIsLaneFolder`) — a board root, a card, a stray,
|
||||||
|
/// and notably a *trash card*, whose parent is `.trash/` rather than the board root.
|
||||||
|
public static func removeLane(at laneFolder: URL) throws(BoardWriteError) {
|
||||||
|
let operation = WriteOperation.delete(title: nil)
|
||||||
|
guard FileManager.default.fileExists(atPath: laneFolder.path) else { return }
|
||||||
|
try checkIsLaneFolder(laneFolder, operation: operation)
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: laneFolder)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: laneFolder.path,
|
||||||
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Permanently removes one card from the trash — the trash's **Delete / Delete Immediately**
|
||||||
|
/// (03-board-ui.md § Trash: "on a trash card, Delete (⌫/⌘⌫) is permanent").
|
||||||
|
///
|
||||||
|
/// `purgeItem` with the container checked: the folder must actually sit in this board's
|
||||||
|
/// `.trash/`, so a mis-aimed permanent delete cannot reach a live card. Delete Immediately
|
||||||
|
/// *from* the board — which skips the trash — is `purgeItem`, not this call.
|
||||||
|
///
|
||||||
|
/// An already-gone folder is success, `purgeItem`'s rule.
|
||||||
|
public static func purgeTrashCard(at cardFolder: URL, inBoard boardRoot: URL) throws(BoardWriteError) {
|
||||||
|
let operation = WriteOperation.purge(title: nil)
|
||||||
|
guard FileManager.default.fileExists(atPath: cardFolder.path) else { return }
|
||||||
|
|
||||||
|
try checkIsUUIDShaped(cardFolder, operation: operation)
|
||||||
|
guard isSameLocation(cardFolder.deletingLastPathComponent(), trashFolder(inBoard: boardRoot)) else {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: cardFolder.path,
|
||||||
|
reason: .unreadable(message: "folder is not in this board's trash")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: cardFolder)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: cardFolder.path,
|
||||||
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// **Empty Trash** (⇧⌘⌫, 03-board-ui.md § Trash): permanently removes every card in
|
||||||
|
/// `<board-root>/.trash/`. Returns what it removed, in folder-name order.
|
||||||
|
///
|
||||||
|
/// **The card folders, not the container.** The design says it "purges the whole `.trash/`",
|
||||||
|
/// and the cards are the whole of it in every board the app produces — but the container is a
|
||||||
|
/// real folder a hand-editor can put things in, and stray tolerance ("preserved verbatim,
|
||||||
|
/// never rendered") does not stop applying because the folder is the app's. Removing only what
|
||||||
|
/// the loader recognizes as a card keeps the count honest (the confirmation names cards) and
|
||||||
|
/// keeps this command from being the one place in the app that destroys a file nobody ever
|
||||||
|
/// saw. The emptied container is left standing; the next delete would only recreate it.
|
||||||
|
///
|
||||||
|
/// **Search-independent**, by construction: this walks the folder, never a filtered view.
|
||||||
|
///
|
||||||
|
/// Removal is per card, in order, and a failure stops the batch and throws — everything
|
||||||
|
/// already removed stays removed, `importAttachments`' rule. A board with no trash at all
|
||||||
|
/// removes nothing and returns `[]`.
|
||||||
|
@discardableResult
|
||||||
|
public static func emptyTrash(inBoard boardRoot: URL) throws(BoardWriteError) -> [ItemID] {
|
||||||
|
let operation = WriteOperation.purge(title: nil)
|
||||||
|
var purged: [ItemID] = []
|
||||||
|
for card in childCandidates(of: trashFolder(inBoard: boardRoot)) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: card)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: card.path,
|
||||||
|
reason: .io(message: "could not remove folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
purged.append(ItemID(rawValue: card.lastPathComponent))
|
||||||
|
}
|
||||||
|
return purged
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refuses any folder that is not a **lane**: UUID-shaped, directly under a board root.
|
||||||
|
///
|
||||||
|
/// The mirror of `checkIsCardFolder`, and it needs one clause that one does not. A lane is
|
||||||
|
/// `<root>/<lane>` and a card is `<root>/<lane>/<card>`, so "parent is not UUID-shaped" tells
|
||||||
|
/// the two apart — except that a **trash card** is `<root>/.trash/<card>`, whose parent is not
|
||||||
|
/// UUID-shaped either. Naming the container explicitly is what keeps a permanent delete of a
|
||||||
|
/// trashed card from being reachable through the lane-delete door.
|
||||||
|
private static func checkIsLaneFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) {
|
||||||
|
try checkIsUUIDShaped(folder, operation: operation)
|
||||||
|
let parentName = folder.deletingLastPathComponent().lastPathComponent
|
||||||
|
guard !BoardLoader.isUUIDShaped(parentName), parentName != BoardLoader.trashFolderName else {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: folder.path,
|
||||||
|
reason: .unreadable(message: "folder is not a lane: only a lane is deleted whole")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtree capture and replay
|
||||||
|
|
||||||
|
/// Every byte of a folder tree, in memory — the capture half of a physical removal's undo
|
||||||
|
/// (13-native-undo.md ▸ Rules; 03-board-ui.md § Trash, "the net is undo, not the trash").
|
||||||
|
///
|
||||||
|
/// **`readIndexText(ofItem:operation:)` widened from one file to a whole tree**, and for the
|
||||||
|
/// same reason: the inverse of a physical removal is a recreation, and the only way ⌘Z can put
|
||||||
|
/// a lane back *with its identity and its cards* is for the step to be holding what was there.
|
||||||
|
/// One `index.md` is enough to replay a create; a lane delete takes its nested cards, their
|
||||||
|
/// `attachments/`, and every stray with it.
|
||||||
|
///
|
||||||
|
/// **Everything, verbatim.** Unlike every other walk in this file, this one does *not* apply
|
||||||
|
/// the loader's stray exclusions: hidden files (`.DS_Store`, a dot-file a hand-editor left),
|
||||||
|
/// non-UUID folders, files with no meaning to the schema — all captured, because the promise is
|
||||||
|
/// that undo restores what was removed rather than what the app would have rendered. Bytes are
|
||||||
|
/// carried as `Data` and never decoded, so encoding, line endings and BOMs are non-questions;
|
||||||
|
/// POSIX permissions ride along per entry.
|
||||||
|
///
|
||||||
|
/// **Symlinks are captured as links, never followed** (01-storage-format.md § Fractal layout ▸
|
||||||
|
/// Rules) — the destination string is recorded and recreated as a link, so a cyclic or
|
||||||
|
/// cross-volume link is neither traversed here nor materialized as a copy of its target.
|
||||||
|
///
|
||||||
|
/// Entries are sorted by name at every level, so a capture is a deterministic value: two
|
||||||
|
/// captures of one unchanged tree are `==`, which is what makes a round-trip assertable.
|
||||||
|
///
|
||||||
|
/// It reads the whole tree into memory, so it is for the sizes a board actually has (a lane
|
||||||
|
/// and its cards) — not a general-purpose archiver.
|
||||||
|
public static func captureSubtree(
|
||||||
|
at folder: URL,
|
||||||
|
operation: WriteOperation
|
||||||
|
) throws(BoardWriteError) -> SubtreeSnapshot {
|
||||||
|
try checkIsDirectory(folder, describedAs: "item folder", operation: operation)
|
||||||
|
|
||||||
|
let entries: [URL]
|
||||||
|
do {
|
||||||
|
entries = try FileManager.default.contentsOfDirectory(
|
||||||
|
at: folder,
|
||||||
|
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: folder.path,
|
||||||
|
reason: .unreadable(message: "could not list folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var captured: [SubtreeSnapshot.Entry] = []
|
||||||
|
for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) {
|
||||||
|
let name = entry.lastPathComponent
|
||||||
|
let values = try? entry.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
|
||||||
|
|
||||||
|
if values?.isSymbolicLink == true {
|
||||||
|
guard let destination = try? FileManager.default.destinationOfSymbolicLink(atPath: entry.path) else {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: entry.path,
|
||||||
|
reason: .unreadable(message: "could not read symbolic link")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
captured.append(.symlink(name: name, destination: destination))
|
||||||
|
} else if values?.isDirectory == true {
|
||||||
|
captured.append(.folder(try captureSubtree(at: entry, operation: operation)))
|
||||||
|
} else {
|
||||||
|
let contents: Data
|
||||||
|
do {
|
||||||
|
contents = try Data(contentsOf: entry)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: entry.path,
|
||||||
|
reason: .unreadable(message: "could not read file: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
captured.append(.file(name: name, contents: contents, permissions: posixPermissions(of: entry)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SubtreeSnapshot(
|
||||||
|
name: folder.lastPathComponent,
|
||||||
|
permissions: posixPermissions(of: folder),
|
||||||
|
entries: captured
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a captured tree back, at `folder`, byte for byte — the replay half of
|
||||||
|
/// `captureSubtree(at:operation:)`, and `recreateItem`'s rules one level of nesting wider.
|
||||||
|
///
|
||||||
|
/// - **The parent must already exist** (`withIntermediateDirectories: false`): a redo whose
|
||||||
|
/// board root has since gone must fail rather than conjure a tree in mid-air.
|
||||||
|
/// - **It refuses to clobber**: anything at `folder` fails loudly rather than being written
|
||||||
|
/// over. Restoring on top of a folder someone recreated meanwhile would silently merge two
|
||||||
|
/// trees.
|
||||||
|
/// - **The bytes are written verbatim** — nothing is stamped, nothing is re-serialized, no
|
||||||
|
/// `index.md` is parsed. This replays; it does not edit.
|
||||||
|
/// - **All-or-nothing**: any failure removes the partial tree best-effort and rethrows, the
|
||||||
|
/// `materializeItem` rule — a half-restored lane is pure residue, since nothing was there.
|
||||||
|
///
|
||||||
|
/// `folder`'s own name governs, not `snapshot.name`: a caller restoring to the path it removed
|
||||||
|
/// passes the same URL, and the snapshot's name is carried for identification, not as an
|
||||||
|
/// instruction.
|
||||||
|
///
|
||||||
|
/// Permissions are applied **after** a folder's children are written, so a captured read-only
|
||||||
|
/// directory does not lock out its own contents on the way back in.
|
||||||
|
public static func recreateSubtree(
|
||||||
|
at folder: URL,
|
||||||
|
from snapshot: SubtreeSnapshot,
|
||||||
|
operation: WriteOperation
|
||||||
|
) throws(BoardWriteError) {
|
||||||
|
guard !FileManager.default.fileExists(atPath: folder.path) else {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: folder.path,
|
||||||
|
reason: .io(message: "something already exists here")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
do throws(BoardWriteError) {
|
||||||
|
try materialize(snapshot, at: folder, operation: operation, intermediates: false)
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: folder)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `recreateSubtree`'s recursion, minus its clobber refusal and its cleanup — both belong to
|
||||||
|
/// the top-level call, which is the only one with a partial tree to remove.
|
||||||
|
private static func materialize(
|
||||||
|
_ snapshot: SubtreeSnapshot,
|
||||||
|
at folder: URL,
|
||||||
|
operation: WriteOperation,
|
||||||
|
intermediates: Bool
|
||||||
|
) throws(BoardWriteError) {
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: intermediates)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: folder.path,
|
||||||
|
reason: .io(message: "could not create folder: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in snapshot.entries {
|
||||||
|
switch entry {
|
||||||
|
case let .file(name, contents, permissions):
|
||||||
|
let fileURL = folder.appendingPathComponent(name)
|
||||||
|
do {
|
||||||
|
try contents.write(to: fileURL)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: fileURL.path,
|
||||||
|
reason: .io(message: "could not write file: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
setPosixPermissions(permissions, of: fileURL)
|
||||||
|
case let .folder(child):
|
||||||
|
try materialize(
|
||||||
|
child,
|
||||||
|
at: folder.appendingPathComponent(child.name, isDirectory: true),
|
||||||
|
operation: operation,
|
||||||
|
intermediates: false
|
||||||
|
)
|
||||||
|
case let .symlink(name, destination):
|
||||||
|
let linkURL = folder.appendingPathComponent(name)
|
||||||
|
do {
|
||||||
|
try FileManager.default.createSymbolicLink(atPath: linkURL.path, withDestinationPath: destination)
|
||||||
|
} catch {
|
||||||
|
throw BoardWriteError(
|
||||||
|
operation: operation,
|
||||||
|
path: linkURL.path,
|
||||||
|
reason: .io(message: "could not create symbolic link: \(error.localizedDescription)")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After the children, so a captured read-only folder cannot lock out its own contents.
|
||||||
|
setPosixPermissions(snapshot.permissions, of: folder)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An item's POSIX permission bits, or `nil` when they cannot be read — `attributesOfItem`
|
||||||
|
/// rather than a `URLResourceValues` key because it is `lstat`-based, so a symlink's own
|
||||||
|
/// attributes are never its target's. Best-effort by design: permissions decorate a capture,
|
||||||
|
/// and a tree that restores with default modes is a far better outcome than one that refuses
|
||||||
|
/// to restore.
|
||||||
|
private static func posixPermissions(of url: URL) -> Int? {
|
||||||
|
(try? FileManager.default.attributesOfItem(atPath: url.path))?[.posixPermissions] as? Int
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func setPosixPermissions(_ permissions: Int?, of url: URL) {
|
||||||
|
guard let permissions else { return }
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: permissions], ofItemAtPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Tombstone (retiring)
|
||||||
|
//
|
||||||
|
// The tombstone model is retired (01-storage-format.md § Deletion, resettled 2026-07-28): the
|
||||||
|
// app's delete is the physical move above, and no `deleted:` key is ever written again. The
|
||||||
|
// three calls below are kept only while their callers are still being moved across — the
|
||||||
|
// migration removes the last keys any of them could act on, and they go with the last consumer.
|
||||||
|
|
||||||
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
/// Tombstones a lane or card in place: writes `deleted: <now>` into its own `index.md` —
|
||||||
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
/// the whole of a delete (01-storage-format.md § Deletion). The folder never moves, never
|
||||||
@@ -1815,6 +2314,46 @@ public struct MoveResult: Sendable, Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Subtree vocabulary
|
||||||
|
|
||||||
|
/// A folder tree captured whole, in memory — what `BoardWriter.captureSubtree(at:operation:)`
|
||||||
|
/// produces and `recreateSubtree(at:from:operation:)` replays.
|
||||||
|
///
|
||||||
|
/// **A value, deliberately**: `Sendable` so an undo step can carry it across isolation domains,
|
||||||
|
/// and `Equatable` so a capture → recreate → capture round trip is one assertion. Equality is
|
||||||
|
/// exact — names, bytes, link destinations, permissions, and order — which holds because a capture
|
||||||
|
/// sorts every level by name.
|
||||||
|
///
|
||||||
|
/// It describes bytes, never meaning. There is no `index.md` here, no frontmatter, no identity:
|
||||||
|
/// a lane, a card, an `attachments/` folder and a hand-made `notes/` are all just folders with
|
||||||
|
/// entries, which is exactly what a byte-faithful restore needs and all it may assume.
|
||||||
|
public struct SubtreeSnapshot: Sendable, Equatable {
|
||||||
|
/// The captured folder's own name. Carried for identification and for nested folders' paths;
|
||||||
|
/// the top-level replay takes its path from the caller instead (see `recreateSubtree`).
|
||||||
|
public let name: String
|
||||||
|
|
||||||
|
/// POSIX permission bits as captured, `nil` when unreadable — applied best-effort on replay.
|
||||||
|
public let permissions: Int?
|
||||||
|
|
||||||
|
/// The folder's direct children, sorted by name.
|
||||||
|
public let entries: [Entry]
|
||||||
|
|
||||||
|
public init(name: String, permissions: Int?, entries: [Entry]) {
|
||||||
|
self.name = name
|
||||||
|
self.permissions = permissions
|
||||||
|
self.entries = entries
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One captured child. Symlinks are their own case rather than a file holding their target's
|
||||||
|
/// bytes — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules), so
|
||||||
|
/// a capture records the link and a replay recreates the link.
|
||||||
|
public enum Entry: Sendable, Equatable {
|
||||||
|
case file(name: String, contents: Data, permissions: Int?)
|
||||||
|
case folder(SubtreeSnapshot)
|
||||||
|
case symlink(name: String, destination: String)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How a copy stamps the files it materializes — the one axis on which the two kinds of copy
|
/// 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).
|
/// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter).
|
||||||
public enum CopyStamps: Sendable {
|
public enum CopyStamps: Sendable {
|
||||||
@@ -1854,9 +2393,23 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case move(title: String?)
|
case move(title: String?)
|
||||||
case reorder(title: String?)
|
case reorder(title: String?)
|
||||||
case copy(title: String?)
|
case copy(title: String?)
|
||||||
case delete(title: String?) // tombstone
|
/// ⌫ / ⌘⌫ — a card moving into `.trash/` (`deleteCardToTrash`) or a lane being removed
|
||||||
|
/// outright (`removeLane`). Still the word the user pressed; the retiring tombstone write
|
||||||
|
/// shares it while it lasts.
|
||||||
|
case delete(title: String?)
|
||||||
case restore(title: String?)
|
case restore(title: String?)
|
||||||
case purge(title: String?)
|
case purge(title: String?)
|
||||||
|
|
||||||
|
/// A legacy `deleted:` key being migrated away — a card relocating into `.trash/` with the key
|
||||||
|
/// removed, or a lane getting the key stripped and returning live (01-storage-format.md
|
||||||
|
/// § Deletion, "Legacy `deleted:` keys migrate on load-and-write, never destroy").
|
||||||
|
///
|
||||||
|
/// Its own case rather than a fold into `.delete` or `.move`, on the vocabulary's standing
|
||||||
|
/// reasoning and `.relocateLooseFile`'s in particular: this is work the *app* started on its
|
||||||
|
/// own, on a board an older version wrote, and a banner telling the user the app "couldn't
|
||||||
|
/// delete 'Fix login'" would name a gesture they never made — on a lane, one whose outcome is
|
||||||
|
/// the opposite of deletion.
|
||||||
|
case migrateTombstone(title: String?)
|
||||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
||||||
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
case resize(title: String?) // a lane's `width` — the edge drag and the stepper alike (03-board-ui.md § Lane)
|
||||||
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
/// An inline title editor's commit — the third inline editor's write (04-interactions.md ▸
|
||||||
@@ -1954,6 +2507,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case .delete: .delete(title: title)
|
case .delete: .delete(title: title)
|
||||||
case .restore: .restore(title: title)
|
case .restore: .restore(title: title)
|
||||||
case .purge: .purge(title: title)
|
case .purge: .purge(title: title)
|
||||||
|
case .migrateTombstone: .migrateTombstone(title: title)
|
||||||
case .style: .style(title: title)
|
case .style: .style(title: title)
|
||||||
case .resize: .resize(title: title)
|
case .resize: .resize(title: title)
|
||||||
case .rename: .rename(title: title)
|
case .rename: .rename(title: title)
|
||||||
@@ -1981,6 +2535,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
|||||||
case let .delete(title): Self.phrase("delete", title)
|
case let .delete(title): Self.phrase("delete", title)
|
||||||
case let .restore(title): Self.phrase("restore", title)
|
case let .restore(title): Self.phrase("restore", title)
|
||||||
case let .purge(title): Self.phrase("purge", title)
|
case let .purge(title): Self.phrase("purge", title)
|
||||||
|
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||||
case let .style(title): Self.phrase("style", title)
|
case let .style(title): Self.phrase("style", title)
|
||||||
case let .resize(title): Self.phrase("resize", title)
|
case let .resize(title): Self.phrase("resize", title)
|
||||||
case let .rename(title): Self.phrase("rename", title)
|
case let .rename(title): Self.phrase("rename", title)
|
||||||
|
|||||||
@@ -372,6 +372,44 @@ struct FolderWatcherTests {
|
|||||||
#expect(log.events == [.treeChanged(.foreign)])
|
#expect(log.events == [.treeChanged(.foreign)])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: .trash coverage
|
||||||
|
|
||||||
|
/// **The trash is watched, unlike `.git`** (01-storage-format.md § Deletion, resettled
|
||||||
|
/// 2026-07-28). It is the one hidden folder in a board whose contents are *rendered* — a
|
||||||
|
/// materialized container of ordinary cards — so a foreign delete, restore or purge has to
|
||||||
|
/// reload the board like any other move. The filter tests for a `.git` component specifically
|
||||||
|
/// rather than for a dot prefix, and this is the test that keeps it that way.
|
||||||
|
@Test("Changes inside .trash are delivered, unlike .git churn")
|
||||||
|
func trashChangesAreDelivered() async throws {
|
||||||
|
let fixture = try WatchFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
fixture.makeDirectory(".git")
|
||||||
|
fixture.makeDirectory(".trash")
|
||||||
|
let log = EventLog()
|
||||||
|
let watcher = FolderWatcher(root: fixture.root, debounce: testDebounce, latency: testLatency) {
|
||||||
|
log.record($0)
|
||||||
|
}
|
||||||
|
#expect(watcher.start())
|
||||||
|
defer { watcher.stop() }
|
||||||
|
await drainStartupChurn(log)
|
||||||
|
|
||||||
|
// What another device's (or an agent's) delete looks like from here: a card folder
|
||||||
|
// appearing inside the container.
|
||||||
|
let card = "2b7c9d10-0000-4000-8000-000000000000"
|
||||||
|
fixture.write(".trash/\(card)/index.md", "---\nschema: 1\norder: -1024\n---\n")
|
||||||
|
await waitUntil { log.count >= 1 }
|
||||||
|
await quiet()
|
||||||
|
#expect(log.events == [.treeChanged(.foreign)])
|
||||||
|
|
||||||
|
// …and a purge of it is a change too, while `.git` in the same board stays filtered.
|
||||||
|
log.reset()
|
||||||
|
fixture.write(".git/index", "fake index")
|
||||||
|
try? FileManager.default.removeItem(at: fixture.root.appendingPathComponent(".trash/\(card)"))
|
||||||
|
await waitUntil { log.count >= 1 }
|
||||||
|
await quiet()
|
||||||
|
#expect(log.events == [.treeChanged(.foreign)])
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Root identity
|
// MARK: Root identity
|
||||||
|
|
||||||
@Test("Deleting the watched root delivers rootChanged and tears the stream down")
|
@Test("Deleting the watched root delivers rootChanged and tears the stream down")
|
||||||
|
|||||||
@@ -0,0 +1,842 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
/// The storage layer of the **materialized trash** (01-storage-format.md § Deletion, resettled
|
||||||
|
/// 2026-07-28; 03-board-ui.md § Trash): `<board-root>/.trash/` as a second card container beside
|
||||||
|
/// the lanes, the Writer primitives that move folders into and out of it, and the read-only
|
||||||
|
/// detection channel that migrates the retired `deleted:` key away.
|
||||||
|
///
|
||||||
|
/// Everything here writes and reads raw bytes on disk rather than going through the store, so
|
||||||
|
/// every assertion is about what is actually in the tree.
|
||||||
|
|
||||||
|
// MARK: - Loader fixture
|
||||||
|
|
||||||
|
/// A board tree under a temp directory — `BoardLoaderTests`' own builder plus the two shapes this
|
||||||
|
/// suite needs (a `.trash/` entry, and a plain folder anywhere).
|
||||||
|
private struct TrashFixture {
|
||||||
|
let root: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("TrashStorageTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
|
||||||
|
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
let text = "---\n\(frontmatter)---\n\(body)"
|
||||||
|
try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func folder(_ relativePath: String) throws -> URL {
|
||||||
|
let url = root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func file(_ relativePath: String, _ contents: String = "stray") throws {
|
||||||
|
let url = root.appendingPathComponent(relativePath)
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uuidName() -> String { UUID().uuidString.lowercased() }
|
||||||
|
|
||||||
|
// MARK: - The trash container, read
|
||||||
|
|
||||||
|
@Suite("BoardLoader ▸ the .trash container")
|
||||||
|
struct TrashContainerLoadTests {
|
||||||
|
|
||||||
|
/// The container's whole ordering story: ordinary `order` ranks, ascending, sorted exactly as a
|
||||||
|
/// lane's cards are — newest-first falls out of *minting* (each arrival takes a rank above the
|
||||||
|
/// current top), never out of a timestamp sort, so the loader has no trash-specific rule at all.
|
||||||
|
@Test("Trash cards load in rank order, in their own container, carrying no deleted key")
|
||||||
|
func trashCardsLoadInRankOrder() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidName()
|
||||||
|
let newest = uuidName()
|
||||||
|
let middle = uuidName()
|
||||||
|
let oldest = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
// Written oldest-first on disk; the ranks are what decides.
|
||||||
|
try fixture.index(".trash/\(oldest)", "schema: 1\norder: 1024\ntitle: Oldest\n")
|
||||||
|
try fixture.index(".trash/\(middle)", "schema: 1\norder: 0\ntitle: Middle\n")
|
||||||
|
try fixture.index(".trash/\(newest)", "schema: 1\norder: -1024\ntitle: Newest\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.model.trash.map(\.id.rawValue) == [newest, middle, oldest])
|
||||||
|
#expect(result.model.trash.map(\.title.value) == ["Newest", "Middle", "Oldest"])
|
||||||
|
// The pivot in one assertion: a trashed card carries no flag, it is simply somewhere else.
|
||||||
|
#expect(result.model.trash.allSatisfy { !$0.isDeleted })
|
||||||
|
#expect(result.model.lanes.map(\.id.rawValue) == [lane])
|
||||||
|
#expect(result.model.lanes[0].cards.isEmpty)
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
#expect(result.legacyTombstones.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A board with no .trash folder has an empty trash, and says nothing about it")
|
||||||
|
func absentTrashIsEmpty() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(uuidName(), "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.trash.isEmpty)
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An empty .trash folder is an empty trash, and is never a stray at board root")
|
||||||
|
func emptyTrashIsNotAStray() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.folder(".trash")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.trash.isEmpty)
|
||||||
|
// The claimed-name rule: `.trash` never earns the stray warning a `notes/` folder would.
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stray tolerance inside the container is the same tolerance every other container gets.
|
||||||
|
@Test("Strays inside .trash are tolerated with the ordinary warnings")
|
||||||
|
func straysInsideTrash() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let card = uuidName()
|
||||||
|
let indexless = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(".trash/\(card)", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.folder(".trash/\(indexless)")
|
||||||
|
try fixture.folder(".trash/notes")
|
||||||
|
try fixture.file(".trash/loose.txt")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.model.trash.map(\.id.rawValue) == [card])
|
||||||
|
#expect(result.warnings.contains(.missingIndex(path: ".trash/\(indexless)")))
|
||||||
|
#expect(result.warnings.contains(.nonUUIDFolderIgnored(path: ".trash/notes")))
|
||||||
|
// A stray *file* is silent everywhere, here included.
|
||||||
|
#expect(result.warnings.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "A lane-shaped nesting inside `.trash` is a stray": the walk stops at a card in the trash
|
||||||
|
/// exactly as it does under a lane, so a UUID-shaped folder *inside* a trash card is content,
|
||||||
|
/// never a level — invisible, preserved, and not warned about (a card's subfolders never are).
|
||||||
|
@Test("A lane-shaped nesting inside .trash renders as one card, its children invisible")
|
||||||
|
func laneShapedNestingInsideTrash() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let outer = uuidName()
|
||||||
|
let nested = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(".trash/\(outer)", "schema: 1\norder: 1024\ntitle: Outer\n")
|
||||||
|
try fixture.index(".trash/\(outer)/\(nested)", "schema: 1\norder: 1024\ntitle: Nested\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.model.trash.map(\.id.rawValue) == [outer])
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
// Nowhere in the snapshot, and still on disk.
|
||||||
|
#expect(!result.model.lanes.contains { $0.cards.contains { $0.id.rawValue == nested } })
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: fixture.root.appendingPathComponent(".trash/\(outer)/\(nested)/index.md").path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A whole lane folder dropped into .trash is skipped, not descended into")
|
||||||
|
func indexlessLaneFolderInsideTrash() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidName()
|
||||||
|
let card = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.folder(".trash/\(lane)")
|
||||||
|
try fixture.index(".trash/\(lane)/\(card)", "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.model.trash.isEmpty)
|
||||||
|
#expect(result.warnings == [.missingIndex(path: ".trash/\(lane)")])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The card parse is *one* parse: a trashed card gets the same attachment listing, the same
|
||||||
|
/// verbatim document, and the same identity leniency as a card under a lane.
|
||||||
|
@Test("A trash card is parsed exactly like a lane card — attachments, body, spelling")
|
||||||
|
func trashCardIsAnOrdinaryCard() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let card = "AABBCCDD-1111-7111-8111-111111111111"
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(
|
||||||
|
".trash/\(card)",
|
||||||
|
"schema: 1\norder: 1024\ntitle: Trashed\nproject: lanework\n",
|
||||||
|
body: "Body *survives*.\n"
|
||||||
|
)
|
||||||
|
try fixture.file(".trash/\(card)/attachments/shot 10.png", "png")
|
||||||
|
try fixture.file(".trash/\(card)/attachments/shot 2.png", "png")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
let trashed = try #require(result.model.trash.first)
|
||||||
|
|
||||||
|
// Uppercase and a v7 nibble: shape-only identity holds in the trash too, spelling preserved.
|
||||||
|
#expect(trashed.id.rawValue == card)
|
||||||
|
#expect(trashed.body == "Body *survives*.\n")
|
||||||
|
#expect(trashed.attachments == ["shot 2.png", "shot 10.png"])
|
||||||
|
#expect(trashed.document.unknownFields.map(\.key) == ["project"])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fail-fast is a property of the card parse, not of the container it ran in.
|
||||||
|
@Test("A malformed order inside .trash fails the load, naming its path")
|
||||||
|
func malformedOrderInTrashFailsFast() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let card = uuidName()
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(".trash/\(card)", "schema: 1\norder: soon\n")
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
Issue.record("expected the load to fail")
|
||||||
|
} catch let error as BoardLoadError {
|
||||||
|
#expect(error.path == ".trash/\(card)/index.md")
|
||||||
|
#expect(error.reason == .malformedOrder(raw: "soon"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Symlinks are never traversed — a symlinked container would render bytes living outside the
|
||||||
|
/// board that FSEvents never reports.
|
||||||
|
@Test("A symlinked .trash is treated as an empty trash, never followed")
|
||||||
|
func symlinkedTrashIsNotFollowed() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let elsewhere = try fixture.folder("elsewhere")
|
||||||
|
try fixture.index("elsewhere/\(uuidName())", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try FileManager.default.createSymbolicLink(
|
||||||
|
at: fixture.root.appendingPathComponent(".trash"),
|
||||||
|
withDestinationURL: elsewhere
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.trash.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - The migration channel
|
||||||
|
|
||||||
|
@Suite("BoardLoader ▸ legacy tombstone detection")
|
||||||
|
struct LegacyTombstoneDetectionTests {
|
||||||
|
|
||||||
|
/// The intermediate this phase deliberately chose: the key is *reported* for migration and the
|
||||||
|
/// item still loads through the retiring tombstone path, so nothing vanishes from view before
|
||||||
|
/// its folder has actually moved (the fix is deferred under any read-only lock).
|
||||||
|
@Test("A tombstoned card is reported for migration and still loads flagged under its lane")
|
||||||
|
func tombstonedCardIsReported() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidName()
|
||||||
|
let card = uuidName()
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index(
|
||||||
|
"\(lane)/\(card)",
|
||||||
|
"schema: 1\norder: 1024\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.legacyTombstones == [
|
||||||
|
LegacyTombstone(kind: .card, laneID: ItemID(rawValue: lane), cardID: ItemID(rawValue: card), title: "Gone")
|
||||||
|
])
|
||||||
|
// Still rendered by the retiring path — read-only detection has moved nothing yet.
|
||||||
|
#expect(result.model.lanes[0].cards.map(\.isDeleted) == [true])
|
||||||
|
#expect(result.model.trash.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A tombstoned lane is reported with no card, and keeps loading flagged")
|
||||||
|
func tombstonedLaneIsReported() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidName()
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Old lane\ndeleted: 2026-01-01T00:00:00Z\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.legacyTombstones == [
|
||||||
|
LegacyTombstone(kind: .lane, laneID: ItemID(rawValue: lane), cardID: nil, title: "Old lane")
|
||||||
|
])
|
||||||
|
#expect(result.model.lanes[0].isDeleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presence, not validity — the same rule the flag has always read by, so a broken timestamp
|
||||||
|
/// still migrates rather than being left behind as the one key the pivot forgot.
|
||||||
|
@Test("A malformed deleted value is still migration input")
|
||||||
|
func malformedDeletedIsStillReported() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidName()
|
||||||
|
let card = uuidName()
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\ndeleted: yesterday\n")
|
||||||
|
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\ndeleted: yesterday\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
|
||||||
|
#expect(result.legacyTombstones.map(\.kind) == [.card, .lane])
|
||||||
|
#expect(result.legacyTombstones.map(\.cardID) == [ItemID(rawValue: card), nil])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "A `deleted:` key at board level remains meaningless — ignored and logged, preserved
|
||||||
|
/// verbatim": a warning, never migration input, because there is no item to relocate and no
|
||||||
|
/// key the app has any business removing.
|
||||||
|
@Test("A board-level deleted key is warned, never migrated")
|
||||||
|
func boardLevelDeletedIsNotMigrated() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.warnings == [.boardLevelDeletedIgnored])
|
||||||
|
#expect(result.legacyTombstones.isEmpty)
|
||||||
|
#expect(try fixture.root.appendingPathComponent("index.md").checkResourceIsReachable())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A board that has already migrated reports nothing")
|
||||||
|
func migratedBoardReportsNothing() throws {
|
||||||
|
let fixture = try TrashFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let lane = uuidName()
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index(lane, "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("\(lane)/\(uuidName())", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index(".trash/\(uuidName())", "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.legacyTombstones.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writer: delete is a move
|
||||||
|
|
||||||
|
@Suite("BoardWriter ▸ deleteCardToTrash")
|
||||||
|
struct DeleteCardToTrashTests {
|
||||||
|
|
||||||
|
@Test("The card folder moves into .trash, which is created on first delete")
|
||||||
|
func moveCreatesTheContainer() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
||||||
|
|
||||||
|
#expect(!fixture.exists(".trash"))
|
||||||
|
let id = try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -1024
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(id == ItemID(rawValue: Ident.card1))
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
#expect(fixture.exists(".trash/\(Ident.card1)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The move's whole edit: the new rank plus the stamps. `modified` is stamped **on purpose** —
|
||||||
|
/// the one exception to moves-don't-stamp, and what a future age-based auto-purge reads.
|
||||||
|
@Test("Only order and the stamps are rewritten; every other byte survives")
|
||||||
|
func onlyOrderAndStampsChange() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "2048", title: "Card"))
|
||||||
|
|
||||||
|
try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -1024
|
||||||
|
)
|
||||||
|
|
||||||
|
let text = try fixture.indexText(".trash/\(Ident.card1)")
|
||||||
|
#expect(text.contains("project: lanework # agent overlay"))
|
||||||
|
#expect(text.contains("labels: [a, b, c]"))
|
||||||
|
#expect(text.contains("created: 2026-01-01T09:00:00Z"))
|
||||||
|
#expect(text.contains("Card body — with *markdown*."))
|
||||||
|
// The stamp is the point, and the external attribution goes as it does on every app write.
|
||||||
|
#expect(!text.contains("modified: 2026-02-02T09:00:00Z"))
|
||||||
|
#expect(!text.contains("modified-by:"))
|
||||||
|
// No key is written and none is left behind — the tombstone is retired.
|
||||||
|
#expect(!text.contains("deleted:"))
|
||||||
|
|
||||||
|
let document = try FrontmatterDocument.parse(text)
|
||||||
|
#expect(document.order == .valid(-1024))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Attachments and strays travel byte-identical — nothing beneath the card is read")
|
||||||
|
func subtreeTravelsVerbatim() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF])
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/nested.bin", Data([1, 2, 3]))
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/notes.txt", Data("loose".utf8))
|
||||||
|
|
||||||
|
try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -1024
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/shot.png") == png)
|
||||||
|
#expect(try fixture.data(".trash/\(Ident.card1)/attachments/sub/nested.bin") == Data([1, 2, 3]))
|
||||||
|
#expect(try fixture.data(".trash/\(Ident.card1)/notes.txt") == Data("loose".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Cards only. Lanes are never trashed" — structural here, not a policy the caller remembers.
|
||||||
|
@Test("A lane, a board root and a stray are all refused")
|
||||||
|
func onlyCardsAreTrashed() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
|
||||||
|
|
||||||
|
for target in [Ident.lane1, "", "notes"] {
|
||||||
|
let error = writeFailure {
|
||||||
|
_ = try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url(target),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -1024
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(error != nil)
|
||||||
|
}
|
||||||
|
#expect(!fixture.exists(".trash"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Board-wide uniqueness spans both containers, so this cannot happen through the app — and if
|
||||||
|
/// a hand copy makes it happen anyway, the move fails loudly rather than clobbering the twin.
|
||||||
|
@Test("A name already in the trash fails the move rather than overwriting it")
|
||||||
|
func collisionInTrashFailsLoudly() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
|
||||||
|
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "Impostor"))
|
||||||
|
|
||||||
|
let error = writeFailure {
|
||||||
|
_ = try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -2048
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(error?.operation == .delete(title: "Live"))
|
||||||
|
// Both folders still there, neither touched.
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
#expect(try fixture.indexText(".trash/\(Ident.card1)").contains("title: Impostor"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discover-before-you-write: the card's `index.md` must be rewritable at the destination, so
|
||||||
|
/// an uneditable one refuses while the folder is still where the user can see it.
|
||||||
|
@Test("An uneditable card refuses before the folder moves")
|
||||||
|
func uneditableCardRefusesBeforeMoving() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.uneditable)
|
||||||
|
|
||||||
|
let error = writeFailure {
|
||||||
|
_ = try BoardWriter.deleteCardToTrash(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -1024
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if case .uneditableFrontmatter = error?.reason {} else {
|
||||||
|
Issue.record("expected an uneditable-frontmatter refusal, got \(String(describing: error?.reason))")
|
||||||
|
}
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card1)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writer: the legacy migration
|
||||||
|
|
||||||
|
@Suite("BoardWriter ▸ legacy tombstone migration")
|
||||||
|
struct TombstoneMigrationTests {
|
||||||
|
|
||||||
|
@Test("A tombstoned card relocates into .trash with every deleted occurrence removed")
|
||||||
|
func cardMigrates() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
// Two occurrences: the surgical removal must take both, or the next load re-migrates it.
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Gone
|
||||||
|
order: 2048
|
||||||
|
deleted: 2026-01-01T00:00:00Z
|
||||||
|
project: lanework # agent overlay
|
||||||
|
deleted: 2026-02-02T00:00:00Z
|
||||||
|
---
|
||||||
|
Body kept.
|
||||||
|
|
||||||
|
""")
|
||||||
|
|
||||||
|
try BoardWriter.migrateTombstonedCard(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root,
|
||||||
|
order: -1024
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
let text = try fixture.indexText(".trash/\(Ident.card1)")
|
||||||
|
#expect(!text.contains("deleted:"))
|
||||||
|
#expect(text.contains("project: lanework # agent overlay"))
|
||||||
|
#expect(text.contains("Body kept."))
|
||||||
|
#expect(try FrontmatterDocument.parse(text).order == .valid(-1024))
|
||||||
|
|
||||||
|
// And the board now loads it as an ordinary trash card.
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.legacyTombstones.isEmpty)
|
||||||
|
#expect(result.model.trash.map(\.id.rawValue) == [Ident.card1])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "A lane carrying `deleted:` returns **live** with the key removed" — resurrection is the
|
||||||
|
/// safe direction, and the folder does not move, so it returns to its own position.
|
||||||
|
@Test("A tombstoned lane keeps its place and returns live")
|
||||||
|
func laneMigratesInPlace() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, """
|
||||||
|
---
|
||||||
|
schema: 1
|
||||||
|
title: Old lane
|
||||||
|
order: 2048
|
||||||
|
deleted: 2026-01-01T00:00:00Z
|
||||||
|
width: 2
|
||||||
|
---
|
||||||
|
Lane notes.
|
||||||
|
|
||||||
|
""")
|
||||||
|
let cardBefore = Item.rich(order: "1024", title: "Kept")
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", cardBefore)
|
||||||
|
|
||||||
|
try BoardWriter.migrateTombstonedLane(at: fixture.url(Ident.lane1))
|
||||||
|
|
||||||
|
let text = try fixture.indexText(Ident.lane1)
|
||||||
|
#expect(!text.contains("deleted:"))
|
||||||
|
#expect(text.contains("width: 2"))
|
||||||
|
#expect(text.contains("Lane notes."))
|
||||||
|
#expect(try FrontmatterDocument.parse(text).order == .valid(2048))
|
||||||
|
// Its cards are none of the lane migration's business.
|
||||||
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == cardBefore)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.legacyTombstones.isEmpty)
|
||||||
|
#expect(result.model.lanes.map(\.isDeleted) == [false])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("The lane migration refuses a card, whose migration is a move")
|
||||||
|
func laneMigrationRefusesACard() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
||||||
|
|
||||||
|
let error = writeFailure {
|
||||||
|
try BoardWriter.migrateTombstonedLane(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
}
|
||||||
|
#expect(error?.operation == .migrateTombstone(title: nil))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writer: lane delete is physical
|
||||||
|
|
||||||
|
@Suite("BoardWriter ▸ removeLane")
|
||||||
|
struct RemoveLaneTests {
|
||||||
|
|
||||||
|
@Test("The lane folder and everything under it go")
|
||||||
|
func laneIsRemovedWhole() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([1]))
|
||||||
|
try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Other"))
|
||||||
|
|
||||||
|
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
|
||||||
|
|
||||||
|
#expect(!fixture.exists(Ident.lane1))
|
||||||
|
#expect(fixture.exists(Ident.lane2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A lane that is already gone is success")
|
||||||
|
func absentLaneIsSuccess() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try BoardWriter.removeLane(at: fixture.url(Ident.lane1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The guard has to tell a lane from a card *and* from a trash card, whose parent is `.trash/`
|
||||||
|
/// rather than a UUID — otherwise a permanent delete would be reachable through this door.
|
||||||
|
@Test("A card, a trash card, a board root and a stray are all refused")
|
||||||
|
func onlyLanesAreRemoved() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Card"))
|
||||||
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
|
||||||
|
try fixture.item("notes", Item.rich(order: "1024", title: "Stray"))
|
||||||
|
|
||||||
|
for target in ["\(Ident.lane1)/\(Ident.card1)", ".trash/\(Ident.card2)", "", "notes"] {
|
||||||
|
#expect(writeFailure { try BoardWriter.removeLane(at: fixture.url(target)) } != nil)
|
||||||
|
}
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
#expect(fixture.exists(".trash/\(Ident.card2)"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writer: capture and replay
|
||||||
|
|
||||||
|
@Suite("BoardWriter ▸ subtree capture and replay")
|
||||||
|
struct SubtreeCaptureTests {
|
||||||
|
|
||||||
|
/// A lane delete's undo in one round trip: capture, remove, recreate, capture again. Equality
|
||||||
|
/// of the two captures is the byte-fidelity assertion — names, bytes, nesting and order.
|
||||||
|
@Test("Capture → remove → recreate → capture is identical, nested cards and attachments included")
|
||||||
|
func roundTripIsByteIdentical() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let png = Data([0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF, 0x0D, 0x0A])
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.uneditable)
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", png)
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/sub/deep.bin", Data([7, 8, 9]))
|
||||||
|
// The verbatim promise covers what the loader would never render, too.
|
||||||
|
try fixture.file("\(Ident.lane1)/\(Ident.card1)/.DS_Store", Data([0, 1]))
|
||||||
|
try fixture.file("\(Ident.lane1)/notes/scratch.md", Data("scratch".utf8))
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.indexless)"),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
|
||||||
|
let lane = fixture.url(Ident.lane1)
|
||||||
|
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
|
||||||
|
try BoardWriter.removeLane(at: lane)
|
||||||
|
#expect(!fixture.exists(Ident.lane1))
|
||||||
|
|
||||||
|
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: "Lane"))
|
||||||
|
|
||||||
|
let recaptured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: "Lane"))
|
||||||
|
#expect(recaptured == captured)
|
||||||
|
// …and spot-checked against disk, so the equality is not two identical bugs.
|
||||||
|
#expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)") == Item.uneditable)
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == png)
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/.DS_Store") == Data([0, 1]))
|
||||||
|
#expect(try fixture.data("\(Ident.lane1)/notes/scratch.md") == Data("scratch".utf8))
|
||||||
|
#expect(try fixture.entryNames(Ident.lane1).contains(Ident.indexless))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A symlink is captured as a link and recreated as one, never followed")
|
||||||
|
func symlinksAreNotFollowed() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try FileManager.default.createSymbolicLink(
|
||||||
|
atPath: fixture.url(Ident.lane1).appendingPathComponent("link").path,
|
||||||
|
withDestinationPath: "../nowhere"
|
||||||
|
)
|
||||||
|
|
||||||
|
let lane = fixture.url(Ident.lane1)
|
||||||
|
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
|
||||||
|
#expect(captured.entries.contains(.symlink(name: "link", destination: "../nowhere")))
|
||||||
|
|
||||||
|
try BoardWriter.removeLane(at: lane)
|
||||||
|
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
|
||||||
|
|
||||||
|
let destination = try FileManager.default.destinationOfSymbolicLink(
|
||||||
|
atPath: lane.appendingPathComponent("link").path
|
||||||
|
)
|
||||||
|
#expect(destination == "../nowhere")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Recreating over something that exists refuses rather than merging")
|
||||||
|
func recreateRefusesToClobber() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
|
||||||
|
let lane = fixture.url(Ident.lane1)
|
||||||
|
let captured = try BoardWriter.captureSubtree(at: lane, operation: .delete(title: nil))
|
||||||
|
let error = writeFailure {
|
||||||
|
try BoardWriter.recreateSubtree(at: lane, from: captured, operation: .delete(title: nil))
|
||||||
|
}
|
||||||
|
#expect(error?.reason == .io(message: "something already exists here"))
|
||||||
|
// The refusal left the folder that was there exactly as it was.
|
||||||
|
#expect(try fixture.indexText(Ident.lane1).contains("title: Lane"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Writer: permanent removal
|
||||||
|
|
||||||
|
@Suite("BoardWriter ▸ purging the trash")
|
||||||
|
struct TrashContainerPurgeTests {
|
||||||
|
|
||||||
|
@Test("A trash card purges; a live card is unreachable through this door")
|
||||||
|
func purgeIsScopedToTheTrash() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Lane"))
|
||||||
|
try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Live"))
|
||||||
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "1024", title: "Trashed"))
|
||||||
|
|
||||||
|
try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card2)"))
|
||||||
|
|
||||||
|
let error = writeFailure {
|
||||||
|
try BoardWriter.purgeTrashCard(
|
||||||
|
at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
inBoard: fixture.root
|
||||||
|
)
|
||||||
|
}
|
||||||
|
#expect(error?.operation == .purge(title: nil))
|
||||||
|
#expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)"))
|
||||||
|
|
||||||
|
// Already gone is success, as everywhere else on the purge path.
|
||||||
|
try BoardWriter.purgeTrashCard(at: fixture.url(".trash/\(Ident.card2)"), inBoard: fixture.root)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash takes every card and leaves a hand-editor's strays standing")
|
||||||
|
func emptyTrashTakesCardsOnly() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
try fixture.item(".trash/\(Ident.card1)", Item.rich(order: "1024", title: "One"))
|
||||||
|
try fixture.item(".trash/\(Ident.card2)", Item.rich(order: "2048", title: "Two"))
|
||||||
|
try fixture.file(".trash/readme.txt", Data("mine".utf8))
|
||||||
|
|
||||||
|
let purged = try BoardWriter.emptyTrash(inBoard: fixture.root)
|
||||||
|
|
||||||
|
#expect(Set(purged) == [ItemID(rawValue: Ident.card1), ItemID(rawValue: Ident.card2)])
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card1)"))
|
||||||
|
#expect(!fixture.exists(".trash/\(Ident.card2)"))
|
||||||
|
#expect(try fixture.data(".trash/readme.txt") == Data("mine".utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Empty Trash on a board with no trash removes nothing")
|
||||||
|
func emptyTrashWithNoTrash() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
try fixture.item("", Item.board)
|
||||||
|
#expect(try BoardWriter.emptyTrash(inBoard: fixture.root).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Board-wide uniqueness spans the trash
|
||||||
|
|
||||||
|
@Suite("BoardWriter ▸ the import boundary sees the trash")
|
||||||
|
struct TrashIdentityTests {
|
||||||
|
|
||||||
|
/// The dedupe walk includes `.trash/`: an arriving card whose UUID is sitting in the
|
||||||
|
/// destination's trash is reminted, because the two would otherwise become one identity in two
|
||||||
|
/// folders the moment the trashed one was dragged back out.
|
||||||
|
@Test("An import colliding with a trashed card is degraded to a copy")
|
||||||
|
func importCollidesWithTrash() throws {
|
||||||
|
let fixture = try WriterFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.item("source", Item.board)
|
||||||
|
try fixture.item("source/\(Ident.lane1)", Item.rich(order: "1024", title: "Source lane"))
|
||||||
|
try fixture.item("source/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Arriving"))
|
||||||
|
|
||||||
|
try fixture.item("destination", Item.board)
|
||||||
|
try fixture.item("destination/\(Ident.lane2)", Item.rich(order: "1024", title: "Destination lane"))
|
||||||
|
// The colliding twin lives only in the trash — nowhere among the lanes.
|
||||||
|
try fixture.item("destination/.trash/\(Ident.card1)", Item.rich(order: "1024", title: "Trashed twin"))
|
||||||
|
|
||||||
|
let result = try BoardWriter.moveItem(
|
||||||
|
at: fixture.url("source/\(Ident.lane1)/\(Ident.card1)"),
|
||||||
|
toParent: fixture.url("destination/\(Ident.lane2)"),
|
||||||
|
sourceBoardRoot: fixture.url("source"),
|
||||||
|
destinationBoardRoot: fixture.url("destination"),
|
||||||
|
order: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(result.reminted.map(\.from) == [ItemID(rawValue: Ident.card1)])
|
||||||
|
#expect(result.id != ItemID(rawValue: Ident.card1))
|
||||||
|
#expect(fixture.exists("destination/\(Ident.lane2)/\(result.id.rawValue)"))
|
||||||
|
// The trashed twin is untouched, and the board holds one folder per identity.
|
||||||
|
#expect(try fixture.indexText("destination/.trash/\(Ident.card1)").contains("title: Trashed twin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user