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 UUIDv4's shape — lowercase hex, `8-4-4-4-12` — 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); since cards are leaves here — this loader /// never scans a card folder's contents beyond checking for `index.md` — that reservation is /// satisfied by construction and needs no explicit filtering. Doubly so under the shape rule: /// were a card folder ever scanned, `attachments` and `comments` are non-UUID-shaped and would /// read as strays, not levels — so they never need special-casing against the stray warning. /// /// 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. public enum BoardLoader: Sendable { /// Schema version this app understands; anything higher fails fast /// (01-storage-format.md § Malformed input). `fileprivate` rather than `private`: also /// read by `BoardLoadError.Reason.description` below, in this same file. fileprivate 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. private 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" 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)") } // Legal per the frontmatter table, meaningless at board level — ignore and log, never // tombstone (01-storage-format.md § Deletion). if !boardDocument.deleted.isMissing { warn(.boardLevelDeletedIgnored) } var lanes: [Lane] = [] for laneURL in try directoryCandidates(in: boardRoot) { let laneName = laneURL.lastPathComponent 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 cardPath = cardRelPath + "/" + indexFileName let cardDocument = try readDocument(at: cardURL.appendingPathComponent(indexFileName), path: cardPath) let cardSchema = try validatedSchema(in: cardDocument, path: cardPath) let cardOrder = try validatedOrder(in: cardDocument, path: cardPath) cards.append(Card( id: ItemID(rawValue: cardName), schema: cardSchema, title: cardDocument.title, created: cardDocument.created, modified: cardDocument.modified, modifiedBy: cardDocument.modifiedBy, deleted: cardDocument.deleted, background: cardDocument.background, icon: cardDocument.icon, iconColor: cardDocument.iconColor, order: cardOrder, document: cardDocument )) } 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 )) } 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 }), document: boardDocument ) return LoadResult(model: model, warnings: warnings) } // 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 lowercase hex characters `isUUIDShaped` accepts in each `-`-delimited group. private static let lowercaseHexDigits = Set("0123456789abcdef") /// Whether `name` has UUIDv4's shape — lowercase hex, `8-4-4-4-12` — gating lane/card level /// detection (01-storage-format.md § Fractal layout ▸ Rules, "Name shape gates level /// detection"). Deliberately permissive about *which* nibbles matter: the version (13th hex /// digit) and variant (17th hex digit) are **not** validated, so any lowercase-hex string in /// this shape reads as a candidate — whether or not it was actually minted by /// `UUID().uuidString.lowercased()`. That reading is intentional, not an oversight: the /// loader's job is recognizing the folder-naming *convention*, not re-deriving RFC 4122 /// conformance every load. Case-sensitive — an uppercase or mixed-case UUID string is a /// stray, matching `ItemID`'s byte-perfect, never-normalized storage of the folder name /// (`BoardModel.swift`). /// /// 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(lowercaseHexDigits.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. /// /// 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 text: String do { let data = try Data(contentsOf: url) guard let decoded = String(validating: data, as: UTF8.self) else { throw BoardLoadError(path: path, reason: .unparseableYAML(message: "file is not UTF-8", line: nil)) } text = decoded } catch let error as BoardLoadError { throw error } catch { throw BoardLoadError( path: path, reason: .unparseableYAML(message: "could not read file: \(error.localizedDescription)", 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)) } } 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] } /// 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 UUIDv4's shape (`isUUIDShaped`) — /// 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)" } } } }