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))) } } // MARK: - CommentThreadChanges /// **What happened to one card's thread between two reads** — `BoardDiff` one level down, and for the /// same consumer: something has to say what changed before anything can say it out loud. /// /// ### Why the thread and not the snapshot /// /// `BoardDiff` compares two `BoardModel`s, and a `BoardModel` has never heard of a comment — that is /// 01-storage-format.md ▸ Enhanced schema's stated exception to snapshot completeness, and it is why /// foreign comment changes are "described by **path shape**" rather than by a diff. The path shape /// answers *what kind* of change a path is (`CommentPath` above); this answers *which comments* /// changed, from the only two pictures anything in the app actually holds — the thread the card /// window was showing, and the thread it has just re-read (`CardComments.reload`). /// /// ### Three buckets, and the one that is deliberately absent /// /// Arrivals, edits and departures, which are the three the verb family names ("Comment on…", "Edit /// comment on…", "Delete comment on…" — 06-history-undo.md's family per 01). **An attachment landing /// on a comment is not a change here**: the comparison is the `index.md` document, so a file imported /// into a comment's `attachments/` moves nothing in this value. That is deliberate rather than an /// omission — 10-accessibility.md announces comment *arrivals*, the design's verb family is about the /// comment's text, and a chip appearing is a visible change with no sentence written for it. /// /// `.draft` and `comments/.trash/` never appear because they are not in a thread at all /// (`CommentThread`'s own exclusion), so "a draft saved on another machine" and "a comment this window /// deleted a moment ago" are both silent by construction rather than by a filter. public struct CommentThreadChanges: Sendable, Equatable { /// Comments in the new thread that were not in the old one. public var arrived: [ItemID] = [] /// Comments in both, whose `index.md` differs. public var edited: [ItemID] = [] /// Comments in the old thread that are not in the new one. public var deleted: [ItemID] = [] public init(arrived: [ItemID] = [], edited: [ItemID] = [], deleted: [ItemID] = []) { self.arrived = arrived self.edited = edited self.deleted = deleted } public var isEmpty: Bool { arrived.isEmpty && edited.isEmpty && deleted.isEmpty } /// The comparison. Order follows the **new** thread for the two buckets it can (the display order /// the loader produced) and the old thread for departures, so a caller naming "the" arrival names /// the one a reader would reach first. public static func between(_ old: CommentThread, _ new: CommentThread) -> CommentThreadChanges { let older = Dictionary(old.comments.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let newer = Set(new.comments.map(\.id)) var changes = CommentThreadChanges() for comment in new.comments { guard let was = older[comment.id] else { changes.arrived.append(comment.id) continue } if was.document != comment.document { changes.edited.append(comment.id) } } for comment in old.comments where !newer.contains(comment.id) { changes.deleted.append(comment.id) } return changes } /// This value with everything the `EchoLedger` vouched for removed — **the app's own writes never /// announce as foreign** (10-accessibility.md ▸ Live board announcements), applied per comment. /// /// It is a narrowing rather than a gate for `EchoVerdicts.foreign`'s reason: a reload can carry an /// app-mediated edit *and* a foreign arrival at once (an agent files a comment in the same debounce /// window as the user's inline save), and the honest sentence describes the half nobody vouched /// for rather than all of it or none of it. public func excluding(_ vouched: Set) -> CommentThreadChanges { guard !vouched.isEmpty else { return self } return CommentThreadChanges( arrived: arrived.filter { !vouched.contains($0) }, edited: edited.filter { !vouched.contains($0) }, deleted: deleted.filter { !vouched.contains($0) } ) } }