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))) } }