import Foundation /// Turns a mutation into a filesystem operation — the single point through which every write /// the app makes reaches disk (02-architecture.md § Layering ▸ Components). Stateless by /// construction: there is no in-flight buffer, no queue, no coalescing. **A write is done when /// the rename completes**, and a failed write is a failure the caller sees, so views — which /// render only what is on disk — can never show phantom state (02-architecture.md § /// Write-failure surfacing). /// /// Four rules from 01-storage-format.md live here and are not negotiable per call site: /// /// - **Atomic writes** (§ Fractal layout ▸ Rules): temp file, rename over `index.md`. Every /// write, no exceptions — a reader (this app's watcher, an agent, git) never sees a partial /// file, and a crash mid-write leaves the previous content intact. /// - **Round-trip, never re-serialize**: mutations go through `FrontmatterDocument`, which /// edits by line span, so unknown keys and their order, comments, blank lines, line endings, /// and the body survive every write by construction rather than by remembering to preserve /// them. /// - **`modified` stamped, `modified-by` cleared** (§ Frontmatter): on every app-mediated write /// path. Absence of `modified-by` means "the board's user, via the app"; the file is being /// rewritten anyway, so clearing an external writer's self-reported stamp costs nothing. /// - **Encoding** (§ Fractal layout ▸ Rules): writes are BOM-less UTF-8; reads are strict /// UTF-8, and a file that does not decode is a loud, specific error rather than a /// lossy best guess. public enum BoardWriter: Sendable { // MARK: - The uniform per-file mutation /// Rewrites one item's `index.md`: read fresh, refuse what cannot be edited, apply `edits`, /// stamp, write atomically. /// /// The order of the four steps is the contract: /// /// 1. **Read fresh from disk**, never from a snapshot. The snapshot a caller is holding may /// be seconds stale — an agent or a hand-editor may have rewritten the file since — and /// the round-trip guarantee is only worth anything against the bytes actually there. /// 2. **Refuse an uneditable shape before `edits` runs** (`FrontmatterDocument.uneditableShape`): /// the settled readable-but-uneditable rule. Such a file loads and renders fine, but a /// surgical edit of it cannot be expressed, so the write fails loudly instead of /// corrupting it. Refusing up front also means `edits` never observes a document it /// cannot affect. /// 3. **`edits`, then the stamps** — `modified` set and `modified-by` removed *after* the /// caller's closure, so the stamp always wins over anything the closure did with those /// two keys, and no call site has to remember them. /// 4. **Atomic replace.** /// /// The **one path that deliberately bypasses this** is the card window's raw-source Apply /// (05-card-window.md): it writes the user's text byte-for-byte and does *not* clear a /// `modified-by` the user typed or kept — the validated-then-verbatim contract outranks the /// clearing rule (01-storage-format.md § Frontmatter). That path goes through /// `atomicReplace` directly; it does not belong here. public static func updateIndex( inItemFolder folder: URL, operation: String, edits: (inout FrontmatterDocument) -> Void ) throws(BoardWriteError) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) var document = try readDocument(at: indexURL, operation: operation) try checkEditable(document, at: indexURL, operation: operation) edits(&document) document.set(FrontmatterKeys.modified, to: .date(Date())) document.remove(FrontmatterKeys.modifiedBy) try atomicReplace(text: document.serialized(), at: indexURL, operation: operation) } // MARK: - Atomic replace /// Writes `text` over `fileURL` atomically: a hidden temp file in the **same directory**, /// then a rename over the destination. /// /// Same directory because a rename is only atomic within one filesystem — a temp in /// `NSTemporaryDirectory()` could land on another volume and degrade to a copy. Dot-prefixed /// (`.index.md.lanework-`) because `BoardLoader.directoryCandidates` skips hidden /// entries: residue from a crashed write is invisible to a load rather than a stray warning /// or, worse, a candidate. The UUID keeps concurrent writers off each other's temp file. /// /// `Data(text.utf8)` is BOM-less UTF-8 by construction — the encoding contract, with no /// encoder to configure and no failure case to handle. On any failure the temp file is /// removed best-effort and `.io` is thrown: the destination is either the old bytes or the /// new ones, never a mix, and never a directory littered with half-written files. static func atomicReplace(text: String, at fileURL: URL, operation: String) throws(BoardWriteError) { let directory = fileURL.deletingLastPathComponent() let tempURL = directory.appendingPathComponent(".\(fileURL.lastPathComponent).lanework-\(UUID().uuidString)") do { try Data(text.utf8).write(to: tempURL) } catch { try? FileManager.default.removeItem(at: tempURL) throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not write temporary file: \(error.localizedDescription)") ) } // POSIX `rename` rather than `FileManager.replaceItemAt`: it atomically overwrites an // existing destination *and* handles one that does not exist yet (the create paths), // without inventing a second temp file of its own. let status = tempURL.withUnsafeFileSystemRepresentation { source in fileURL.withUnsafeFileSystemRepresentation { destination in guard let source, let destination else { return EINVAL } return rename(source, destination) == 0 ? 0 : errno } } guard status == 0 else { try? FileManager.default.removeItem(at: tempURL) throw BoardWriteError( operation: operation, path: fileURL.path, reason: .io(message: "could not replace file: \(String(cString: strerror(status)))") ) } } // MARK: - Renumber /// Renumbers a parent's visible children to whole multiples of 1024 — the renumber fallback /// for exhausted midpoint precision (01-storage-format.md § Ordering), and **the sole /// 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). /// - **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 /// still deterministic, and the next renumber finishes the job. That is the accepted cost /// 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 candidates: [URL] do { candidates = try BoardLoader.directoryCandidates(in: parentFolder) } catch { throw BoardWriteError( operation: operation, path: parentFolder.path, reason: .unreadable(message: error.description) ) } var visible: [(folder: URL, order: Double)] = [] for folder in candidates where BoardLoader.isUUIDShaped(folder.lastPathComponent) { let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName) guard FileManager.default.fileExists(atPath: indexURL.path) else { continue } let document = try readDocument(at: indexURL, operation: operation) guard document.deleted.isMissing else { continue } try checkEditable(document, at: indexURL, operation: operation) switch document.order { case .missing: throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "missing required 'order' field") ) case let .malformed(raw): throw BoardWriteError( operation: operation, path: indexURL.path, reason: .unreadable(message: "malformed 'order' field: \(raw)") ) case let .valid(order): 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)) } } } // MARK: - Reading /// Reads and parses an `index.md` for rewriting. **Strict, byte-faithful UTF-8**: /// `String(validating:as:)` rejects malformed sequences outright and — unlike Foundation's /// NSString-backed decoders — does not silently swallow a leading BOM, which would turn a /// rewrite of a BOM'd file into a whole-file byte change. A file that does not decode, or /// whose frontmatter does not parse, is `.unreadable` with the specifics: the app declines /// to write a file it cannot round-trip (01-storage-format.md § Fractal layout ▸ Rules). private static func readDocument(at url: URL, operation: String) throws(BoardWriteError) -> FrontmatterDocument { let data: Data do { data = try Data(contentsOf: url) } catch { throw BoardWriteError( operation: operation, path: url.path, reason: .unreadable(message: "could not read file: \(error.localizedDescription)") ) } guard let text = String(validating: data, as: UTF8.self) else { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: "file is not UTF-8")) } do { return try FrontmatterDocument.parse(text) } catch { throw BoardWriteError(operation: operation, path: url.path, reason: .unreadable(message: error.description)) } } private static func checkEditable( _ document: FrontmatterDocument, at url: URL, operation: String ) throws(BoardWriteError) { if let shape = document.uneditableShape { throw BoardWriteError(operation: operation, path: url.path, reason: .uneditableFrontmatter(shape)) } } } // MARK: - Error /// A write that did not happen, said out loud: which operation, which file, and why — /// the vocabulary 02-architecture.md § Write-failure surfacing renders in the banner /// ("Couldn't move 'Fix login' — disk full"). Nothing here is swallowed or retried behind the /// user's back; a one-shot action fails once and waits for them to act again. public struct BoardWriteError: Error, Sendable, Equatable, CustomStringConvertible { /// An imperative human phrase for what was being attempted — "reorder card", "renumber /// children" — supplied by the call site, because only it knows what the user asked for. public let operation: String /// The file or folder involved. Absolute at this layer: the writer works in URLs and has no /// board root to be relative to (contrast `BoardLoadError.path`, which is root-relative). public let path: String public let reason: Reason public var description: String { "\(operation): \(path): \(reason.description)" } public enum Reason: Sendable, Equatable, CustomStringConvertible { /// The file is missing, is not UTF-8, or its frontmatter does not parse — `message` /// carries the specifics. A rewrite the app cannot round-trip is not attempted. case unreadable(message: String) /// The settled readable-but-uneditable refusal (01-storage-format.md § Frontmatter): /// the file loads and renders, but its frontmatter has a shape the surgical editor /// cannot address, so writing it would risk corruption. Names the shape. case uneditableFrontmatter(FrontmatterDocument.UneditableShape) /// Any I/O failure writing the temp file or renaming it into place — disk full, /// permissions, volume error. The destination still holds its previous bytes. case io(message: String) public var description: String { switch self { case let .unreadable(message): "unreadable: \(message)" case let .uneditableFrontmatter(shape): "frontmatter cannot be edited in place: \(shape.description)" case let .io(message): message } } } }