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. Any non-reserved directory containing `index.md` at those depths is a level /// regardless of its name — no UUID-shape filtering, no name-based gating. /// /// 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. /// /// 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" private 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 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 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) } /// 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. /// /// 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. private 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 private static func readDocument(at url: URL, path: String) throws(BoardLoadError) -> FrontmatterDocument { let text: String do { text = try String(contentsOf: url, encoding: .utf8) } 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 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. case missingIndex(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 .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)" } } } }