Build BoardLoader — fail-fast fractal tree loading
Pure tree walk: root → lanes → cards, level is position. Fail-fast with path+reason for bad YAML, missing/malformed schema or order, newer schema, rootless board; index-less folders skip with a collected+logged warning; strays, hidden files, and symlinks ignored; board-level deleted ignored with a warning; tombstones loaded and flagged. 17 tests. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -0,0 +1,323 @@
|
|||||||
|
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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import Kanban
|
||||||
|
|
||||||
|
// MARK: - Fixture builder
|
||||||
|
|
||||||
|
/// A tiny in-memory-driven builder for synthetic board trees under a temp directory. Smoke
|
||||||
|
/// coverage only (per 02-architecture.md § Testing) — a comprehensive golden-fixture suite
|
||||||
|
/// over real trees under `Fixtures/` is a separate, later card.
|
||||||
|
private struct BoardFixture {
|
||||||
|
let root: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
root = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("BoardLoaderTests-\(UUID().uuidString)", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tearDown() {
|
||||||
|
try? FileManager.default.removeItem(at: root)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Writes `index.md` at `relativePath` (created if needed), with `body` after the
|
||||||
|
/// frontmatter delimiters.
|
||||||
|
@discardableResult
|
||||||
|
func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL {
|
||||||
|
let folder = relativePath.isEmpty ? root : root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
let text = "---\n\(frontmatter)---\n\(body)"
|
||||||
|
try text.write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8)
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a folder with no `index.md` — the "interrupted two-step create" shape.
|
||||||
|
@discardableResult
|
||||||
|
func emptyFolder(_ relativePath: String) throws -> URL {
|
||||||
|
let folder = root.appendingPathComponent(relativePath, isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
|
||||||
|
return folder
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stray file, not a folder — never a level candidate regardless of its name.
|
||||||
|
func strayFile(_ relativePath: String, contents: String = "stray") throws {
|
||||||
|
let url = root.appendingPathComponent(relativePath)
|
||||||
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try contents.write(to: url, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func expectFailure(
|
||||||
|
_ expectedReason: BoardLoadError.Reason,
|
||||||
|
path: String,
|
||||||
|
_ operation: () throws -> Void
|
||||||
|
) {
|
||||||
|
do {
|
||||||
|
try operation()
|
||||||
|
Issue.record("expected BoardLoadError(\(path), \(expectedReason)) but load succeeded")
|
||||||
|
} catch let error as BoardLoadError {
|
||||||
|
#expect(error.path == path)
|
||||||
|
#expect(error.reason == expectedReason)
|
||||||
|
} catch {
|
||||||
|
Issue.record("expected a BoardLoadError, got \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Well-formed board
|
||||||
|
|
||||||
|
struct BoardLoaderWellFormedTests {
|
||||||
|
@Test func loadsCompleteStructureWithOrderingAndTombstoneFlags() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\ntitle: Demo Board\ntemplate: {order: 3}\n")
|
||||||
|
try fixture.index("b-lane", "schema: 1\norder: 2048\ntitle: B Lane\n")
|
||||||
|
try fixture.index("a-lane", "schema: 1\norder: 1024\ntitle: A Lane\n")
|
||||||
|
|
||||||
|
try fixture.index("a-lane/card-2", "schema: 1\norder: 2048\ntitle: Second\n")
|
||||||
|
try fixture.index("a-lane/card-1", "schema: 1\norder: 1024\ntitle: First\n")
|
||||||
|
try fixture.index(
|
||||||
|
"a-lane/card-deleted",
|
||||||
|
"schema: 1\norder: 512\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
let model = result.model
|
||||||
|
|
||||||
|
#expect(model.title.value == "Demo Board")
|
||||||
|
#expect(model.schema == 1)
|
||||||
|
if case let .mapping(pairs) = model.template {
|
||||||
|
#expect(pairs.count == 1)
|
||||||
|
} else {
|
||||||
|
Issue.record("expected template to be a mapping, got \(String(describing: model.template))")
|
||||||
|
}
|
||||||
|
|
||||||
|
#expect(model.lanes.map(\.id.rawValue) == ["a-lane", "b-lane"])
|
||||||
|
|
||||||
|
let laneA = try #require(model.lanes.first { $0.id.rawValue == "a-lane" })
|
||||||
|
#expect(laneA.cards.map(\.id.rawValue) == ["card-deleted", "card-1", "card-2"])
|
||||||
|
#expect(laneA.cards.map(\.isDeleted) == [true, false, false])
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func tiesAreBrokenByFolderNameNotTitle() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
// Same order; titles are reversed relative to folder-name order, to catch a loader
|
||||||
|
// that accidentally wires title into the tie-break instead of the folder name.
|
||||||
|
try fixture.index("zzz-lane", "schema: 1\norder: 1024\ntitle: Should Be Second\n")
|
||||||
|
try fixture.index("aaa-lane", "schema: 1\norder: 1024\ntitle: Should Be First\n")
|
||||||
|
|
||||||
|
let model = try BoardLoader.load(boardRoot: fixture.root).model
|
||||||
|
#expect(model.lanes.map(\.id.rawValue) == ["aaa-lane", "zzz-lane"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Skip rules
|
||||||
|
|
||||||
|
struct BoardLoaderSkipTests {
|
||||||
|
@Test func indexlessFolderBelowRootIsSkippedWithWarning() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.emptyFolder("orphan-lane")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"])
|
||||||
|
#expect(result.warnings.contains(.missingIndex(path: "orphan-lane")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func indexlessCardFolderIsSkippedWithWarningAndRestOfBoardStillLoads() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.emptyFolder("lane-1/orphan-card")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
let lane = try #require(result.model.lanes.first)
|
||||||
|
#expect(lane.cards.map(\.id.rawValue) == ["card-1"])
|
||||||
|
#expect(result.warnings.contains(.missingIndex(path: "lane-1/orphan-card")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Strays ignored
|
||||||
|
|
||||||
|
struct BoardLoaderStrayTests {
|
||||||
|
@Test func strayFilesAndHiddenEntriesAreIgnoredWithoutWarning() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("lane-1/card-1", "schema: 1\norder: 1024\n")
|
||||||
|
|
||||||
|
try fixture.strayFile("notes.txt")
|
||||||
|
try fixture.strayFile(".DS_Store")
|
||||||
|
try fixture.strayFile("lane-1/notes.txt")
|
||||||
|
_ = try fixture.emptyFolder(".git")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.lanes.map(\.id.rawValue) == ["lane-1"])
|
||||||
|
#expect(result.model.lanes[0].cards.map(\.id.rawValue) == ["card-1"])
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func directorySymlinkIsTreatedAsStrayNotFollowed() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
let realLane = try fixture.index("real-lane", "schema: 1\norder: 1024\n")
|
||||||
|
try FileManager.default.createSymbolicLink(
|
||||||
|
at: fixture.root.appendingPathComponent("linked-lane"),
|
||||||
|
withDestinationURL: realLane
|
||||||
|
)
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.lanes.map(\.id.rawValue) == ["real-lane"])
|
||||||
|
#expect(result.warnings.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Board-level deleted
|
||||||
|
|
||||||
|
struct BoardLoaderBoardLevelDeletedTests {
|
||||||
|
@Test func boardLevelDeletedIsIgnoredAndWarned() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\ndeleted: 2026-01-01T00:00:00Z\n")
|
||||||
|
|
||||||
|
let result = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
#expect(result.model.lanes.isEmpty)
|
||||||
|
#expect(result.warnings.contains(.boardLevelDeletedIgnored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fail-fast
|
||||||
|
|
||||||
|
struct BoardLoaderFailFastTests {
|
||||||
|
@Test func rootMissingIndexThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
expectFailure(.boardRootMissingIndex, path: "index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func rootThatIsAFileThrowsNotADirectory() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
let fileRoot = fixture.root.appendingPathComponent("not-a-folder")
|
||||||
|
try "hello".write(to: fileRoot, atomically: true, encoding: .utf8)
|
||||||
|
|
||||||
|
expectFailure(.notADirectory, path: ".") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fileRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unreadableRootThrows() throws {
|
||||||
|
let missing = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent("BoardLoaderTests-does-not-exist-\(UUID().uuidString)")
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: missing)
|
||||||
|
Issue.record("expected a BoardLoadError but load succeeded")
|
||||||
|
} catch {
|
||||||
|
#expect(error.path == ".")
|
||||||
|
if case .unreadableRoot = error.reason {
|
||||||
|
// expected
|
||||||
|
} else {
|
||||||
|
Issue.record("expected .unreadableRoot, got \(error.reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func unparseableYAMLThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
// No closing delimiter.
|
||||||
|
try "---\nschema: 1\n".write(
|
||||||
|
to: fixture.root.appendingPathComponent("index.md"),
|
||||||
|
atomically: true,
|
||||||
|
encoding: .utf8
|
||||||
|
)
|
||||||
|
|
||||||
|
do {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
Issue.record("expected a BoardLoadError but load succeeded")
|
||||||
|
} catch {
|
||||||
|
#expect(error.path == "index.md")
|
||||||
|
if case .unparseableYAML = error.reason {
|
||||||
|
// expected
|
||||||
|
} else {
|
||||||
|
Issue.record("expected .unparseableYAML, got \(error.reason)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingSchemaThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "title: No Schema\n")
|
||||||
|
|
||||||
|
expectFailure(.missingSchema, path: "index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func malformedSchemaThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: not-a-number\n")
|
||||||
|
|
||||||
|
expectFailure(.malformedSchema(raw: "not-a-number"), path: "index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func schemaNewerThanAppThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 2\n")
|
||||||
|
|
||||||
|
expectFailure(.schemaNewerThanApp(found: 2), path: "index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingOrderOnLaneThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index("lane-1", "schema: 1\n")
|
||||||
|
|
||||||
|
expectFailure(.missingOrder, path: "lane-1/index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func malformedOrderOnLaneThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index("lane-1", "schema: 1\norder: not-a-number\n")
|
||||||
|
|
||||||
|
expectFailure(.malformedOrder(raw: "not-a-number"), path: "lane-1/index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func missingOrderOnCardThrows() throws {
|
||||||
|
let fixture = try BoardFixture()
|
||||||
|
defer { fixture.tearDown() }
|
||||||
|
|
||||||
|
try fixture.index("", "schema: 1\n")
|
||||||
|
try fixture.index("lane-1", "schema: 1\norder: 1024\n")
|
||||||
|
try fixture.index("lane-1/card-1", "schema: 1\n")
|
||||||
|
|
||||||
|
expectFailure(.missingOrder, path: "lane-1/card-1/index.md") {
|
||||||
|
_ = try BoardLoader.load(boardRoot: fixture.root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user