From 4cf5f09d9343ec8186bf218cba466a765eda4b31 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 15:55:40 -0400 Subject: [PATCH] =?UTF-8?q?Materialize=20the=20trash=20=E2=80=94=20storage?= =?UTF-8?q?=20layer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Kanban/App/AppModel.swift | 6 + Kanban/LiveStore/BannerCenter.swift | 7 + Kanban/LiveStore/FolderWatcher.swift | 7 + Kanban/Storage/BoardLoader.swift | 266 ++++++++- Kanban/Storage/BoardModel.swift | 51 +- Kanban/Storage/BoardWriter.swift | 559 +++++++++++++++++- KanbanTests/FolderWatcherTests.swift | 38 ++ KanbanTests/TrashStorageTests.swift | 842 +++++++++++++++++++++++++++ 8 files changed, 1746 insertions(+), 30 deletions(-) create mode 100644 KanbanTests/TrashStorageTests.swift diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 56d56f2..7ddcd9a 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -634,6 +634,12 @@ public final class AppModel { /// not validity, so a malformed `deleted:` counts as deleted here exactly as it does everywhere /// 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 /// discover a counting bug. public static func liveCounts(of snapshot: BoardModel) -> (lanes: Int, cards: Int) { diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 4214423..d5464a1 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -635,6 +635,13 @@ public final class BannerCenter { if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" } case let .purge(title): 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): if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" } case let .resize(title): diff --git a/Kanban/LiveStore/FolderWatcher.swift b/Kanban/LiveStore/FolderWatcher.swift index da65461..7a223ac 100644 --- a/Kanban/LiveStore/FolderWatcher.swift +++ b/Kanban/LiveStore/FolderWatcher.swift @@ -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 /// 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 /// redundant reload than a missed one. private func isGitInternal(_ path: String) -> Bool { diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 122d7ff..ba7fc94 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -43,6 +43,33 @@ import os /// 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 /// cross-volume or cyclic trees. +/// +/// ## The trash is a container, not a level +/// +/// `/.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 { /// 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. 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 = [ + trashFolderName, "claude.md", "claude.user.md", ".gitignore", + ] + /// 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 /// … 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`). 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 - // 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 { warn(.boardLevelDeletedIgnored) } @@ -110,6 +169,10 @@ public enum BoardLoader: Sendable { var lanes: [Lane] = [] for laneURL in try directoryCandidates(in: boardRoot) { 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 { warn(.nonUUIDFolderIgnored(path: laneName)) continue @@ -137,10 +200,7 @@ public enum BoardLoader: Sendable { continue } - let cardPath = cardRelPath + "/" + indexFileName - 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) + let card = try parseCard(at: cardURL, path: cardRelPath) // Noticed, never acted on: the relocation is the store's, through the Writer. let loose = looseFileNames(in: cardURL) @@ -148,27 +208,35 @@ public enum BoardLoader: Sendable { looseCardFiles.append(LooseCardFiles( laneID: ItemID(rawValue: laneName), cardID: ItemID(rawValue: cardName), - title: cardDocument.title.value, + title: card.title.value, fileNames: loose )) logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/") } - cards.append(Card( - id: ItemID(rawValue: cardName), - schema: cardSchema, - title: cardDocument.title, - created: cardDocument.created, - modified: cardDocument.modified, - modifiedBy: cardDocument.modifiedBy, - deleted: cardDocument.deleted, - background: cardDocument.background, - icon: cardDocument.icon, - iconColor: cardDocument.iconColor, - order: cardOrder, - attachments: attachmentNames(in: cardURL), - document: cardDocument + // Detection only, the loose-file precedent exactly: the relocation into `.trash/` + // and the key's removal are the store's, through the Writer. + if card.isDeleted { + legacyTombstones.append(LegacyTombstone( + kind: .card, + laneID: ItemID(rawValue: laneName), + cardID: ItemID(rawValue: cardName), + title: card.title.value + )) + 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( @@ -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( rootURL: boardRoot, schema: boardSchema, @@ -202,10 +285,80 @@ public enum BoardLoader: Sendable { iconColor: boardDocument.iconColor, template: boardDocument.value(for: templateKey), lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }), + trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }), 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 `/.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 @@ -364,6 +517,11 @@ public enum BoardLoader: Sendable { /// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing /// 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 /// 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 @@ -512,7 +670,73 @@ public struct LoadResult: Sendable { /// 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 /// 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] = [] + + /// 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 diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index fb28d3d..68663fa 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -92,11 +92,39 @@ public struct BoardModel: Sendable, Equatable { /// parsed into a dedicated Swift shape, so future subkeys need no model change. public let template: YAMLValue? - /// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break) — **including - /// tombstoned lanes**, which stay in the snapshot flagged (`Lane.isDeleted`) for the trash - /// view (01-storage-format.md § Deletion). + /// Lanes in display order (`Ranks.sortedForDisplay`, folder-name tie-break). + /// + /// **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] + /// The board's **materialized trash**: the card folders sitting directly in + /// `/.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`, /// `remote`, …) ride along uninterpreted via `document.unknownFields` so a future writer /// 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). public let width: FieldValue - /// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break) - /// — **including tombstoned cards**, which stay in the snapshot flagged (`Card.isDeleted`) - /// for the trash view. + /// Cards in this lane, in display order (`Ranks.sortedForDisplay`, folder-name tie-break). + /// A card still carrying a legacy `deleted:` key rides along flagged (`Card.isDeleted`) until + /// its migration relocates it into `BoardModel.trash` — see `BoardModel.lanes`. public let cards: [Card] /// 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 /// means "not deleted" (`FieldValue.isMissing` already treats an explicit `deleted: null` /// 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 } } @@ -199,6 +234,8 @@ public struct Card: Identifiable, Sendable, Equatable { public var body: String { document.body } /// 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 } } diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 927146f..1f11162 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -589,6 +589,16 @@ public enum BoardWriter: Sendable { 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 } @@ -897,7 +907,496 @@ public enum BoardWriter: Sendable { return ItemID(rawValue: root.lastPathComponent) } - // MARK: - Tombstone + // MARK: - The materialized trash + + /// `/.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 `/.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 + /// `/.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 + /// `/` and a card is `//`, so "parent is not UUID-shaped" tells + /// the two apart — except that a **trash card** is `/.trash/`, 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: ` into its own `index.md` — /// 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 /// differ (01-storage-format.md § Fractal layout ▸ Rules; § Frontmatter). public enum CopyStamps: Sendable { @@ -1854,9 +2393,23 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case move(title: String?) case reorder(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 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 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 ▸ @@ -1954,6 +2507,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case .delete: .delete(title: title) case .restore: .restore(title: title) case .purge: .purge(title: title) + case .migrateTombstone: .migrateTombstone(title: title) case .style: .style(title: title) case .resize: .resize(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 .restore(title): Self.phrase("restore", 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 .resize(title): Self.phrase("resize", title) case let .rename(title): Self.phrase("rename", title) diff --git a/KanbanTests/FolderWatcherTests.swift b/KanbanTests/FolderWatcherTests.swift index 447bb56..01f896e 100644 --- a/KanbanTests/FolderWatcherTests.swift +++ b/KanbanTests/FolderWatcherTests.swift @@ -372,6 +372,44 @@ struct FolderWatcherTests { #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 @Test("Deleting the watched root delivers rootChanged and tears the stream down") diff --git a/KanbanTests/TrashStorageTests.swift b/KanbanTests/TrashStorageTests.swift new file mode 100644 index 0000000..8578968 --- /dev/null +++ b/KanbanTests/TrashStorageTests.swift @@ -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): `/.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")) + } +}