From 8c0cec06ee4669c896961c098a778dfd56d3ff6f Mon Sep 17 00:00:00 2001 From: rzen Date: Sun, 26 Jul 2026 17:01:09 -0400 Subject: [PATCH] Implement create operations for board, lane, and card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createBoard fills or mints a .kanban (or extension-less) folder and refuses to clobber an existing board; createLane/createCard mint fresh lowercase-UUIDv4 folders and append at max+1024 among visible siblings via the same strict scan renumber uses (extracted, shared). New files carry schema/title?/order?/created/modified — created==modified, no modified-by — written LF through the same atomic temp+rename path. The editability pre-flight is now scoped to operations that rewrite siblings: renumber refuses on an uneditable sibling, a create beside one proceeds. Schema constant unified on BoardLoader.supportedSchema. Info.plist declares the .kanban document type (UTI conforming to com.apple.package) so Finder treats a board as one document. 20 new unit tests; 196 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A --- Kanban/Info.plist | 34 +++ Kanban/Storage/BoardLoader.swift | 8 +- Kanban/Storage/BoardWriter.swift | 220 +++++++++++++++++-- KanbanTests/BoardWriterTests.swift | 327 +++++++++++++++++++++++++++++ 4 files changed, 567 insertions(+), 22 deletions(-) diff --git a/Kanban/Info.plist b/Kanban/Info.plist index 0d32009..7462934 100644 --- a/Kanban/Info.plist +++ b/Kanban/Info.plist @@ -26,5 +26,39 @@ © 2026 rzen NSPrincipalClass NSApplication + UTExportedTypeDeclarations + + + UTTypeIdentifier + dev.rzen.indie.kanban-board + UTTypeConformsTo + + com.apple.package + public.directory + + UTTypeDescription + Lanework Board + UTTypeTagSpecification + + public.filename-extension + kanban + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Lanework Board + CFBundleTypeRole + Editor + LSItemContentTypes + + dev.rzen.indie.kanban-board + + LSTypeIsPackage + + + diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 45c62d4..fb8741a 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -30,9 +30,11 @@ import os 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 + /// (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 diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index ad14e35..e54bddf 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -114,6 +114,163 @@ public enum BoardWriter: Sendable { } } + // MARK: - Create + + /// Creates a new board: the folder (if it does not already exist) and its `index.md` — the + /// write side of `.kanban` packaging (01-storage-format.md § Document packaging). Two + /// callers: template instantiation, which passes the user-chosen document name as `title` + /// so the window title and the Finder name start out matching (§ Board naming), and a bare + /// "New Board" flow, which passes `nil` and lets the UI fall back to the folder name. + /// + /// - `rootURL` is created if missing (`withIntermediateDirectories: true`); an existing + /// *empty* folder is simply filled in, since that is exactly what an interrupted create or + /// a hand-made folder looks like. + /// - **Refuses to clobber an existing board**: if `index.md` is already there, the call + /// fails `.io` naming the path and the existing file is never touched — a create must + /// never overwrite a board that already exists. + /// - `title == nil` writes no `title` key at all — the folder-name fallback (§ Board + /// naming) is a *missing* key, not an empty string, which would be a real (if blank) title. + /// - Key order: `schema`, `title` (only when supplied), `created`, `modified`. No `order` — + /// that field belongs to lanes and cards, never the board root. + /// - Extension-less board folders are exactly as legal a target as a `.kanban`-suffixed one + /// (§ Document packaging, "Extension-less board folders still open") — this call never + /// looks at `rootURL`'s extension. + public static func createBoard(at rootURL: URL, title: String?) throws(BoardWriteError) { + let operation = "create board" + let indexURL = rootURL.appendingPathComponent(BoardLoader.indexFileName) + + guard !FileManager.default.fileExists(atPath: indexURL.path) else { + throw BoardWriteError(operation: operation, path: indexURL.path, reason: .io(message: "a board already exists here")) + } + + do { + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + } catch { + throw BoardWriteError( + operation: operation, + path: rootURL.path, + reason: .io(message: "could not create board folder: \(error.localizedDescription)") + ) + } + + try atomicReplace(text: newDocumentText(title: title, order: nil), at: indexURL, operation: operation) + } + + /// Creates a lane in a board: mints a fresh lowercase-UUIDv4 folder directly under + /// `rootURL`, appends it after the board's current visible lanes, and writes its + /// `index.md`. Returns the new identity. See `createChild(inParent:title:operation:)` for + /// the shared mechanics. + public static func createLane(inBoard rootURL: URL, title: String?) throws(BoardWriteError) -> ItemID { + try createChild(inParent: rootURL, title: title, operation: "create lane") + } + + /// Creates a card in a lane: mints a fresh lowercase-UUIDv4 folder directly under + /// `laneURL`, appends it after the lane's current visible cards, and writes its `index.md`. + /// Returns the new identity. See `createChild(inParent:title:operation:)` for the shared + /// mechanics. + public static func createCard(inLane laneURL: URL, title: String?) throws(BoardWriteError) -> ItemID { + try createChild(inParent: laneURL, title: title, operation: "create card") + } + + /// The shared body of `createLane`/`createCard` — a lane under a board and a card under a + /// lane are the same operation one level apart (01-storage-format.md § Fractal layout, + /// "Level is position"): check the parent, place the new identity after the current visible + /// siblings, mint it, write its file. + /// + /// - **The parent must already exist as a directory.** Anything else — missing, or a file + /// where a folder belongs — is a loud `.unreadable` naming `parentFolder` up front, never + /// a silent `mkdir` into a broken location. + /// - **Order is `max + 1024` among visible siblings** (`Ranks.append(toVisible:)`), read by + /// `visibleSiblings(of:operation:)` — the identical strict scan `renumberVisibleChildren` + /// uses, so the two can never disagree about who counts as a sibling. An empty parent's + /// first child lands at `1024`, the board convention (01-storage-format.md § Ordering). + /// - **Two-step creation is inherent, not a shortcut taken here**: the folder is created, + /// then `index.md` is written, as two separate filesystem operations — there is no atomic + /// "create a directory with content already in it" primitive to reach for. A crash between + /// the two leaves a UUID-shaped folder with no `index.md`, exactly the shape + /// `BoardLoader`'s `.missingIndex` skip-and-warn rule (and `visibleSiblings`'s own + /// `fileExists` check) already tolerate — see that type's doc comment. + private static func createChild( + inParent parentFolder: URL, + title: String?, + operation: String + ) throws(BoardWriteError) -> ItemID { + try checkIsDirectory(parentFolder, operation: operation) + + let siblings = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: false) + let order = Ranks.append(toVisible: siblings.map(\.order)) + + let folder = try mintUUIDFolder(in: parentFolder, operation: operation) + let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) + try atomicReplace(text: newDocumentText(title: title, order: order), at: indexURL, operation: operation) + + return ItemID(rawValue: folder.lastPathComponent) + } + + /// The frontmatter text for a file the app is minting outright — never a rewrite, so this + /// goes through `FrontmatterDocument(body:)` and `set`, not `updateIndex`: there is no prior + /// document to read fresh, refuse an uneditable shape from, or stamp over. `created` and + /// `modified` share one `Date()` so the two stamps are identical, not merely close; + /// `modified-by` is never written, matching the engine's absence-means-app-authored + /// convention (§ Frontmatter). Key order — `schema`, `title` (only when supplied), `order` + /// (only when supplied — `nil` for a board, always present for a lane/card), `created`, + /// `modified` — is simply the order `set` is called in, since each call appends a fresh key + /// before the closing delimiter of an otherwise-empty document. + private static func newDocumentText(title: String?, order: Double?) -> String { + var document = FrontmatterDocument(body: "") + document.set(FrontmatterKeys.schema, to: .int(BoardLoader.supportedSchema)) + if let title { + document.set(FrontmatterKeys.title, to: .string(title)) + } + if let order { + document.set(FrontmatterKeys.order, to: .double(order)) + } + let now = Date() + document.set(FrontmatterKeys.created, to: .date(now)) + document.set(FrontmatterKeys.modified, to: .date(now)) + return document.serialized() + } + + /// A fresh lowercase-UUIDv4 folder under `parentFolder` — the folder-naming convention + /// itself (01-storage-format.md § Fractal layout ▸ Rules, "Folder names are lowercase + /// UUIDv4"). `UUID().uuidString` is uppercase; `.lowercased()` is what makes the name match + /// `BoardLoader.isUUIDShaped`, which is case-sensitive by design. A freshly minted UUID + /// already existing is astronomically unlikely — 122 bits of randomness per mint — but + /// checked for and re-minted anyway rather than assumed away; the loop body is trivial + /// precisely because the case it handles essentially never fires. + private static func mintUUIDFolder(in parentFolder: URL, operation: String) throws(BoardWriteError) -> URL { + var candidate: URL + repeat { + candidate = parentFolder.appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true) + } while FileManager.default.fileExists(atPath: candidate.path) + + do { + try FileManager.default.createDirectory(at: candidate, withIntermediateDirectories: false) + } catch { + throw BoardWriteError( + operation: operation, + path: candidate.path, + reason: .io(message: "could not create folder: \(error.localizedDescription)") + ) + } + return candidate + } + + /// The parent-exists check `createChild` runs before touching the filesystem any further — + /// shared instead of folded into `createChild` because "does this folder exist" has nothing + /// to do with siblings or minting, and inlining it would bury the one thing a caller most + /// needs to see at a glance: a missing or wrong-shaped parent is rejected before anything + /// else happens. + private static func checkIsDirectory(_ url: URL, operation: String) throws(BoardWriteError) { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent folder does not exist")) + } + guard isDirectory.boolValue else { + throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "parent is not a directory")) + } + } + // MARK: - Renumber /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback @@ -121,20 +278,14 @@ public enum BoardWriter: Sendable { /// exception to "a reorder rewrites only the moved item"**. Uniform across levels: the /// parent is a lane (renumbering its cards) or the board root (renumbering its lanes). /// - /// - **Runs over loaded, valid children.** Every UUID-shaped child folder holding an - /// `index.md` is parsed first; one that fails to parse, or that lacks a usable `order`, - /// fails the whole operation before anything is written. A renumber is bookkeeping inside - /// a user action that already succeeded in principle — it must not be the thing that - /// discovers a broken sibling halfway through rewriting the lane. - /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is - /// *present* is not counted, not sorted, and not rewritten — presence, not validity, is - /// the test, exactly as `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is - /// absence to both). + /// - **Runs over loaded, valid children** (`visibleSiblings(of:operation:)`): one that fails + /// to parse, or that lacks a usable `order`, fails the whole operation before anything is + /// written. A renumber is bookkeeping inside a user action that already succeeded in + /// principle — it must not be the thing that discovers a broken sibling halfway through + /// rewriting the lane. /// - **Display order is the assignment order** (`Ranks.isOrderedForDisplay`: `order` /// ascending, folder name breaking ties) — the same rule the loader sorts by, so a /// renumber is guaranteed to be sequence-preserving: nothing visibly moves. - /// - **Strays are untouched**: non-UUID-shaped folders and UUID-shaped folders without an - /// `index.md` are skipped here for the same reasons `BoardLoader` skips them. /// /// Each child's rewrite is atomic; the batch is not. An interrupted renumber leaves some /// siblings renumbered and some not — every `order` still a valid float, display order @@ -142,7 +293,42 @@ public enum BoardWriter: Sendable { /// noted in § Ordering, which the deterministic tie-break exists to make harmless. public static func renumberVisibleChildren(of parentFolder: URL) throws(BoardWriteError) { let operation = "renumber children" + let visible = try visibleSiblings(of: parentFolder, operation: operation, requireEditable: true) + let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent }) + for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) { + try updateIndex(inItemFolder: child.folder, operation: operation) { document in + document.set(FrontmatterKeys.order, to: .double(rank)) + } + } + } + + /// The strict visible-sibling scan shared by `renumberVisibleChildren` and the create + /// operations' order assignment — extracted into one place so the two can never disagree + /// about who counts as a sibling. Walks `parentFolder`'s UUID-shaped children holding an + /// `index.md` (`BoardLoader.directoryCandidates`/`isUUIDShaped` — the same level-detection + /// rule the loader uses), each parsed strictly: + /// + /// - **Tombstones are inert to ordering** (§ Deletion): a child whose `deleted` key is + /// *present* is not counted — presence, not validity, is the test, exactly as + /// `Lane`/`Card.isDeleted` reads it (an explicit `deleted: null` is absence to both). + /// - **Strays are untouched**: non-UUID-shaped folders, and UUID-shaped folders without an + /// `index.md` (an interrupted two-step create — the loader's own `.missingIndex` warning + /// tolerates exactly this), are skipped here for the same reasons `BoardLoader` skips them. + /// - A visible sibling's missing or malformed `order` fails the *whole* operation, naming + /// that sibling's file, before anything is written — the same discover-before-you-write + /// guarantee `renumberVisibleChildren`'s batch depends on. + /// - `requireEditable` scopes the readable-but-uneditable pre-flight to the caller that + /// will actually *rewrite* the siblings: renumber passes `true` (it must not discover an + /// unwritable sibling halfway through the batch), the creates pass `false` — a create + /// only *reads* its siblings' orders, and a flow-mapping sibling that loads and renders + /// normally (01-storage-format.md § Frontmatter) must not block creating a new item + /// beside it. + private static func visibleSiblings( + of parentFolder: URL, + operation: String, + requireEditable: Bool + ) throws(BoardWriteError) -> [(folder: URL, order: Double)] { let candidates: [URL] do { candidates = try BoardLoader.directoryCandidates(in: parentFolder) @@ -161,7 +347,9 @@ public enum BoardWriter: Sendable { let document = try readDocument(at: indexURL, operation: operation) guard document.deleted.isMissing else { continue } - try checkEditable(document, at: indexURL, operation: operation) + if requireEditable { + try checkEditable(document, at: indexURL, operation: operation) + } switch document.order { case .missing: @@ -180,13 +368,7 @@ public enum BoardWriter: Sendable { visible.append((folder: folder, order: order)) } } - - let ordered = Ranks.sortedForDisplay(visible, order: { $0.order }, name: { $0.folder.lastPathComponent }) - for (child, rank) in zip(ordered, Ranks.renumbered(count: ordered.count)) { - try updateIndex(inItemFolder: child.folder, operation: operation) { document in - document.set(FrontmatterKeys.order, to: .double(rank)) - } - } + return visible } // MARK: - Reading diff --git a/KanbanTests/BoardWriterTests.swift b/KanbanTests/BoardWriterTests.swift index e892e6f..f2af662 100644 --- a/KanbanTests/BoardWriterTests.swift +++ b/KanbanTests/BoardWriterTests.swift @@ -593,3 +593,330 @@ struct BoardWriterLoaderIntegrationTests { #expect(result.model.lanes.first?.cards.first?.title == .valid("Card")) } } + +// MARK: - Create board + +/// `BoardWriter.createBoard`: the folder (if missing) plus a freshly minted `index.md` — never +/// a rewrite, so these tests check what a brand-new file looks like rather than what a rewrite +/// preserved. +struct BoardWriterCreateBoardTests { + @Test func createsAFolderAndAValidIndexTheLoaderOpens() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("MyBoard.kanban") + + try BoardWriter.createBoard(at: root, title: "My Board") + + let result = try BoardLoader.load(boardRoot: root) + #expect(result.warnings.isEmpty) + #expect(result.model.schema == 1) + #expect(result.model.title == .valid("My Board")) + #expect(result.model.modifiedBy == .missing) + + let created = try #require(result.model.created.value) + let modified = try #require(result.model.modified.value) + #expect(abs(created.timeIntervalSinceNow) < 60) + #expect(abs(modified.timeIntervalSinceNow) < 60) + } + + @Test func keyOrderIsSchemaTitleCreatedModified() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("MyBoard.kanban") + + try BoardWriter.createBoard(at: root, title: "My Board") + + let document = try FrontmatterDocument.parse(fixture.indexText("MyBoard.kanban")) + #expect(document.keys == ["schema", "title", "created", "modified"]) + } + + @Test func aNilTitleWritesNoTitleKeyAndTheLoaderReadsItMissing() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("Untitled.kanban") + + try BoardWriter.createBoard(at: root, title: nil) + + let document = try FrontmatterDocument.parse(fixture.indexText("Untitled.kanban")) + #expect(document.keys == ["schema", "created", "modified"]) + #expect(!document.contains(FrontmatterKeys.title)) + + let result = try BoardLoader.load(boardRoot: root) + #expect(result.model.title == .missing) + } + + @Test func refusesToClobberAnExistingBoard() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let existing = "---\nschema: 1\ntitle: Existing\n---\nbody\n" + let root = try fixture.item("Existing.kanban", existing) + + let error = writeFailure { + try BoardWriter.createBoard(at: root, title: "New") + } + guard case .io = error?.reason else { + Issue.record("expected .io, got \(String(describing: error?.reason))") + return + } + #expect(error?.path.hasSuffix("Existing.kanban/index.md") == true) + #expect(try fixture.indexText("Existing.kanban") == existing) + } + + @Test func anExtensionLessBoardFolderIsCreatedAndOpensSuccessfully() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("PlainBoard") + + try BoardWriter.createBoard(at: root, title: "Plain") + + let result = try BoardLoader.load(boardRoot: root) + #expect(result.warnings.isEmpty) + #expect(result.model.title == .valid("Plain")) + } + + @Test func createdEqualsModifiedAtCreationTime() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("Board.kanban") + + try BoardWriter.createBoard(at: root, title: "Board") + + let document = try FrontmatterDocument.parse(fixture.indexText("Board.kanban")) + #expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified)) + } +} + +// MARK: - Create lane / card + +/// `BoardWriter.createLane`/`createCard`: minting a fresh identity and placing it after the +/// current visible siblings (01-storage-format.md § Ordering). `fixture.root` doubles as the +/// board root for lane creation — `createChild` never reads the parent's own `index.md`, only +/// its children, so a bare directory is as good a board root as a fully-formed one for these +/// tests. +struct BoardWriterCreateChildTests { + @Test func mintsLowercaseUUIDShapedNamesDistinctAcrossCalls() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + let first = try BoardWriter.createLane(inBoard: fixture.root, title: "Todo") + let second = try BoardWriter.createLane(inBoard: fixture.root, title: "Doing") + + for id in [first, second] { + #expect(BoardLoader.isUUIDShaped(id.rawValue)) + #expect(id.rawValue == id.rawValue.lowercased()) + } + #expect(first != second) + } + + @Test func laneKeyOrderIsSchemaTitleOrderCreatedModified() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") + + let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) + #expect(document.keys == ["schema", "title", "order", "created", "modified"]) + #expect(document.schema == .valid(1)) + #expect(document.modifiedBy == .missing) + } + + @Test func anEmptyParentsFirstChildLandsAt1024() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Only") + + let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) + #expect(document.order == .valid(1024)) + } + + @Test func appendsAfterVisibleSiblingsAtMaxPlus1024() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") + try fixture.item(Child.b, "---\nschema: 1\norder: 2048\ntitle: B\n---\nbody\n") + + let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + + let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) + #expect(document.order == .valid(3072)) + } + + /// The highest-order sibling is tombstoned — tombstones are inert to ordering, so it must + /// not factor into the append target at all (not `9999 + 1024`, not anything derived from it). + @Test func aTombstonedHighestOrderSiblingIsExcludedFromTheAppendTarget() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") + try fixture.item( + Child.deleted, + "---\nschema: 1\norder: 9999\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n" + ) + + let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + + let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) + #expect(document.order == .valid(2048)) + } + + /// A UUID-shaped sibling folder with no `index.md` — an interrupted create — is skipped + /// during the order scan, not fatal; the new item still lands correctly, and the board as a + /// whole still loads, surfacing only the loader's ordinary `.missingIndex` warning. + @Test func aSiblingFolderWithoutIndexIsSkippedNotFatal() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", "---\nschema: 1\ntitle: Board\n---\n") + try fixture.item(Child.a, "---\nschema: 1\norder: 1024\ntitle: A\n---\nbody\n") + try FileManager.default.createDirectory(at: fixture.url(Child.indexless), withIntermediateDirectories: true) + + let newID = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + + let document = try FrontmatterDocument.parse(fixture.indexText(newID.rawValue)) + #expect(document.order == .valid(2048)) + + let result = try BoardLoader.load(boardRoot: fixture.root) + #expect(result.warnings == [.missingIndex(path: Child.indexless)]) + #expect(Set(result.model.lanes.map(\.id.rawValue)) == Set([Child.a, newID.rawValue])) + } + + @Test func aSiblingWithAMalformedOrderFailsTheCreateNamingTheSibling() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item(Child.a, "---\nschema: 1\norder: banana\ntitle: A\n---\nbody\n") + let before = try fixture.entryNames("") + + let error = writeFailure { + _ = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + } + #expect(error?.reason == .unreadable(message: "malformed 'order' field: banana")) + #expect(error?.path.contains(Child.a) == true) + #expect(error?.operation == "create lane") + // Nothing was minted: the scan fails before the new folder is ever created. + #expect(try fixture.entryNames("") == before) + } + + @Test func aMissingParentFolderIsALoudUnreadableError() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let missingParent = fixture.url("does-not-exist") + + let error = writeFailure { + _ = try BoardWriter.createLane(inBoard: missingParent, title: "New") + } + guard case .unreadable = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + } + + @Test func aParentThatIsAFileIsALoudUnreadableError() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let filePath = fixture.root.appendingPathComponent("afile") + try Data("x".utf8).write(to: filePath) + + let error = writeFailure { + _ = try BoardWriter.createLane(inBoard: filePath, title: "New") + } + guard case .unreadable = error?.reason else { + Issue.record("expected .unreadable, got \(String(describing: error?.reason))") + return + } + } + + @Test func createdEqualsModifiedAtCreationTime() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + + let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") + + let document = try FrontmatterDocument.parse(fixture.indexText(laneID.rawValue)) + #expect(document.rawValue(for: FrontmatterKeys.created) == document.rawValue(for: FrontmatterKeys.modified)) + } + + /// `createCard` is `createLane` one level down — a lane folder (need not have its own + /// `index.md` for this to work; `createChild` never reads the parent's own file) is just as + /// good a parent as a board root. + @Test func createCardMintsUnderALaneFolderWithTheSameRules() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let laneID = try BoardWriter.createLane(inBoard: fixture.root, title: "Lane") + let laneFolder = fixture.url(laneID.rawValue) + + let first = try BoardWriter.createCard(inLane: laneFolder, title: "First") + let second = try BoardWriter.createCard(inLane: laneFolder, title: "Second") + + #expect(BoardLoader.isUUIDShaped(first.rawValue)) + #expect(first != second) + let firstDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(first.rawValue)")) + let secondDocument = try FrontmatterDocument.parse(fixture.indexText("\(laneID.rawValue)/\(second.rawValue)")) + #expect(firstDocument.order == .valid(1024)) + #expect(secondDocument.order == .valid(2048)) + } +} + +// MARK: - Create round-trip + +struct BoardWriterCreateIntegrationTests { + /// `createBoard` → `createLane` → `createCard` → `BoardLoader.load`: the whole structure + /// shows up, in append order, exactly as created. + @Test func createBoardCreateLaneCreateCardRoundTripsThroughTheLoader() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + let root = fixture.url("Trip.kanban") + + try BoardWriter.createBoard(at: root, title: "Trip") + let lane1 = try BoardWriter.createLane(inBoard: root, title: "Todo") + let lane2 = try BoardWriter.createLane(inBoard: root, title: "Doing") + let lane1Folder = root.appendingPathComponent(lane1.rawValue, isDirectory: true) + let card1 = try BoardWriter.createCard(inLane: lane1Folder, title: "First") + let card2 = try BoardWriter.createCard(inLane: lane1Folder, title: "Second") + + let result = try BoardLoader.load(boardRoot: root) + #expect(result.warnings.isEmpty) + #expect(result.model.title == .valid("Trip")) + #expect(result.model.lanes.map(\.id.rawValue) == [lane1.rawValue, lane2.rawValue]) + #expect(result.model.lanes[0].title == .valid("Todo")) + #expect(result.model.lanes[1].title == .valid("Doing")) + #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [card1.rawValue, card2.rawValue]) + #expect(result.model.lanes[0].cards[0].title == .valid("First")) + #expect(result.model.lanes[0].cards[1].title == .valid("Second")) + #expect(result.model.lanes[1].cards.isEmpty) + } +} + +// MARK: - Editability scope: reads vs rewrites + +/// The readable-but-uneditable pre-flight belongs only to operations that will *rewrite* +/// siblings. A flow-mapping sibling loads and renders normally (01-storage-format.md § +/// Frontmatter), so it must not block creating a new item beside it — but renumber, which +/// would have to rewrite that very file, refuses up front, before anything is written. +struct BoardWriterEditabilityScopeTests { + private static let flowSibling = "---\n{schema: 1, order: 5000}\n---\n" + + @Test func createSucceedsBesideAnUneditableSibling() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", "---\nschema: 1\n---\n") + try fixture.item(Child.a, Self.flowSibling) + + let id = try BoardWriter.createLane(inBoard: fixture.root, title: "New") + let text = try fixture.indexText(id.rawValue) + #expect(text.contains("order: 6024")) + } + + @Test func renumberRefusesOnAnUneditableSibling() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", "---\nschema: 1\n---\n") + try fixture.item(Child.a, Self.flowSibling) + try fixture.item(Child.b, "---\nschema: 1\norder: 1024\n---\n") + let before = try fixture.indexData(Child.b) + + let error = writeFailure { try BoardWriter.renumberVisibleChildren(of: fixture.root) } + #expect(error?.reason == .uneditableFrontmatter(.keyWithoutOwnLine)) + #expect(error?.path.hasSuffix("\(Child.a)/index.md") == true) + #expect(try fixture.indexData(Child.b) == before) + #expect(try fixture.indexText(Child.a) == Self.flowSibling) + } +}