import Foundation import os /// Walks a board's folder tree and produces an immutable `BoardModel` snapshot — a pure /// function of the tree (02-architecture.md § Layering ▸ Components). Enforces the fractal /// layout's fail-fast and skip rules (01-storage-format.md § Fractal layout, Malformed input) /// so a bad file either loudly rejects the whole load or is cleanly ignored — never a silent /// partial result. /// /// Level is position: root `index.md` → board, depth-1 folders → lanes, depth-2 folders → /// cards. **Name shape gates level detection** (01-storage-format.md § Fractal layout ▸ /// Rules): only a folder whose name has a UUID's shape — hex, `8-4-4-4-12`, **any case and any /// version** — is a lane/card *candidate* at those depths; see `isUUIDShaped` below for exactly /// what's checked. /// Anything else — even a directory holding a perfectly valid `index.md` — is a stray: skipped /// with a `.nonUUIDFolderIgnored` warning, preserved verbatim on disk, and never descended /// into. A hand-made `notes/` folder (or a broken `index.md` inside one) can never brick a /// load; only a UUID-shaped candidate that is itself missing `index.md` still gets the older /// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast. /// /// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card* /// (01-storage-format.md § Fractal layout ▸ Rules), and cards are leaves *structurally*: the /// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the /// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not /// levels, so they never need special-casing against the stray warning. /// /// **Two reads inside a card folder**, both of them flat name listings and nothing more — neither /// opens a file, descends, warns, or fails a load; each degrades to `[]`: /// /// - `attachmentNames(in:)` — `attachments/`, feeding `Card.attachments`. The board window's face /// needs it before a card window exists (the quiet paperclip indicator — 03-board-ui.md § Card /// face), and the snapshot is where it reads from. /// - `looseFileNames(in:)` — the card folder *itself*, feeding `LoadResult.looseCardFiles`. This is /// the loose-file carve-out's **detection** half (01-storage-format.md § Fractal layout ▸ Rules, /// settled 2026-07-28): a regular file sitting beside a card's `index.md` belongs in /// `attachments/`, and the app relocates it. Detection stays read-only *here* — this loader is a /// pure function of the tree and writes nothing, ever (the Repair precedent); the relocation is a /// Writer-mediated app write the store schedules off the snapshot /// (`BoardStore.relocateLooseCardFiles`). /// /// Everything else about a card folder's contents remains outside this loader's business. /// /// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never /// followed, whether it points to a file or a directory — this loader does not resolve /// cross-volume or cyclic trees. /// /// ## The trash is a container, not a level /// /// `/.trash/` is a **reserved, app-claimed board-root name** holding card folders directly /// (01-storage-format.md § Deletion, resettled 2026-07-28) — "same shape as a lane's children, no /// `index.md` of its own". The walk therefore treats it as a second card container beside the /// lanes: `trashCards(in:)` parses its UUID-shaped children with exactly the card parse the lane /// walk uses (same fail-fast on `schema`/`order`, same skip-and-warn rules), and the result lands /// in `BoardModel.trash` rather than under any lane. Being reserved, it is **never a stray** and /// never warns; absent, the trash is simply empty. /// /// ## The migration window /// /// The tombstone model is retired: no `deleted:` key is ever written again, and a key found on /// load is *migration input* — a card relocates into `.trash/` with the key removed, a lane /// returns live with the key removed, a board-level key stays meaningless (ignored + logged). /// Detection is read-only here, the loose-file carve-out's posture exactly: this loader reports /// what it found through `LoadResult.legacyTombstones` and the store schedules the Writer-mediated /// fix. /// /// **Until that write lands, such items still load through the retiring tombstone path** — /// a `deleted:`-carrying lane or card stays filed under its lane with `isDeleted` set. That is a /// deliberate intermediate, not an oversight: the migration's whole promise is "never destroy", /// and the safe direction while the fix is still pending (it is deferred under any read-only lock, /// and can be pending for a whole session) is for nothing to silently vanish from view before its /// folder has actually moved. The window closes per board on the first successful migration write, /// after which no `deleted:` key exists to read and the flag is permanently `false`. public enum BoardLoader: Sendable { /// Schema version this app understands; anything higher fails fast /// (01-storage-format.md § Malformed input). Internal rather than `private`: read by /// `BoardLoadError.Reason.description` below, and it is also the version `BoardWriter` /// stamps into files it creates — one symbol, so the app can never write a file its own /// loader would reject as newer-than-supported. static let supportedSchema = 1 /// Board-level key for `BoardModel.template` — not schema-owned in the engine's sense /// (`FrontmatterKeys.schemaOwned`), because its value is opaque and read raw here rather /// than through a typed `FrontmatterDocument` accessor. /// /// Internal rather than `private` for `indexFileName`'s reason: `TemplateEngine` writes this /// key on a Save as Template copy — "the one writer of keyed files is Save as Template" /// (09-templates.md ▸ Storage) — and the reader and that one writer must never disagree about /// how it is spelled. static let templateKey = "template" /// Internal rather than `private`: `BoardWriter` names the same file, and the loader and /// the writer must never disagree about which file a folder's content lives in. static let indexFileName = "index.md" /// The materialized trash container at board root (01-storage-format.md § Deletion, resettled /// 2026-07-28) — **app-claimed, never a stray**, joining `CLAUDE.md`, `CLAUDE.user.md` and the /// seeded `.gitignore` on the claimed list. /// /// Dot-prefixed, which is doing real work rather than being decoration: `directoryCandidates` /// skips hidden entries, so the container can never be mistaken for a lane candidate and can /// never earn a `.nonUUIDFolderIgnored` warning. The lane walk skips it by name as well /// (`reservedRootNames`) so the rule holds even where hidden-file semantics don't. /// /// Internal rather than `private`: `BoardWriter` moves folders into and out of this exact /// name, and a board can have only one trash. static let trashFolderName = ".trash" /// Board-root names the app claims, and therefore the names the lane walk skips **without a /// stray warning** (01-storage-format.md § Fractal layout ▸ Rules: "Three board-root names are /// app-claimed, not strays", plus `.trash/` from § Deletion). /// /// Only `.trash` is a *folder* and so the only one the directory walk could ever reach; the /// files are listed because the claim is about names, and a future check that needs the set /// should find it complete rather than build a second one. Compared lowercased, like /// `reservedCardChildNames` and for its reason — the filesystem this runs on usually is. static let reservedRootNames: Set = [ trashFolderName, "claude.md", "claude.user.md", ".gitignore", ] /// The card-level names the app claims, and therefore the three the loose-file carve-out /// never touches (01-storage-format.md § Fractal layout ▸ Rules: "Reserved card-level names /// … untouched"): the card's own `index.md` plus the two reserved children. `comments` is /// listed because the schema reserves the name, not because anything writes it yet — /// `attachments/` is still the one folder this app ever creates under a card. /// /// **Compared lowercased**, because the filesystem this runs on usually is: a file spelled /// `Index.md` *is* the card's index to `fileExists`, and a case-sensitive reservation check /// would hand the loose-file relocation a card's own content to move into `attachments/`. /// /// Internal rather than `private`: `BoardWriter.relocateLooseFiles` refuses the same three /// names on its own, so a caller passing a hand-made list cannot reach past this rule. static let reservedCardChildNames: Set = [ indexFileName, BoardWriter.attachmentsFolderName, "comments", ] private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "loader") // MARK: - Entry point public static func load(boardRoot: URL) throws(BoardLoadError) -> LoadResult { try checkIsReadableDirectory(boardRoot) let boardIndexURL = boardRoot.appendingPathComponent(indexFileName) guard FileManager.default.fileExists(atPath: boardIndexURL.path) else { throw BoardLoadError(path: indexFileName, reason: .boardRootMissingIndex) } let boardDocument = try readDocument(at: boardIndexURL, path: indexFileName) let boardSchema = try validatedSchema(in: boardDocument, path: indexFileName) var warnings: [LoadWarning] = [] func warn(_ warning: LoadWarning) { warnings.append(warning) logger.warning("\(warning.description, privacy: .public)") } // The carve-out's detection channel — deliberately *not* `warnings`, which is the // stray-*tolerance* vocabulary (see `LoadResult.looseCardFiles`). var looseCardFiles: [LooseCardFiles] = [] // The retired tombstone model's detection channel, on the same reasoning and in the same // idiom (see `LoadResult.legacyTombstones`). var legacyTombstones: [LegacyTombstone] = [] // Legal per the frontmatter table, meaningless at board level — ignore and log, never // tombstone, and **never migrate**: "a `deleted:` key at board level remains meaningless // — ignored and logged, preserved verbatim" (01-storage-format.md § Deletion). It is // deliberately absent from `legacyTombstones`: there is no item to relocate and no key // the app has any business removing from a file it was told to leave alone. if !boardDocument.deleted.isMissing { warn(.boardLevelDeletedIgnored) } var lanes: [Lane] = [] for laneURL in try directoryCandidates(in: boardRoot) { let laneName = laneURL.lastPathComponent // The app-claimed board-root names are not strays and must not warn as such. Hidden // entries never reach here anyway (`.trash` included), so this is the rule stated // rather than the mechanism relied on. guard !reservedRootNames.contains(laneName.lowercased()) else { continue } guard isUUIDShaped(laneName) else { warn(.nonUUIDFolderIgnored(path: laneName)) continue } guard hasIndex(laneURL) else { warn(.missingIndex(path: laneName)) continue } let lanePath = laneName + "/" + indexFileName let laneDocument = try readDocument(at: laneURL.appendingPathComponent(indexFileName), path: lanePath) let laneSchema = try validatedSchema(in: laneDocument, path: lanePath) let laneOrder = try validatedOrder(in: laneDocument, path: lanePath) var cards: [Card] = [] for cardURL in try directoryCandidates(in: laneURL) { let cardName = cardURL.lastPathComponent let cardRelPath = laneName + "/" + cardName guard isUUIDShaped(cardName) else { warn(.nonUUIDFolderIgnored(path: cardRelPath)) continue } guard hasIndex(cardURL) else { warn(.missingIndex(path: cardRelPath)) continue } let card = try parseCard(at: cardURL, path: cardRelPath) // Noticed, never acted on: the relocation is the store's, through the Writer. let loose = looseFileNames(in: cardURL) if !loose.isEmpty { looseCardFiles.append(LooseCardFiles( laneID: ItemID(rawValue: laneName), cardID: ItemID(rawValue: cardName), title: card.title.value, fileNames: loose )) logger.info("\(cardRelPath, privacy: .public): \(loose.count, privacy: .public) loose file(s) beside index.md — to be relocated into attachments/") } // Detection only, the loose-file precedent exactly: the relocation into `.trash/` // and the key's removal are the store's, through the Writer. if card.isDeleted { legacyTombstones.append(LegacyTombstone( kind: .card, laneID: ItemID(rawValue: laneName), cardID: ItemID(rawValue: cardName), title: card.title.value )) logger.info("\(cardRelPath, privacy: .public): legacy 'deleted' key — card to be relocated into \(trashFolderName, privacy: .public)/") } cards.append(card) } if !laneDocument.deleted.isMissing { legacyTombstones.append(LegacyTombstone( kind: .lane, laneID: ItemID(rawValue: laneName), cardID: nil, title: laneDocument.title.value )) logger.info("\(laneName, privacy: .public): legacy 'deleted' key — lane to be returned live with the key removed") } lanes.append(Lane( id: ItemID(rawValue: laneName), schema: laneSchema, title: laneDocument.title, created: laneDocument.created, modified: laneDocument.modified, modifiedBy: laneDocument.modifiedBy, deleted: laneDocument.deleted, background: laneDocument.background, icon: laneDocument.icon, iconColor: laneDocument.iconColor, order: laneOrder, width: laneDocument.width, cards: Ranks.sortedForDisplay(cards, order: \.order, name: { $0.id.rawValue }), document: laneDocument )) } var trash: [Card] = [] for cardURL in trashCandidates(in: boardRoot) { let cardName = cardURL.lastPathComponent let cardRelPath = trashFolderName + "/" + cardName guard isUUIDShaped(cardName) else { warn(.nonUUIDFolderIgnored(path: cardRelPath)) continue } guard hasIndex(cardURL) else { warn(.missingIndex(path: cardRelPath)) continue } trash.append(try parseCard(at: cardURL, path: cardRelPath)) } let model = BoardModel( rootURL: boardRoot, schema: boardSchema, title: boardDocument.title, created: boardDocument.created, modified: boardDocument.modified, modifiedBy: boardDocument.modifiedBy, deleted: boardDocument.deleted, background: boardDocument.background, icon: boardDocument.icon, iconColor: boardDocument.iconColor, template: boardDocument.value(for: templateKey), lanes: Ranks.sortedForDisplay(lanes, order: \.order, name: { $0.id.rawValue }), trash: Ranks.sortedForDisplay(trash, order: \.order, name: { $0.id.rawValue }), document: boardDocument ) return LoadResult( model: model, warnings: warnings, looseCardFiles: looseCardFiles, legacyTombstones: legacyTombstones ) } /// One card folder read into a `Card` — **the card parse, shared by both containers**. /// /// A trashed card is "an ordinary card in a special place" (03-board-ui.md § Trash), and this /// function is what makes that literally true rather than a claim two code paths have to keep /// agreeing on: the same strict `schema`/`order` validation, the same attachment listing, the /// same verbatim document. Its callers keep what genuinely differs by container — the /// lane-keyed loose-file and legacy-tombstone channels — outside it. /// /// `path` is root-relative and names the *folder*; the errors this throws name its `index.md`. /// Callers guard `isUUIDShaped` and `hasIndex` first, exactly as the lane walk always has. private static func parseCard(at cardURL: URL, path: String) throws(BoardLoadError) -> Card { let cardPath = path + "/" + indexFileName let document = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath) let schema = try validatedSchema(in: document, path: cardPath) let order = try validatedOrder(in: document, path: cardPath) return Card( id: ItemID(rawValue: cardURL.lastPathComponent), schema: schema, title: document.title, created: document.created, modified: document.modified, modifiedBy: document.modifiedBy, deleted: document.deleted, background: document.background, icon: document.icon, iconColor: document.iconColor, order: order, attachments: attachmentNames(in: cardURL), document: document ) } /// The candidate card folders inside `/.trash/`, or `[]` when there is no trash. /// /// **Absent is empty, not an error** — the container is minted by the first delete, so most /// boards never have one, and a board without a trash is a board with an empty trash. /// /// **A `.trash` that is not a plain directory yields nothing**: a file by that name, or a /// *symlink* — "symlinks are never traversed" (01-storage-format.md § Fractal layout ▸ Rules), /// and a symlinked trash would render bytes living outside the board that FSEvents never /// reports. Logged rather than warned: `LoadWarning` is the stray vocabulary and a claimed /// name is not a stray, so there is no case here that fits and nothing for a user to do about /// a name the app claims. /// /// Entries are `directoryCandidates` — hidden entries and symlinks already excluded, in /// folder-name order — so the trash gets the same stray tolerance every other container gets, /// including the one that matters most here: a **lane-shaped nesting** inside `.trash` (a /// whole lane folder dropped in by hand) is not a level. Its own UUID-shaped children are /// never enumerated, because the walk stops at a card exactly as it does under a lane; the /// outer folder either parses as a card (it has an `index.md`) or is skipped as /// `.missingIndex`, and either way nothing below it renders. private static func trashCandidates(in boardRoot: URL) -> [URL] { let trashURL = boardRoot.appendingPathComponent(trashFolderName, isDirectory: true) guard let values = try? trashURL.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return [] } guard values.isDirectory == true, values.isSymbolicLink != true else { logger.warning("\(trashFolderName, privacy: .public): not a plain directory, treated as an empty trash") return [] } return (try? directoryCandidates(in: trashURL)) ?? [] } // MARK: - Filesystem helpers private static func checkIsReadableDirectory(_ url: URL) throws(BoardLoadError) { var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { throw BoardLoadError(path: ".", reason: .unreadableRoot(message: "no such file or directory")) } guard isDirectory.boolValue else { throw BoardLoadError(path: ".", reason: .notADirectory) } } private static func hasIndex(_ folder: URL) -> Bool { FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path) } /// The names of `/attachments/`'s **top-level regular files** — the flat view /// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are /// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`. /// /// Three exclusions, the same three `directoryCandidates` makes and for the same reasons: /// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a /// subfolder stays reachable through Reveal in Finder and through body-relative paths, but /// never appears as an attachment), and symlinks (this loader resolves nothing — the same /// stance the level walk takes). /// /// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before /// `"shot 10.png"`: the order has to be stable across loads, and where it is already the /// sidebar's order it may as well be the same one. /// /// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for /// structure (01-storage-format.md § Malformed input), and this field decorates a card — a /// permissions race here must never be the reason a whole board refuses to open. /// /// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's /// authoritative listing — answers through this same function behind its own card-folder /// guard, so the face and the sidebar can never disagree about what a card's attachments are. /// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder /// the app ever creates under a card. static func attachmentNames(in cardFolder: URL) -> [String] { let folder = cardFolder.appendingPathComponent( BoardWriter.attachmentsFolderName, isDirectory: true ) guard let entries = try? FileManager.default.contentsOfDirectory( at: folder, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles] ) else { return [] } return entries .filter { url in guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { return false } return values.isRegularFile == true && values.isSymbolicLink != true } .map(\.lastPathComponent) .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } /// A card folder's **loose top-level files** — the one carve-out to uniform stray tolerance /// (01-storage-format.md § Fractal layout ▸ Rules, settled 2026-07-28, "Lanework-owns-the-board"): /// "a regular file sitting beside a card's `index.md` (not `attachments/`, not a reserved name) /// belongs in `attachments/`, and the app moves it there". /// /// **This function only notices.** It opens nothing, moves nothing, and creates nothing; the /// relocation is `BoardWriter.relocateLooseFiles`, run through the store's write bracket. A load /// is a pure function of the tree and stays one. /// /// Four exclusions, three of them `attachmentNames(in:)`' own and for its reasons: /// /// - **Directories.** The carve-out is exactly *files*. A stray folder in a card — a nested /// clone, a hand-made subfolder — keeps the verbatim posture, because "relocating a directory /// into the flat attachment model would be wrong". /// - **Symlinks**, never touched and never traversed (§ Rules) — the same stance the level walk /// takes. `isSymbolicLink` is checked *beside* `isRegularFile` rather than trusted to imply /// it, exactly as `directoryCandidates` does, so a link pointing at a file is excluded on its /// own account. /// - **Hidden entries.** `.DS_Store` and friends are not the user's files, and relocating one /// would surface it in a card's attachment list — the loudest possible way to be wrong about /// a file nobody wrote on purpose. It is also what keeps a crashed write's dot-prefixed /// residue out of the relocation. /// - **The reserved card-level names** (`reservedCardChildNames`), case-insensitively. /// /// Finder order (`localizedStandardCompare`), like every other name listing here, so the notice /// the store posts names files the way the board would sort them. /// /// Failure is silent (`[]`): a permissions race here must never be the reason a board refuses /// to open, and "nothing to relocate" is the safe reading of "cannot tell". static func looseFileNames(in cardFolder: URL) -> [String] { guard let entries = try? FileManager.default.contentsOfDirectory( at: cardFolder, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles] ) else { return [] } return entries .filter { url in guard !reservedCardChildNames.contains(url.lastPathComponent.lowercased()), let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else { return false } return values.isRegularFile == true && values.isSymbolicLink != true } .map(\.lastPathComponent) .sorted { $0.localizedStandardCompare($1) == .orderedAscending } } /// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**, /// per the shape-only identity predicate below. private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF") /// Whether `name` has a UUID's shape — hex, `8-4-4-4-12`, **any case and any version** — /// gating lane/card level detection (01-storage-format.md § Fractal layout ▸ Rules, "Name /// shape gates level detection"). This is *the* identity predicate, and it is deliberately /// **shape-only**: lowercase v4 is the app's emission rule, not the gate. /// /// - **Any case.** `uuidgen(1)` and Swift's own `UUID().uuidString` both print *uppercase*, /// so a strict lowercase gate would turn an agent's standard-tool card into a silently /// skipped stray — the worst failure mode for a files-first app. Accept liberally, emit /// conservatively: `BoardWriter` still writes only lowercase v4 and never renames an /// existing folder to canonicalize it. /// - **Any version.** The version (13th hex digit) and variant (17th hex digit) nibbles are /// **not** validated: they protect no invariant here — an agent's v7 is exactly as unique /// as a v4 — and the loader's job is recognizing the folder-naming *convention*, not /// re-deriving RFC 4122 conformance every load. /// /// Equivalent to "does `UUID(uuidString:)` parse it", which is how 01-storage-format.md /// states the rule; kept as a manual scan because that is the cheaper answer on the hot path /// (every folder of every load) and needs no bridging. /// /// Recognizing a name is not the same as *comparing* two of them: identity comparison is /// UUID-*value* equality, so two case-spellings of one UUID are one identity everywhere — /// see `ItemID` (`BoardModel.swift`), which stores the folder's exact spelling but compares /// canonically. /// /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` walks the same /// candidates the loader walked, and level detection has to be one rule, not two. static func isUUIDShaped(_ name: String) -> Bool { let groups = name.split(separator: "-", omittingEmptySubsequences: false) guard groups.map(\.count) == [8, 4, 4, 4, 12] else { return false } return groups.allSatisfy { $0.allSatisfy(uuidGroupCharacters.contains) } } /// Direct subdirectories of `folder`, in deterministic (folder-name) order, excluding /// hidden entries (`.DS_Store`, `.git`, …) and symlinks — the loader's uniform stray /// tolerance (01-storage-format.md § Fractal layout ▸ Rules). Stray *files* are excluded /// here too: only directories are level candidates at all, and the caller further narrows /// those to actual lane/card candidates by name shape (`isUUIDShaped`) before doing /// anything else with them. /// /// **`.trash/` is hidden, so it never appears among a board root's candidates** — which is /// exactly right: it is a container, not a level, and the walk reaches it by name instead /// (`trashCandidates(in:)`). This same call then enumerates *inside* it, so the trash's own /// children get identical stray tolerance. /// /// An unreadable non-root folder (permission changed mid-walk, races) degrades to "no /// candidates" rather than failing the whole load — fail-fast is reserved for the board /// root and for malformed `index.md` content, not transient directory-listing races below /// it. /// Internal rather than `private`: `BoardWriter.renumberVisibleChildren` enumerates /// siblings through this same door, so the writer's idea of "the children" can never drift /// from the loader's. It is also why `BoardWriter`'s temp files are dot-prefixed — the /// `.skipsHiddenFiles` here is what makes a crashed write's residue invisible to a load. static func directoryCandidates(in folder: URL) throws(BoardLoadError) -> [URL] { guard let entries = try? FileManager.default.contentsOfDirectory( at: folder, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], options: [.skipsHiddenFiles] ) else { return [] } return entries .filter { url in guard let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) else { return false } return values.isDirectory == true && values.isSymbolicLink != true } .sorted { $0.lastPathComponent < $1.lastPathComponent } } // MARK: - Document reading + field validation /// **Strict, byte-faithful UTF-8** — the same decode `BoardWriter` uses, and for the same /// reason: Foundation's NSString-backed `String(contentsOf:encoding:)` silently strips a /// leading BOM, which would let a BOM'd file *load* here and then refuse every write over /// in `BoardWriter` — a baffling split. 01-storage-format.md § Fractal layout ▸ Rules is /// explicit that a BOM'd file is rejected at load (it fails the frontmatter delimiter); /// decoding byte-faithfully is what makes that stated rejection actually happen. private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument { let data: Data do { data = try Data(contentsOf: url) } catch { throw BoardLoadError( path: path, reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", line: nil) ) } return try parseDocument(data, path: path) } /// The decode-and-parse half of `readDocument(at:path:)`, over bytes rather than a URL. /// /// Split out for the raw-source outlet, which validates bytes that are **not on disk yet** /// (`validateCardIndex`) — and split rather than copied on purpose: "Apply validates through the /// same fail-fast parse the loader uses" (05-card-window.md ▸ Raw source outlet) is only true if /// it is literally the same function. The strict UTF-8 decode is half of what that buys — a BOM'd /// or non-UTF-8 proposal is rejected here by the same two lines that reject one on disk /// (01-storage-format.md § Fractal layout ▸ Rules). static func parseDocument(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument { guard let text = String(validating: data, as: UTF8.self) else { throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil)) } do { return try FrontmatterDocument.parse(text) } catch { let line: Int? = if case let .unparseableYAML(_, line) = error { line } else { nil } throw BoardLoadError(path: path, reason: .unparseableYAML(message: error.description, line: line)) } } /// Whether `data` would load as a **card's** `index.md` — the raw-source Apply's gate /// (05-card-window.md ▸ Raw source outlet: "Apply validates through the same fail-fast parse the /// loader uses (detailed alert on error, stays in source mode) before writing byte-for-byte"). /// /// **Exactly the three checks `load(boardRoot:)` runs on a card**, in its order and through its /// own functions: decode + parse (`parseDocument`), then `schema` (present, well-formed, not /// newer than this app) and `order` (present, well-formed) — the two fields a card must carry. /// Nothing card-shaped is checked beyond that, because nothing else *is*: `title` is optional, /// unknown keys are the whole point of the outlet, and the body is free text. /// /// It deliberately does **not** check `uneditableShape`: that refusal exists for surgical /// span edits (`BoardWriter.updateIndex`), and raw source replaces the whole file — a flow-mapping /// frontmatter is precisely one of the things the escape hatch exists to let a user rewrite. /// /// The error is the loader's own, undiluted, so the alert can show the taxonomy's display text /// (line numbers included) rather than a re-worded copy. /// /// - Parameter path: what the error names — `indexFileName` from every call site today, which is /// what the card window's alert is about. public static func validateCardIndex(_ data: Data, path: String) throws(BoardLoadError) -> FrontmatterDocument { let document = try parseDocument(data, path: path) _ = try validatedSchema(in: document, path: path) _ = try validatedOrder(in: document, path: path) return document } private static func validatedSchema(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Int { switch document.schema { case .missing: throw BoardLoadError(path: path, reason: .missingSchema) case let .malformed(raw): throw BoardLoadError(path: path, reason: .malformedSchema(raw: raw)) case let .valid(value): guard value <= supportedSchema else { throw BoardLoadError(path: path, reason: .schemaNewerThanApp(found: value)) } return value } } private static func validatedOrder(in document: FrontmatterDocument, path: String) throws(BoardLoadError) -> Double { switch document.order { case .missing: throw BoardLoadError(path: path, reason: .missingOrder) case let .malformed(raw): throw BoardLoadError(path: path, reason: .malformedOrder(raw: raw)) case let .valid(value): return value } } } // MARK: - Result /// A successful load: the snapshot plus anything tolerated-but-notable encountered along the /// way. `warnings` is also logged as it accumulates (`os.Logger(subsystem: "dev.rzen.indie.Kanban", /// category: "loader")`) so it shows up in Console even if a caller never inspects it. public struct LoadResult: Sendable { public var model: BoardModel public var warnings: [LoadWarning] /// The cards this walk found carrying loose files, in the order the walk met them — the /// loose-file carve-out's detection channel (01-storage-format.md § Fractal layout ▸ Rules, /// settled 2026-07-28). /// /// **Its own field rather than a `LoadWarning` case**, because the two say opposite things. /// `warnings` is the *stray-tolerance* vocabulary: "this was ignored, it is staying exactly /// where it is, there is nothing to do". A loose card file is the one thing on a board that is /// **not** tolerated — it is pending work, and the store acts on it. Folding it into the /// warning channel would also mean throwing away everything the act needs (which lane, which /// card, which title, which names) and re-deriving it from a display string. /// /// Nothing renders this: a loose file is not content, and it reaches no view. Its one consumer /// is `BoardStore.relocateLooseCardFiles()`, which relocates and posts the notice. /// /// Tombstoned cards are included, and cards under tombstoned lanes with them. Where a file /// belongs on disk is a question about the *tree*, not about what the board is currently /// rendering — the same reason the loader flags a tombstoned card at all rather than dropping /// it. /// /// **Cards in `.trash/` are deliberately *not* walked for loose files in this version.** The /// channel is keyed by lane (`LooseCardFiles.laneID`, the store's path key) and a trashed card /// has no lane; widening the key is the store-side change that belongs with the store-side /// scheduling. Loose files beside a trashed card's `index.md` therefore keep the ordinary /// stray posture — tolerated, preserved verbatim — and are tidied the moment the card is /// restored into a lane, which is the only state in which they matter. public var looseCardFiles: [LooseCardFiles] = [] /// The legacy `deleted:` keys this walk found — the retired tombstone model's **migration /// input** (01-storage-format.md § Deletion, resettled 2026-07-28: "Legacy `deleted:` keys /// migrate on load-and-write, never destroy"). /// /// **The `looseCardFiles` idiom, for the same reason it exists**: `warnings` is the /// stray-*tolerance* vocabulary — "this was ignored, it is staying exactly where it is, there /// is nothing to do" — and a legacy tombstone is the opposite, pending work the store acts on. /// Folding it into the warning channel would also throw away everything the act needs (which /// lane, which card, which title) and force it to be re-derived from a display string. /// /// Nothing renders this. Its consumer is the store, which relocates each `.card` into /// `.trash/` with the key removed, strips each `.lane`'s key in place (a lane returns **live** /// — resurrection is the safe direction), and posts the warning-tone notice. Like the /// relocation it mirrors, the write is deferred under any read-only lock; the items stay /// rendered through the retiring tombstone path until it lands (see this type's `BoardLoader` /// note on the migration window). /// /// Board-level `deleted:` never appears here — it is meaningless, ignored and logged /// (`LoadWarning.boardLevelDeletedIgnored`), and nothing about it is the app's to rewrite. /// /// Order is the walk's: a lane's tombstoned cards, then the lane itself, lane by lane. public var legacyTombstones: [LegacyTombstone] = [] } /// One item found carrying a legacy `deleted:` key — everything its migration and notice need, /// and nothing more. /// /// The path is carried as its identity components rather than as a URL — `LooseCardFiles`' /// convention, for its reason: the write derives its path from the store's *current* root, which /// may have been re-resolved since the load. `title` is the item's as written, `nil` for an /// untitled one, because "Untitled" is a rendering and never a value (03-board-ui.md § Card face). public struct LegacyTombstone: Sendable, Equatable { /// Which migration this item takes — the two are genuinely different acts, not one act at two /// levels: a card *moves* (into `.trash/`, at a minted top-of-trash rank) and a lane stays /// exactly where it is (the key is stripped and it returns live). public enum Kind: Sendable, Equatable { case card case lane } public let kind: Kind /// The lane's own identity for `.lane`; the card's **containing** lane for `.card` — the /// context the relocation needs to find the folder at all. public let laneID: ItemID /// The card's identity for `.card`, `nil` for `.lane`. Two fields rather than an enum payload /// so the common "which folder is this" question is one path join at every call site. public let cardID: ItemID? public let title: String? public init(kind: Kind, laneID: ItemID, cardID: ItemID?, title: String?) { self.kind = kind self.laneID = laneID self.cardID = cardID self.title = title } } /// One card found holding files that belong in its `attachments/` — everything the relocation and /// its notice need, and nothing more. /// /// `title` is the card's as written, `nil` for an untitled one: "Untitled" is a rendering, never a /// value (03-board-ui.md § Card face), so the phrasing layer decides what to call it. The path is /// carried as its two identity components rather than as a URL, `ItemPath`'s convention, /// so the write derives its path from the store's *current* root. public struct LooseCardFiles: Sendable, Equatable { public let laneID: ItemID public let cardID: ItemID public let title: String? /// The loose files' names, in Finder order (`BoardLoader.looseFileNames`). Never empty — a card /// with nothing loose contributes no entry at all. public let fileNames: [String] public init(laneID: ItemID, cardID: ItemID, title: String?, fileNames: [String]) { self.laneID = laneID self.cardID = cardID self.title = title self.fileNames = fileNames } } /// A tolerated anomaly the loader kept going past. Never blocks a load — see `BoardLoadError` /// for what does. public enum LoadWarning: Sendable, Equatable, CustomStringConvertible { /// A **UUID-shaped** folder below the board root has no `index.md` — skipped, not /// fail-fast (an interrupted two-step create must not brick the board). `path` is relative /// to the board root. Only reachable for a folder that passed `isUUIDShaped`; a /// non-UUID-shaped folder missing `index.md` gets `.nonUUIDFolderIgnored` instead, never /// this case. case missingIndex(path: String) /// A lane/card-depth folder whose name doesn't have a UUID's shape (`isUUIDShaped` — hex, /// `8-4-4-4-12`, any case, any version) — /// skipped, not fail-fast, regardless of whether it holds a valid `index.md`, a broken one, /// or none at all (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level /// detection"). Preserved verbatim on disk, never descended into. `path` is relative to the /// board root. case nonUUIDFolderIgnored(path: String) /// A board-level `deleted:` key is legal per the frontmatter table but meaningless /// (01-storage-format.md § Deletion) — ignored, never tombstones the board. case boardLevelDeletedIgnored public var description: String { switch self { case let .missingIndex(path): "\(path): folder has no index.md, skipped" case let .nonUUIDFolderIgnored(path): "\(path): folder name is not UUID-shaped, ignored as a stray" case .boardLevelDeletedIgnored: "index.md: board-level 'deleted' key is meaningless, ignored" } } } // MARK: - Error /// A fail-fast structural failure loading a board — loud and specific: `path` (relative to /// the board root where one exists) plus `reason` says exactly what's wrong. No partial /// loads: throwing this means `BoardLoader.load` produced nothing at all. public struct BoardLoadError: Error, Sendable, Equatable, CustomStringConvertible { public let path: String public let reason: Reason public var description: String { "\(path): \(reason.description)" } public enum Reason: Sendable, Equatable, CustomStringConvertible { /// The board root itself has no `index.md` — unlike every level below it, this is not /// skip-and-warn: there is no board without one. case boardRootMissingIndex /// Wraps any `FrontmatterError` from parsing — bad delimiters, bad YAML, a /// frontmatter block that isn't a mapping. `line` is 1-based within the file when the /// underlying error carries one. case unparseableYAML(message: String, line: Int?) case missingSchema case malformedSchema(raw: String) /// `schema` is present, valid, and greater than this app's `supportedSchema`. case schemaNewerThanApp(found: Int) /// `order` is required on lanes and cards, never on the board itself. case missingOrder case malformedOrder(raw: String) /// The board root exists but is a file, not a directory. case notADirectory /// The board root doesn't exist, or its contents couldn't be listed. case unreadableRoot(message: String) public var description: String { switch self { case .boardRootMissingIndex: "board root is missing index.md" case let .unparseableYAML(message, line): if let line { "unparseable YAML at line \(line): \(message)" } else { "unparseable YAML: \(message)" } case .missingSchema: "missing required 'schema' field" case let .malformedSchema(raw): "malformed 'schema' field: \(raw)" case let .schemaNewerThanApp(found): "schema \(found) is newer than this app supports (schema \(BoardLoader.supportedSchema))" case .missingOrder: "missing required 'order' field" case let .malformedOrder(raw): "malformed 'order' field: \(raw)" case .notADirectory: "board root is not a directory" case let .unreadableRoot(message): "board root is unreadable: \(message)" } } } }