diff --git a/Kanban/App/BoardTreeCopy.swift b/Kanban/App/BoardTreeCopy.swift index 24a0980..962e107 100644 --- a/Kanban/App/BoardTreeCopy.swift +++ b/Kanban/App/BoardTreeCopy.swift @@ -106,8 +106,23 @@ enum BoardTreeCopy { throw .failed(url: source, error: error) } + // The one exclusion this walk makes without being asked (01-storage-format.md § Enhanced + // schema: `comments/.trash/` is "**stripped at every copy boundary** (clipboard staging, + // Duplicate, Save as Template) — a copy must not carry ghosts no window session will ever + // purge"). Unconditional because all three of this walk's callers *are* copy boundaries, and + // an opt-in flag would be three call sites agreeing to one rule instead of one rule. + // + // It is the one depth-sensitive exclusion, hence the parent check rather than a name in + // `excluded`: `.trash` at a board root is the board's own and carries or not per each flow's + // stated exclusions, while `.trash` inside a `comments/` is undo's backing store and never + // travels. + let isCommentThread = source.lastPathComponent.lowercased() == IntegrityRules.commentsFolderName + for entry in entries.sorted(by: { $0.lastPathComponent < $1.lastPathComponent }) { guard !excluded.contains(entry.lastPathComponent.lowercased()) else { continue } + guard !isCommentThread + || entry.lastPathComponent.lowercased() != IntegrityRules.commentTrashFolderName + else { continue } // Between items, never mid-item: this is the whole of "checks cancellation between // items", and the reason the copy is a walk at all. diff --git a/Kanban/App/ClipboardStore.swift b/Kanban/App/ClipboardStore.swift index f88a135..9b5be2d 100644 --- a/Kanban/App/ClipboardStore.swift +++ b/Kanban/App/ClipboardStore.swift @@ -477,6 +477,13 @@ public final class ClipboardStore { /// makes the next paste **refuse whole**, naming it (`perform`'s preflight), rather than /// materializing it hollow from the manifest's embedded `index.md`. Failing to stage is therefore /// as loud as it should be, one gesture later. + /// **Staging is a copy boundary**, so `comments/.trash/` does not survive it (01-storage-format.md + /// § Enhanced schema: "stripped at every copy boundary (clipboard staging, Duplicate, Save as + /// Template)"). Stripped from the *snapshot* rather than skipped during it, because the snapshot is + /// one monolithic `copyItem` — and stripped through the Writer's own call so the rule is one + /// function (`BoardWriter.stripCommentTrash`), not a second reading of it here. Best-effort like + /// the copy above it: a snapshot that could not be tidied is still a snapshot, and the paste that + /// materializes from it strips again at its own boundary. private func stage(_ jobs: [StagingJob], into stagingDir: URL) { enqueue { [jobs, stagingDir] in guard (try? FileManager.default.createDirectory( @@ -485,6 +492,7 @@ public final class ClipboardStore { )) != nil else { return } for job in jobs { try? FileManager.default.copyItem(at: job.source, to: job.destination) + try? BoardWriter.stripCommentTrash(under: job.destination, operation: .copy(title: nil)) } } } diff --git a/Kanban/History/HistoryPhrase.swift b/Kanban/History/HistoryPhrase.swift index d957d53..c398bbb 100644 --- a/Kanban/History/HistoryPhrase.swift +++ b/Kanban/History/HistoryPhrase.swift @@ -64,12 +64,17 @@ public enum HistoryPhrase { case card case lane case board + /// One comment. Only `.delete` reaches it: posting has its own phrase (`comment`, below), and + /// the draft save, the inline edit and the trash purge register no step at all + /// (13-native-undo.md — no byte capture in any tier, and the permanent-delete posture). + case comment var singular: String { switch self { case .card: "Card" case .lane: "Lane" case .board: "Board" + case .comment: "Comment" } } @@ -78,10 +83,22 @@ public enum HistoryPhrase { case .card: "Cards" case .lane: "Lanes" case .board: "Board" + case .comment: "Comments" } } } + // MARK: The comment family + + /// **The post's phrase** — 06-history-undo.md's path-shaped "Comment on '⟨card⟩'" as a menu + /// title, which is the verb on its own. + /// + /// Not `name(_:kind:count:)` with a `Verb.comment`, because the composition would read "Comment + /// Comment": here the verb already names its object, which is `Kind.board`'s count-less rule + /// arriving from the other direction. The destination clause a commit subject carries ("on 'Fix + /// login'") is dropped exactly as every other phrase drops it — a menu row has to stay short. + public static let comment = "Comment" + // MARK: Composition /// The phrase for one gesture: `"Move Card"`, `"Move 3 Cards"`, `"Restyle Board"`. diff --git a/Kanban/LiveStore/BannerCenter.swift b/Kanban/LiveStore/BannerCenter.swift index 85e5217..87cab9f 100644 --- a/Kanban/LiveStore/BannerCenter.swift +++ b/Kanban/LiveStore/BannerCenter.swift @@ -848,6 +848,31 @@ public final class BannerCenter { // The buffer is still on screen: the banner says the app could not put those bytes on // disk, not that they are gone. if let title { "Couldn't apply source changes to '\(title)'" } else { "Couldn't apply the source changes" } + + // **The comment family names the card, because that is the title these operations carry** — + // a comment has none of its own (01-storage-format.md § Enhanced schema: "No `title`, no + // `order`"), and the card is the thing on the window in front of them. Each sentence is the + // gesture's own word, the vocabulary's standing rule: they pressed ⌘↩, or Delete, or nothing + // at all in the draft's case. + case let .saveCommentDraft(title): + // **"draft", never "save the comment"**: nothing has been posted, and a banner claiming a + // comment could not be saved would name one that does not exist yet. The typed text is + // still in the composer — this says the app could not put it on disk. + if let title { "Couldn't save your draft comment on '\(title)'" } else { "Couldn't save your draft comment" } + case let .postComment(title): + // The Comment button's own word (05-card-window.md ▸ The comments column, "⌘↩ posts"). + // The draft is untouched on disk, which is what makes this a retry rather than a loss. + if let title { "Couldn't post your comment on '\(title)'" } else { "Couldn't post your comment" } + case let .editComment(title): + if let title { "Couldn't save your edit to a comment on '\(title)'" } else { "Couldn't save your edit to the comment" } + case let .deleteComment(title): + if let title { "Couldn't delete a comment on '\(title)'" } else { "Couldn't delete the comment" } + case .purgeCommentTrash: + // No title, and no mention of a trash the user has never seen: `comments/.trash/` is + // "never a UI surface", so the sentence is about the *card's* files being tidied — the + // agent guide's posture, one level down. Nothing is lost either way; the next window + // open sweeps again. + "Couldn't tidy up deleted comments" } } diff --git a/Kanban/LiveStore/BoardStoreComments.swift b/Kanban/LiveStore/BoardStoreComments.swift new file mode 100644 index 0000000..f60e813 --- /dev/null +++ b/Kanban/LiveStore/BoardStoreComments.swift @@ -0,0 +1,244 @@ +import Foundation + +// MARK: - The comment gestures + +/// **The comment thread at the store boundary** — every comment write the app makes, bracketed, with +/// its inverse registered where 13-native-undo.md says one belongs. +/// +/// An extension in its own file for `BoardStoreHistory.swift`'s reason, and with its reason for +/// living on `BoardStore` at all: comments are read by the *card window* and written through the +/// *board's* store, because "one stack per board, owned by the board session. Not per-window: every +/// window over a board (board window, its card windows) shares the store and shares the stack" +/// (13 ▸ Rules). A comment posted in a card window is undone by ⌘Z in the board window, which is only +/// true if the step was registered here. +/// +/// ### What registers a step, and what deliberately does not +/// +/// Two of the five (13 ▸ Interaction with the trash, the comment clause; 13 ▸ Rules): +/// +/// - **`postComment`** — inverse: the rename back to `.draft`. Move-based, no capture. +/// - **`deleteComment`** — inverse: the move back out of `comments/.trash/`. Move-based, no capture. +/// - **`saveCommentDraft` and `editComment` register nothing**, and that is the no-capture rule +/// rather than a deferral: undoing a body edit means holding the prior bytes, which 13 forbids in +/// every tier. An inline edit's revert is its *session*'s (Cancel/Escape reverts to session-start +/// bytes — 05-card-window.md), which is a live buffer, not a stack entry. +/// - **`purgeCommentTrash` registers nothing** — the permanent-delete posture. It is also what makes +/// the interaction with the stack correct for free: leftover comment steps go stale after a purge +/// and skip with the ordinary info-tone banner, because the folders their expectations name are +/// gone. **The expectations validate disk, never the snapshot** (`HistoryStaleness`), which is +/// exactly why this works — comments are not in the snapshot at all. +@MainActor +extension BoardStore { + + // MARK: Reading + + /// One card's thread, read fresh from disk — **window-scoped**, never cached on the store and + /// never part of `snapshot` (01-storage-format.md § Enhanced schema: "the board snapshot never + /// loads comment content", so the board walk stays O(cards)). + /// + /// `.empty` for an id that names no card on the board — the same vanished-target answer every + /// other card-scoped call gives (`boardItem`). + public func commentThread(inCard id: ItemID) -> CommentThread { + guard let card = commentSubject(id) else { return .empty } + return CommentThread.load(inCard: card.folder, path: card.path) + } + + // MARK: The draft + + /// Saves the composer's draft — one bracket, no step. + /// + /// - Returns: what landed, or `nil` when the write failed (the banner is already + /// `performWrite`'s) or the card is gone. + @discardableResult + public func saveCommentDraft(inCard id: ItemID, body: String) -> CommentDraftOutcome? { + guard let card = commentSubject(id) else { return nil } + return try? performWrite { () throws(BoardWriteError) -> CommentDraftOutcome in + try BoardWriter.saveCommentDraft(inCard: card.folder, body: body, cardTitle: card.title) + } + } + + /// **Posts the draft, and registers the one step the gesture owes** (⌘↩ or the Comment button). + /// + /// The step's predicate is the brief the ruling gives it: the undo needs the posted folder still + /// at its path **and `.draft` still absent** — a draft the user has started typing since is not + /// this step's to overwrite — and the redo needs the mirror. Both are `HistoryExpectation`s over + /// disk, and the `.absent` half is the same one an undone create uses. + /// + /// **The redo replays the captured identity and the captured instant**, never fresh ones: a redo + /// that re-minted would post a *different* comment, and any step registered above this one naming + /// the posted id would name nothing. + @discardableResult + public func postComment(inCard id: ItemID) -> ItemID? { + guard let card = commentSubject(id) else { return nil } + guard let posted = try? performWrite({ () throws(BoardWriteError) -> PostedComment in + try BoardWriter.postComment(inCard: card.folder, cardTitle: card.title) + }) else { + return nil + } + + let folder = card.folder + let title = card.title + let draftFolder = CommentThread.draftFolder(inCard: folder) + let postedFolder = CommentThread.commentFolder(posted.id, inCard: folder) + registerStep( + HistoryPhrase.comment, + subject: title, + undoExpects: [.present(postedFolder), .absent(draftFolder)], + redoExpects: [.present(draftFolder), .absent(postedFolder)] + ) { _ in + try BoardWriter.unpostComment(posted.id, inCard: folder, cardTitle: title) + } redo: { _ in + try BoardWriter.repostComment( + as: posted.id, + inCard: folder, + stamping: posted.posted, + cardTitle: title + ) + } + return posted.id + } + + // MARK: Editing + + /// An inline edit session's save — one bracket, **no step** (13's no-capture rule; the session + /// owns Cancel). + /// + /// - Returns: whether bytes were written; `false` also for a card or comment that is gone. + @discardableResult + public func editComment(_ commentID: ItemID, inCard id: ItemID, body: String) -> Bool { + guard let card = commentSubject(id) else { return false } + let folder = CommentThread.commentFolder(commentID, inCard: card.folder) + let wrote = try? performWrite { () throws(BoardWriteError) -> Bool in + try BoardWriter.editComment(at: folder, body: body, cardTitle: card.title) + } + return wrote ?? false + } + + // MARK: Delete and its inverse + + /// **Deletes a comment — a move into `comments/.trash/`, immediate, no confirm, undoable** + /// (01-storage-format.md § Enhanced schema; 05-card-window.md ▸ The comments column). + /// + /// The step is the move read backwards, `moveToTrash`'s registration one level down and without + /// its rank half: a comment's trash has no order, so there is no `.order` after-value to compare + /// and existence is the whole predicate. The container rides in the path exactly as it does at + /// board level — the undo expects the comment in `comments/.trash/`, the redo expects it back in + /// the thread — so a foreign restore or a foreign re-delete skips the right half by itself. + @discardableResult + public func deleteComment(_ commentID: ItemID, inCard id: ItemID) -> Bool { + guard let card = commentSubject(id) else { return false } + let folder = card.folder + let title = card.title + let live = CommentThread.commentFolder(commentID, inCard: folder) + + let landed: Void? = try? performWrite { () throws(BoardWriteError) -> Void in + _ = try BoardWriter.deleteComment(at: live, cardTitle: title) + } + guard landed != nil else { return false } + + let trashed = CommentThread.trashedCommentFolder(commentID, inCard: folder) + registerStep( + HistoryPhrase.name(.delete, kind: .comment), + subject: title, + undoExpects: [.present(trashed)], + redoExpects: [.present(live)] + ) { _ in + try BoardWriter.restoreComment(commentID, inCard: folder, cardTitle: title) + } redo: { _ in + _ = try BoardWriter.deleteComment(at: live, cardTitle: title) + } + return true + } + + // MARK: The purge, and the crash residue it leaves + + /// **Empties one card's `comments/.trash/`** — the card window's close flush (01-storage-format.md + /// § Enhanced schema: "purged when the card window closes (rides the close flush)"). + /// + /// One bracket, no step. Leftover comment steps on the board's stack are not pruned here and must + /// not be: invalidation is lazy (13 ▸ Rules), so they stay on the stack, look full, and skip with + /// the ordinary info-tone banner the first time one is crossed. + public func purgeCommentTrash(inCard id: ItemID) { + guard let card = commentSubject(id) else { return } + try? performWrite { () throws(BoardWriteError) -> Void in + _ = try BoardWriter.purgeCommentTrash(inCard: card.folder) + } + } + + /// **The crash-residue sweep**, run when a card window opens (§ Enhanced schema: "crash residue + /// sweeps at the next card-window open, armed-then-cleared like every heal memo"). + /// + /// The same six steps every scheduled heal gets, through the same engine: **rest** when the trash + /// is empty (which is every open on a board that closed cleanly, and costs no bracket at all), + /// defer under a read-only lock, compare the signature, arm before attempting, one bracket, clear + /// on success. + /// + /// **Silent** — `HealNotice.none`. `comments/.trash/` is "never a UI surface", and the residue is + /// the app's own leftovers from a session that died; there is nothing here a user could act on. + /// + /// The memo is board-wide and keyed by class, so two cards' residue swept in turn re-arm each + /// other's picture. That is harmless rather than tolerated: the picture *is* the work, the write + /// half re-verifies against disk, and a sweep with nothing to remove is a no-op. + public func sweepCommentTrashResidue(inCard id: ItemID) { + guard let card = commentSubject(id) else { return } + let folder = card.folder + let residue = CommentThread.trashedCommentIDs(inCard: folder) + heals.run( + .commentTrashResidue, + signature: Set(residue.map { "comment-trash:\(card.path)/\($0.rawValue)" }), + on: self + ) { () throws(BoardWriteError) -> Void in + _ = try BoardWriter.purgeCommentTrash(inCard: folder) + } + } + + // MARK: The thread's claimed names + + /// Displaces the claimed names one thread read found squatted — `comments/.draft`, + /// `comments/.trash`, and a comment's own `attachments` (01-storage-format.md § Fractal layout + /// ▸ Rules, the level-uniform displacement, applied at the two levels the thread adds). + /// + /// **One bracket over the batch, and no memo**, which is the one place this heal differs from its + /// board-level twin (`displaceClaimedNames`) — deliberately, and worth stating: + /// `HealScheduler`'s memo is keyed by defect *class*, and these squatters are the same class as + /// the board walk's. Sharing the key would have one card's thread picture overwrite the board's + /// and back again on every reload, so the two would spend their memos fighting instead of guarding. + /// What replaces the memo here is the trigger: a thread is re-read when its window opens or its + /// files change, not on a timer, so there is no hot loop for a memo to break. + /// + /// - Parameter squatters: the defects a `CommentThread` read reported (its `defects`, filtered). + /// - Returns: what was actually moved aside, for the caller's warning-tone notice — the Writer + /// re-verifies each against disk and answers `nil` for one that freed itself. + @discardableResult + public func displaceCommentClaimedNames(_ squatters: [ClaimedNameSquatter]) -> [BannerCenter.Displacement] { + guard !squatters.isEmpty else { return [] } + let root = rootURL + var displaced: [BannerCenter.Displacement] = [] + try? performWrite { () throws(BoardWriteError) -> Void in + for squatter in squatters { + guard let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: root) else { + continue + } + displaced.append(BannerCenter.Displacement(name: squatter.name, movedTo: freed)) + } + } + return displaced + } + + // MARK: - Resolving the card + + /// Where a card's thread lives and what to call the gesture — the three facts every comment write + /// needs, resolved off the snapshot the way every other card-scoped call resolves them + /// (`boardItem`): `nil` for an id that names no live card, which is the vanished-target guard. + /// + /// The path is rebuilt from `rootURL` rather than remembered, so a board renamed mid-session + /// writes at its new location. + private func commentSubject(_ id: ItemID) -> (folder: URL, path: String, title: String?)? { + guard let item = Self.boardItem(id, in: snapshot), let cardID = item.cardID else { return nil } + return ( + folder: ItemPath.card(lane: item.laneID, id: cardID).folder(under: rootURL), + path: "\(item.laneID.rawValue)/\(cardID.rawValue)", + title: item.title + ) + } +} diff --git a/Kanban/LiveStore/CommentPathShape.swift b/Kanban/LiveStore/CommentPathShape.swift new file mode 100644 index 0000000..0e9f8a7 --- /dev/null +++ b/Kanban/LiveStore/CommentPathShape.swift @@ -0,0 +1,87 @@ +import Foundation + +// MARK: - CommentPath + +/// **What a changed path under a card's `comments/` is** — the pure classification the announcer and +/// the commit-message composer read (01-storage-format.md § Enhanced schema: "foreign comment changes +/// are described by **path shape** — the 'Update agent guide (vN)' mechanism: a changed path under +/// `…/comments//` composes 'Comment on ⟨card title⟩' / 'Edit comment on…' / 'Delete comment +/// on…', and the announcer speaks arrivals the same way"). +/// +/// ### Why a path, and not the snapshot +/// +/// Comments are window-scoped and the board snapshot never carries their content, so the two +/// consumers that describe *change* have no diff to read: the composer is "a pure snapshot diff" and +/// the snapshot has nothing to say here, exactly as it has nothing to say about `CLAUDE.md`. Path +/// shape is what is left, and it is enough — the verb family is a function of *where* a file sits, +/// not of what it contains. +/// +/// ### It classifies, it does not name +/// +/// The shape says comment / draft / trashed and which card. Which *verb* that composes needs one more +/// fact the path cannot carry — whether the folder is an arrival or a change to one already there — +/// and that belongs to the caller with the before-and-after in hand. This type stays a pure function +/// of a string so both consumers can share it without sharing anything else. +/// +/// Homed beside `EchoLedger` and `BoardDiff`, which are the two things that turn observed paths into +/// described events. +public struct CommentPath: Sendable, Equatable { + + /// The **card**'s path relative to the board root — `/`, or `.trash/` for a + /// trashed card, which carries its thread like any other content. + public let cardPath: String + + /// Which of the thread's three homes the path is in. + public let kind: Kind + + public enum Kind: Sendable, Equatable { + /// A posted comment — `/comments//…`. The thread's own content. + case comment(ItemID) + /// The card's single draft — `/comments/.draft/…`. Composes the quiet + /// "Draft comment on '⟨card⟩'". + case draft + /// A deleted comment waiting for the close purge — `/comments/.trash//…`. + case trashed(ItemID) + } + + /// The comment's identity, or `nil` for the draft — which has none, and is the one member of the + /// thread that is a name rather than an id. + public var id: ItemID? { + switch kind { + case let .comment(id), let .trashed(id): id + case .draft: nil + } + } + + /// Classifies one **root-relative, `/`-separated** path, or `nil` when it is not inside a thread. + /// + /// The rule is one index: a thread lives at `//comments/` and a trashed card's at + /// `.trash//comments/`, so `comments` is always the third component and the card is always + /// the second — one check covers both containers without either being spelled twice. + /// + /// Everything else answers `nil`, including the paths that are *nearly* one: `comments/` itself + /// (a container, never an event), a stray folder inside it, `comments/.trash` with no entry under + /// it. A `nil` is not a defect — it is this function saying the path is somebody else's to + /// describe. + public static func classify(_ relativePath: String) -> CommentPath? { + let components = relativePath.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + guard components.count >= 4, + components[2].lowercased() == IntegrityRules.commentsFolderName, + IntegrityRules.isIdentityShaped(components[1]) + else { + return nil + } + let cardPath = components[0] + "/" + components[1] + + let entry = components[3] + if entry.lowercased() == IntegrityRules.commentDraftFolderName { + return CommentPath(cardPath: cardPath, kind: .draft) + } + if entry.lowercased() == IntegrityRules.commentTrashFolderName { + guard components.count >= 5, IntegrityRules.isIdentityShaped(components[4]) else { return nil } + return CommentPath(cardPath: cardPath, kind: .trashed(ItemID(rawValue: components[4]))) + } + guard IntegrityRules.isIdentityShaped(entry) else { return nil } + return CommentPath(cardPath: cardPath, kind: .comment(ItemID(rawValue: entry))) + } +} diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 2a577f6..d7b615b 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -376,9 +376,10 @@ public enum BoardLoader: Sendable { heldCards: children().count, document: document )) - case .card, .board: - // `kind: board` never reaches here as itself — `trashKind` treats it as unrecognized - // and answers by shape — so this arm is the card answer and nothing else. + case .card, .board, .comment: + // Neither `kind: board` nor `kind: comment` reaches here as itself — `trashKind` + // treats both as unrecognized and answers by shape — so this arm is the card answer + // and nothing else. trash.append(Card( id: id, schema: schema, diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index c982ec7..08a50f2 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -1,4 +1,5 @@ import Foundation +import os /// Turns a mutation into a filesystem operation — the single point through which every write /// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by @@ -133,14 +134,15 @@ public enum BoardWriter: Sendable { /// a kind onto whatever was pointed at, which is the one thing the value-names-the-kind posture /// cannot afford. private static func derivedKind(ofItemFolder folder: URL) -> IntegrityRules.ObjectKind? { - switch IntegrityRules.placement( - ofFolderNamed: folder.lastPathComponent, - inParentNamed: folder.deletingLastPathComponent().lastPathComponent - ) { + // The URL form, deliberately: it is the only one that can tell the board's `.trash/` from a + // comment thread's own `comments/.trash/`, which share a name. + switch IntegrityRules.placement(ofFolder: folder) { case .card: return .card case .lane: return .lane + case .comment: + return .comment case .insideTrash: // The value cannot have answered — a document carrying `kind` is never backfilled, so // this is only reached for one that does not — which is precisely when shape decides. @@ -386,7 +388,9 @@ public enum BoardWriter: Sendable { /// probe: the minted name is lowercase, so it can only match a canonical set. A freshly /// minted UUID hitting either is astronomically unlikely — 122 bits of randomness per mint — /// but the loop body is trivial precisely because the case it handles essentially never fires. - private static func freshUUIDName(in parentFolder: URL, avoiding taken: Set) -> String { + /// Internal rather than `private`, with `renameFolder` and for its reason: a posted comment's + /// identity is minted by this exact rule. + static func freshUUIDName(in parentFolder: URL, avoiding taken: Set) -> String { var name: String repeat { name = UUID().uuidString.lowercased() @@ -401,7 +405,9 @@ public enum BoardWriter: Sendable { /// most needs to see at a glance: a missing or wrong-shaped folder is rejected before /// anything else happens. `role` names it the way the failing user action would ("parent /// folder", "item folder") — the error goes straight into the write-failure banner. - private static func checkIsDirectory( + /// **Internal rather than `private`**: the comment writer (`CommentWriter.swift`) is this + /// file's own extension one level down and runs the identical pre-flights. + static func checkIsDirectory( _ url: URL, describedAs role: String, operation: WriteOperation @@ -746,14 +752,19 @@ public enum BoardWriter: Sendable { /// entries and symlinks already excluded) narrowed by `isUUIDShaped`, which is the loader's /// level-detection rule and therefore the only definition of "an identity-bearing child" /// this writer is allowed to have. - private static func childCandidates(of folder: URL) -> [URL] { + /// Internal rather than `private`: a comment thread's own children are enumerated by the same + /// rule, one level down. + static func childCandidates(of folder: URL) -> [URL] { let candidates = (try? BoardLoader.directoryCandidates(in: folder)) ?? [] return candidates.filter { BoardLoader.isUUIDShaped($0.lastPathComponent) } } /// Renames a folder in place, keeping its parent — the whole of an identity repair, and of a /// copy's remint. The folder's contents, `index.md` included, are never opened. - private static func renameFolder( + /// + /// Internal rather than `private`: posting a comment is this rename and nothing else + /// (`comments/.draft/` → a fresh identity), and its undo is the same rename read backwards. + static func renameFolder( _ folder: URL, toSiblingNamed name: String, operation: WriteOperation @@ -777,7 +788,8 @@ public enum BoardWriter: Sendable { /// away. The import boundary turns on this one comparison, so it is deliberately about /// *location*, not spelling: `/tmp/B.kanban` and `/private/tmp/B.kanban/.` are one board, /// and treating them as two would remint every arriving item for nothing. - private static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool { + /// Internal rather than `private`: the comment purge checks its container the same way. + static func isSameLocation(_ lhs: URL, _ rhs: URL) -> Bool { lhs.resolvingSymlinksInPath().standardizedFileURL.path == rhs.resolvingSymlinksInPath().standardizedFileURL.path } @@ -867,6 +879,12 @@ public enum BoardWriter: Sendable { } do { + // **`comments/.trash/` never crosses a copy boundary** (01-storage-format.md § Enhanced + // schema: "stripped at every copy boundary … a copy must not carry ghosts no window + // session will ever purge"). Before the remint, so the strip walks the paths the source + // had rather than minted ones nobody has seen. + try stripCommentTrash(under: root, operation: operation) + var copied: [URL] = [] try remintDescendants(of: root, collecting: &copied, operation: operation) @@ -903,6 +921,16 @@ public enum BoardWriter: Sendable { /// ▸ Instantiation) is `copyItem`'s rule applied to a whole board rather than to one item, and /// pointing this at a copied *board root* is literally that. A second implementation of "which /// folders are identities" is exactly what must not exist. + /// **The thread comes too** (01-storage-format.md § Enhanced schema: "**Copies carry the thread** + /// — a copy is a fork, and dropping a subtree would be the one place a copy loses content; comment + /// folders remint like every copied folder"). `comments/` is not identity-shaped, so the recursion + /// above cannot reach through it; the second loop is that one step, and it does not recurse + /// because a comment has no identity-bearing children of its own (flat, this iteration). + /// + /// **`comments/.draft/` is not reminted, and that is not an omission**: it is a dot-named folder, + /// so `directoryCandidates` never offers it, and it has no identity to mint away from. It carries + /// verbatim, which is the ruling ("copies and the trash carry it like any comment folder + /// (fork-lossless)"). `comments/.trash/` is gone before this runs (`stripCommentTrash`). static func remintDescendants( of folder: URL, collecting copied: inout [URL], @@ -916,6 +944,44 @@ public enum BoardWriter: Sendable { copied.append(renamed) try remintDescendants(of: renamed, collecting: &copied, operation: operation) } + + let thread = folder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) + for comment in childCandidates(of: thread) { + let fresh = freshUUIDName(in: thread, avoiding: []) + try renameFolder(comment, toSiblingNamed: fresh, operation: operation) + copied.append(thread.appendingPathComponent(fresh, isDirectory: true)) + } + } + + /// **Removes every `comments/.trash/` in a copied tree** — the copy boundary's strip + /// (01-storage-format.md § Enhanced schema, ruled 2026-07-29). Called on the *destination*, so + /// nothing a user still owns is ever removed by it; the source's thread trash stays exactly where + /// it is, waiting for its own window's close purge. + /// + /// The reach is the root plus its identity-bearing descendants, which is every folder that can + /// have a thread: a comment has none, and a board root's own `comments/` would be a stray. + /// + /// **Internal rather than `private`**: the clipboard's staging snapshot is a copy boundary the + /// Writer does not perform (`ClipboardStore.stage`) and strips through this same call — "stripped + /// at every copy boundary (clipboard staging, Duplicate, Save as Template)" is only one rule if it + /// is one function. + static func stripCommentTrash(under folder: URL, operation: WriteOperation) throws(BoardWriteError) { + for item in [folder] + identityDescendants(of: folder) { + let trash = item + .appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) + .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true) + guard IntegrityRules.node(at: trash) != nil else { continue } + do { + try FileManager.default.removeItem(at: trash) + } catch { + throw BoardWriteError( + operation: operation, + path: trash.path, + reason: .io(message: "could not remove the copied comment trash: \(error.localizedDescription)") + ) + } + EchoLedger.current?.recordDeletion(at: trash) + } } /// **The copy contract's frontmatter edits**, applied to every folder an item-level copy @@ -968,10 +1034,17 @@ public enum BoardWriter: Sendable { /// carries — is then the whole of what can honestly be said about it. /// /// **A folder with no `index.md` is not an offense** and is skipped: it is interrupted-create - /// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail. The - /// walk is `identityDescendants`', which is `remintDescendants`' own reach — so the set checked - /// here is exactly the set that will be stamped, never a superset that could refuse a copy over a - /// file nobody was going to touch. + /// residue, the loader skips it too (`.missingIndex`), and there is no contract work to fail. + /// + /// **Comment folders are deliberately outside this preflight** (added 2026-07-30 with the comment + /// storage, and worth stating because it is a *narrowing* of a 2026-07-29 ruling): 01's copy + /// transaction says "refuses whole, loudly, naming the offending item", and its enhanced-schema + /// section says "**comment defects never refuse the board** — worst case is the stray posture … + /// a broken leaf annotation must not brick a load; deliberate, proportionate divergence from card + /// fail-fast". A ⌘V that refuses because one comment on one card inside a pasted lane has + /// hand-broken frontmatter is that divergence read the other way round. So the walk stays + /// `identityDescendants`' — cards and lanes — and a comment the contract cannot be applied to is + /// copied verbatim with a log line instead (`stampCopiedComment`). /// /// **Internal rather than `private`**: template instantiation preflights its own tree with this, /// for `remintDescendants`' reason — one definition of what a copy owes its descendants. @@ -987,9 +1060,11 @@ public enum BoardWriter: Sendable { } } - /// Every identity-bearing folder beneath `folder`, depth first — `remintDescendants`' walk with - /// the renaming taken out, so the preflight and the remint can never disagree about which folders - /// a copy materializes as items. + /// Every **card or lane** beneath `folder`, depth first — `remintDescendants`' recursion with the + /// renaming taken out, so the preflight and the remint cannot disagree about which folders a copy + /// materializes as *items*. Comment folders are not here, by the carve-out + /// `checkCopiedDescendantsAreStampable` states; it is also exactly the right reach for + /// `stripCommentTrash`, since a thread lives under a card and nowhere else. private static func identityDescendants(of folder: URL) -> [URL] { var found: [URL] = [] for child in childCandidates(of: folder) { @@ -1023,11 +1098,37 @@ public enum BoardWriter: Sendable { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { return } + // The one lenient branch, and the preflight's own carve-out read from the write side: a + // comment was never checked, so a comment that cannot be stamped is a *tolerated* defect + // rather than a disk failure — it copies verbatim, keeping whatever `remote` and + // `modified-by` it carried, and says so in the log (01-storage-format.md § Enhanced schema, + // "comment defects never refuse … tolerated, logged"). + guard !isCommentFolder(folder) else { + do { + try updateIndex(inItemFolder: folder, kind: .comment, operation: operation) { document in + applyCopyContract(to: &document, stamps: stamps, now: now) + } + } catch { + logger.warning( + "\(folder.path, privacy: .public): copied comment left unstamped — \(error.description, privacy: .public)" + ) + } + return + } + try updateIndex(inItemFolder: folder, operation: operation) { document in applyCopyContract(to: &document, stamps: stamps, now: now) } } + /// Whether `folder` is a comment — its parent is a card's `comments/`. Position, like every other + /// kind question here (`IntegrityRules.placement`). + static func isCommentFolder(_ folder: URL) -> Bool { + IntegrityRules.placement(ofFolder: folder) == .comment + } + + static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "writer") + // MARK: - The materialized trash /// `/.trash/` — the board's trash container, named but not created. @@ -1185,8 +1286,12 @@ public enum BoardWriter: Sendable { try checkIsDirectory(itemFolder, describedAs: kind == .lane ? "lane folder" : "card folder", operation: operation) try checkIsDirectory(boardRoot, describedAs: "board folder", operation: operation) switch kind { + // `.board` and `.comment` are unreachable — the three callers pass `.card` or `.lane`, and + // neither a board nor a comment is a thing the *board's* trash ever holds (a deleted comment + // moves into its own thread's `comments/.trash/`). Both take the card guard, which refuses + // them both loudly rather than letting an unexpected caller through. case .lane: try checkIsLaneFolder(itemFolder, operation: operation) - case .card, .board: try checkIsCardFolder(itemFolder, operation: operation) + case .card, .board, .comment: try checkIsCardFolder(itemFolder, operation: operation) } operation = try checkIndexIsRewritable(inItemFolder: itemFolder, operation: operation) @@ -1949,7 +2054,9 @@ public enum BoardWriter: Sendable { /// 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) { + /// Internal rather than `private`: every comment write is *about* a card, and reaches it + /// through this same guard. + static func checkIsCardFolder(_ folder: URL, operation: WriteOperation) throws(BoardWriteError) { try checkIsUUIDShaped(folder, operation: operation) guard BoardLoader.isUUIDShaped(folder.deletingLastPathComponent().lastPathComponent) else { throw BoardWriteError( @@ -2287,7 +2394,8 @@ public enum BoardWriter: Sendable { /// travels, so this is the one place `moveItem`/`copyItem` can learn it at all. Returned /// rather than discarded so every failure after the pre-flight passes (the `FileManager` /// move/copy itself, the post-arrival `updateIndex`) also names the item. - private static func checkIndexIsRewritable( + /// Internal rather than `private`: the comment writer's own discover-before-you-write pre-flight. + static func checkIndexIsRewritable( inItemFolder folder: URL, operation: WriteOperation ) throws(BoardWriteError) -> WriteOperation { @@ -2306,7 +2414,9 @@ public enum BoardWriter: Sendable { /// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or /// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines /// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules). - private static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument { + /// Internal rather than `private`: the comment writer reads through the same door, so the two + /// cannot disagree about what "could not be read" means. + static func readDocument(at url: URL, operation: WriteOperation) throws(BoardWriteError) -> FrontmatterDocument { let data: Data do { data = try Data(contentsOf: url) @@ -2329,7 +2439,8 @@ public enum BoardWriter: Sendable { } } - private static func checkEditable( + /// Internal rather than `private`, with `readDocument` and for its reason. + static func checkEditable( _ document: FrontmatterDocument, at url: URL, operation: WriteOperation @@ -2565,6 +2676,49 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { /// alert rather than the banner, so the Apply phrasing is never shown for one. case rawSource(title: String?) + // MARK: The comment family + // + // Five operations, one path-shaped verb family (06-history-undo.md ▸ Commit messages, the + // vocabulary 01-storage-format.md § Enhanced schema names: "Comment on '⟨card title⟩'" / "Edit + // comment on…" / "Delete comment on…" / "Draft comment on '⟨card⟩'"). **Every one of them carries + // the *card's* title, not the comment's** — a comment has no `title` key at all (§ Enhanced + // schema: "No `title`, no `order`"), and the thing a user recognizes is the card they are + // commenting on. That is also why all five are identity in `withTitle`: there is no document to + // enrich from, and the value arrives already filled in from the window the gesture came from. + + /// The composer's slow-cadence save into `comments/.draft/` — blur, window close, quit, and the + /// ~30 s tick (05-card-window.md ▸ The comments column: "Draft saves are slow-cadence, never + /// prompted"). Its own case rather than a fold into `.editComment`, on the vocabulary's standing + /// reasoning: a draft is not yet a comment, and telling someone the app "couldn't edit a comment" + /// after they typed one that has never been posted would name a thing that does not exist. + case saveCommentDraft(title: String?) + + /// ⌘↩ — the draft renamed to a fresh identity and restamped, one bracket (§ Enhanced schema: + /// "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in the same + /// bracket — chronology is post time, not drafting time — one commit"). + case postComment(title: String?) + + /// An inline comment edit session's save — the body-edit session in miniature (05 ▸ The comments + /// column). Its own case beside `.editBody` for that case's reason: both write a body, but one is + /// the card the window is about and the other is one annotation on it. + case editComment(title: String?) + + /// A comment moving into `comments/.trash/` — "delete = move into `comments/.trash/`", immediate, + /// no confirm, undone by the ordinary move back (§ Enhanced schema; 13-native-undo.md). + /// + /// **The inverse rides this same case**, deliberately: the restore is a move with no gesture of + /// its own — the user pressed ⌘Z on a delete — and `.delete`'s own "there is no `restore` case" + /// note is the precedent one level up. + case deleteComment(title: String?) + + /// `comments/.trash/` emptied — at card-window close, and as the crash-residue sweep at the next + /// open (§ Enhanced schema). + /// + /// **No payload, unlike its four siblings**, and for `.agentGuide`'s reason: this is bookkeeping + /// the app does on its own over a folder that is "never a UI surface", with one outcome nobody + /// asked for and nothing to name. The path-shaped verb family has no word for it either. + case purgeCommentTrash + /// Fills in the title once the Writer has read it off the document the operation is acting /// on — identity for the cases with no title slot at all: `createBoard`/`createLane`/ /// `createCard` are minting a file, not reading one; `importAttachment`, `removeAttachment` and @@ -2581,9 +2735,13 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { // `.repairDuplicateID` is identity here even though it carries a title: the remint never // opens an `index.md` — it is a rename — so there is no `readDocument` to enrich from, and // its title arrives already filled in from the load that detected the duplicate. + // The comment family is identity for `.repairDuplicateID`'s reason, doubled: a comment's + // `index.md` carries no `title` to enrich from, and the title these five hold is the *card's*, + // filled in by the caller from the window the gesture came from. case .createBoard, .createLane, .createCard, .importAttachment, .listAttachments, .removeAttachment, .renumberChildren, .relocateLooseFile, .agentGuide, - .displaceClaimedName, .repairDuplicateID: + .displaceClaimedName, .repairDuplicateID, .saveCommentDraft, .postComment, + .editComment, .deleteComment, .purgeCommentTrash: self case .move: .move(title: title) case .reorder: .reorder(title: title) @@ -2638,10 +2796,15 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { switch self { case .reorder, .renumberChildren: true + // The comment family writes content, never a rank: a comment has no `order` at all + // (01-storage-format.md § Enhanced schema), so there is nothing here for the order-only + // reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the + // plain container reason its board-level twin does. case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone, .style, .resize, .rename, .duplicateBoard, .saveAsTemplate, .paste, .importAttachment, .listAttachments, .removeAttachment, .relocateLooseFile, .agentGuide, .displaceClaimedName, - .repairDuplicateID, .toggleTask, .editBody, .rawSource: + .repairDuplicateID, .toggleTask, .editBody, .rawSource, .saveCommentDraft, .postComment, + .editComment, .deleteComment, .purgeCommentTrash: false } } @@ -2680,6 +2843,12 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible { case let .toggleTask(title): Self.phrase("toggle a checkbox in", title) case let .editBody(title): Self.phrase("save the body of", title) case let .rawSource(title): Self.phrase("apply source changes to", title) + // The card's title, never the comment's — see the family's own note above. + case let .saveCommentDraft(title): Self.phrase("save the comment draft on", title) + case let .postComment(title): Self.phrase("post a comment on", title) + case let .editComment(title): Self.phrase("edit a comment on", title) + case let .deleteComment(title): Self.phrase("delete a comment on", title) + case .purgeCommentTrash: "purge deleted comments" } } diff --git a/Kanban/Storage/CommentThread.swift b/Kanban/Storage/CommentThread.swift new file mode 100644 index 0000000..1405458 --- /dev/null +++ b/Kanban/Storage/CommentThread.swift @@ -0,0 +1,295 @@ +import Foundation +import os + +// MARK: - Comment + +/// One comment: a UUID-named folder under a card's `comments/`, holding `index.md` and optionally +/// `attachments/` — "a card's anatomy one level down" (01-storage-format.md § Enhanced schema, +/// storage specified 2026-07-29). +/// +/// The field table is the common schema minus two and plus one: **no `title`, no `order`**, and +/// `author` — self-reported *content* that survives every app write, unlike `modified-by`. +/// +/// `Card`'s shape deliberately, one level down: identity, the lenient fields as `FieldValue`s, the +/// attachment listing, and the whole parsed document so unknown and reserved keys ride along +/// uninterpreted. +public struct Comment: Identifiable, Sendable, Equatable { + public let id: ItemID + + /// The schema number as read. Lenient here, unlike every other level: a comment defect never + /// refuses anything (§ Enhanced schema), so a missing or unreadable `schema` costs the thread a + /// rendered comment, never a load. + public let schema: FieldValue + + /// Who says they wrote it — the app writes the macOS account's full name, agents write their + /// own, tracker sync writes the remote author verbatim. **Missing renders unattributed**; there + /// is no identity system behind it and none is implied. + public let author: FieldValue + + /// Load-bearing, unlike anywhere else in the schema: the thread's order *is* `created` + /// ascending (§ Enhanced schema — "Ordering is chronology, not ranks"). + public let created: FieldValue + public let modified: FieldValue + public let modifiedBy: FieldValue + + /// The comment's attachment file names — flat, top-level regular files, Finder order, through + /// the same enumeration a card's listing uses (`BoardLoader.attachmentNames`), so a chip row and + /// a card's sidebar can never disagree about what a folder holds. + public let attachments: [String] + + /// The full parsed `index.md`; unknown and reserved keys ride along uninterpreted. + public let document: FrontmatterDocument + + /// The comment's Markdown — the card-body subset. Equivalent to `document.body`. + public var body: String { document.body } + + /// **The edited indicator is `modified` differing from `created`, and no extra field** + /// (§ Enhanced schema). A post writes both from one `Date`, so a comment that has never been + /// edited reads `false` by construction; one of the pair missing is not evidence of an edit. + public var isEdited: Bool { + guard let created = created.value, let modified = modified.value else { return false } + return modified != created + } +} + +// MARK: - CommentThread + +/// A card's comment thread, read from disk — **window-scoped, outside the board snapshot** +/// (01-storage-format.md § Enhanced schema: "the walk stays O(cards): the card window reads its own +/// thread … and the board snapshot never loads comment content"). +/// +/// ### It never refuses +/// +/// There is no `throws` anywhere in this type, and that is the ruling rather than convenience: +/// "**Comment defects never refuse the board** — worst case is the stray posture (tolerated, logged, +/// unrendered): a broken leaf annotation must not brick a load; deliberate, proportionate divergence +/// from card fail-fast". A folder that is not identity-shaped, one with no `index.md`, one whose +/// frontmatter does not parse, one that is not UTF-8 — each is skipped with a log line, preserved +/// verbatim on disk, and the rest of the thread renders. +/// +/// ### The two dot-named folders are not comments +/// +/// `comments/.draft/` and `comments/.trash/` are excluded from the listing (§ Enhanced schema), and +/// they are excluded *for free*: both are dot-prefixed, and `BoardLoader.directoryCandidates` skips +/// hidden entries. The exclusion is stated in the enumeration's own rule rather than re-implemented +/// here, exactly as `.trash/` is at board level. +public struct CommentThread: Sendable, Equatable { + + /// The thread in display order — `created` ascending (see `sorted(_:)` for the fallback). + public let comments: [Comment] + + /// Folders under `comments/` that are not comments — reported for the log's sake and rendered by + /// nothing. The tolerate tier, one level down. + public let strays: [Stray] + + /// Pending work and coerce-tier observations this read found — claimed names squatted inside + /// `comments/` or inside one comment, and every lenient field that had no sensible reading. The + /// same typed stream the board walk fills (`IntegrityRules.Defect`), so the heal engine needs no + /// second vocabulary for a thread. + public let defects: [IntegrityRules.Defect] + + /// Whether the card has a draft on disk. The composer reads its bytes itself; what a *thread* + /// needs to know is only that one exists, which is the answer restore-on-reopen turns on. + public let hasDraft: Bool + + /// One folder under `comments/` the thread would not render, and why — never an error, always a + /// log line. + public struct Stray: Sendable, Equatable { + public enum Reason: Sendable, Equatable { + /// Not identity-shaped: a hand-made folder, a nested clone. The shape-only identity + /// predicate one level down. + case notIdentityShaped + /// Identity-shaped with no `index.md` — two-step-create tolerance, verbatim from the + /// card rule (01-storage-format.md § Fractal layout ▸ Rules). + case missingIndex + /// The bytes are there and could not be read as a comment: not UTF-8, frontmatter that + /// does not parse. The one reason a *card* would have failed the whole load. + case unreadable(message: String) + } + + public let name: String + public let reason: Reason + } + + public static let empty = CommentThread(comments: [], strays: [], defects: [], hasDraft: false) + + // MARK: - Where a thread lives + + /// `/comments/` — named but never created here. One place, so the loader's read and every + /// write in `CommentWriter.swift` can never disagree about where a thread is + /// (`BoardWriter.trashFolder(inBoard:)`'s precedent). + public static func folder(inCard cardFolder: URL) -> URL { + cardFolder.appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) + } + + /// `/comments/.draft/` — the card's single draft. + public static func draftFolder(inCard cardFolder: URL) -> URL { + folder(inCard: cardFolder) + .appendingPathComponent(IntegrityRules.commentDraftFolderName, isDirectory: true) + } + + /// `/comments/.trash/` — undo's backing store, purged at window close. + public static func trashFolder(inCard cardFolder: URL) -> URL { + folder(inCard: cardFolder) + .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true) + } + + /// `/comments//` — one posted comment. + public static func commentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL { + folder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true) + } + + /// `/comments/.trash//` — one deleted comment, waiting for the close purge or a ⌘Z. + public static func trashedCommentFolder(_ id: ItemID, inCard cardFolder: URL) -> URL { + trashFolder(inCard: cardFolder).appendingPathComponent(id.rawValue, isDirectory: true) + } + + /// The identities currently sitting in `comments/.trash/` — what a close purge would remove, and + /// what the crash-residue sweep signs its memo with. + /// + /// The listing rule is the thread's own (`directoryCandidates` narrowed by the identity + /// predicate), so a stray a hand-editor put in there is neither counted nor purged — the same + /// honesty `emptyTrash` keeps at board level. + public static func trashedCommentIDs(inCard cardFolder: URL) -> [ItemID] { + ((try? BoardLoader.directoryCandidates(in: trashFolder(inCard: cardFolder))) ?? []) + .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) } + .map { ItemID(rawValue: $0.lastPathComponent) } + } + + // MARK: - Reading + + private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "comments") + + /// Reads one card's thread. Total: a card with no `comments/`, an unreadable one, or one held by + /// a file answers `.empty`, which is what "no comments yet" looks like and is not a defect this + /// read has any business inventing — the squatted-name case *is* reported, as work. + /// + /// - Parameter path: the card folder's path relative to the board root (`/`, or + /// `.trash/` for a trashed one — "a trashed card carries its `comments/`"). Carried into + /// every defect so the heal lands wherever the board lives at write time, and into the log + /// lines so a stray names something a human can find. + public static func load(inCard cardFolder: URL, path: String) -> CommentThread { + let threadFolder = folder(inCard: cardFolder) + var defects: [IntegrityRules.Defect] = [] + + switch IntegrityRules.node(at: threadFolder) { + case nil: + return .empty + case .directory: + break + case .file, .symlink: + // The claimed name held by the wrong kind of node. Detection only, like every other + // defect in this app — the displacement is the store's, through the Writer. Nothing else + // about the thread can be read while a file wears the name, so the listing is empty and + // the work is the whole answer. + return CommentThread( + comments: [], + strays: [], + defects: IntegrityRules.squattedClaimedNames(inCardAt: cardFolder, path: path) + .map(IntegrityRules.Defect.claimedNameSquatted), + hasDraft: false + ) + } + + for squatter in IntegrityRules.squattedClaimedNames(inCommentThreadAt: threadFolder, cardPath: path) { + defects.append(.claimedNameSquatted(squatter)) + logger.warning( + "\(path, privacy: .public)/comments/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced" + ) + } + + var comments: [Comment] = [] + var strays: [Stray] = [] + // Hidden entries and symlinks are already out, which is exactly how `.draft` and `.trash` are + // excluded from the thread — see the type's own note. + for commentURL in (try? BoardLoader.directoryCandidates(in: threadFolder)) ?? [] { + let name = commentURL.lastPathComponent + let commentPath = path + "/" + IntegrityRules.commentsFolderName + "/" + name + + guard IntegrityRules.isIdentityShaped(name) else { + strays.append(Stray(name: name, reason: .notIdentityShaped)) + logger.warning("\(commentPath, privacy: .public): not a comment identity — ignored") + continue + } + let indexURL = commentURL.appendingPathComponent(IntegrityRules.indexFileName) + guard let data = try? Data(contentsOf: indexURL) else { + strays.append(Stray(name: name, reason: .missingIndex)) + logger.warning("\(commentPath, privacy: .public): no index.md — ignored") + continue + } + let document: FrontmatterDocument + do { + document = try BoardLoader.parseDocument(data, path: commentPath) + } catch { + strays.append(Stray(name: name, reason: .unreadable(message: error.reason.description))) + logger.warning( + "\(commentPath, privacy: .public): \(error.reason.description, privacy: .public) — ignored" + ) + continue + } + + let fields = document.coercedFields + if !fields.isEmpty { + let indexPath = commentPath + "/" + IntegrityRules.indexFileName + defects.append(.coercedFrontmatter(CoercedFrontmatter(path: indexPath, fields: fields))) + for field in fields { + logger.info( + "\(indexPath, privacy: .public): '\(field.key, privacy: .public)' has no sensible reading — \(field.raw, privacy: .public) — rendering the field's default" + ) + } + } + for squatter in IntegrityRules.squattedClaimedNames(inCommentAt: commentURL, path: commentPath) { + defects.append(.claimedNameSquatted(squatter)) + logger.warning( + "\(commentPath, privacy: .public)/\(squatter.name, privacy: .public): claimed name held by \(squatter.found.description, privacy: .public) — to be displaced" + ) + } + + comments.append(Comment( + id: ItemID(rawValue: name), + schema: document.schema, + author: document.author, + created: document.created, + modified: document.modified, + modifiedBy: document.modifiedBy, + attachments: BoardLoader.attachmentNames(in: commentURL), + document: document + )) + } + + return CommentThread( + comments: sorted(comments), + strays: strays, + defects: defects, + hasDraft: IntegrityRules.node(at: draftFolder(inCard: cardFolder)) == .directory + ) + } + + /// **Chronology, with the undated after the dated** (01-storage-format.md § Enhanced schema, + /// ruled 2026-07-29): "the thread sorts by `created` ascending … ties and missing/malformed + /// `created` (coerce-tier fallback, logged) sort after dated siblings, folder-name order". + /// + /// The folder-name tie-break compares the **canonical lowercase spelling**, the corpus-wide rule + /// for every folder-name tie-break (§ Ordering: "ordering follows the value-based identity model, + /// never an uppercase folder's ASCII accident") — an agent's uppercase `uuidgen` output must not + /// sort into a different place than the same identity spelled lowercase. + static func sorted(_ comments: [Comment]) -> [Comment] { + comments.sorted { lhs, rhs in + switch (lhs.created.value, rhs.created.value) { + case let (left?, right?): + left == right ? isOrderedByName(lhs, rhs) : left < right + case (.some, .none): + true + case (.none, .some): + false + case (.none, .none): + isOrderedByName(lhs, rhs) + } + } + } + + private static func isOrderedByName(_ lhs: Comment, _ rhs: Comment) -> Bool { + IntegrityRules.canonicalIdentity(lhs.id.rawValue) + < IntegrityRules.canonicalIdentity(rhs.id.rawValue) + } +} diff --git a/Kanban/Storage/CommentWriter.swift b/Kanban/Storage/CommentWriter.swift new file mode 100644 index 0000000..1ac7838 --- /dev/null +++ b/Kanban/Storage/CommentWriter.swift @@ -0,0 +1,503 @@ +import Foundation + +// MARK: - Outcomes + +/// What one draft save produced on disk — the composer's slow cadence has four possible answers and +/// the caller needs to tell them apart (the create is what a first keystroke does, the delete is the +/// emptied-draft rule firing, and `.unchanged` is the ~30 s tick finding nothing to do). +public enum CommentDraftOutcome: Sendable, Equatable { + /// `comments/.draft/` did not exist and now does, carrying the ordinary comment schema. + case created + /// Its body span was replaced and `modified` stamped. + case updated + /// The bytes on disk already said this. Nothing was written and no `mtime` moved. + case unchanged + /// **The emptied-draft rule**: no text, no attachments, so the folder is gone + /// (01-storage-format.md § Enhanced schema — "a draft emptied of text with no attachments is + /// deleted by the app, never litter"). + case deleted +} + +/// What a post landed: the fresh identity, and the instant `created` and `modified` were both +/// stamped with. Both halves are what a redo replays (13-native-undo.md — a redo repeats the values +/// its gesture wrote, never re-derives them). +public struct PostedComment: Sendable, Equatable { + public let id: ItemID + /// One `Date` for both stamps, so a freshly posted comment reads "not edited" by construction + /// rather than by rounding (`Comment.isEdited`). + public let posted: Date +} + +// MARK: - The comment writer + +/// **The comment thread's write primitives** — `BoardWriter`'s own vocabulary one level down +/// (01-storage-format.md § Enhanced schema, storage specified 2026-07-29; 05-card-window.md ▸ The +/// comments column). +/// +/// An extension in its own file for `BoardStoreHistory.swift`'s reason: this is the same type, and +/// the rules it obeys are that type's rules — atomic temp+rename through `atomicReplace`, surgical +/// span edits through `FrontmatterDocument`, `modified` stamped and `modified-by` cleared on every +/// content write, receipts dropped in the EchoLedger by the four disk primitives — but the *subject* +/// is one thing, and a reader looking for what a comment write does should find it in one place. +/// +/// ### What is different one level down, and why +/// +/// - **The title every operation carries is the card's.** A comment has no `title` key at all, and +/// the path-shaped verb family names the card ("Comment on '⟨card⟩'"), so each entry point takes +/// `cardTitle` and hands it to its `WriteOperation`. `withTitle` is identity for all five. +/// - **`author` survives.** `updateIndex` clears `modified-by` and only that, so the lenient +/// self-reported `author` rides through every rewrite untouched, which is the field table's whole +/// point ("unlike `modified-by` it survives app writes"). +/// - **Nothing here has an `order`.** Chronology is the thread's order, so there are no ranks to +/// mint, no ladder to thread, and no renumber to fall back on. +/// - **No byte capture, anywhere** (13-native-undo.md): the delete is a move, its inverse is the +/// move back, and the post's inverse is the rename back. Nothing in this file reads a body in +/// order to hold it. +extension BoardWriter { + + // MARK: - The draft + + /// **Saves the card's single draft** into `comments/.draft/` — create on the first save, a body + /// span replacement on every one after (05-card-window.md ▸ The comments column: "The composer + /// edits `comments/.draft/`", written "on composer blur, window close, quit, and a lazy interval + /// (~30 s)"). + /// + /// The sequence: + /// + /// 1. **It must be a card** (`checkIsCardFolder`) — a thread hangs off a card and nothing else. + /// 2. **The emptied-draft rule first**, before anything is created: a save with no text and no + /// attachments *removes* the folder, and a save with no text against a draft that does not + /// exist creates nothing at all. Typing one character and deleting it must not leave a folder + /// behind ("never litter"). + /// 3. **Create**, minting `comments/` on the way if the card has never had one: `schema`, + /// `author`, `created`, `modified`, `kind: comment`. The two stamps share one `Date`. + /// 4. **Update**: the body span and the `modified` stamp, with the identical-bytes gate + /// `writeBody` has and for its reason — the ~30 s tick fires whether or not anything changed, + /// and a no-op save that stamped `modified` would make an untouched draft look edited every + /// half minute (and, on a Pro board, commit). + /// + /// **"No attachments" is the listing the user sees** (`BoardLoader.attachmentNames`): top-level + /// regular files in `attachments/`, which is exactly the set the composer renders as chips. A + /// draft holding only an empty `attachments/` folder, or only a subfolder, has nothing to lose + /// and is deleted — the chips are the promise, not the directory. + @discardableResult + public static func saveCommentDraft( + inCard cardFolder: URL, + body: String, + cardTitle: String? + ) throws(BoardWriteError) -> CommentDraftOutcome { + let operation = WriteOperation.saveCommentDraft(title: cardTitle) + try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) + try checkIsCardFolder(cardFolder, operation: operation) + + let draft = CommentThread.draftFolder(inCard: cardFolder) + let exists = IntegrityRules.node(at: draft) == .directory + + if body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !exists || BoardLoader.attachmentNames(in: draft).isEmpty { + guard exists else { return .unchanged } + do { + try FileManager.default.removeItem(at: draft) + } catch { + throw BoardWriteError( + operation: operation, + path: draft.path, + reason: .io(message: "could not remove the emptied draft: \(error.localizedDescription)") + ) + } + EchoLedger.current?.recordDeletion(at: draft) + return .deleted + } + + let indexURL = draft.appendingPathComponent(BoardLoader.indexFileName) + // A folder with no `index.md` takes the create path rather than failing: that is the shape an + // interrupted create leaves, and two-step-create tolerance is one of the fractal rules that + // apply here verbatim (01-storage-format.md § Enhanced schema). + guard exists, FileManager.default.fileExists(atPath: indexURL.path) else { + if !exists { + do { + try FileManager.default.createDirectory(at: draft, withIntermediateDirectories: true) + } catch { + throw BoardWriteError( + operation: operation, + path: draft.path, + reason: .io(message: "could not create the draft folder: \(error.localizedDescription)") + ) + } + } + try atomicReplace(text: newCommentText(body: body), at: indexURL, operation: operation) + return .created + } + + return try writeCommentBody(at: draft, body: body, operation: operation) ? .updated : .unchanged + } + + /// **Posts the draft**: `comments/.draft/` renamed to a fresh lowercase UUID, `created` and + /// `modified` restamped, **in one bracket** (01-storage-format.md § Enhanced schema, ruled + /// 2026-07-29: "posting renames it to a fresh lowercase UUID and restamps `created`/`modified` in + /// the same bracket — chronology is post time, not drafting time — one commit"). + /// + /// **One `Date` for both stamps**, `newDocumentText`'s convention and here it is load-bearing: + /// the edited indicator is `modified` differing from `created`, so two `Date()` calls straddling + /// a second boundary would post a comment that renders as already edited. + /// + /// **The rename is the identity mint**, so nothing is copied and no bytes move: `attachments/`, + /// strays and every unknown key arrive exactly as the draft held them. The `author` the draft was + /// created with rides through the restamp untouched, which is what makes posting a draft written + /// last week still attributed to whoever wrote it. + /// + /// A draft folder with no readable `index.md` is minted one first (the create path's tolerance); + /// a draft whose frontmatter cannot be round-tripped refuses the post before the rename — + /// discover-before-you-write, `moveItem`'s rule, and the gesture the user pressed is the thing + /// that failed rather than some other comment being tolerated. + public static func postComment( + inCard cardFolder: URL, + cardTitle: String? + ) throws(BoardWriteError) -> PostedComment { + let operation = WriteOperation.postComment(title: cardTitle) + try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation) + try checkIsCardFolder(cardFolder, operation: operation) + + let thread = CommentThread.folder(inCard: cardFolder) + let draft = CommentThread.draftFolder(inCard: cardFolder) + guard IntegrityRules.node(at: draft) == .directory else { + throw BoardWriteError( + operation: operation, + path: draft.path, + reason: .unreadable(message: "there is no draft to post") + ) + } + + let draftIndexURL = draft.appendingPathComponent(BoardLoader.indexFileName) + if !FileManager.default.fileExists(atPath: draftIndexURL.path) { + try atomicReplace(text: newCommentText(body: ""), at: draftIndexURL, operation: operation) + } + _ = try checkIndexIsRewritable(inItemFolder: draft, operation: operation) + + let name = freshUUIDName(in: thread, avoiding: []) + try renameFolder(draft, toSiblingNamed: name, operation: operation) + let posted = thread.appendingPathComponent(name, isDirectory: true) + + let now = Date() + try restampComment(at: posted, to: now, operation: operation) + return PostedComment(id: ItemID(rawValue: name), posted: now) + } + + // MARK: - Editing + + /// **An inline comment edit's save** — the body-edit session in miniature (05-card-window.md ▸ + /// The comments column: "debounced saves to the comment's own file keep it crash-safe, Save (or + /// ⌘↩) ends the session as its commit point"). + /// + /// `writeBody`'s three properties, one level down: the body span and nothing else changes, the + /// bytes above the closing delimiter are the bytes they were, and **identical bytes write + /// nothing** (returning `false` with an untouched `mtime`). `modified` is stamped and + /// `modified-by` cleared; `author` and `created` are not touched, which is exactly how "· edited" + /// comes to be true without a field existing for it. + /// + /// **Cancel is not here.** The session reverts to its own start-of-session bytes, which is the + /// UI session's business (phase 2) and not a Writer primitive: there is no capture in this file. + /// + /// - Returns: `true` when bytes were written, `false` when the body on disk already matched. + @discardableResult + public static func editComment( + at commentFolder: URL, + body: String, + cardTitle: String? + ) throws(BoardWriteError) -> Bool { + let operation = WriteOperation.editComment(title: cardTitle) + try checkIsCommentFolder(commentFolder, operation: operation) + return try writeCommentBody(at: commentFolder, body: body, operation: operation) + } + + // MARK: - Delete, restore, purge + + /// **Deleting a comment is a move into `comments/.trash/`** (01-storage-format.md § Enhanced + /// schema, re-ruled 2026-07-29: "the materialized-trash pattern one level down, joining `.draft` + /// in the claimed names, excluded from the thread, never a UI surface"). + /// + /// `deleteCardToTrash`'s body one level down, minus the thing a comment does not have: there is + /// no rank to mint, because the trash a comment lands in has no order at all — it is undo's + /// backing store for the life of one window, not a browsable column. + /// + /// **The stamp is the container rule's plainest instance, again**: the move changes the comment's + /// container, so `updateIndex` stamps `modified` and clears `modified-by` without a trash branch + /// existing anywhere (§ Enhanced schema: "The container-change stamping rule applies — the move + /// stamps `modified`"). `kind: .comment` is passed rather than derived so the on-touch backfill + /// cannot mistake a folder inside a `.trash` for a board-trash resident. + /// + /// **No confirm, no capture** — undo is the net, and its inverse is `restoreComment`. + @discardableResult + public static func deleteComment( + at commentFolder: URL, + cardTitle: String? + ) throws(BoardWriteError) -> ItemID { + let operation = WriteOperation.deleteComment(title: cardTitle) + try checkIsCommentFolder(commentFolder, operation: operation) + // The move rewrites this file at the destination, so a file that cannot be round-tripped + // refuses before the folder travels (`moveItem`'s discover-before-you-write rule). + _ = try checkIndexIsRewritable(inItemFolder: commentFolder, operation: operation) + + let trash = commentFolder + .deletingLastPathComponent() + .appendingPathComponent(IntegrityRules.commentTrashFolderName, isDirectory: true) + try moveComment(commentFolder, into: trash, operation: operation) + return ItemID(rawValue: commentFolder.lastPathComponent) + } + + /// **The delete's inverse: the move back out** (13-native-undo.md ▸ Interaction with the trash, + /// the comment clause: "a comment delete is a move into the card's `comments/.trash/` … so its + /// inverse is the ordinary move back"). + /// + /// It stamps for the reason the forward move does — the container changed again — and it takes + /// `.deleteComment`'s own vocabulary word, `.delete`'s "there is no `restore` case" precedent one + /// level down: a failed restore is a comment that could not be moved, and inventing a sixth + /// operation to say so would name a gesture the user never pressed (they pressed ⌘Z). + public static func restoreComment( + _ id: ItemID, + inCard cardFolder: URL, + cardTitle: String? + ) throws(BoardWriteError) { + let operation = WriteOperation.deleteComment(title: cardTitle) + let trashed = CommentThread.trashedCommentFolder(id, inCard: cardFolder) + try checkIsDirectory(trashed, describedAs: "comment folder", operation: operation) + try moveComment(trashed, into: CommentThread.folder(inCard: cardFolder), operation: operation) + } + + /// **Empties one card's `comments/.trash/`** — at card-window close, and as the crash-residue + /// sweep at the next open (01-storage-format.md § Enhanced schema: "purged when the card window + /// closes (rides the close flush; crash residue sweeps at the next card-window open, + /// armed-then-cleared like every heal memo)"). + /// + /// `emptyTrash`'s rules, one level down and for its reasons: + /// + /// - **The entries, not the container.** Only identity-shaped children are removed; a stray a + /// hand-editor put in there keeps the verbatim posture, and the emptied folder is left standing + /// because the next delete would only recreate it. + /// - Removal is per entry, in order; a failure stops the batch and throws, and everything already + /// removed stays removed. + /// - A card with no thread trash removes nothing and answers `[]`. + /// + /// **It registers no undo step** — the permanent-delete posture (13-native-undo.md), which is + /// also what makes the leftover comment steps on the board stack go stale and skip with a banner + /// rather than resurrect a folder that is gone. + @discardableResult + public static func purgeCommentTrash(inCard cardFolder: URL) throws(BoardWriteError) -> [ItemID] { + let operation = WriteOperation.purgeCommentTrash + var purged: [ItemID] = [] + for entry in childCandidates(of: CommentThread.trashFolder(inCard: cardFolder)) { + do { + try FileManager.default.removeItem(at: entry) + } catch { + throw BoardWriteError( + operation: operation, + path: entry.path, + reason: .io(message: "could not remove folder: \(error.localizedDescription)") + ) + } + EchoLedger.current?.recordDeletion(at: entry) + purged.append(ItemID(rawValue: entry.lastPathComponent)) + } + return purged + } + + // MARK: - The post's inverse family + + /// **The post's inverse: the posted folder renamed back to `.draft`** (13-native-undo.md, the + /// comment clause: "post-undo naturally rides the same rail" — the move-based inverse family, + /// with no byte capture in any tier). + /// + /// **A rename and nothing else, so it stamps nothing.** The folder keeps its parent, so no + /// container changed, and the existing write discipline answers without a rule of its own: "a + /// heal that only renames or relocates folders and files never opens `index.md` and stamps + /// nothing" (§ Validation and healing) — an identity change, not an edit. The post-time + /// `created`/`modified` therefore survive on the un-posted draft, and the next post restamps them + /// anyway, which is the whole reason the post restamps at all. + /// + /// **It refuses a `.draft` that is already there**, rather than clobbering it: two drafts cannot + /// exist, and one the user has typed since is not this step's to overwrite. The step's own + /// staleness predicate expects exactly that absence, so in practice the refusal is unreachable — + /// it stands because the Writer owns the bytes and promises this against every caller. + public static func unpostComment( + _ id: ItemID, + inCard cardFolder: URL, + cardTitle: String? + ) throws(BoardWriteError) { + let operation = WriteOperation.postComment(title: cardTitle) + let posted = CommentThread.commentFolder(id, inCard: cardFolder) + try checkIsDirectory(posted, describedAs: "comment folder", operation: operation) + + let draft = CommentThread.draftFolder(inCard: cardFolder) + guard IntegrityRules.node(at: draft) == nil else { + throw BoardWriteError( + operation: operation, + path: draft.path, + reason: .io(message: "a draft is already here") + ) + } + try renameFolder(posted, toSiblingNamed: IntegrityRules.commentDraftFolderName, operation: operation) + } + + /// **The post, replayed** — the same captured identity and the same captured instant + /// (13-native-undo.md: a redo repeats the values its gesture wrote). Re-minting here would produce + /// a *different* comment, and every step registered above this one on the stack that names the + /// posted id would then name nothing — `recreateItem`'s reasoning, one level down. + public static func repostComment( + as id: ItemID, + inCard cardFolder: URL, + stamping instant: Date, + cardTitle: String? + ) throws(BoardWriteError) { + let operation = WriteOperation.postComment(title: cardTitle) + let draft = CommentThread.draftFolder(inCard: cardFolder) + try checkIsDirectory(draft, describedAs: "draft folder", operation: operation) + + let posted = CommentThread.commentFolder(id, inCard: cardFolder) + guard IntegrityRules.node(at: posted) == nil else { + throw BoardWriteError( + operation: operation, + path: posted.path, + reason: .io(message: "something already exists here") + ) + } + try renameFolder(draft, toSiblingNamed: id.rawValue, operation: operation) + try restampComment(at: posted, to: instant, operation: operation) + } + + // MARK: - Shared mechanics + + /// The frontmatter text for a comment the app is minting outright — `newDocumentText`'s twin, and + /// its key order minus the two fields a comment has not got. + /// + /// `schema`, `author`, `created`, `modified`, `kind` — simply the order `set` is called in, with + /// `kind` last where the common table puts it and where the on-touch backfill would append one. + /// + /// **`author` is the macOS account's full name** (01-storage-format.md § Enhanced schema: "the + /// app writes the macOS account's full name (the identity 06-history-undo.md's derived default + /// already uses)"). `NSFullUserName()` is that identity; 06's derived commit default is Pro's and + /// is not coded yet, so this is the first place the app spells it. An empty answer — a stripped + /// account record — writes **no key at all** rather than `author: ""`: missing renders + /// unattributed, and an empty string is a real (if blank) author, the `title` rule's precedent. + private static func newCommentText(body: String) -> String { + var document = FrontmatterDocument(body: body) + document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema)) + let author = NSFullUserName() + if !author.isEmpty { + document.set(FrontmatterKeys.author, to: .string(author)) + } + let now = Date() + document.set(FrontmatterKeys.created, to: .date(now)) + document.set(FrontmatterKeys.modified, to: .date(now)) + document.set(FrontmatterKeys.kind, to: .string(IntegrityRules.ObjectKind.comment.rawValue)) + return document.serialized() + } + + /// Replaces a comment's body span and stamps — `writeBody`'s four steps against a folder that is + /// not identity-shaped when it is the draft, which is the only reason this is not that call. + private static func writeCommentBody( + at folder: URL, + body: String, + operation: WriteOperation + ) throws(BoardWriteError) -> Bool { + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + var document = try readDocument(at: indexURL, operation: operation) + try checkEditable(document, at: indexURL, operation: operation) + + guard document.body != body else { return false } + + document.body = body + IntegrityRules.healOnTouch(&document, kind: .comment) + document.set(FrontmatterKeys.modified, to: .date(Date())) + document.remove(FrontmatterKeys.modifiedBy) + + try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) + return true + } + + /// Sets `created` and `modified` to one instant — the post's whole frontmatter edit, spelled + /// directly rather than through `updateIndex` for one reason: `updateIndex` stamps `modified` + /// with a `Date()` of its own, and a post whose two stamps came from two clock reads could land + /// either side of a second boundary and render as edited the moment it appeared. + /// + /// Everything else `updateIndex` would have done is here in its order: refuse an uneditable + /// shape, run the on-touch backfill, clear `modified-by`, replace atomically. + private static func restampComment( + at folder: URL, + to instant: Date, + operation: WriteOperation + ) throws(BoardWriteError) { + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + var document = try readDocument(at: indexURL, operation: operation) + try checkEditable(document, at: indexURL, operation: operation) + + IntegrityRules.healOnTouch(&document, kind: .comment) + document.set(FrontmatterKeys.created, to: .date(instant)) + document.set(FrontmatterKeys.modified, to: .date(instant)) + document.remove(FrontmatterKeys.modifiedBy) + + try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) + } + + /// The physical half of both container-changing comment moves — into `comments/.trash/` and back + /// out of it. `moveIntoTrash`'s shape: mint the destination if absent, move, then rewrite the + /// arrived `index.md` so the container change stamps. + private static func moveComment( + _ folder: URL, + into destination: URL, + operation: WriteOperation + ) throws(BoardWriteError) { + do { + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + } catch { + throw BoardWriteError( + operation: operation, + path: destination.path, + reason: .io(message: "could not create the folder: \(error.localizedDescription)") + ) + } + + let arrived = destination.appendingPathComponent(folder.lastPathComponent, isDirectory: true) + do { + try FileManager.default.moveItem(at: folder, to: arrived) + } catch { + throw BoardWriteError( + operation: operation, + path: folder.path, + reason: .io(message: "could not move the comment: \(error.localizedDescription)") + ) + } + // The move pair reads correctly from either end, `moveIntoTrash`'s receipt and for its + // reason: the thread sees an absence, the trash sees an arrival. + EchoLedger.current?.recordMove(from: folder, to: arrived) + + try updateIndex(inItemFolder: arrived, kind: .comment, operation: operation) { _ in } + } + + /// Refuses any folder that is not a **posted comment**: identity-shaped, directly under a card's + /// `comments/`. + /// + /// `checkIsCardFolder`'s mirror one level down, and it deliberately refuses the two dot-named + /// folders as well as everything else: `.draft` is the composer's, reached through + /// `saveCommentDraft`, and a folder inside `comments/.trash/` is undo's — neither is editable or + /// deletable as a comment, and letting either through this door would put a surface with no + /// window behind it on disk (`readRawSource`'s rule). + private static func checkIsCommentFolder( + _ folder: URL, + operation: WriteOperation + ) throws(BoardWriteError) { + try checkIsDirectory(folder, describedAs: "comment folder", operation: operation) + // The parent is named explicitly rather than asked of `placement`, which answers `.comment` + // for a folder inside `comments/.trash/` too — correct for the *kind* it stamps, and exactly + // the case this guard has to keep out. + guard IntegrityRules.isIdentityShaped(folder.lastPathComponent), + folder.deletingLastPathComponent().lastPathComponent.lowercased() + == IntegrityRules.commentsFolderName + else { + throw BoardWriteError( + operation: operation, + path: folder.path, + reason: .unreadable(message: "folder is not a comment") + ) + } + } +} diff --git a/Kanban/Storage/FrontmatterDocument.swift b/Kanban/Storage/FrontmatterDocument.swift index 6ed6c9d..0b5e2b4 100644 --- a/Kanban/Storage/FrontmatterDocument.swift +++ b/Kanban/Storage/FrontmatterDocument.swift @@ -589,6 +589,22 @@ public enum FrontmatterKeys { public static let remote = "remote" public static let remoteState = "remote-state" + /// **A comment's self-reported author** (01-storage-format.md § Enhanced schema, the + /// `kind: comment` field table): "lenient — self-reported *content*, not overlay: **unlike + /// `modified-by` it survives app writes**; the app writes the macOS account's full name, agents + /// write their own, tracker sync writes the remote author verbatim, and missing renders + /// unattributed". + /// + /// **Named here without joining `schemaOwned`**, `remote`'s precedent and its reason turned + /// around: the key belongs to the comment field table and to no other kind, so on a board, a lane + /// or a card it is an ordinary unknown key — and `schemaOwned` is exactly the set the card + /// window's Details section subtracts. Listing it would *hide* a hand-added `author:` from the one + /// surface that exists to show hand-added keys; a comment has no such surface to be affected + /// either way. + /// + /// Nothing clears it, anywhere: `updateIndex` clears `modified-by` and only that. + public static let author = "author" + public static let schemaOwned: Set = [ schema, title, order, width, created, modified, modifiedBy, deleted, background, icon, iconColor, kind, diff --git a/Kanban/Storage/FrontmatterFields.swift b/Kanban/Storage/FrontmatterFields.swift index 43b6ce9..12ebae2 100644 --- a/Kanban/Storage/FrontmatterFields.swift +++ b/Kanban/Storage/FrontmatterFields.swift @@ -95,6 +95,7 @@ extension FrontmatterDocument { record(FrontmatterKeys.created, created) record(FrontmatterKeys.modified, modified) record(FrontmatterKeys.modifiedBy, modifiedBy) + record(FrontmatterKeys.author, author) record(FrontmatterKeys.background, background) record(FrontmatterKeys.icon, icon) record(FrontmatterKeys.iconColor, iconColor) @@ -158,6 +159,13 @@ extension FrontmatterDocument { /// Schema-owned, not an unknown key: the app clears it on every app-mediated write. public var modifiedBy: FieldValue { read(FrontmatterKeys.modifiedBy, Self.string) } + /// A comment's self-reported `author` — lenient like every other string field, and **content** + /// rather than overlay: no app write ever clears it (01-storage-format.md § Enhanced schema; see + /// `FrontmatterKeys.author`). On any other kind the key is an ordinary unknown one; reading it + /// here costs the same one pass and keeps the coerce tier's trace complete for the one kind whose + /// field table names it. + public var author: FieldValue { read(FrontmatterKeys.author, Self.string) } + /// The object's kind as written — `board`, `lane`, `card` (01-storage-format.md § Frontmatter, /// re-ruled 2026-07-29). Lenient like every other string field: any scalar coerces to the text /// the author typed, and **the value is never policed** — a reading outside the schema's three diff --git a/Kanban/Storage/IntegrityRules.swift b/Kanban/Storage/IntegrityRules.swift index 194b78e..c6dc483 100644 --- a/Kanban/Storage/IntegrityRules.swift +++ b/Kanban/Storage/IntegrityRules.swift @@ -110,21 +110,41 @@ public enum IntegrityRules: Sendable { public static let trashFolderName = ".trash" /// A card's attachment folder (01-storage-format.md § Attachments) — the one folder the app - /// ever creates under a card. + /// ever creates under a card. **Fractal**: a comment folder has one too (§ Enhanced schema). public static let attachmentsFolderName = "attachments" /// The file every level's content lives in. public static let indexFileName = "index.md" + /// A card's **comment thread** (01-storage-format.md § Enhanced schema) — a plain reserved + /// child, never a level and never identity; the identities are the UUID folders inside it. + public static let commentsFolderName = "comments" + + /// The card's single comment draft, inside `comments/` — "a reserved dot-named folder holding + /// ordinary comment schema … excluded from the thread listing" (§ Enhanced schema, ruled + /// 2026-07-29). + public static let commentDraftFolderName = ".draft" + + /// The thread's own trash — **the board's name one level down**, deliberately the same spelling: + /// "the materialized-trash pattern one level down, joining `.draft` in the claimed names" + /// (§ Enhanced schema). Undo's backing store, never a UI surface. + public static let commentTrashFolderName = trashFolderName + /// **The card-level reserved names** (01-storage-format.md § Fractal layout ▸ Rules): 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. + /// card's own `index.md` plus the two reserved children. /// /// **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/`. public static let reservedCardChildNames: Set = [ - indexFileName, attachmentsFolderName, "comments", + indexFileName, attachmentsFolderName, commentsFolderName, + ] + + /// **The same table inside one comment folder** — "a card's anatomy one level down, so the + /// fractal rules apply verbatim" (01-storage-format.md § Enhanced schema). A comment has no + /// `comments/` of its own: replies are deliberately deferred and flat is this iteration's rule. + public static let reservedCommentChildNames: Set = [ + indexFileName, attachmentsFolderName, ] /// What kind of node a name is allowed to be. @@ -188,14 +208,13 @@ public enum IntegrityRules: Sendable { /// name) displaces by the same ladder (`attachments` → `attachments 2`), so imports, Finder drops, /// and the sidebar listing never fail one gesture at a time against a squatted name"). /// - /// **`attachments` displaces; `comments` does not**, and the split is the *timing principle* - /// rather than a hedge — 01 calls the reserved-but-unconsumed `comments` "the timing principle's own - /// illustration": nothing reads that name until the tracker era, so a wrong-kind holder degrades no - /// behavior while it stands and stays a **tolerated stray** today, joining the scheduled class the - /// day the name becomes load-bearing. `attachments`, by contrast, is load-bearing now: while a file - /// wears the name, every import into that card, every Finder drop on it, and the card window's - /// listing are broken — which is exactly the "proactive when the defect is load-bearing now" - /// condition (§ Validation and healing). + /// **`comments` graduated with the feature** (the timing principle, run forwards): 01 called the + /// reserved-but-unconsumed name "the timing principle's own illustration" — "a wrong-kind holder is + /// a tolerated stray today and joins the scheduled class **the day the name becomes load-bearing**". + /// That day is this one: a file or symlink wearing `comments` now breaks the card window's whole + /// thread — no draft can be saved, no comment posted, nothing read — which is exactly the + /// "proactive when the defect is load-bearing now" condition (§ Validation and healing). + /// `attachments` was load-bearing already and displaced from the start. /// /// `index.md` is deliberately not here. It is not a *reserved child* the app protects from /// squatters — it is the card's content, and a directory named `index.md` makes the folder an @@ -203,7 +222,26 @@ public enum IntegrityRules: Sendable { /// would mean the app deciding a folder's content is a squatter. public static let claimedCardChildNames: [ClaimedName] = [ ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true), - ClaimedName(name: "comments", expected: .directory, displacesSquatters: false), + ClaimedName(name: commentsFolderName, expected: .directory, displacesSquatters: true), + ] + + /// **The names claimed inside a card's `comments/`** — the thread's two lifecycle folders + /// (01-storage-format.md § Enhanced schema: "`.draft` joins the claimed names (a wrong-kind node + /// squatting it displaces by the ladder)", and `comments/.trash/` "joining `.draft` in the claimed + /// names"). + /// + /// Both displace: a file wearing `.draft` makes the composer unsaveable and a file wearing + /// `.trash` makes every comment delete fail, so neither is latent while it stands. + public static let claimedCommentThreadNames: [ClaimedName] = [ + ClaimedName(name: commentDraftFolderName, expected: .directory, displacesSquatters: true), + ClaimedName(name: commentTrashFolderName, expected: .directory, displacesSquatters: true), + ] + + /// **The card's table, one level down** — a comment's own `attachments`, claimed exactly as a + /// card's is (§ Enhanced schema: "displacement of a squatted `attachments`" is named among the + /// fractal rules that "apply verbatim"). + public static let claimedCommentChildNames: [ClaimedName] = [ + ClaimedName(name: attachmentsFolderName, expected: .directory, displacesSquatters: true), ] /// The claimed names as the lane walk needs them: lowercased, for a `contains` against a @@ -262,9 +300,43 @@ public enum IntegrityRules: Sendable { /// - Parameter path: the card folder's path **relative to the board root**, carried into the defect /// so the write lands wherever the board lives at heal time (`LooseCardFiles`' convention). public static func squattedClaimedNames(inCardAt cardFolder: URL, path: String) -> [ClaimedNameSquatter] { - claimedCardChildNames.compactMap { claimed in + squatters(among: claimedCardChildNames, in: cardFolder, at: .card(path: path)) + } + + /// The claimed-name defects inside one card's **`comments/`** — `.draft` and `.trash` + /// (`claimedCommentThreadNames`, 01-storage-format.md § Enhanced schema). + /// + /// **Window-scoped, unlike its card-level twin**: the board walk stays O(cards) and never opens a + /// thread (§ Enhanced schema — "Comments are window-scoped, outside the board snapshot"), so this + /// is asked by the card window's own thread read, not by the loader. + /// + /// - Parameter cardPath: the card folder's path relative to the board root, so the heal lands + /// wherever the board lives at write time (`LooseCardFiles`' convention). + public static func squattedClaimedNames( + inCommentThreadAt threadFolder: URL, + cardPath: String + ) -> [ClaimedNameSquatter] { + squatters(among: claimedCommentThreadNames, in: threadFolder, at: .commentThread(cardPath: cardPath)) + } + + /// The claimed-name defects inside **one comment folder** — its `attachments`, the card's rule + /// read one level down (`claimedCommentChildNames`). Window-scoped, for its sibling's reason. + /// + /// - Parameter path: the comment folder's path relative to the board root. + public static func squattedClaimedNames(inCommentAt commentFolder: URL, path: String) -> [ClaimedNameSquatter] { + squatters(among: claimedCommentChildNames, in: commentFolder, at: .comment(path: path)) + } + + /// The shared body of the three plural detections: one table, one folder, one location. The board + /// root's singular answer stays its own, for the reason its doc comment gives. + private static func squatters( + among table: [ClaimedName], + in folder: URL, + at location: ClaimedNameSquatter.Location + ) -> [ClaimedNameSquatter] { + table.compactMap { claimed in guard claimed.displacesSquatters, - let found = node(at: cardFolder.appendingPathComponent(claimed.name)), + let found = node(at: folder.appendingPathComponent(claimed.name)), found != claimed.expected else { return nil @@ -273,7 +345,7 @@ public enum IntegrityRules: Sendable { name: claimed.name, found: found, expected: claimed.expected, - location: .card(path: path) + location: location ) } } @@ -281,13 +353,14 @@ public enum IntegrityRules: Sendable { // MARK: - Object kinds /// The kinds the schema knows (01-storage-format.md § Frontmatter ▸ Common to all levels, the - /// `kind` row). `comment` is reserved with the enhanced schema and deliberately absent until it - /// lands — an unrecognized value on disk is trusted as itself and never policed, so nothing here - /// has to anticipate it. + /// `kind` row). `comment` joined them with the enhanced schema's storage (§ Enhanced schema, the + /// `kind: comment` field table) — the first kind whose position is not a *level*: a comment lives + /// under a card's `comments/`, which is a reserved child rather than a depth. public enum ObjectKind: String, Sendable, Equatable, CaseIterable { case board case lane case card + case comment } /// Where a folder's **position** places it — "level is position" (01-storage-format.md @@ -298,6 +371,9 @@ public enum IntegrityRules: Sendable { case card /// Identity-shaped, under something that is neither a lane nor the trash — a lane. case lane + /// Under a card's `comments/` — a posted comment, or the `.draft` that is one in every + /// respect but its name (01-storage-format.md § Enhanced schema: "ordinary comment schema"). + case comment /// Inside `.trash/`, where the container is flat and **position cannot answer**: use /// `trashKind(kindValue:hasIdentityShapedChildIndex:)`. case insideTrash @@ -313,13 +389,37 @@ public enum IntegrityRules: Sendable { /// The trash check sits *between* the two identity checks deliberately: a trashed card and a /// live lane are both identity-shaped folders whose parent is not, and the container is the only /// thing that tells them apart. + /// + /// **The `comments/` check sits ahead of the trash's**, and ahead of the lane's for the reason + /// that matters: a posted comment is an identity-shaped folder whose parent is not identity-shaped + /// — shape-identical to a lane — so without it every comment would read as a lane and the on-touch + /// backfill would stamp `kind: lane` into a thread. + /// + /// **One ambiguity two names cannot resolve**: `comments/.trash/` answers `.insideTrash` + /// here, because `.trash` is the board's spelling one level down. `placement(ofFolder:)` is the + /// form that can tell them apart, and every caller holding a URL should use it. public static func placement(ofFolderNamed name: String, inParentNamed parent: String) -> Placement { if isIdentityShaped(parent) { return .card } + if parent.lowercased() == commentsFolderName { return .comment } if parent.lowercased() == trashFolderName { return .insideTrash } if isIdentityShaped(name) { return .lane } return .unknown } + /// The same rule read off a **URL** — the only form that can tell the board's `.trash/` from a + /// comment thread's own one level down, since the two share a name and the two-name rule sees + /// only the name. + public static func placement(ofFolder url: URL) -> Placement { + let parent = url.deletingLastPathComponent() + let placement = placement(ofFolderNamed: url.lastPathComponent, inParentNamed: parent.lastPathComponent) + guard placement == .insideTrash, + parent.deletingLastPathComponent().lastPathComponent.lowercased() == commentsFolderName + else { + return placement + } + return .comment + } + /// **The trash's `kind` discriminator** (01-storage-format.md § Deletion, re-ruled 2026-07-29 — /// the value-names-the-kind posture): depth defines meaning on the live board, but the trash is /// flat, and an empty lane folder is shape-identical to a card folder. @@ -330,9 +430,10 @@ public enum IntegrityRules: Sendable { /// identity-shaped children with their own `index.md` → lane (the key backfills on the next /// touch), else card. /// - /// `kind: board` in the trash is *not* a third answer: a board is not a thing that can be - /// trashed, so the value is unrecognized here and shape decides — the same shrug an arbitrary - /// string gets. + /// `kind: board` in the trash is *not* a third answer, and neither is `kind: comment`: neither is + /// a thing that can be in the *board's* trash (a deleted comment moves into its own thread's + /// `comments/.trash/`, 01-storage-format.md § Enhanced schema), so both values are unrecognized + /// here and shape decides — the same shrug an arbitrary string gets. /// /// The shape half is `@autoclosure` so that the rule stays a pure function of two facts while /// its caller pays for the directory listing **only when the value did not answer** — which on @@ -344,7 +445,7 @@ public enum IntegrityRules: Sendable { switch kindValue.flatMap(ObjectKind.init(rawValue:)) { case .lane: return .lane case .card: return .card - case .board, nil: return hasIdentityShapedChildIndex() ? .lane : .card + case .board, .comment, nil: return hasIdentityShapedChildIndex() ? .lane : .card } } @@ -388,9 +489,14 @@ public enum IntegrityRules: Sendable { /// Whether an object of `kind` must carry `order` — the per-kind field table, as a rule rather /// than as two hand-written call sites in the loader's walk. + /// + /// **A comment carries none, and never gains one** (01-storage-format.md § Enhanced schema: + /// "**No `title`, no `order`**" — "Ordering is chronology, not ranks", because a conversation's + /// semantics *are* chronology and tracker-synced comments carry independent clocks where minted + /// ranks would interleave arbitrarily). public static func requiresOrder(_ kind: ObjectKind) -> Bool { switch kind { - case .board: false + case .board, .comment: false case .lane, .card: true } } @@ -533,12 +639,20 @@ public enum IntegrityRules: Sendable { /// read at the moment of healing (`AgentGuide.inspect`), not something a tree walk reports. /// It is a class here because the engine treats it exactly like the others — same gates, /// same memo, same clear-on-success. + /// + /// `commentTrashResidue` is the second such class, for the same reason one level down: the + /// residue is whatever a crashed session left in one card's `comments/.trash/`, read at the + /// moment the card window opens (01-storage-format.md § Enhanced schema — "purged when the + /// card window closes; crash residue sweeps at the next card-window open, armed-then-cleared + /// like every heal memo"). The board walk never opens a thread, so no tree walk could report + /// it. public enum Class: Sendable, Equatable, Hashable, CaseIterable { case looseCardFiles case legacyTombstone case claimedNameSquatted case duplicateIdentity case staleAgentGuide + case commentTrashResidue } /// The scheduled-heal class this defect belongs to, or **`nil` where there is no heal** — the @@ -899,21 +1013,35 @@ public struct ClaimedNameSquatter: Sendable, Equatable { public enum Location: Sendable, Equatable { case boardRoot case card(path: String) + /// A card's `comments/` container — where `.draft` and `.trash` are claimed + /// (01-storage-format.md § Enhanced schema). `cardPath` names the *card*, so the one thing a + /// caller has to know is where the card is, exactly as `.card` asks. + case commentThread(cardPath: String) + /// One comment's own folder — where `attachments` is claimed, the card's rule read one level + /// down. `path` is the comment folder's, root-relative. + case comment(path: String) /// The folder the claimed name lives in, under `root`. public func folder(under root: URL) -> URL { switch self { - case .boardRoot: root - case let .card(path): root.appendingPathComponent(path, isDirectory: true) + case .boardRoot: + root + case let .card(path), let .comment(path): + root.appendingPathComponent(path, isDirectory: true) + case let .commentThread(cardPath): + root + .appendingPathComponent(cardPath, isDirectory: true) + .appendingPathComponent(IntegrityRules.commentsFolderName, isDirectory: true) } } /// The location as a signature component — `""` for the board root, so the existing root-level - /// signature spelling is unchanged and only a card-level defect adds a path segment. + /// signature spelling is unchanged and only a nested defect adds path segments. var signatureComponent: String { switch self { case .boardRoot: "" - case let .card(path): path + "/" + case let .card(path), let .comment(path): path + "/" + case let .commentThread(cardPath): cardPath + "/" + IntegrityRules.commentsFolderName + "/" } } } diff --git a/KanbanTests/ClaimedNameHealTests.swift b/KanbanTests/ClaimedNameHealTests.swift index 2fa102b..873da2e 100644 --- a/KanbanTests/ClaimedNameHealTests.swift +++ b/KanbanTests/ClaimedNameHealTests.swift @@ -349,15 +349,18 @@ struct CardClaimedNameTests { #expect(result.claimedNameSquatters.map(\.found) == [.symlink]) } - /// **`comments` stays tolerated** — the timing principle, stated as the absence of a defect. - @Test("A file on a card's comments is not displaced — the name is not load-bearing yet") - func fileOnCommentsIsTolerated() throws { + /// **`comments` graduated** (2026-07-30, with the comment storage) — the timing principle run + /// forwards: the name became load-bearing, so its squatter joined the scheduled class exactly as + /// 01 said it would ("a wrong-kind holder is a tolerated stray today and joins the scheduled class + /// the day the name becomes load-bearing"). This test used to pin the absence of the defect. + @Test("A file on a card's comments is displaced now that the thread consumes the name") + func fileOnCommentsIsADefect() throws { let fixture = try makeBoard() defer { fixture.tearDown() } try fixture.file("\(Ident.lane1)/\(Ident.card1)/comments", Data("someday".utf8)) let result = try BoardLoader.load(boardRoot: fixture.root) - #expect(result.claimedNameSquatters.isEmpty) + #expect(result.claimedNameSquatters.map(\.name) == ["comments"]) #expect(result.looseCardFiles.isEmpty, "a reserved name is not a loose file either") } @@ -447,14 +450,14 @@ struct CardClaimedNameTests { #expect(try fixture.data("\(Ident.lane1)/\(Ident.card2)/attachments 2") == Data("two".utf8)) } - /// The table is the only thing to edit when `comments` graduates — pinned so the split is a stated - /// rule rather than an accident of the probe's implementation. - @Test("The card-level table claims attachments and comments, and displaces only attachments") + /// The table is the whole rule — pinned so the level-uniform claim is stated rather than being an + /// accident of the probe's implementation. Both card-level names displace since `comments` + /// graduated with the comment storage (2026-07-30). + @Test("The card-level table claims attachments and comments, and both displace") func theTableStatesTheSplit() { let names = IntegrityRules.claimedCardChildNames #expect(names.map(\.name) == ["attachments", "comments"]) #expect(names.allSatisfy { $0.expected == .directory }) - #expect(names.first { $0.name == "attachments" }?.displacesSquatters == true) - #expect(names.first { $0.name == "comments" }?.displacesSquatters == false) + #expect(names.allSatisfy { $0.displacesSquatters }) } } diff --git a/KanbanTests/CommentThreadTests.swift b/KanbanTests/CommentThreadTests.swift new file mode 100644 index 0000000..6f172f1 --- /dev/null +++ b/KanbanTests/CommentThreadTests.swift @@ -0,0 +1,524 @@ +import Foundation +import Testing +@testable import Kanban + +/// The **read** half of the comment thread — the window-scoped loader, the `kind: comment` field +/// table, the claimed names the thread adds, and the path-shape classification the announcer and the +/// composer will read (01-storage-format.md § Enhanced schema, storage specified 2026-07-29). +/// +/// Everything here writes raw bytes and reads them back through the real loader, like every other +/// storage suite: `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`. + +// MARK: - Fixtures + +/// Literal comment identities. Deliberately spanning cases and versions — the identity predicate is +/// shape-only, and a thread must not sort or dedupe by an uppercase folder's ASCII accident. +enum CommentIdent { + static let one = "aaaaaaaa-0000-4000-8000-000000000001" + static let two = "bbbbbbbb-0000-4000-8000-000000000002" + static let three = "cccccccc-0000-4000-8000-000000000003" + static let upper = "DDDDDDDD-0000-4000-8000-000000000004" + static let seven = "eeeeeeee-0000-7000-8000-000000000005" +} + +/// A comment's `index.md` with everything a write has to leave alone: an unknown key with an inline +/// comment, a reserved tracker key, a self-reported `author`, and a body. +func commentText( + author: String? = "Ada Lovelace", + created: String? = "2026-01-01T09:00:00Z", + modified: String? = "2026-01-01T09:00:00Z", + remote: String? = nil, + body: String = "A comment body — with *markdown*.\n" +) -> String { + var lines = ["---", "schema: 1"] + if let author { lines.append("author: \(author)") } + lines.append("project: lanework # agent overlay") + if let remote { lines.append("remote: \(remote)") } + if let created { lines.append("created: \(created)") } + if let modified { lines.append("modified: \(modified)") } + lines.append("kind: comment") + lines.append("---") + return lines.joined(separator: "\n") + "\n" + body +} + +/// A one-lane, one-card board — the smallest thing a thread can hang off. +@discardableResult +func makeCommentBoard(_ fixture: WriterFixture) throws -> String { + 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 "\(Ident.lane1)/\(Ident.card1)" +} + +func commentPath(_ id: String, inCard cardPath: String) -> String { + "\(cardPath)/comments/\(id)" +} + +// MARK: - Loading + +@Suite("Comments ▸ thread load") +struct CommentThreadLoadTests { + + @Test("The thread is the UUID folders under comments/, and nothing else") + func loadsPostedCommentsOnly() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + try fixture.item("\(card)/comments/.draft", commentText(created: nil, modified: nil)) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one]) + #expect(thread.hasDraft) + #expect(thread.strays.isEmpty) + } + + @Test("A card with no comments/ answers empty, and that is not a defect") + func noThreadIsEmpty() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread == .empty) + } + + @Test("Chronology ascending is the order") + func sortsByCreatedAscending() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + // Written in an order that neither matches the dates nor the folder names, so neither can + // be the thing producing the answer by accident. + try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: "2026-03-03T09:00:00Z")) + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "2026-05-05T09:00:00Z")) + try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-01-01T09:00:00Z")) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two, CommentIdent.three, CommentIdent.one]) + } + + @Test("Ties break by the canonical lowercase folder name") + func tiesBreakByCanonicalName() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + // `DDDDDDDD-…` sorts before every lowercase name by raw ASCII and after `cccccccc-…` by the + // canonical fold, which is the rule (01 § Ordering — "comparing the canonical lowercase + // spelling … never an uppercase folder's ASCII accident"). + let stamp = "2026-02-02T09:00:00Z" + try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: stamp)) + try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: stamp)) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.three, CommentIdent.upper]) + } + + @Test("A missing or malformed created sorts after every dated sibling, by name") + func undatedSortLast() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: nil)) + try fixture.item(commentPath(CommentIdent.upper, inCard: card), commentText(created: "[not, a, date]")) + // The one dated sibling, and deliberately the *latest* possible name, so a name-only sort + // would put it last and a date-first sort puts it first. + try fixture.item(commentPath(CommentIdent.seven, inCard: card), commentText(created: "2026-09-09T09:00:00Z")) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.seven, CommentIdent.one, CommentIdent.upper]) + } + + @Test("A coerce-tier fallback is logged as a defect, and changes nothing else") + func coercionsReported() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(created: "[not, a, date]")) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.count == 1, "the comment still renders — coercion changes no behavior") + let coerced = thread.defects.compactMap { defect -> CoercedFrontmatter? in + if case let .coercedFrontmatter(work) = defect { work } else { nil } + } + #expect(coerced.count == 1) + #expect(coerced.first?.fields.map(\.key) == [FrontmatterKeys.created]) + #expect(coerced.first?.path == "\(card)/comments/\(CommentIdent.one)/index.md") + } + + @Test("A malformed author is a coerce-tier fallback too, and the field reads unattributed") + func authorCoerces() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText(author: "[a, b]")) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.first?.author.isMalformed == true) + #expect(thread.comments.first?.author.value == nil) + let keys = thread.defects.flatMap { defect -> [String] in + if case let .coercedFrontmatter(work) = defect { work.fields.map(\.key) } else { [] } + } + #expect(keys.contains(FrontmatterKeys.author)) + } + + @Test("Edited is modified differing from created, and nothing else") + func editedIndicator() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + try fixture.item( + commentPath(CommentIdent.two, inCard: card), + commentText(created: "2026-01-01T09:00:00Z", modified: "2026-02-02T09:00:00Z") + ) + try fixture.item(commentPath(CommentIdent.three, inCard: card), commentText(created: nil, modified: nil)) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + let edited = Dictionary(uniqueKeysWithValues: thread.comments.map { ($0.id.rawValue, $0.isEdited) }) + #expect(edited[CommentIdent.one] == false) + #expect(edited[CommentIdent.two] == true) + #expect(edited[CommentIdent.three] == false, "a missing pair is not evidence of an edit") + } + + @Test("Attachments list flat, in Finder order") + func attachmentsAreFinderOrdered() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + + try fixture.item(path, commentText()) + try fixture.file("\(path)/attachments/shot 10.png", Data("ten".utf8)) + try fixture.file("\(path)/attachments/shot 2.png", Data("two".utf8)) + try fixture.file("\(path)/attachments/nested/deep.png", Data("deep".utf8)) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.first?.attachments == ["shot 2.png", "shot 10.png"]) + } +} + +// MARK: - Defect tolerance + +@Suite("Comments ▸ defects never refuse") +struct CommentDefectToleranceTests { + + /// Every shape that would have failed a *card* load outright, each beside one healthy sibling + /// so the claim is "the rest of the thread renders" and not merely "nothing threw". + @Test("A broken comment is a stray: skipped, logged, and the thread still renders") + func brokenCommentsAreStrays() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + // Unparseable YAML. + try fixture.item(commentPath(CommentIdent.two, inCard: card), "---\nschema: 1\n bad: [\n---\nbody\n") + // Not UTF-8. + try fixture.item(commentPath(CommentIdent.upper, inCard: card), bytes: Data([0xFF, 0xFE, 0x00])) + // Two-step-create residue: an identity-shaped folder with no index.md. + try FileManager.default.createDirectory( + at: fixture.url(commentPath(CommentIdent.seven, inCard: card)), + withIntermediateDirectories: true + ) + // A stray folder that is not identity-shaped at all. + try fixture.item("\(card)/comments/notes", commentText()) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one]) + #expect(thread.strays.count == 4) + #expect(thread.strays.contains { $0.name == "notes" && $0.reason == .notIdentityShaped }) + #expect(thread.strays.contains { $0.name == CommentIdent.seven && $0.reason == .missingIndex }) + // Every stray's bytes are still exactly where they were: tolerated is preserved-verbatim. + #expect(fixture.exists(commentPath(CommentIdent.two, inCard: card))) + #expect(fixture.exists("\(card)/comments/notes")) + } + + @Test("A missing schema does not stop a comment's body from being unreadable-tolerated") + func schemaIsLenientOnAComment() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nauthor: Ada\n---\nstill readable\n") + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.count == 1, "a comment's schema is lenient, unlike every level's") + #expect(thread.comments.first?.schema.isMissing == true) + #expect(thread.comments.first?.body == "still readable\n") + } + + @Test("A board carrying a broken comment still loads") + func theBoardIsUnaffected() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item(commentPath(CommentIdent.one, inCard: card), "---\nnot: [valid\n---\n") + + let model = try fixture.snapshot() + #expect(model.lanes.first?.cards.count == 1) + } +} + +// MARK: - The per-kind field table + +@Suite("Comments ▸ the kind: comment field table") +struct CommentFieldTableTests { + + @Test("schema is the only required field — never order, never title") + func requiredFields() throws { + #expect(IntegrityRules.requiresOrder(.comment) == false) + + let withoutOrder = Data("---\nschema: 1\nkind: comment\n---\nbody\n".utf8) + #expect(throws: Never.self) { + try IntegrityRules.validateIndex(withoutOrder, path: "index.md", kind: .comment, supportedSchema: 1) + } + let withoutSchema = Data("---\nkind: comment\n---\nbody\n".utf8) + #expect(throws: BoardLoadError.self) { + try IntegrityRules.validateIndex(withoutSchema, path: "index.md", kind: .comment, supportedSchema: 1) + } + } + + @Test("Position places a comment, and the URL form tells the two trashes apart") + func placement() { + let uuid = CommentIdent.one + #expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: "comments") == .comment) + #expect(IntegrityRules.placement(ofFolderNamed: ".draft", inParentNamed: "comments") == .comment) + // The two-name form cannot tell a thread's `.trash` from the board's. + #expect(IntegrityRules.placement(ofFolderNamed: uuid, inParentNamed: ".trash") == .insideTrash) + + let root = URL(fileURLWithPath: "/b.kanban") + let card = root.appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1) + let thread = card.appendingPathComponent("comments") + #expect(IntegrityRules.placement(ofFolder: thread.appendingPathComponent(uuid)) == .comment) + #expect( + IntegrityRules.placement(ofFolder: thread.appendingPathComponent(".trash").appendingPathComponent(uuid)) + == .comment, + "a comment in its thread's trash is still a comment" + ) + #expect( + IntegrityRules.placement(ofFolder: root.appendingPathComponent(".trash").appendingPathComponent(uuid)) + == .insideTrash, + "the board's trash is unchanged" + ) + } + + @Test("kind: comment is never a board-trash answer — shape decides there") + func trashKindIgnoresComment() { + #expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: false) == .card) + #expect(IntegrityRules.trashKind(kindValue: "comment", hasIdentityShapedChildIndex: true) == .lane) + } + + @Test("The on-touch backfill stamps kind: comment, never kind: lane") + func backfillIsComment() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + // A comment folder is identity-shaped under a non-identity-shaped parent — shape-identical + // to a lane, which is exactly the mistake `Placement.comment` exists to prevent. + try fixture.item(path, "---\nschema: 1\nauthor: Ada\n---\nbody\n") + + try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login") + + let document = try FrontmatterDocument.parse(fixture.indexText(path)) + #expect(document.kind.value == "comment") + } + + @Test("author survives every app write; modified-by does not") + func authorSurvives() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, "---\nschema: 1\nauthor: Ada Lovelace\nmodified-by: claude\nkind: comment\n---\nbody\n") + + try BoardWriter.editComment(at: fixture.url(path), body: "edited\n", cardTitle: "Fix login") + + let document = try FrontmatterDocument.parse(fixture.indexText(path)) + #expect(document.author.value == "Ada Lovelace") + #expect(document.modifiedBy.isMissing, "modified-by is overlay; author is content") + } + + @Test("author is an ordinary unknown key on every other kind — the Details section still shows it") + func authorIsNotSchemaOwnedElsewhere() throws { + let document = try FrontmatterDocument.parse("---\nschema: 1\norder: 1024\nauthor: Ada\n---\nbody\n") + #expect(document.unknownFields.map(\.key) == [FrontmatterKeys.author]) + } +} + +// MARK: - Claimed names + +@Suite("Comments ▸ claimed names") +struct CommentClaimedNameTests { + + @Test("comments graduated: a squatter at card level is now scheduled work") + func commentsDisplacesNow() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.file("\(card)/comments", Data("squatter".utf8)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + let squatters = result.defects.compactMap { defect -> ClaimedNameSquatter? in + if case let .claimedNameSquatted(work) = defect { work } else { nil } + } + #expect(squatters.count == 1) + #expect(squatters.first?.name == "comments") + #expect(squatters.first?.found == .file) + #expect(squatters.first?.location == .card(path: card)) + } + + @Test("The thread read reports .draft and .trash squatters, and a comment's own attachments") + func threadLevelSquatters() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + try fixture.file("\(card)/comments/.draft", Data("squatter".utf8)) + try fixture.file("\(card)/comments/.trash", Data("squatter".utf8)) + try fixture.file("\(path)/attachments", Data("squatter".utf8)) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + let squatters = thread.defects.compactMap { defect -> ClaimedNameSquatter? in + if case let .claimedNameSquatted(work) = defect { work } else { nil } + } + #expect(Set(squatters.map(\.name)) == [".draft", ".trash", "attachments"]) + #expect(squatters.contains { $0.location == .commentThread(cardPath: card) && $0.name == ".draft" }) + #expect(squatters.contains { $0.location == .comment(path: path) && $0.name == "attachments" }) + // The board walk stays out of it — a thread is window-scoped. + let boardDefects = try BoardLoader.load(boardRoot: fixture.root).defects + #expect(!boardDefects.contains { if case .claimedNameSquatted = $0 { true } else { false } }) + } + + @Test("A file wearing comments makes the thread empty and the work the whole answer") + func squattedThreadFolder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.file("\(card)/comments", Data("squatter".utf8)) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.isEmpty) + #expect(thread.defects.count == 1) + } + + @Test("Each location displaces by the Finder ladder, and destroys nothing") + func displacementLadder() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + try fixture.file("\(card)/comments/.draft", Data("draft squatter".utf8)) + try fixture.file("\(path)/attachments", Data("attachment squatter".utf8)) + + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + for defect in thread.defects { + guard case let .claimedNameSquatted(squatter) = defect else { continue } + let freed = try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root) + #expect(freed == "\(squatter.name) 2") + } + + #expect(try fixture.data("\(card)/comments/.draft 2") == Data("draft squatter".utf8)) + #expect(try fixture.data("\(path)/attachments 2") == Data("attachment squatter".utf8)) + #expect(!fixture.exists("\(card)/comments/.draft")) + #expect(!fixture.exists("\(path)/attachments")) + } + + @Test("A displacement that lost its race is success, not an error") + func displacementReVerifies() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let squatter = ClaimedNameSquatter( + name: ".draft", + found: .file, + expected: .directory, + location: .commentThread(cardPath: card) + ) + #expect(try BoardWriter.displaceClaimedName(squatter, atBoardRoot: fixture.root) == nil) + } + + @Test("Two locations sign differently, so one heal's failure is not the other's") + func signaturesAreDistinct() { + let thread = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter( + name: ".trash", found: .file, expected: .directory, location: .commentThread(cardPath: "l/c") + )) + let root = IntegrityRules.Defect.claimedNameSquatted(ClaimedNameSquatter( + name: ".trash", found: .file, expected: .directory + )) + #expect(root.signatures == ["claimed:.trash:file"], "the board root's spelling is unchanged") + #expect(thread.signatures == ["claimed:l/c/comments/.trash:file"]) + } +} + +// MARK: - Path shape + +@Suite("Comments ▸ path shape") +struct CommentPathShapeTests { + + private let lane = Ident.lane1 + private let card = Ident.card1 + + @Test("A posted comment's path names its card and its identity") + func classifiesComment() { + let path = CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)/index.md") + #expect(path?.cardPath == "\(lane)/\(card)") + #expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one))) + #expect(path?.id == ItemID(rawValue: CommentIdent.one)) + } + + @Test("The folder itself classifies, not only files inside it") + func classifiesTheFolder() { + #expect(CommentPath.classify("\(lane)/\(card)/comments/\(CommentIdent.one)")?.kind + == .comment(ItemID(rawValue: CommentIdent.one))) + } + + @Test("The draft is its own shape, and has no identity") + func classifiesDraft() { + let path = CommentPath.classify("\(lane)/\(card)/comments/.draft/index.md") + #expect(path?.kind == .draft) + #expect(path?.id == nil) + } + + @Test("A deleted comment classifies from the thread's own trash") + func classifiesTrashed() { + let path = CommentPath.classify("\(lane)/\(card)/comments/.trash/\(CommentIdent.two)/index.md") + #expect(path?.kind == .trashed(ItemID(rawValue: CommentIdent.two))) + } + + @Test("A trashed card carries its thread, and the shape reads the same") + func classifiesUnderTheBoardTrash() { + let path = CommentPath.classify(".trash/\(card)/comments/\(CommentIdent.one)/index.md") + #expect(path?.cardPath == ".trash/\(card)") + #expect(path?.kind == .comment(ItemID(rawValue: CommentIdent.one))) + } + + @Test("Everything else is somebody else's to describe") + func classifiesNothingElse() { + let cases = [ + "\(lane)/\(card)/index.md", + "\(lane)/\(card)/attachments/shot.png", + "\(lane)/\(card)/comments", + "\(lane)/\(card)/comments/notes/index.md", + "\(lane)/\(card)/comments/.trash", + "\(lane)/\(card)/comments/.trash/notes", + "\(lane)/notacard/comments/\(CommentIdent.one)/index.md", + "CLAUDE.md", + "", + ] + for path in cases { + #expect(CommentPath.classify(path) == nil, "'\(path)' should not classify") + } + } +} diff --git a/KanbanTests/CommentWriteTests.swift b/KanbanTests/CommentWriteTests.swift new file mode 100644 index 0000000..1a57f91 --- /dev/null +++ b/KanbanTests/CommentWriteTests.swift @@ -0,0 +1,775 @@ +import Foundation +import Testing +@testable import Kanban + +/// The **write** half of the comment thread: the five Writer primitives, the two move-based inverses +/// registered at the store boundary, and the copy-boundary matrix (01-storage-format.md § Enhanced +/// schema; 05-card-window.md ▸ The comments column; 13-native-undo.md). +/// +/// Assertions are against the **bytes on disk**, like every other write suite here — a comment that +/// is merely equivalent in a model is not the claim. `WriterFixture`, `Ident` and `Item` come from +/// `WriterTestSupport.swift`; `CommentIdent`, `commentText` and `makeCommentBoard` from +/// `CommentThreadTests.swift`. + +// MARK: - Helpers + +private let cardTitle = "Fix login" + +private func document(_ fixture: WriterFixture, _ relativePath: String) throws -> FrontmatterDocument { + try FrontmatterDocument.parse(fixture.indexText(relativePath)) +} + +/// The one comment folder in a thread, as a path — the posted identity is minted, so a test that +/// posts has to find it rather than name it. +private func postedNames(_ fixture: WriterFixture, inCard card: String) throws -> [String] { + try fixture.entryNames("\(card)/comments") + .filter { IntegrityRules.isIdentityShaped($0) } + .sorted() +} + +@MainActor +private func makeStore(_ fixture: WriterFixture) throws -> (store: BoardStore, history: NativeHistoryProvider) { + let store = try BoardStore(rootURL: fixture.root) + let history = NativeHistoryProvider() + store.history = history + return (store, history) +} + +// MARK: - The draft + +@Suite("Comments ▸ draft lifecycle") +struct CommentDraftTests { + + @Test("The first save creates .draft with the comment schema, no title and no order") + func createStampsTheSchema() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + let outcome = try BoardWriter.saveCommentDraft( + inCard: fixture.url(card), body: "Half a thought", cardTitle: cardTitle + ) + #expect(outcome == .created) + + let draft = "\(card)/comments/.draft" + let text = try fixture.indexText(draft) + let parsed = try FrontmatterDocument.parse(text) + #expect(parsed.schema.value == 1) + #expect(parsed.kind.value == "comment") + #expect(parsed.author.value == NSFullUserName()) + #expect(parsed.created.value != nil) + // One `Date` for both stamps, so a draft never reads as edited before it is posted. + #expect(parsed.created.value == parsed.modified.value) + #expect(parsed.title.isMissing, "a comment has no title") + #expect(parsed.order.isMissing, "a comment has no order") + #expect(parsed.body == "Half a thought") + #expect(text.hasPrefix("---\nschema: 1\n")) + } + + @Test("An update replaces the body span and stamps modified; author and created stay") + func updateWritesTheBody() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let draft = "\(card)/comments/.draft" + try fixture.item(draft, commentText()) + + let outcome = try BoardWriter.saveCommentDraft( + inCard: fixture.url(card), body: "Second thought\n", cardTitle: cardTitle + ) + #expect(outcome == .updated) + + let parsed = try document(fixture, draft) + #expect(parsed.body == "Second thought\n") + #expect(parsed.author.value == "Ada Lovelace") + #expect(parsed.created.value == parsed.created.value) + #expect(parsed.modified.value != parsed.created.value) + // The unknown key, its inline comment and its position are untouched. + #expect(try fixture.indexText(draft).contains("project: lanework # agent overlay")) + } + + @Test("Identical bytes write nothing at all — the ~30 s tick must not stamp") + func unchangedWritesNothing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let draft = "\(card)/comments/.draft" + try fixture.item(draft, commentText(body: "Same\n")) + let before = try fixture.indexData(draft) + let stamp = try FileManager.default + .attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date + + let outcome = try BoardWriter.saveCommentDraft( + inCard: fixture.url(card), body: "Same\n", cardTitle: cardTitle + ) + #expect(outcome == .unchanged) + #expect(try fixture.indexData(draft) == before) + let after = try FileManager.default + .attributesOfItem(atPath: fixture.url(draft).appendingPathComponent("index.md").path)[.modificationDate] as? Date + #expect(after == stamp) + } + + @Test("A draft emptied of text with no attachments deletes its folder — never litter") + func emptiedDraftIsDeleted() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.draft", commentText(body: "typed\n")) + + let outcome = try BoardWriter.saveCommentDraft( + inCard: fixture.url(card), body: " \n\n", cardTitle: cardTitle + ) + #expect(outcome == .deleted) + #expect(!fixture.exists("\(card)/comments/.draft")) + #expect(fixture.exists("\(card)/comments"), "the container stays — the next draft mints into it") + } + + @Test("A draft emptied of text but holding attachments survives") + func emptiedDraftWithAttachmentsSurvives() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let draft = "\(card)/comments/.draft" + try fixture.item(draft, commentText(body: "typed\n")) + try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8)) + + let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle) + #expect(outcome == .updated) + #expect(try document(fixture, draft).body == "") + #expect(fixture.exists("\(draft)/attachments/shot.png")) + } + + @Test("An empty save against no draft creates nothing") + func emptySaveCreatesNothing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + let outcome = try BoardWriter.saveCommentDraft(inCard: fixture.url(card), body: "", cardTitle: cardTitle) + #expect(outcome == .unchanged) + #expect(!fixture.exists("\(card)/comments")) + } + + @Test("An interrupted create — a draft folder with no index.md — is filled in, not refused") + func indexlessDraftIsFilledIn() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try FileManager.default.createDirectory( + at: fixture.url("\(card)/comments/.draft"), withIntermediateDirectories: true + ) + + #expect(try BoardWriter.saveCommentDraft( + inCard: fixture.url(card), body: "recovered\n", cardTitle: cardTitle + ) == .created) + #expect(try document(fixture, "\(card)/comments/.draft").body == "recovered\n") + } + + @Test("A draft is not a card write: only a card folder takes one") + func refusesNonCards() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try makeCommentBoard(fixture) + + let failure = writeFailure { + try BoardWriter.saveCommentDraft(inCard: fixture.url(Ident.lane1), body: "x", cardTitle: nil) + } + #expect(failure?.operation == .saveCommentDraft(title: nil)) + } +} + +// MARK: - Posting + +@Suite("Comments ▸ post") +struct CommentPostTests { + + @Test("Posting renames the draft to a fresh lowercase identity and restamps both dates") + func postRenamesAndRestamps() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let draft = "\(card)/comments/.draft" + try fixture.item(draft, commentText(created: "2020-01-01T09:00:00Z", modified: "2020-01-01T09:00:00Z")) + try fixture.file("\(draft)/attachments/shot.png", Data("png".utf8)) + let draftBody = try document(fixture, draft).body + + let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) + + #expect(!fixture.exists(draft)) + #expect(posted.id.rawValue == posted.id.rawValue.lowercased()) + #expect(IntegrityRules.isIdentityShaped(posted.id.rawValue)) + #expect(try postedNames(fixture, inCard: card) == [posted.id.rawValue]) + + let path = "\(card)/comments/\(posted.id.rawValue)" + let parsed = try document(fixture, path) + #expect(parsed.created.value == parsed.modified.value, "post time is one instant, not two") + #expect(parsed.created.value.map { abs($0.timeIntervalSince(posted.posted)) < 1 } == true) + #expect(parsed.created.value != nil && parsed.created.value! > Date(timeIntervalSince1970: 1_700_000_000)) + // Everything else is the draft's own bytes: the body, the author, the unknown key and its + // inline comment, and the attachment that rode the rename. + #expect(parsed.body == draftBody) + #expect(parsed.author.value == "Ada Lovelace") + #expect(try fixture.indexText(path).contains("project: lanework # agent overlay")) + #expect(fixture.exists("\(path)/attachments/shot.png")) + } + + @Test("A posted comment joins the thread; the draft no longer does") + func postedCommentIsInTheThread() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.draft", commentText()) + + let posted = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id) == [posted.id]) + #expect(!thread.hasDraft) + } + + @Test("Posting nothing refuses, naming the card") + func postWithoutADraftRefuses() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + let failure = writeFailure { _ = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) } + #expect(failure?.operation == .postComment(title: cardTitle)) + } + + @Test("A second post mints a second identity beside the first") + func twoPostsAreTwoComments() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + + try fixture.item("\(card)/comments/.draft", commentText(body: "one\n")) + let first = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) + try fixture.item("\(card)/comments/.draft", commentText(body: "two\n")) + let second = try BoardWriter.postComment(inCard: fixture.url(card), cardTitle: cardTitle) + + #expect(first.id != second.id) + #expect(try postedNames(fixture, inCard: card).count == 2) + } +} + +// MARK: - Editing + +@Suite("Comments ▸ edit") +struct CommentEditTests { + + @Test("An edit writes the body and stamps modified — '· edited' falls out of the pair") + func editStampsModified() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + + #expect(try BoardWriter.editComment(at: fixture.url(path), body: "revised\n", cardTitle: cardTitle)) + + let parsed = try document(fixture, path) + #expect(parsed.body == "revised\n") + #expect(parsed.created.value != parsed.modified.value) + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.first?.isEdited == true) + } + + @Test("An unchanged body writes nothing") + func unchangedEditWritesNothing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText(body: "same\n")) + let before = try fixture.indexData(path) + + #expect(try BoardWriter.editComment(at: fixture.url(path), body: "same\n", cardTitle: cardTitle) == false) + #expect(try fixture.indexData(path) == before) + } + + @Test("Only a posted comment is editable — never the draft, never a trashed one") + func refusesTheTwoDotFolders() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.draft", commentText()) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) + + for path in ["\(card)/comments/.draft", "\(card)/comments/.trash/\(CommentIdent.one)"] { + let failure = writeFailure { + _ = try BoardWriter.editComment(at: fixture.url(path), body: "x", cardTitle: cardTitle) + } + #expect(failure?.operation == .editComment(title: cardTitle)) + } + } +} + +// MARK: - Delete, restore, purge + +@Suite("Comments ▸ delete and purge") +struct CommentDeleteTests { + + @Test("A delete is a move into comments/.trash that stamps and clears modified-by") + func deleteMovesAndStamps() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, "---\nschema: 1\nauthor: Ada\nmodified-by: claude\ncreated: 2026-01-01T09:00:00Z\nmodified: 2026-01-01T09:00:00Z\nkind: comment\n---\nbody\n") + + let id = try BoardWriter.deleteComment(at: fixture.url(path), cardTitle: cardTitle) + #expect(id.rawValue == CommentIdent.one) + #expect(!fixture.exists(path)) + + let trashed = "\(card)/comments/.trash/\(CommentIdent.one)" + let parsed = try document(fixture, trashed) + #expect(parsed.modified.value != parsed.created.value, "a container change stamps") + #expect(parsed.modifiedBy.isMissing) + #expect(parsed.author.value == "Ada", "author is content and survives even this") + #expect(parsed.body == "body\n") + } + + @Test("A deleted comment leaves the thread") + func deletedIsExcluded() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + try fixture.item(commentPath(CommentIdent.two, inCard: card), commentText(created: "2026-02-02T09:00:00Z")) + + try BoardWriter.deleteComment(at: fixture.url(commentPath(CommentIdent.one, inCard: card)), cardTitle: cardTitle) + let thread = CommentThread.load(inCard: fixture.url(card), path: card) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.two]) + } + + @Test("Restoring is the ordinary move back out, and stamps again") + func restoreMovesBack() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) + + try BoardWriter.restoreComment( + ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card), cardTitle: cardTitle + ) + #expect(fixture.exists(commentPath(CommentIdent.one, inCard: card))) + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + #expect(try document(fixture, commentPath(CommentIdent.one, inCard: card)).body + == "A comment body — with *markdown*.\n") + } + + @Test("The purge removes the entries, keeps strays, and leaves the container") + func purgeRemovesEntriesOnly() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + try fixture.file("\(card)/comments/.trash/notes.txt", Data("mine".utf8)) + + let purged = try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)) + #expect(Set(purged.map(\.rawValue)) == [CommentIdent.one, CommentIdent.two]) + #expect(fixture.exists("\(card)/comments/.trash")) + #expect(try fixture.data("\(card)/comments/.trash/notes.txt") == Data("mine".utf8)) + } + + @Test("A card with no thread trash purges nothing") + func purgeIsTotal() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + #expect(try BoardWriter.purgeCommentTrash(inCard: fixture.url(card)).isEmpty) + } +} + +// MARK: - The crash-residue memo + +@MainActor +@Suite("Comments ▸ crash residue") +struct CommentResidueTests { + + @Test("Residue left by a crashed session sweeps at the next open, and the memo clears") + func residueSweeps() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) + let (store, _) = try makeStore(fixture) + + store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1)) + + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + #expect(store.heals.memo(for: .commentTrashResidue) == nil, "cleared on success") + } + + @Test("An open with nothing to sweep rests — no bracket, no memo") + func cleanOpenRests() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try makeCommentBoard(fixture) + let (store, _) = try makeStore(fixture) + + store.sweepCommentTrashResidue(inCard: ItemID(rawValue: Ident.card1)) + #expect(store.heals.memo(for: .commentTrashResidue) == nil) + } + + @Test("The close purge and the residue sweep converge on the same disk state") + func purgeAndSweepAgree() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let card = try makeCommentBoard(fixture) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText()) + let (store, _) = try makeStore(fixture) + + store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1)) + #expect(try fixture.entryNames("\(card)/comments/.trash").isEmpty) + } +} + +// MARK: - Undo + +@MainActor +@Suite("Comments ▸ undo") +struct CommentUndoTests { + + private func board() throws -> (fixture: WriterFixture, card: String) { + let fixture = try WriterFixture() + let card = try makeCommentBoard(fixture) + return (fixture, card) + } + + @Test("Posting registers one step, named for the 06 verb family") + func postRegistersOneStep() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + try fixture.item("\(card)/comments/.draft", commentText()) + let (store, history) = try makeStore(fixture) + + _ = store.postComment(inCard: ItemID(rawValue: Ident.card1)) + #expect(history.canUndo) + #expect(history.undoActionName == "Comment") + } + + @Test("Undoing a post renames back to .draft; redo replays the same identity and instant") + func postRoundTrip() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + try fixture.item("\(card)/comments/.draft", commentText(body: "drafted\n")) + let (store, history) = try makeStore(fixture) + + let posted = store.postComment(inCard: ItemID(rawValue: Ident.card1)) + let id = try #require(posted) + let postedText = try fixture.indexText("\(card)/comments/\(id.rawValue)") + + history.undo() + #expect(!fixture.exists("\(card)/comments/\(id.rawValue)")) + #expect(fixture.exists("\(card)/comments/.draft")) + // A rename stamps nothing: the un-posted draft carries the post's own bytes, untouched. + #expect(try fixture.indexText("\(card)/comments/.draft") == postedText) + + history.redo() + #expect(try postedNames(fixture, inCard: card) == [id.rawValue], "the same identity, not a fresh mint") + #expect(try fixture.indexText("\(card)/comments/\(id.rawValue)") == postedText, "the same instant, restamped") + } + + @Test("A .draft typed since makes the post's undo stale — it skips, it never clobbers") + func postUndoSkipsWhenADraftIsBack() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + try fixture.item("\(card)/comments/.draft", commentText(body: "first\n")) + let (store, history) = try makeStore(fixture) + + let id = try #require(store.postComment(inCard: ItemID(rawValue: Ident.card1))) + // A new draft, the way the composer would leave one. + try fixture.item("\(card)/comments/.draft", commentText(body: "second\n")) + let untouched = try fixture.indexText("\(card)/comments/.draft") + + history.undo() + + #expect(try fixture.indexText("\(card)/comments/.draft") == untouched, "the new draft is not overwritten") + #expect(fixture.exists("\(card)/comments/\(id.rawValue)"), "and the posted comment stays posted") + #expect(!history.canUndo, "the stale step was popped") + #expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"]) + } + + @Test("Deleting registers one step; undo moves it back and redo moves it in") + func deleteRoundTrip() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + let (store, history) = try makeStore(fixture) + + #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1))) + #expect(history.undoActionName == "Delete Comment") + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + + history.undo() + #expect(fixture.exists(path)) + #expect(!fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + #expect(try document(fixture, path).body == "A comment body — with *markdown*.\n") + + history.redo() + #expect(!fixture.exists(path)) + #expect(fixture.exists("\(card)/comments/.trash/\(CommentIdent.one)")) + } + + @Test("A purge leaves the delete step stale — it skips with a banner, resurrecting nothing") + func purgeMakesDeleteStepsStale() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + let (store, history) = try makeStore(fixture) + + #expect(store.deleteComment(ItemID(rawValue: CommentIdent.one), inCard: ItemID(rawValue: Ident.card1))) + store.purgeCommentTrash(inCard: ItemID(rawValue: Ident.card1)) + #expect(history.canUndo, "invalidation is lazy — the stack still looks full") + + history.undo() + #expect(!fixture.exists(commentPath(CommentIdent.one, inCard: card))) + #expect(!history.canUndo) + #expect(store.banners.signposts.map(\.message) == ["Undo skipped — '\(cardTitle)' changed outside Lanework"]) + } + + @Test("The draft save, the inline edit and the purge register nothing") + func theThreeSilentOperations() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + try fixture.item(commentPath(CommentIdent.one, inCard: card), commentText()) + try fixture.item("\(card)/comments/.trash/\(CommentIdent.two)", commentText()) + let (store, history) = try makeStore(fixture) + let cardID = ItemID(rawValue: Ident.card1) + + store.saveCommentDraft(inCard: cardID, body: "typing\n") + #expect(store.editComment(ItemID(rawValue: CommentIdent.one), inCard: cardID, body: "revised\n")) + store.purgeCommentTrash(inCard: cardID) + + #expect(!history.canUndo, "no byte capture in any tier — 13's rule") + } + + @Test("Comment expectations validate disk, which is why comments need no snapshot") + func expectationsReadDisk() throws { + let (fixture, card) = try board() + defer { fixture.tearDown() } + let path = commentPath(CommentIdent.one, inCard: card) + try fixture.item(path, commentText()) + + #expect(HistoryStaleness.isCurrent([.present(fixture.url(path))])) + #expect(HistoryStaleness.isCurrent([.absent(fixture.url("\(card)/comments/.draft"))])) + try FileManager.default.removeItem(at: fixture.url(path)) + #expect(!HistoryStaleness.isCurrent([.present(fixture.url(path))])) + } +} + +// MARK: - Copy boundaries + +@Suite("Comments ▸ copy boundaries") +struct CommentCopyTests { + + /// A card with a full thread: one posted comment carrying a tracker key and an attachment, a + /// draft, and one comment sitting in the thread's trash. + private func threadedCard(_ fixture: WriterFixture, in lane: String, card: String) throws -> String { + let path = "\(lane)/\(card)" + try fixture.item(path, Item.rich(order: "1024", title: "Fix login")) + try fixture.item("\(path)/comments/\(CommentIdent.one)", commentText(remote: "gitea#42")) + try fixture.file("\(path)/comments/\(CommentIdent.one)/attachments/shot.png", Data("png".utf8)) + try fixture.item("\(path)/comments/.draft", commentText(body: "unposted\n")) + try fixture.item("\(path)/comments/.trash/\(CommentIdent.two)", commentText()) + return path + } + + @Test("An item-level copy carries the thread, remints it, severs remote, and strips the trash") + func itemCopyMatrix() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) + + let copy = try BoardWriter.copyItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url(Ident.lane2), + order: 1024, + stamps: .fork + ) + let copied = "\(Ident.lane2)/\(copy.rawValue)" + + // The thread came, reminted. + let names = try postedNames(fixture, inCard: copied) + #expect(names.count == 1) + #expect(names[0] != CommentIdent.one, "comment folders remint like every copied folder") + #expect(names[0] == names[0].lowercased()) + + // The contract applied at comment depth. + let parsed = try document(fixture, "\(copied)/comments/\(names[0])") + #expect(parsed.value(for: FrontmatterKeys.remote) == nil, "the tracker claim is severed") + #expect(parsed.author.value == "Ada Lovelace") + #expect(parsed.created.value != nil, ".fork keeps created") + #expect(fixture.exists("\(copied)/comments/\(names[0])/attachments/shot.png")) + + // The draft carries, fork-lossless; the trash does not travel. + #expect(try document(fixture, "\(copied)/comments/.draft").body == "unposted\n") + #expect(!fixture.exists("\(copied)/comments/.trash")) + + // The source is untouched, thread trash included. + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(CommentIdent.two)")) + #expect(fixture.exists("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)")) + } + + @Test("A copied lane's cards' threads are reminted too — arbitrary depth") + func laneCopyReachesCommentDepth() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) + + let copy = try BoardWriter.copyItem( + at: fixture.url(Ident.lane1), toParent: fixture.root, order: 4096, stamps: .fork + ) + let cards = try fixture.entryNames(copy.rawValue).filter { IntegrityRules.isIdentityShaped($0) } + #expect(cards.count == 1) + let copiedCard = "\(copy.rawValue)/\(cards[0])" + let comments = try postedNames(fixture, inCard: copiedCard) + #expect(comments.count == 1) + #expect(comments[0] != CommentIdent.one) + #expect(try document(fixture, "\(copiedCard)/comments/\(comments[0])").value(for: FrontmatterKeys.remote) == nil) + #expect(!fixture.exists("\(copiedCard)/comments/.trash")) + } + + @Test("A comment nobody can stamp never refuses the copy — it travels verbatim") + func brokenCommentDoesNotRefuseACopy() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Fix login")) + // Readable, uneditable — the shape that refuses a copy at card level. + try fixture.item("\(Ident.lane1)/\(Ident.card1)/comments/\(CommentIdent.one)", Item.uneditable) + + let copy = try BoardWriter.copyItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url(Ident.lane2), + order: 1024, + stamps: .fork + ) + let copied = "\(Ident.lane2)/\(copy.rawValue)" + let names = try postedNames(fixture, inCard: copied) + #expect(names.count == 1, "the copy landed whole") + #expect(try fixture.indexText("\(copied)/comments/\(names[0])") == Item.uneditable, "verbatim") + } + + @Test("Template instantiation is born-today at comment depth too") + func instantiationRestampsComments() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing")) + try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) + + let copy = try BoardWriter.copyItem( + at: fixture.url("\(Ident.lane1)/\(Ident.card1)"), + toParent: fixture.url(Ident.lane2), + order: 1024, + stamps: .born + ) + let copied = "\(Ident.lane2)/\(copy.rawValue)" + let names = try postedNames(fixture, inCard: copied) + let created = try #require(document(fixture, "\(copied)/comments/\(names[0])").created.value) + #expect(created.timeIntervalSinceNow > -60, ".born restamps created") + } + + @Test("A whole-board fork carries the thread verbatim — but never its trash") + func wholeBoardForkCarriesVerbatim() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) + + let destination = fixture.root.deletingLastPathComponent() + .appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: destination) } + try BoardTreeCopy.createDirectory(at: destination) + try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false }) + + let thread = destination + .appendingPathComponent(Ident.lane1).appendingPathComponent(Ident.card1) + .appendingPathComponent("comments") + // GUIDs kept, tracker keys kept — a fork is a fork. + let forked = thread.appendingPathComponent(CommentIdent.one).appendingPathComponent("index.md") + #expect(FileManager.default.fileExists(atPath: forked.path)) + let text = try String(decoding: Data(contentsOf: forked), as: UTF8.self) + #expect(text.contains("remote: gitea#42")) + // The draft too. + #expect(FileManager.default.fileExists( + atPath: thread.appendingPathComponent(".draft").appendingPathComponent("index.md").path + )) + // The thread trash, never. + #expect(!FileManager.default.fileExists(atPath: thread.appendingPathComponent(".trash").path)) + } + + @Test("The board's own .trash is untouched by the comment strip") + func theBoardTrashIsNotTheThreadTrash() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item(".trash/\(Ident.card4)", Item.rich(order: "1024", title: "Trashed")) + + let destination = fixture.root.deletingLastPathComponent() + .appendingPathComponent("Fork-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: destination) } + try BoardTreeCopy.createDirectory(at: destination) + try BoardTreeCopy.copy(contentsOf: fixture.root, into: destination, isCancelled: { false }) + + #expect(FileManager.default.fileExists( + atPath: destination.appendingPathComponent(".trash/\(Ident.card4)/index.md").path + )) + } + + @Test("A trashed card carries its thread — and the trash interplay costs nothing") + func trashedCardsCarryTheirThread() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + let card = try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) + + try BoardWriter.deleteCardToTrash(at: fixture.url(card), inBoard: fixture.root, order: 1024) + + let trashedCard = ".trash/\(Ident.card1)" + #expect(fixture.exists("\(trashedCard)/comments/\(CommentIdent.one)")) + #expect(fixture.exists("\(trashedCard)/comments/.draft")) + #expect(fixture.exists("\(trashedCard)/comments/.trash/\(CommentIdent.two)"), "a move carries everything") + + let thread = CommentThread.load(inCard: fixture.url(trashedCard), path: trashedCard) + #expect(thread.comments.map(\.id.rawValue) == [CommentIdent.one]) + + // Restoring is the ordinary move back, thread intact. + _ = try BoardWriter.moveItem( + at: fixture.url(trashedCard), + toParent: fixture.url(Ident.lane1), + sourceBoardRoot: fixture.root, + destinationBoardRoot: fixture.root, + order: 1024 + ) + #expect(fixture.exists("\(card)/comments/\(CommentIdent.one)")) + } + + @Test("The purge takes the whole card, thread and all") + func purgeTakesTheThread() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try threadedCard(fixture, in: Ident.lane1, card: Ident.card1) + try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)") + + try BoardWriter.purgeTrashEntry(at: fixture.url(".trash/\(Ident.card1)"), inBoard: fixture.root) + #expect(!fixture.exists(".trash/\(Ident.card1)")) + } +}