From af1860debfb60f58c5b91e8e133c4d02972ae173 Mon Sep 17 00:00:00 2001 From: rzen Date: Tue, 28 Jul 2026 09:09:31 -0400 Subject: [PATCH] Relocate loose card files into attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 01's Lanework-owns-the-board carve-out: a regular file beside a card's index.md belongs in attachments/, and the app moves it there. The loader detects read-only — a new LoadResult.looseCardFiles channel, separate from the stray-tolerance warnings because it says the opposite thing — skipping directories, symlinks, hidden entries, and the reserved names compared case-insensitively (on APFS, Index.md IS the index). The relocation rides one performWrite bracket at the tail of every successful reload, which makes lock deferral free: the reload that lifts a read-only lock is the reload that relocates. A lane/card/filename memo keeps a failing relocation from hot-looping — one one-shot, then silence until disk changes. The notice rides the loss-row class, phrasing folded by BannerCenter (one file, one card's files, a multi-card sweep), naming original filenames per the importAttachment rule. Paste normalizes at the import boundary: staged snapshots' loose files land in the pasted card's attachments silently, every arrival path declaring its side via an explicit normalizingLooseFiles parameter — drag paths decline and fall back to the destination's own carve-out. checkIsCardFolder closes the hole where a lane's notes.txt would have been relocated: card depth is exact, UUID under UUID. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY --- Fixtures/README.md | 2 +- Kanban/App/ClipboardStore.swift | 26 +- Kanban/LiveStore/BannerCenter.swift | 84 +++ Kanban/LiveStore/BoardStore.swift | 193 ++++- Kanban/LiveStore/BoardStoreRegistry.swift | 13 + Kanban/Storage/BoardLoader.swift | 149 +++- Kanban/Storage/BoardWriter.swift | 176 ++++- KanbanTests/FixtureBoardTests.swift | 33 + KanbanTests/LooseFileRelocationTests.swift | 778 +++++++++++++++++++++ README.md | 2 +- 10 files changed, 1434 insertions(+), 22 deletions(-) create mode 100644 KanbanTests/LooseFileRelocationTests.swift diff --git a/Fixtures/README.md b/Fixtures/README.md index 2e8e0fc..88fd68d 100644 --- a/Fixtures/README.md +++ b/Fixtures/README.md @@ -15,7 +15,7 @@ Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never | `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Its `attachments/` also carries all four listing shapes — two ordinary files, a hidden one, and a subfolder with a file — so `Card.attachments`' flat rule (01-storage-format.md § Attachments) is asserted against a real tree. Also the board every `index.md` in the tree is round-tripped against. | | `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). | | `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. | -| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. | +| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. The **card-level** one (`scratch.md`) is also the loose-file carve-out's golden case: tolerated everywhere else, it is reported in `LoadResult.looseCardFiles` for the app to relocate into `attachments/` (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28). Detection is read-only, so the file stays put on disk. | | `tombstones.kanban` | A tombstoned lane and a tombstoned card, both still on disk and still in the snapshot, flagged (`isDeleted`) rather than removed. Also proves a tombstoned lane doesn't recursively flag its own un-deleted children. | | `duplicate-order-tie-break.kanban` | Three cards sharing one `order` in one lane, and two lanes sharing one `order` — both broken by folder name, ascending. | | `unknown-key-order.kanban` | Unknown/reserved frontmatter keys interleaved with schema-owned ones at board, lane, and card level — `document.unknownFields` must preserve exactly the order they were written in. | diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index 28c0e59..e8796f0 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -320,6 +320,15 @@ public final class ClipboardStore { /// does rather than earning a sub-rule for the one case where the items were already on this /// board. It is cleared here rather than at ⌘V so the two staleness guards keep their meaning: a /// paste the pasteboard moved under lands nothing, and so clears nothing. + /// + /// **A paste is an import boundary, so normalization applies** (04 ▸ Clipboard, settled + /// 2026-07-28 — 01-storage-format.md's loose-file carve-out): every arrival below passes + /// `normalizingLooseFiles: true`, so a loose file the staged snapshot faithfully carried beside + /// a card's `index.md` lands inside the pasted card's `attachments/`, Finder-renamed on + /// collision. Both branches and both operations, unqualified, because 04's sentence is + /// unqualified. Nothing is dropped and nothing is announced: the snapshot preserved the file, + /// the paste kept it, and it is where the schema says it belongs — the carve-out's notice is for + /// files the app moves *without* being asked, which is the loader's path, not this one. private func perform(_ manifest: ClipboardManifest, plan: Plan, into store: BoardStore) { refresh() // The pasteboard moved under this paste (another app copied while the chain settled): the @@ -338,10 +347,17 @@ public final class ClipboardStore { operation: .move, toLane: target.laneID, at: target.index, - clearingTombstones: false + clearingTombstones: false, + normalizingLooseFiles: true ) case let .lanes(index): - store.receiveLanes(sources, operation: .move, at: index, clearingTombstones: false) + store.receiveLanes( + sources, + operation: .move, + at: index, + clearingTombstones: false, + normalizingLooseFiles: true + ) } consumeCut() return @@ -383,14 +399,16 @@ public final class ClipboardStore { operation: .copy, toLane: target.laneID, at: target.index, - clearingTombstones: clearingTombstones + clearingTombstones: clearingTombstones, + normalizingLooseFiles: true ) case let .lanes(index): store.receiveLanes( sources, operation: .copy, at: index, - clearingTombstones: clearingTombstones + clearingTombstones: clearingTombstones, + normalizingLooseFiles: true ) } store.banners.postDegradedPaste(losses) diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index ac85715..fe244b6 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -359,6 +359,44 @@ public final class BannerCenter { postLoss(message) } + /// One card whose loose files were relocated into `attachments/` — what + /// `relocatedLooseFilesMessage(for:)` names. + /// + /// `fileNames` are the names the files had **beside `index.md`**, not the Finder-renamed ones + /// they may have landed under: those are the names the user or their agent wrote, and the one + /// they would recognize in a sentence (`WriteOperation.importAttachment`'s own rule, read for + /// the relocation). `title` is the card's as written, `nil` for an untitled one — "Untitled" is + /// a rendering, never a value (03-board-ui.md § Card face). + public struct Relocation: Sendable, Equatable { + public let title: String? + public let fileNames: [String] + + public init(title: String?, fileNames: [String]) { + self.title = title + self.fileNames = fileNames + } + } + + /// **The loose-file relocation** (01-storage-format.md § Fractal layout ▸ Rules, settled + /// 2026-07-28): a file was sitting beside a card's `index.md`, the app moved it into that card's + /// `attachments/`, and this is the row that says so — "surfacing a graceful warning-tone notice + /// naming the card and files". + /// + /// **A loss row, though nothing was lost.** The class is the vocabulary's warning-tone, + /// user-dismissed, never-expiring one — `LossBanner`'s "their future kin" — and this is exactly + /// that shape read once more: the app did something to the user's files that they did not ask + /// for, so it must be said out loud, it must not evaporate unread, and it must not rank as an + /// error, because no action failed. `signpost` would be too quiet (it ranks last and may + /// collapse behind "+N more"); `oneShot` would be a lie (it carries a `BoardWriteError`, and + /// the write succeeded). The name of the class is about its *lifecycle and tone*, not about + /// loss being the only thing it can report. + /// + /// A relocation that moved nothing posts nothing. + public func postRelocatedLooseFiles(_ relocations: [Relocation]) { + guard let message = Self.relocatedLooseFilesMessage(for: relocations) else { return } + postLoss(message) + } + /// Posts the skipped-folders loss row for a Finder drop that imported its files but refused its /// folders (04-interactions.md ▸ Selection, drag & drop, "Folders are refused at hover"): "a /// mixed drag proposes for its files only, and the drop imports the files while a one-shot @@ -595,6 +633,14 @@ public final class BannerCenter { "Couldn't read this card's attachments" case .renumberChildren: "Couldn't renumber cards" + case let .relocateLooseFile(filename): + // The verb matches the successful notice's ("Moved 'notes.txt' into attachments"), so + // the failure reads as the same sentence negated rather than as a different event. + // It stays in the ordinary one-shot precedence class rather than joining the attachment + // imports at the bottom: the relocation is work the *app* started on its own, and a + // failure the user did not provoke is exactly the one they have no other way to learn + // about. + "Couldn't move '\(filename)' into attachments" } } @@ -679,6 +725,44 @@ public final class BannerCenter { "Folders can't be attached — \(count) skipped" } + /// The loose-file relocation's line — 01-storage-format.md's own example sentence, "Moved + /// 'notes.txt' into attachments — 'Fix login'", generalized over the two axes it varies on. + /// + /// **Plurals fold twice**, which is the design's word for it ("plurals fold"; "multiple files + /// one card → 'Moved 3 files into attachments — Fix login'"): + /// + /// - **One card, one file** names the file *and* the card, which is the sentence the design + /// wrote: both facts fit, so both are said. + /// - **One card, several files** drops the filenames for their count. A banner is one line, and + /// a list of names would be the first thing to truncate; the card is still named, which is + /// what makes the notice actionable — the user knows exactly which `attachments/` to look in. + /// - **Several cards** folds again, to two counts: "Moved 5 files into attachments — 3 cards" + /// (settled here, the judgment 01 leaves to the implementation). It is the degraded paste's + /// own shape — "Pasted 2 items without their 5 files" — and for its reason: the true total + /// plus the true item count is the honest summary where a truncated list of titles would not + /// be. This case is the whole-board sweep (a board opened after an agent scattered files + /// across it), where naming three cards of eleven would read as a bug. + /// + /// The multi-card branch never has to spell a singular: two cards carry at least two files. + /// + /// `nil` when nothing moved — a relocation that relocated nothing is not news. Entries with no + /// filenames are dropped first, so a caller need not filter its own list. + public nonisolated static func relocatedLooseFilesMessage(for relocations: [Relocation]) -> String? { + let cards = relocations.filter { !$0.fileNames.isEmpty } + guard let only = cards.first else { return nil } + + let total = cards.reduce(0) { $0 + $1.fileNames.count } + guard cards.count == 1 else { + return "Moved \(total) files into attachments — \(cards.count) cards" + } + + let subject = only.title.map { "'\($0)'" } ?? "an untitled card" + guard total == 1, let name = only.fileNames.first else { + return "Moved \(total) files into attachments — \(subject)" + } + return "Moved '\(name)' into attachments — \(subject)" + } + /// The suspended-history line. It names the *consequence* the user cares about — undo and the /// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries /// the diagnosis as its tail. diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index f1d34d3..1bf506b 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -147,6 +147,16 @@ public final class BoardStore { /// describe the tree currently on screen. public private(set) var loadWarnings: [LoadWarning] + /// The cards the load that produced `snapshot` found holding loose files, exactly as the loader + /// reported them — the loose-file carve-out's detection channel (01-storage-format.md § Fractal + /// layout ▸ Rules, settled 2026-07-28). Replaced with the snapshot, like `loadWarnings`, so it + /// always describes the tree currently on screen. + /// + /// **Nothing renders it.** A loose file is not content — it reaches no view, and the card it + /// sits in draws exactly as it would without it. Its one consumer is + /// `relocateLooseCardFiles()`, immediately below the reload that produced it. + public private(set) var looseCardFiles: [LooseCardFiles] + /// The standing read-side condition: the error from the last reload that failed, `nil` when the /// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path /// and what is wrong with it — which is the whole of what the banner needs to render @@ -302,6 +312,12 @@ public final class BoardStore { @ObservationIgnored private var quiescenceWaiters: [CheckedContinuation] = [] + /// The loose-file set the last relocation attempt was made against — the loop guard + /// `relocateLooseCardFiles()` documents. Empty means "nothing has been attempted against the + /// current picture", which is both the opening state and what a clean board resets it to. + @ObservationIgnored + private var attemptedRelocation: Set = [] + /// Awaited off the main actor **after** a tree walk finishes and **before** its result is /// applied — the one seam this type keeps, `nil` in production. /// @@ -327,11 +343,19 @@ public final class BoardStore { /// /// The walk is synchronous because the caller has nothing to render until it lands; the /// asynchronous, off-main pipeline starts with the first reload. + /// + /// **It writes nothing, the opened board's loose files included.** `looseCardFiles` is recorded + /// here and acted on by whoever wired this store up — `BoardStoreRegistry.acquire` calls + /// `relocateLooseCardFiles()` once the watcher and the brackets exist, so the relocation is a + /// bracketed write with a reload behind it rather than a write into a board nothing is watching + /// yet. A store built directly (a test, a storeless consumer) relocates when it is asked to, and + /// on every reload thereafter. public init(rootURL: URL) throws(BoardLoadError) { let result = try BoardLoader.load(boardRoot: rootURL) self.rootURL = rootURL self.snapshot = result.model self.loadWarnings = result.warnings + self.looseCardFiles = result.looseCardFiles self.reloadFailure = nil self.readOnlyLock = nil self.transient = TransientBoardState() @@ -478,6 +502,7 @@ public final class BoardStore { // Breakage always heals on a success — it *is* the claim "the last reload failed", and // this one did not. reloadFailure = nil + looseCardFiles = result.looseCardFiles clearLockIfDisproved(by: origin) // The registry write-through, for the same "not board structure" reason the lock // clearing sits out here: whether this board's row needs a new title, icon, or @@ -485,6 +510,11 @@ public final class BoardStore { // guard), not a decision this store makes by comparing against its own prior // snapshot. displayStateDelegate?() + // Last, and after `clearLockIfDisproved` deliberately: this is the seam the deferred + // relocation is armed on. A board that was locked read-only tolerated its loose files + // for exactly as long as the lock stood, and the reload that clears the lock is the + // reload that lets them move — see `relocateLooseCardFiles()`. + relocateLooseCardFiles() case let .failure(error): // `snapshot`, `loadWarnings` and the transient state are untouched: a failed reload @@ -1455,7 +1485,14 @@ public final class BoardStore { /// finest grain (01-storage-format.md's per-folder degradation, which is `moveItem`'s own /// behaviour rather than something this method arranges). public func receiveCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { - receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: false) + receive( + sources.map(ItemSource.folder), + operation: operation, + toLane: laneID, + at: index, + clearingTombstones: false, + normalizingLooseFiles: false + ) } /// **The clipboard's card arrival** — `receiveCards`/`receiveRestoredCards` with the two axes a @@ -1467,14 +1504,35 @@ public final class BoardStore { /// is stripped **at materialization**". A cut is live-only (⌘X is disabled in the trash), so the /// two flags never both fire; the parameter is not narrowed for that, because which of them is /// reachable is the *clipboard's* rule and this method's job is only to obey both. + /// + /// `normalizingLooseFiles` is the third axis: **"a paste is an import boundary, so normalization + /// applies"** (04-interactions.md ▸ Clipboard, settled 2026-07-28 — 01-storage-format.md's + /// loose-file rule). Loose files the staged snapshot carries beside a card's `index.md` land in + /// the pasted card's `attachments/`, Finder-renamed on collision, so "nothing the snapshot + /// preserved is dropped on arrival" *and* nothing arrives out of place. + /// + /// It has **no default**, here and on `receiveLanes`, so every arrival path states which side of + /// the import boundary it is on rather than inheriting an answer. The clipboard passes `true` + /// (both operations: 04 says "a paste is an import boundary" unqualified, and an armed cut's + /// move is a paste); the drag passes `false` and leaves its arrivals to the destination board's + /// own carve-out, which relocates on the next reload with the notice a user-initiated paste has + /// no need of. public func receiveCards( _ sources: [ItemSource], operation: TransferOperation, toLane laneID: ItemID, at index: Int, - clearingTombstones: Bool + clearingTombstones: Bool, + normalizingLooseFiles: Bool ) { - receive(sources, operation: operation, toLane: laneID, at: index, clearingTombstones: clearingTombstones) + receive( + sources, + operation: operation, + toLane: laneID, + at: index, + clearingTombstones: clearingTombstones, + normalizingLooseFiles: normalizingLooseFiles + ) } /// The cross-board half of drag-to-restore (04-interactions.md ▸ The trash): tombstoned rows @@ -1495,7 +1553,14 @@ public final class BoardStore { /// because `restoreItem` is already the one expression in the app for "remove the `deleted:` /// key" — the bytes are never rewritten any other way. public func receiveRestoredCards(_ sources: [URL], operation: TransferOperation, toLane laneID: ItemID, at index: Int) { - receive(sources.map(ItemSource.folder), operation: operation, toLane: laneID, at: index, clearingTombstones: true) + receive( + sources.map(ItemSource.folder), + operation: operation, + toLane: laneID, + at: index, + clearingTombstones: true, + normalizingLooseFiles: false + ) } private func receive( @@ -1503,7 +1568,8 @@ public final class BoardStore { operation: TransferOperation, toLane laneID: ItemID, at index: Int, - clearingTombstones: Bool + clearingTombstones: Bool, + normalizingLooseFiles: Bool ) { guard !sources.isEmpty, let destination = snapshot.lanes.first(where: { $0.id == laneID && !$0.isDeleted }) @@ -1535,8 +1601,14 @@ public final class BoardStore { sourceBoardRoot: Self.boardRoot(ofCardFolder:), order: rank ) else { continue } + let cardFolder = laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true) + // Inside the same bracket, so the card lands normalized in one round trip rather + // than appearing loose for a reload and being tidied afterwards. + if normalizingLooseFiles { + try BoardWriter.normalizeLooseFiles(inCard: cardFolder) + } guard clearingTombstones else { continue } - try BoardWriter.restoreItem(at: laneFolder.appendingPathComponent(arrived.rawValue, isDirectory: true)) + try BoardWriter.restoreItem(at: cardFolder) } } } @@ -1600,7 +1672,13 @@ public final class BoardStore { /// not exist by drag at all (⌥ is ignored on lane drags; the clipboard is that operation's one /// home), so this method is cross-board by construction. public func receiveLanes(_ sources: [URL], operation: TransferOperation, at stripIndex: Int) { - receiveLanes(sources.map(ItemSource.folder), operation: operation, at: stripIndex, clearingTombstones: false) + receiveLanes( + sources.map(ItemSource.folder), + operation: operation, + at: stripIndex, + clearingTombstones: false, + normalizingLooseFiles: false + ) } /// **The clipboard's lane arrival** — `receiveLanes` with the staging-less fallback and the @@ -1614,11 +1692,16 @@ public final class BoardStore { /// destination — the lane-level twin of `receiveRestoredCards`, and the reason the strip runs /// first is that the two writes touch different files and the strip's target list is the one that /// must be read before anything is rewritten. + /// + /// `normalizingLooseFiles` is the import boundary's, exactly as on `receiveCards` and with the + /// same no-default rule; at lane level it reaches each arriving lane's **cards**, which is the + /// only level the carve-out has (a lane's own loose files keep the verbatim posture). public func receiveLanes( _ sources: [ItemSource], operation: TransferOperation, at stripIndex: Int, - clearingTombstones: Bool + clearingTombstones: Bool, + normalizingLooseFiles: Bool ) { guard !sources.isEmpty else { return } @@ -1654,6 +1737,11 @@ public final class BoardStore { if operation == .copy { try BoardWriter.stripTombstonedChildren(of: laneFolder) } + // After the strip, so a tombstoned card the copy is about to remove is not tidied + // on its way to being deleted. + if normalizingLooseFiles { + try BoardWriter.normalizeLooseFiles(inLane: laneFolder) + } if clearingTombstones { try BoardWriter.restoreItem(at: laneFolder) } @@ -1703,6 +1791,95 @@ public final class BoardStore { } } + // MARK: - The loose-file carve-out + + /// Moves every loose file the last applied snapshot found beside a card's `index.md` into that + /// card's `attachments/`, and posts one notice naming what moved — the **act** half of + /// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28, + /// "Lanework-owns-the-board"; the loader's `looseCardFiles` is the notice half). + /// + /// **It is an ordinary app write and nothing more.** One `performWrite` bracket over the whole + /// board's worth of relocation, so the churn rounds back as a single app-mediated reload and (on + /// git boards) a single commit — the style batch's rule, applied to a batch the app started + /// itself. The snapshot is not touched here any more than it is anywhere else: the files move, + /// the watcher notices, the reload lands. + /// + /// ### The read-only lock defers it, it does not cancel it + /// + /// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." A + /// locked board returns here having written nothing **and having remembered nothing**, so the + /// next attempt is a fresh one. The arming seam is `land(_:generation:origin:)`: every lock + /// clears on a successful reload and nowhere else, and this runs at the end of every successful + /// reload, after `clearLockIfDisproved` — so the reload that lifts the lock is the reload that + /// performs the relocation, with no timer, no queue, and no second state to keep in step. + /// + /// ### It cannot hot-loop + /// + /// The relocation's own reload re-walks the tree, which is the loop the guard exists for. After + /// a success the walk finds nothing loose, `looseCardFiles` empties, and the memo below is + /// cleared — the ordinary resting state. After a *failure* the walk finds the same files again, + /// and an unguarded call would fail again, forever, at the speed of a directory walk. So an + /// attempt is made only when the loose-file set **differs from the last one attempted**: one + /// failure, one banner row, then silence until the picture on disk actually changes (a file + /// added, removed, or partially moved by the failed attempt itself — each of which is a + /// different set and so a fresh attempt). + /// + /// The failure is the banner's already: `performWrite` posts every `BoardWriteError` before it + /// rethrows, and the rethrow is swallowed here like every other gesture with nothing else to do + /// about it. Files moved before the failure stay moved, and the notice names exactly those. + public func relocateLooseCardFiles() { + let work = looseCardFiles + guard !work.isEmpty else { + // The resting state, and the memo's reset: a board with nothing loose has nothing to + // remember having tried. + attemptedRelocation = [] + return + } + // Deferred, not abandoned — and deliberately *before* the memo is written, so the attempt + // this lock refused is not the attempt the guard below remembers. + guard readOnlyLock == nil else { + Self.logger.debug("loose-file relocation deferred — the board is read-only") + return + } + let signature = Self.relocationSignature(of: work) + guard signature != attemptedRelocation else { return } + attemptedRelocation = signature + + let root = rootURL + var relocated: [BannerCenter.Relocation] = [] + try? performWrite { () throws(BoardWriteError) -> Void in + for card in work { + let folder = root + .appendingPathComponent(card.laneID.rawValue, isDirectory: true) + .appendingPathComponent(card.cardID.rawValue, isDirectory: true) + let moved = try BoardWriter.relocateLooseFiles(card.fileNames, inCard: folder) + // A card whose files all vanished under the write contributes no line: the Writer + // skipped them because they are gone, and nothing was moved to report. + guard !moved.isEmpty else { continue } + relocated.append(BannerCenter.Relocation( + title: card.title, + fileNames: moved.map { $0.sourceURL.lastPathComponent } + )) + } + } + banners.postRelocatedLooseFiles(relocated) + } + + /// The loose-file picture as a comparable value: one entry per file, keyed by where it sits. + /// + /// A `Set` rather than the array itself because the *identity* of the work is what matters, not + /// the order the walk happened to meet it in — and because two loads of an unchanged tree must + /// compare equal even if a lane's folder-name ordering shifted underneath them. + nonisolated static func relocationSignature(of work: [LooseCardFiles]) -> Set { + var signature: Set = [] + for card in work { + for name in card.fileNames { + signature.insert("\(card.laneID.rawValue)/\(card.cardID.rawValue)/\(name)") + } + } + return signature + } + /// Creates one card per file at `index` in `laneID`, each titled with its filename minus the /// extension and carrying that file as its attachment — the drop-into-a-lane half. /// diff --git a/Kanban/LiveStore/BoardStoreRegistry.swift b/Kanban/LiveStore/BoardStoreRegistry.swift index d7f2880..473be07 100644 --- a/Kanban/LiveStore/BoardStoreRegistry.swift +++ b/Kanban/LiveStore/BoardStoreRegistry.swift @@ -204,6 +204,19 @@ public final class BoardStoreRegistry { bookmark: bookmark, lastKnownRoot: rootURL ) + + // The loose-file carve-out's first firing (01-storage-format.md § Fractal layout ▸ Rules, + // settled 2026-07-28): files an agent or a hand-editor left beside a card's `index.md` + // while this board was closed are relocated into `attachments/` now, with the notice. + // + // **Here rather than in `BoardStore.init`**, and last rather than first: the store's own + // init is one tree walk and no writes, and a relocation written before the brackets and the + // watcher exist would be a write nothing is watching — landing on disk with the snapshot + // above it left one reload stale. By this line the pair is wired, so it is an ordinary + // bracketed app write whose echo reload refreshes the board like any other. Every reload + // thereafter re-fires it from `BoardStore.land`; this call is only the one the opening walk + // would otherwise have no reload behind. + store.relocateLooseCardFiles() return store } diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 80cdab6..0525198 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -24,12 +24,21 @@ import os /// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not /// levels, so they never need special-casing against the stray warning. /// -/// **The one read inside a card folder** is `attachmentNames(in:)`: a single flat listing of -/// `attachments/`, feeding `Card.attachments`. It is a *names* read and nothing more — it never -/// opens a file, never descends, never warns, and degrades to `[]` on any failure. The board -/// window's face needs it before a card window exists (the quiet paperclip indicator — -/// 03-board-ui.md § Card face), and the snapshot is where it reads from. Everything else about a -/// card folder's contents remains outside this loader's business. +/// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither +/// opens a file, descends, warns, or fails a load; each degrades to `[]`: +/// +/// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face +/// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card +/// face), and the snapshot is where it reads from. +/// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is +/// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules, +/// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in +/// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a +/// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a +/// Writer-mediated app write the store schedules off the snapshot +/// (`BoardStore.relocateLooseCardFiles`). +/// +/// Everything else about a card folder's contents remains outside this loader's business. /// /// 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 @@ -52,6 +61,22 @@ public enum BoardLoader: Sendable { /// the writer must never disagree about which file a folder's content lives in. static let indexFileName = "index.md" + /// 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 + /// listed because the schema reserves the name, not because anything writes it yet — + /// `attachments/` is still the one folder this app ever creates under a card. + /// + /// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled + /// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check + /// would hand the loose-file relocation a card's own content to move into `attachments/`. + /// + /// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three + /// names on its own, so a caller passing a hand-made list cannot reach past this rule. + static let reservedCardChildNames: Set = [ + indexFileName, BoardWriter.attachmentsFolderName, "comments", + ] + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader") // MARK: - Entry point @@ -72,6 +97,10 @@ public enum BoardLoader: Sendable { logger.warning("\(warning.description, privacy: .public)") } + // The carve-out's detection channel — deliberately *not* `warnings`, which is the + // stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`). + var looseCardFiles: [LooseCardFiles] = [] + // Legal per the frontmatter table, meaningless at board level — ignore and log, never // tombstone (01-storage-format.md § Deletion). if !boardDocument.deleted.isMissing { @@ -113,6 +142,18 @@ public enum BoardLoader: Sendable { let cardSchema = try validatedSchema(in: cardDocument, path: cardPath) let cardOrder = try validatedOrder(in: cardDocument, path: cardPath) + // Noticed, never acted on: the relocation is the store's, through the Writer. + let loose = looseFileNames(in: cardURL) + if !loose.isEmpty { + looseCardFiles.append(LooseCardFiles( + laneID: ItemID(rawValue: laneName), + cardID: ItemID(rawValue: cardName), + title: cardDocument.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, @@ -164,7 +205,7 @@ public enum BoardLoader: Sendable { document: boardDocument ) - return LoadResult(model: model, warnings: warnings) + return LoadResult(model: model, warnings: warnings, looseCardFiles: looseCardFiles) } // MARK: - Filesystem helpers @@ -229,6 +270,57 @@ public enum BoardLoader: Sendable { .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } + /// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance + /// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"): + /// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name) + /// belongs in `attachments/`, and the app moves it there". + /// + /// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the + /// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load + /// is a pure function of the tree and stays one. + /// + /// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons: + /// + /// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested + /// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory + /// into the flat attachment model would be wrong". + /// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk + /// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply + /// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its + /// own account. + /// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one + /// would surface it in a card's attachment list — the loudest possible way to be wrong about + /// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed + /// residue out of the relocation. + /// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively. + /// + /// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice + /// the store posts names files the way the board would sort them. + /// + /// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses + /// to open, and "nothing to relocate" is the safe reading of "cannot tell". + static func looseFileNames(in cardFolder: URL) -> [String] { + guard let entries = try? FileManager.default.contentsOfDirectory( + at: cardFolder, + includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], + options: [.skipsHiddenFiles] + ) else { + return [] + } + + return entries + .filter { url in + guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()), + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + else { + return false + } + return values.isRegularFile == true && values.isSymbolicLink != true + } + .map(\.lastPathComponent) + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + /// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**, /// per the shape-only identity predicate below. private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF") @@ -366,6 +458,49 @@ public enum BoardLoader: Sendable { public struct LoadResult: Sendable { public var model: BoardModel public var warnings: [LoadWarning] + + /// The cards this walk found carrying loose files, in the order the walk met them — the + /// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules, + /// settled 2026-07-28). + /// + /// **Its own field rather than a `LoadWarning` case**, because the two say opposite things. + /// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly + /// where it is, there is nothing to do". A loose card file is the one thing on a board that is + /// **not** tolerated — it is pending work, and the store acts on it. Folding it into the + /// warning channel would also mean throwing away everything the act needs (which lane, which + /// card, which title, which names) and re-deriving it from a display string. + /// + /// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer + /// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice. + /// + /// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file + /// 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. + public var looseCardFiles: [LooseCardFiles] = [] +} + +/// One card found holding files that belong in its `attachments/` — everything the relocation and +/// its notice need, and nothing more. +/// +/// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a +/// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is +/// carried as its two identity components rather than as a URL, `BoardStore.liveItem`'s convention, +/// so the write derives its path from the store's *current* root. +public struct LooseCardFiles: Sendable, Equatable { + public let laneID: ItemID + public let cardID: ItemID + public let title: String? + /// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card + /// with nothing loose contributes no entry at all. + public let fileNames: [String] + + public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) { + self.laneID = laneID + self.cardID = cardID + self.title = title + self.fileNames = fileNames + } } /// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError` diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 3a16224..4d97fa2 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1080,6 +1080,168 @@ public enum BoardWriter: Sendable { return landed } + /// Moves loose files out of a card folder and into its `attachments/` — the **write half** of + /// 01-storage-format.md's loose-file carve-out (§ Fractal layout ▸ Rules, settled 2026-07-28, + /// "Lanework-owns-the-board"): "a regular file sitting beside a card's `index.md` … belongs in + /// `attachments/`, and the app moves it there — Finder-style rename on collision". + /// + /// **A move, not a copy** — the file is not being imported from somewhere else, it is being put + /// where it already belonged, and leaving a second copy beside `index.md` would leave the very + /// thing this call exists to clear. `FileManager.moveItem` within one folder is a `rename(2)`: + /// atomic, and byte-preserving by not touching bytes at all. + /// + /// **`index.md` is never opened.** Relocating a stray says nothing about the card's content, so + /// no `modified` stamp is written and no frontmatter is read — which is also why a card whose + /// frontmatter is uneditable (a flow mapping) still gets its files tidied. + /// + /// `names` is the caller's list — the loader's `looseFileNames` at the store, or this file's own + /// `normalizeLooseFiles(inCard:)` at the import boundary — and **every name is re-checked + /// against disk before it is touched** (`isRelocatable`). A name that has stopped being a plain + /// non-hidden regular file since it was listed, or that names a reserved child, or that is not a + /// bare filename at all, is **skipped silently**: the reload is the authority on what is there, + /// and a file the user deleted between the walk and the write is not a failure to report. That + /// re-check is also what makes the rule "folders and symlinks are never relocated" a property of + /// this call rather than of its callers. + /// + /// The batch is `importAttachments`' shape exactly: in order, one finished move at a time, the + /// first failure stopping it and throwing while everything already moved stays moved. Returns + /// what actually landed, in input order — `sourceURL` naming the file where it sat, `fileName` + /// the (possibly Finder-renamed) name it took inside `attachments/`. + /// + /// `attachments/` is created only when something is actually going to move into it, so a card + /// whose loose files all vanished under the write is left exactly as it was — no empty folder + /// minted for nothing. + /// + /// **`cardFolder` must really be a card** (`checkIsCardFolder`, which is stricter than the + /// UUID-shape guard the rest of this file uses): a lane's own loose files keep the verbatim + /// posture, and no other write in the app has to tell the two levels apart. + @discardableResult + public static func relocateLooseFiles( + _ names: [String], + inCard cardFolder: URL + ) throws(BoardWriteError) -> [ImportedAttachment] { + guard !names.isEmpty else { return [] } + + // Before the per-file loop starts no single file is implicated yet — the first name stands + // in for the batch, exactly as `importAttachments` lets its first source name it. + let batchOperation = WriteOperation.relocateLooseFile(filename: names[0]) + try checkIsDirectory(cardFolder, describedAs: "card folder", operation: batchOperation) + try checkIsCardFolder(cardFolder, operation: batchOperation) + + let relocatable = names.filter { isRelocatable($0, in: cardFolder) } + guard !relocatable.isEmpty else { return [] } + + let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true) + do { + try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true) + } catch { + throw BoardWriteError( + operation: batchOperation, + path: cardFolder.path, + reason: .io(message: "could not create attachments folder: \(error.localizedDescription)") + ) + } + + var moved: [ImportedAttachment] = [] + for name in relocatable { + // Each file names itself — the ORIGINAL name, not the Finder-style renamed one decided + // on the next line, for `importAttachments`' reason: the operation describes the file + // the user (or their agent) actually wrote. + let operation = WriteOperation.relocateLooseFile(filename: name) + let sourceURL = cardFolder.appendingPathComponent(name) + let landed = freshAttachmentName(for: name, in: attachmentsFolder) + do { + try FileManager.default.moveItem(at: sourceURL, to: attachmentsFolder.appendingPathComponent(landed)) + } catch { + throw BoardWriteError( + operation: operation, + path: sourceURL.path, + reason: .io(message: "could not move file into attachments: \(error.localizedDescription)") + ) + } + moved.append(ImportedAttachment(sourceURL: sourceURL, fileName: landed)) + } + return moved + } + + /// Discovers *and* relocates — the loose-file rule applied at an **import boundary**, where + /// there is no loader round trip to discover through (04-interactions.md ▸ Clipboard, settled + /// 2026-07-28: "A paste is an import boundary, so normalization applies … loose files the staged + /// snapshot carries beside a card's `index.md` land in the pasted card's `attachments/`, + /// Finder-renamed on collision — nothing the snapshot preserved is dropped on arrival"). + /// + /// The pasted card therefore lands **already normalized**, rather than arriving loose and being + /// tidied a reload later: the write is happening anyway, and one that leaves work behind for the + /// carve-out to find would also post the carve-out's notice — a warning row about a mess the + /// user's own paste made and the app immediately cleaned up. + /// + /// A card with nothing loose is one directory listing and no write at all. + @discardableResult + public static func normalizeLooseFiles(inCard cardFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] { + try relocateLooseFiles(BoardLoader.looseFileNames(in: cardFolder), inCard: cardFolder) + } + + /// The lane-level face of the same import-boundary normalization: every card of an arriving + /// lane, in folder order. + /// + /// Children are `childCandidates` — the loader's own level detection — so a stray *folder* + /// inside the arriving lane is neither descended into nor tidied, and nothing below a card is + /// reached: the carve-out is card-level and one level deep, exactly as 01 states it. + @discardableResult + public static func normalizeLooseFiles(inLane laneFolder: URL) throws(BoardWriteError) -> [ImportedAttachment] { + var moved: [ImportedAttachment] = [] + for card in childCandidates(of: laneFolder) { + moved.append(contentsOf: try normalizeLooseFiles(inCard: card)) + } + return moved + } + + /// Refuses any folder that is not a **card**: UUID-shaped, *under* a UUID-shaped parent. + /// + /// `checkIsUUIDShaped` is the guard every other item write leans on, and it is the wrong one + /// here because it cannot tell a lane from a card — both are UUID-shaped, which is exactly the + /// distinction the carve-out turns on ("everything at board or lane level keeps the verbatim + /// posture"; "board/lane-level strays … are legitimate residents"). Pointing the relocation at a + /// lane would sweep a hand-editor's `notes.txt` into an `attachments/` folder no lane should + /// ever have. + /// + /// The parent test is exact rather than heuristic because 01-storage-format.md § Fractal layout + /// fixes the depth: a card is `//` and a lane is `/`, so a + /// UUID-shaped folder whose parent is *also* UUID-shaped is a card and nothing else. It is the + /// same reading `BoardStore.boardRoot(ofCardFolder:)` already derives a root from. + private static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) { + try checkIsUUIDShaped(folder, operation: operation) + guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else { + throw BoardWriteError( + operation: operation, + path: folder.path, + reason: .unreadable(message: "folder is not a card: only a card's own files are relocated") + ) + } + } + + /// Whether `name` inside `cardFolder` is a file this app may relocate: a bare filename (never a + /// path), not hidden, not one of the reserved card-level names, and — read from disk, at write + /// time — a regular file that is not a symlink. + /// + /// The four name rules restate the loader's listing exclusions rather than trusting them, + /// because `relocateLooseFiles` takes a caller's list: this is where "the carve-out is exactly + /// that narrow" stops being a convention and becomes something the filesystem-touching code + /// enforces on its own. + private static func isRelocatable(_ name: String, in cardFolder: URL) -> Bool { + guard !name.isEmpty, + !name.hasPrefix("."), + !name.contains("/"), + !BoardLoader.reservedCardChildNames.contains(name.lowercased()), + let values = try? cardFolder + .appendingPathComponent(name) + .resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + else { + return false + } + return values.isRegularFile == true && values.isSymbolicLink != true + } + /// The Finder-style collision-free name for `originalName` landing in `folder`: the name /// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`, … /// — counting up from 2 against what is on disk *at decision time*, one collision at a time. @@ -1324,6 +1486,16 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case listAttachments case renumberChildren // order-maintenance sweep (compaction) + /// A loose file being moved out of a card folder into its `attachments/` — the loose-file + /// carve-out's write (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28). + /// + /// Its own case rather than a fold into `.importAttachment`, on `.rename`'s and + /// `.duplicateBoard`'s reasoning: nothing was *imported* — no file crossed into the board, the + /// user dropped nothing, and a banner saying the app "couldn't import 'notes.txt'" would + /// describe a gesture that never happened. `filename` is the name as it sat beside `index.md`, + /// never the Finder-renamed one it would have landed under. + case relocateLooseFile(filename: String) + /// Fills in the title once the Writer has read it off the document the operation is acting /// on — identity for the six cases with no title slot at all: `createBoard`/`createLane`/ /// `createCard` are minting a file, not reading one; `importAttachment`'s "title" is the @@ -1335,7 +1507,8 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// case is immutable once a caller has it in hand — there is nothing to "forget" later. public func withTitle(_ title: String?) -> WriteOperation { switch self { - case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .renumberChildren: + case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, + .renumberChildren, .relocateLooseFile: self case .move: .move(title: title) case .reorder: .reorder(title: title) @@ -1374,6 +1547,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .importAttachment(filename): "import attachment '\(filename)'" case .listAttachments: "list attachments" case .renumberChildren: "renumber children" + case let .relocateLooseFile(filename): "relocate loose file '\(filename)'" } } diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index 2cd1520..f03002a 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -223,6 +223,39 @@ struct FixtureStrayFilesTests { #expect(result.model.lanes.map(\.id.rawValue) == [lane]) #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card]) } + + /// The board's card-level `scratch.md` is the **one** stray this fixture holds that is not + /// tolerated: the loose-file carve-out (01-storage-format.md § Fractal layout ▸ Rules, settled + /// 2026-07-28) says a regular file beside a card's `index.md` belongs in `attachments/`. It is + /// reported on its own channel — never as a `warning`, which is the *tolerance* vocabulary — + /// and the board-level and lane-level strays around it stay exactly as tolerated as they were. + /// + /// **Detection does not mutate**: this is the loader, over a fixture that lives in git, and the + /// assertion that the file is still there afterwards is the read-only claim stated on the one + /// tree where a stray write would be visible in `git status`. + @Test func aCardLevelLooseFileIsReportedForRelocationWithoutBeingTouched() throws { + let lane = "10000000-0000-4000-8000-000000000001" + let card = "20000000-0000-4000-8000-000000000002" + + let result = try loadFixture("Valid/stray-files.kanban") + #expect(result.warnings.isEmpty) + #expect(result.looseCardFiles == [ + LooseCardFiles( + laneID: ItemID(rawValue: lane), + cardID: ItemID(rawValue: card), + title: result.model.lanes[0].cards[0].title.value, + fileNames: ["scratch.md"] + ), + ]) + + let scratch = fixtureBoard("Valid/stray-files.kanban") + .appendingPathComponent("\(lane)/\(card)/scratch.md") + #expect(FileManager.default.fileExists(atPath: scratch.path)) + #expect(!FileManager.default.fileExists( + atPath: fixtureBoard("Valid/stray-files.kanban") + .appendingPathComponent("\(lane)/\(card)/attachments").path + )) + } } // MARK: - Valid/tombstones.kanban diff --git a/KanbanTests/LooseFileRelocationTests.swift b/KanbanTests/LooseFileRelocationTests.swift new file mode 100644 index 0000000..7af6151 --- /dev/null +++ b/KanbanTests/LooseFileRelocationTests.swift @@ -0,0 +1,778 @@ +import Foundation +import Testing +@testable import Kanban + +/// The loose-file carve-out, end to end (01-storage-format.md § Fractal layout ▸ Rules, settled +/// 2026-07-28, "Lanework-owns-the-board"; 04-interactions.md ▸ Clipboard for the paste boundary). +/// +/// The rule is one sentence with four halves, and this file is organized as those four: +/// +/// 1. **The loader notices, and only notices** — a regular file beside a card's `index.md` is +/// reported on its own channel, and the walk that reported it changed nothing on disk. +/// 2. **The Writer moves it** — into `attachments/`, Finder-renamed on collision, byte-faithfully, +/// without opening `index.md`. +/// 3. **The store schedules that write** — one bracket, one loss row, deferred under the read-only +/// lock, and never hot-looping on a failure. +/// 4. **A paste normalizes at the boundary** — the pasted card lands already tidy. +/// +/// Like every other write suite here these read back through the loader or through raw bytes, never +/// through a snapshot the store handed out: the claims are about the files. `WriterFixture`, `Ident` +/// and `Item` come from `WriterTestSupport.swift`; `FakePasteboard` and `ClipboardHarness` from +/// `ClipboardTests.swift`. + +// MARK: - Shared fixtures + +/// A one-lane, one-card board, ready for whatever the test wants to leave beside `index.md`. +private func makeCardBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) + return fixture +} + +private let cardPath = "\(Ident.lane1)/\(Ident.card1)" + +private func cardFolder(in fixture: WriterFixture) -> URL { + fixture.url(cardPath) +} + +/// The loader's own reading of the tree — the detection channel, read fresh from disk. +private func looseFiles(in fixture: WriterFixture) throws -> [LooseCardFiles] { + try BoardLoader.load(boardRoot: fixture.root).looseCardFiles +} + +/// A file's bytes and mtime — "this file was not rewritten", stated the way `WriteFidelityTests` +/// states it. +private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let modified = attributes[.modificationDate] as? Date else { + throw NSError(domain: "LooseFileRelocationTests", code: 1) + } + return (try Data(contentsOf: url), modified) +} + +private func symlink(_ name: String, to destination: String, in folder: URL) throws { + try FileManager.default.createSymbolicLink( + atPath: folder.appendingPathComponent(name).path, + withDestinationPath: destination + ) +} + +// MARK: - 1. Detection (read-only, in the loader) + +@Suite("Loose files ▸ detection") +struct LooseFileDetectionTests { + + @Test("A regular file beside a card's index.md is reported — and nothing else is") + func looseFileIsReported() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.looseCardFiles == [ + LooseCardFiles( + laneID: ItemID(rawValue: Ident.lane1), + cardID: ItemID(rawValue: Ident.card1), + title: "Fix login", + fileNames: ["notes.txt"] + ), + ]) + // It is not a *stray* — the tolerance vocabulary says nothing about it either way. + #expect(result.warnings.isEmpty) + } + + /// Detection is read-only: "the Repair precedent". The walk that noticed the file must leave it + /// exactly where it was and must not mint the `attachments/` it is destined for. + @Test("Loading does not move the file, and does not create attachments/") + func detectionMutatesNothing() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + let loose = try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + let before = try stat(loose) + + _ = try BoardLoader.load(boardRoot: fixture.root) + _ = try BoardLoader.load(boardRoot: fixture.root) + + let after = try stat(loose) + #expect(after.bytes == before.bytes) + #expect(after.modified == before.modified) + #expect(!fixture.exists("\(cardPath)/attachments")) + } + + /// "The carve-out is exactly that narrow." A stray *folder* in a card keeps the verbatim + /// posture — relocating a directory into the flat attachment model would be wrong — whether it + /// is UUID-shaped (a nested clone) or not (a hand-made subfolder). + @Test("Stray folders in a card are not loose files") + func strayFoldersAreNotFlagged() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8)) + try fixture.item("\(cardPath)/\(Ident.card2)", Item.rich(order: "1024", title: "Nested clone")) + + #expect(try looseFiles(in: fixture).isEmpty) + } + + /// Symlinks are never touched and never traversed (§ Rules) — including one whose name would + /// otherwise read as an ordinary loose file, and one pointing at a directory. + @Test("Symlinks beside index.md are never loose files") + func symlinksAreNotFlagged() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8)) + try symlink("notes.txt", to: "attachments/real.png", in: cardFolder(in: fixture)) + try symlink("elsewhere", to: "attachments", in: cardFolder(in: fixture)) + + #expect(try looseFiles(in: fixture).isEmpty) + } + + /// "Everything at board or lane level keeps the verbatim posture" — `CLAUDE.user.md` and a + /// hand-made `notes/` folder are legitimate residents. + @Test("Board-level and lane-level files are untouched strays, not loose files") + func boardAndLaneLevelFilesAreNotFlagged() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("CLAUDE.user.md", Data("mine".utf8)) + try fixture.file("\(Ident.lane1)/notes.txt", Data("lane".utf8)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.looseCardFiles.isEmpty) + #expect(result.warnings.isEmpty) + } + + /// "Reserved card-level names (`attachments/`, `comments/`, `index.md`) untouched" — and the + /// reservation is case-insensitive, because on this filesystem `Index.md` *is* the card's index + /// and relocating it would move a card's content into its own attachments. + @Test("index.md, attachments/, comments/ and their case-spellings are never loose") + func reservedNamesAreNotFlagged() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8)) + try fixture.file("\(cardPath)/comments/note.md", Data("comment".utf8)) + + #expect(try looseFiles(in: fixture).isEmpty) + + // A *file* by a reserved name is malformed rather than loose: still not relocated. + let bare = try WriterFixture() + defer { bare.tearDown() } + try bare.item("", Item.board) + try bare.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try bare.item(cardPath, Item.rich(order: "1024", title: "Fix login")) + try bare.file("\(cardPath)/attachments", Data("not a folder".utf8)) + try bare.file("\(cardPath)/comments", Data("not a folder either".utf8)) + + #expect(try looseFiles(in: bare).isEmpty) + } + + /// `.DS_Store` and friends are not the user's files; relocating one would surface it as an + /// attachment, which is the loudest possible way to be wrong about a file nobody wrote. + @Test("Hidden files are not loose files") + func hiddenFilesAreNotFlagged() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8)) + try fixture.file("\(cardPath)/.hidden-draft.md", Data("hidden".utf8)) + + #expect(try looseFiles(in: fixture).isEmpty) + } + + @Test("Several files on several cards come back in Finder order, one entry per card") + func severalFilesAndCards() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.file("\(cardPath)/shot 10.png", Data("ten".utf8)) + try fixture.file("\(cardPath)/shot 2.png", Data("two".utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card2)/notes.txt", Data("notes".utf8)) + + let found = try looseFiles(in: fixture) + #expect(found.count == 2) + #expect(found.first?.fileNames == ["shot 2.png", "shot 10.png"]) + #expect(found.last?.fileNames == ["notes.txt"]) + #expect(found.last?.title == "Second") + } + + /// A tombstoned card's files are the tree's business too: where a file belongs on disk is not a + /// question about what the board is currently rendering. + @Test("A tombstoned card's loose file is still reported") + func tombstonedCardIsStillReported() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Ident.card2)", """ + --- + schema: 1 + title: Trashed + order: 2048 + deleted: 2026-03-03T09:00:00Z + --- + + """) + try fixture.file("\(Ident.lane1)/\(Ident.card2)/notes.txt", Data("notes".utf8)) + + #expect(try looseFiles(in: fixture).map(\.title) == ["Trashed"]) + } +} + +// MARK: - 2. The write (BoardWriter) + +@Suite("Loose files ▸ the relocation write") +struct LooseFileWriteTests { + + @Test("The file moves into attachments/, byte for byte, and index.md is not touched") + func movesByteFaithfullyWithoutTouchingIndex() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + let bytes = Data([0x00, 0xFF, 0x10, 0x89, 0x50, 0x4E, 0x47]) + try fixture.file("\(cardPath)/photo.png", bytes) + let indexBefore = try stat(cardFolder(in: fixture).appendingPathComponent("index.md")) + + let moved = try BoardWriter.relocateLooseFiles(["photo.png"], inCard: cardFolder(in: fixture)) + + #expect(moved.map(\.fileName) == ["photo.png"]) + #expect(try fixture.data("\(cardPath)/attachments/photo.png") == bytes) + #expect(!fixture.exists("\(cardPath)/photo.png")) + + // A relocation says nothing about the card's content: no `modified` stamp, no rewrite. + let indexAfter = try stat(cardFolder(in: fixture).appendingPathComponent("index.md")) + #expect(indexAfter.bytes == indexBefore.bytes) + #expect(indexAfter.modified == indexBefore.modified) + } + + @Test("A name already taken in attachments/ is Finder-renamed, never overwritten") + func collisionIsFinderRenamed() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8)) + try fixture.file("\(cardPath)/attachments/notes 2.txt", Data("the second".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8)) + + let moved = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: cardFolder(in: fixture)) + + #expect(moved.map(\.fileName) == ["notes 3.txt"]) + // The reported source keeps the ORIGINAL name — the one the user wrote. + #expect(moved.map { $0.sourceURL.lastPathComponent } == ["notes.txt"]) + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("the attachment".utf8)) + #expect(try fixture.data("\(cardPath)/attachments/notes 2.txt") == Data("the second".utf8)) + #expect(try fixture.data("\(cardPath)/attachments/notes 3.txt") == Data("the loose one".utf8)) + } + + /// The Writer re-checks every name against disk, so the narrowness of the carve-out is enforced + /// where the filesystem is touched rather than trusted to the caller's list. + @Test("Folders, symlinks, hidden and reserved names are skipped even when named explicitly") + func nonRelocatableNamesAreSkipped() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8)) + try fixture.file("\(cardPath)/attachments/real.png", Data("png".utf8)) + try fixture.file("\(cardPath)/.DS_Store", Data("finder".utf8)) + try symlink("link.txt", to: "attachments/real.png", in: cardFolder(in: fixture)) + + let moved = try BoardWriter.relocateLooseFiles( + ["scratch", "link.txt", ".DS_Store", "index.md", "attachments", "comments", "gone.txt", "../escape.txt"], + inCard: cardFolder(in: fixture) + ) + + #expect(moved.isEmpty) + #expect(fixture.exists("\(cardPath)/scratch")) + #expect(fixture.exists("\(cardPath)/.DS_Store")) + #expect(fixture.exists("\(cardPath)/index.md")) + #expect(try fixture.entryNames("\(cardPath)/attachments") == ["real.png"]) + let link = try FileManager.default + .attributesOfItem(atPath: cardFolder(in: fixture).appendingPathComponent("link.txt").path) + #expect(link[.type] as? FileAttributeType == .typeSymbolicLink) + } + + /// Nothing to move means nothing is made: a card whose loose files all vanished under the write + /// is left exactly as it was, with no empty `attachments/` minted for it. + @Test("attachments/ is not created when nothing is relocatable") + func noEmptyAttachmentsFolder() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + + #expect(try BoardWriter.relocateLooseFiles(["gone.txt"], inCard: cardFolder(in: fixture)).isEmpty) + #expect(try BoardWriter.relocateLooseFiles([], inCard: cardFolder(in: fixture)).isEmpty) + #expect(!fixture.exists("\(cardPath)/attachments")) + } + + /// The carve-out is card-level, so the guard has to be card-level too: a lane is UUID-shaped + /// exactly like a card, and only its *parent* tells them apart. A lane's `notes.txt` and a + /// board's `CLAUDE.user.md` are legitimate residents. + @Test("A lane folder or a board root is refused") + func onlyCardsAreValidTargets() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(Ident.lane1)/notes.txt", Data("lane".utf8)) + try fixture.file("notes.txt", Data("board".utf8)) + + let laneFailure = writeFailure { + _ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: fixture.url(Ident.lane1)) + } + #expect(laneFailure?.operation == .relocateLooseFile(filename: "notes.txt")) + #expect(fixture.exists("\(Ident.lane1)/notes.txt")) + + // The board root's folder name is never UUID-shaped, so the same guard covers it. + #expect(writeFailure { + _ = try BoardWriter.relocateLooseFiles(["notes.txt"], inCard: fixture.root) + } != nil) + #expect(fixture.exists("notes.txt")) + } + + @Test("normalizeLooseFiles discovers the card's own loose files") + func normalizeDiscoversForItself() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/a.txt", Data("a".utf8)) + try fixture.file("\(cardPath)/b.txt", Data("b".utf8)) + try fixture.file("\(cardPath)/scratch/inside.txt", Data("nested".utf8)) + + let moved = try BoardWriter.normalizeLooseFiles(inCard: cardFolder(in: fixture)) + #expect(moved.map(\.fileName) == ["a.txt", "b.txt"]) + #expect(try fixture.entryNames("\(cardPath)").sorted() == ["attachments", "index.md", "scratch"]) + } + + @Test("normalizeLooseFiles(inLane:) reaches every card and nothing else") + func normalizeALaneReachesItsCards() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.file("\(cardPath)/a.txt", Data("a".utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card2)/b.txt", Data("b".utf8)) + try fixture.file("\(Ident.lane1)/lane-level.txt", Data("lane".utf8)) + + let moved = try BoardWriter.normalizeLooseFiles(inLane: fixture.url(Ident.lane1)) + #expect(moved.count == 2) + #expect(try fixture.data("\(cardPath)/attachments/a.txt") == Data("a".utf8)) + #expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments/b.txt") == Data("b".utf8)) + // The lane's own stray keeps the verbatim posture. + #expect(fixture.exists("\(Ident.lane1)/lane-level.txt")) + } +} + +// MARK: - 3. The store's scheduling + +/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up — +/// `BoardStoreTests`' own helper, which is `private` there. +@MainActor +private final class RelocationBracketLog { + private(set) var begins = 0 + + func attach(to store: BoardStore) { + store.watcherBrackets = (begin: { self.begins += 1 }, end: {}) + } +} + +@MainActor +@Suite("Loose files ▸ the store's relocation") +struct LooseFileStoreTests { + + @Test("The relocation moves the file and posts the ruling's notice") + func relocatesAndPostsTheNotice() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + let store = try BoardStore(rootURL: fixture.root) + let brackets = RelocationBracketLog() + brackets.attach(to: store) + + store.relocateLooseCardFiles() + + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) + #expect(!fixture.exists("\(cardPath)/notes.txt")) + #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) + #expect(store.banners.oneShots.isEmpty) + // One gesture, one bracket — one app-mediated reload and, on git boards, one commit. + #expect(brackets.begins == 1) + } + + @Test("A collision is Finder-renamed, and the notice still names the file the user wrote") + func collisionIsRenamedThroughTheStore() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8)) + let store = try BoardStore(rootURL: fixture.root) + + store.relocateLooseCardFiles() + + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("the attachment".utf8)) + #expect(try fixture.data("\(cardPath)/attachments/notes 2.txt") == Data("the loose one".utf8)) + #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) + } + + @Test("A reload is what fires it — detection rides the snapshot") + func aReloadFiresIt() async throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + #expect(store.looseCardFiles.isEmpty) + + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) + #expect(store.banners.losses.count == 1) + + // The echo reload finds nothing loose, so nothing fires again and no second row appears. + store.handleWatcherEvent(.treeChanged(.appMediated)) + await store.awaitQuiescence() + #expect(store.looseCardFiles.isEmpty) + #expect(store.banners.losses.count == 1) + } + + /// "The relocation … waits out any read-only lock — strays stay tolerated until it clears." + /// `.unwritableLocation` is the lock a reload does not disprove by itself, so an ordinary reload + /// under it proves the deferral rather than racing it. + @Test("A locked board writes nothing, and relocates when the lock clears") + func lockedBoardDefers() async throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + let store = try BoardStore(rootURL: fixture.root) + let brackets = RelocationBracketLog() + brackets.attach(to: store) + store.enterUnwritableLock() + + store.relocateLooseCardFiles() + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + // Tolerated, exactly as before the carve-out existed: still beside index.md, nothing said. + #expect(fixture.exists("\(cardPath)/notes.txt")) + #expect(!fixture.exists("\(cardPath)/attachments")) + #expect(store.banners.losses.isEmpty) + #expect(brackets.begins == 0) + #expect(store.isReadOnly) + + // A reconciling reload re-probes writability, the lock clears — and the same reload + // performs the relocation it had been holding back. + store.handleWatcherEvent(.treeChanged(.reconciling)) + await store.awaitQuiescence() + + #expect(!store.isReadOnly) + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) + #expect(store.banners.losses.map(\.message) == ["Moved 'notes.txt' into attachments — 'Fix login'"]) + #expect(brackets.begins == 1) + } + + /// The loop the guard exists for: a relocation that fails leaves the same files on disk, so the + /// next walk hands back the same work. One failure, one row, then silence. + @Test("A failing relocation is attempted once, not forever") + func repeatedFailureDoesNotHotLoop() async throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.file("\(cardPath)/notes.txt", Data("notes".utf8)) + let store = try BoardStore(rootURL: fixture.root) + let brackets = RelocationBracketLog() + brackets.attach(to: store) + + // Readable (so the walk still sees the file) but not writable (so the move cannot land). + try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: cardFolder(in: fixture).path) + + store.relocateLooseCardFiles() + #expect(store.banners.oneShots.count == 1) + #expect(store.banners.oneShots.first?.error.operation == .relocateLooseFile(filename: "notes.txt")) + #expect(store.banners.losses.isEmpty) + #expect(brackets.begins == 1) + + for _ in 0 ..< 3 { + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + } + + // Same picture on disk, so no second attempt and no second row. + #expect(store.looseCardFiles.count == 1) + #expect(store.banners.oneShots.count == 1) + #expect(brackets.begins == 1) + + // A picture that actually changed is a fresh attempt. + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: cardFolder(in: fixture).path) + try fixture.file("\(cardPath)/second.txt", Data("second".utf8)) + store.handleWatcherEvent(.treeChanged(.foreign)) + await store.awaitQuiescence() + + #expect(brackets.begins == 2) + #expect(try fixture.data("\(cardPath)/attachments/notes.txt") == Data("notes".utf8)) + #expect(try fixture.data("\(cardPath)/attachments/second.txt") == Data("second".utf8)) + } + + @Test("Several cards fold into one bracket and one row") + func severalCardsFold() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second")) + try fixture.file("\(cardPath)/a.txt", Data("a".utf8)) + try fixture.file("\(cardPath)/b.txt", Data("b".utf8)) + try fixture.file("\(Ident.lane1)/\(Ident.card2)/c.txt", Data("c".utf8)) + let store = try BoardStore(rootURL: fixture.root) + let brackets = RelocationBracketLog() + brackets.attach(to: store) + + store.relocateLooseCardFiles() + + #expect(brackets.begins == 1) + #expect(store.banners.losses.map(\.message) == ["Moved 3 files into attachments — 2 cards"]) + #expect(try fixture.entryNames("\(cardPath)/attachments") == ["a.txt", "b.txt"]) + #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == ["c.txt"]) + } + + @Test("A board with nothing loose writes nothing and says nothing") + func cleanBoardIsSilent() throws { + let fixture = try makeCardBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let brackets = RelocationBracketLog() + brackets.attach(to: store) + + store.relocateLooseCardFiles() + + #expect(brackets.begins == 0) + #expect(store.banners.losses.isEmpty) + #expect(store.banners.oneShots.isEmpty) + } +} + +// MARK: - The phrasing (BannerCenter owns every word) + +@Suite("Loose files ▸ phrasing") +struct LooseFileMessageTests { + + private func relocation(_ title: String?, _ names: String...) -> BannerCenter.Relocation { + BannerCenter.Relocation(title: title, fileNames: names) + } + + @Test("One card, one file names both — the design's own sentence") + func oneFileOneCard() { + #expect( + BannerCenter.relocatedLooseFilesMessage(for: [relocation("Fix login", "notes.txt")]) + == "Moved 'notes.txt' into attachments — 'Fix login'" + ) + } + + @Test("Several files on one card fold to a count, keeping the card named") + func severalFilesOneCard() { + #expect( + BannerCenter.relocatedLooseFilesMessage( + for: [relocation("Fix login", "a.txt", "b.txt", "c.txt")] + ) == "Moved 3 files into attachments — 'Fix login'" + ) + } + + @Test("Several cards fold to two counts") + func severalCards() { + #expect( + BannerCenter.relocatedLooseFilesMessage(for: [ + relocation("Fix login", "a.txt", "b.txt"), + relocation("Ship it", "c.txt"), + relocation(nil, "d.txt", "e.txt"), + ]) == "Moved 5 files into attachments — 3 cards" + ) + } + + @Test("An untitled card is named as one — 'Untitled' is a rendering, never a value") + func untitledCard() { + #expect( + BannerCenter.relocatedLooseFilesMessage(for: [relocation(nil, "notes.txt")]) + == "Moved 'notes.txt' into attachments — an untitled card" + ) + } + + @Test("Nothing moved says nothing") + func nothingMoved() { + #expect(BannerCenter.relocatedLooseFilesMessage(for: []) == nil) + #expect(BannerCenter.relocatedLooseFilesMessage(for: [relocation("Fix login")]) == nil) + } + + /// The row is a **loss row** — warning tone, dismissable, and ranked above the ambient notices + /// rather than at the bottom of the strip with them. + @Test("It rides the loss-row class") + @MainActor + func ridesTheLossRowClass() { + let center = BannerCenter() + center.postRelocatedLooseFiles([relocation("Fix login", "notes.txt")]) + + let rows = BannerCenter.rows( + lock: nil, + breakage: nil, + oneShots: [], + losses: center.losses, + suspension: nil, + operations: [], + signposts: [InfoSignpost(message: "elsewhere")] + ) + #expect(rows.first?.tone == .warning) + #expect(rows.first?.dismissID == center.losses.first?.id) + #expect(rows.count == 2) + } + + /// A failed relocation is a one-shot write failure, and the banner owns its words too. + @Test("A failed relocation says so in the relocation's own verb") + func failureHeadline() { + let error = BoardWriteError( + operation: .relocateLooseFile(filename: "notes.txt"), + path: "/tmp/card/notes.txt", + reason: .io(message: "disk full") + ) + #expect(BannerCenter.headline(for: error) == "Couldn't move 'notes.txt' into attachments — disk full") + } +} + +// MARK: - 4. The paste boundary (04-interactions.md ▸ Clipboard) + +/// A source board whose card carries an attachment, two loose files — one of them colliding with +/// that attachment — and a symlink stray. +@MainActor +private func makeLooseFileClipboardBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(cardPath, Item.rich(order: "1024", title: "Fix login")) + try fixture.file("\(cardPath)/attachments/notes.txt", Data("the attachment".utf8)) + try fixture.file("\(cardPath)/notes.txt", Data("the loose one".utf8)) + try fixture.file("\(cardPath)/draft.md", Data("draft".utf8)) + try symlink("link.txt", to: "attachments/notes.txt", in: fixture.url(cardPath)) + return fixture +} + +/// The destination: one lane holding one resident card, so an arrival has neighbours. +@MainActor +private func makePasteDestination() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane4, Item.rich(order: "1024", title: "Inbox")) + try fixture.item("\(Ident.lane4)/\(Ident.indexless)", Item.rich(order: "1024", title: "Resident")) + return fixture +} + +/// The folder name of the card that just arrived in the destination's lane. +private func arrivedCard(in fixture: WriterFixture) throws -> String { + let lane = try #require( + try BoardLoader.load(boardRoot: fixture.root).model.lanes.first { $0.id.rawValue == Ident.lane4 } + ) + return try #require(lane.cards.filter { !$0.isDeleted }.last?.id.rawValue) +} + +@MainActor +@Suite("Loose files ▸ the paste boundary") +struct LooseFilePasteTests { + + @Test("A pasted card lands already normalized, its collision Finder-renamed") + func pasteNormalizes() async throws { + let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) + defer { harness.tearDown() } + let destination = try makePasteDestination() + defer { destination.tearDown() } + let target = try BoardStore(rootURL: destination.root) + + harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.clipboard.copy(from: harness.store) + target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + await harness.clipboard.paste(into: target)?.value + + let arrived = try arrivedCard(in: destination) + let path = "\(Ident.lane4)/\(arrived)" + // Nothing the snapshot preserved is dropped on arrival — and nothing arrives out of place. + #expect(try destination.data("\(path)/attachments/notes.txt") == Data("the attachment".utf8)) + #expect(try destination.data("\(path)/attachments/notes 2.txt") == Data("the loose one".utf8)) + #expect(try destination.data("\(path)/attachments/draft.md") == Data("draft".utf8)) + #expect(!destination.exists("\(path)/notes.txt")) + #expect(!destination.exists("\(path)/draft.md")) + // A normalized arrival is not news: the user asked for this paste. + #expect(target.banners.losses.isEmpty) + #expect(target.banners.oneShots.isEmpty) + } + + /// "Symlinks (never touched/traversed)" — a paste copies one verbatim, as the stray it is, and + /// the destination board's own loader agrees it is nothing to relocate. + @Test("A symlink stray pastes verbatim, beside index.md") + func symlinkStrayPastesVerbatim() async throws { + let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) + defer { harness.tearDown() } + let destination = try makePasteDestination() + defer { destination.tearDown() } + let target = try BoardStore(rootURL: destination.root) + + harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.clipboard.copy(from: harness.store) + target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + await harness.clipboard.paste(into: target)?.value + + let arrived = try arrivedCard(in: destination) + let link = destination.url("\(Ident.lane4)/\(arrived)").appendingPathComponent("link.txt") + let attributes = try FileManager.default.attributesOfItem(atPath: link.path) + #expect(attributes[.type] as? FileAttributeType == .typeSymbolicLink) + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: link.path) == "attachments/notes.txt" + ) + #expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty) + } + + /// The source is untouched by a copy-paste — the loose files it still holds are its own board's + /// carve-out to handle, on its own reload. + @Test("The source board's loose files are left where they are") + func sourceIsUntouched() async throws { + let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) + defer { harness.tearDown() } + let destination = try makePasteDestination() + defer { destination.tearDown() } + let target = try BoardStore(rootURL: destination.root) + + harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.clipboard.copy(from: harness.store) + target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + await harness.clipboard.paste(into: target)?.value + + #expect(harness.fixture.exists("\(cardPath)/notes.txt")) + #expect(harness.fixture.exists("\(cardPath)/draft.md")) + } + + @Test("A pasted lane normalizes every card it carries") + func lanePasteNormalizesItsCards() async throws { + let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) + defer { harness.tearDown() } + let destination = try makePasteDestination() + defer { destination.tearDown() } + let target = try BoardStore(rootURL: destination.root) + + harness.store.select([ItemID(rawValue: Ident.lane1)], liveness: .live) + harness.clipboard.copy(from: harness.store) + await harness.clipboard.paste(into: target)?.value + + let model = try BoardLoader.load(boardRoot: destination.root).model + let arrivedLane = try #require(model.lanes.first { $0.id.rawValue != Ident.lane4 }) + let arrivedCard = try #require(arrivedLane.cards.first) + let path = "\(arrivedLane.id.rawValue)/\(arrivedCard.id.rawValue)" + + #expect(try destination.data("\(path)/attachments/notes 2.txt") == Data("the loose one".utf8)) + #expect(try destination.data("\(path)/attachments/draft.md") == Data("draft".utf8)) + #expect(!destination.exists("\(path)/notes.txt")) + #expect(try BoardLoader.load(boardRoot: destination.root).looseCardFiles.isEmpty) + } + + /// The staging-less fallback carries only `index.md`, so there is nothing to normalize and the + /// normalization must not invent an `attachments/` for a card that has none. + @Test("A degraded paste normalizes nothing and mints no attachments folder") + func degradedPasteIsUnaffected() async throws { + let harness = try ClipboardHarness(fixture: try makeLooseFileClipboardBoard()) + defer { harness.tearDown() } + let destination = try makePasteDestination() + defer { destination.tearDown() } + let target = try BoardStore(rootURL: destination.root) + + harness.store.select([ItemID(rawValue: Ident.card1)], liveness: .live) + harness.clipboard.copy(from: harness.store) + await harness.clipboard.stagingSettled() + // The snapshot goes missing between the copy and the paste — 04's degraded paste. + for staged in try harness.stagedCopyIDs() { + try FileManager.default.removeItem(at: harness.staging.appendingPathComponent(staged)) + } + + target.select([ItemID(rawValue: Ident.lane4)], liveness: .live) + await harness.clipboard.paste(into: target)?.value + + let arrived = try arrivedCard(in: destination) + #expect(!destination.exists("\(Ident.lane4)/\(arrived)/attachments")) + #expect(try destination.entryNames("\(Ident.lane4)/\(arrived)") == ["index.md"]) + } +} diff --git a/README.md b/README.md index 53197e0..a679f3f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ The defining consequence: anything that can read and write files is a first-clas Lanework is in early development. This list tracks what has actually shipped and grows milestone by milestone; the full design lives in [DESIGN/](DESIGN/). - **Storage contract, read side** — frontmatter engine with a byte-perfect round-trip guarantee (unknown keys, comments, and formatting survive every rewrite; duplicate keys read last-wins; wrong-type scalars coerce read-side), gapped fractional ordering (Ranks), and a fail-fast board loader with UUID-gated level detection, warning-collecting skips, and tombstone-aware snapshots — pinned by a golden fixture suite of 18 on-disk boards. -- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. +- **Storage contract, write side** — BoardWriter turns every mutation into an atomic temp-file+rename over exactly the files it touches: creates mint lowercase-UUIDv4 identities and `.kanban` packages; moves keep the UUID (with per-folder collision repair at the cross-board import boundary); copies mint fresh identities at every level; deletes are in-place tombstones with position-perfect restore and physical purge; attachment imports never overwrite and never refuse (Finder-style renames). Every app write stamps `modified`, clears `modified-by`, and preserves everything it didn't change byte-for-byte; readable-but-uneditable frontmatter shapes refuse loudly instead of corrupting. Strays are preserved verbatim everywhere with exactly one carve-out: a loose *file* dropped beside a card's `index.md` belongs in that card's `attachments/`, so the app moves it there — Finder-renamed on collision, byte-faithfully, without touching `index.md` — and says so in a dismissable warning row naming the card and the files. Detection stays read-only in the loader; the move is an ordinary bracketed write that waits out the read-only lock and never retries a failure in a loop. Stray *folders*, symlinks, and everything at board or lane level keep the verbatim posture untouched, and a paste normalizes at the import boundary so a pasted card lands already tidy. - **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state. - **Window architecture** — the three window types and their lifecycle: a welcome window (below), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted. - **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the drag surface, a plain click selecting the lane and movement carrying it away.