import { LockStore, lockPathFor, parseLock, serializeLock } from "./lockfile"; /** What a run needs of the file system to keep a script's lock file. */ export interface LockIo { fileExists(path: string): boolean; readFile(path: string): string; writeFile(path: string, data: Buffer): void; now(): number; } export interface OpenedLock { path: string; existed: boolean; /** Absent when the file is there but cannot be used; then the run goes on with no lock and the file is never written. */ store?: LockStore; problem?: string; } export type LockStatus = "created" | "updated" | "unchanged" | "none" | "unreadable" | "write-failed"; export interface LockReport { path: string; status: LockStatus; captured: number; reused: number; ignored: number; repaired: number; message?: string; } const messageOf = (e: unknown) => (e instanceof Error ? e.message : String(e)); /** `seen` is written to the second, like the examples in data-formats.md. */ const stamp = (ms: number): string => new Date(ms).toISOString().replace(/\.\d{3}Z$/, "Z"); export function openLock(scriptPath: string, io: LockIo, frozen = false): OpenedLock { const path = lockPathFor(scriptPath); const now = () => stamp(io.now()); if (!io.fileExists(path)) return { path, existed: false, store: new LockStore(undefined, now, frozen) }; let text: string; try { text = io.readFile(path); } catch (err) { return { path, existed: true, problem: `lock file ${path} could not be read: ${messageOf(err)}` }; } const parsed = parseLock(text); if (!parsed.ok) return { path, existed: true, problem: `lock file ${path} is not usable: ${parsed.message}` }; return { path, existed: true, store: new LockStore(parsed.lock, now, frozen) }; } /** Writes the lock file if the run captured something new; never touches a file it could not read. */ export function closeLock(opened: OpenedLock, io: LockIo): LockReport { const { path } = opened; if (opened.store === undefined) return { path, status: "unreadable", captured: 0, reused: 0, ignored: 0, repaired: 0, ...(opened.problem !== undefined ? { message: opened.problem.replace(/^lock file \S+ (?:could not be read|is not usable): /, "") } : {}) }; const counts = opened.store.summary(); if (!opened.store.dirty) return { path, status: counts.captured === 0 && !opened.existed ? "none" : "unchanged", ...counts }; try { io.writeFile(path, Buffer.from(serializeLock(opened.store.toFile()))); } catch (err) { return { path, status: "write-failed", ...counts, message: messageOf(err) }; } return { path, status: opened.existed ? "updated" : "created", ...counts }; } const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; /** A line for the end of the run's output, or nothing when the lock file is as it was. */ export function describeLockReport(r: LockReport): string | undefined { switch (r.status) { case "created": case "updated": { const parts = [...(r.captured > 0 ? [`${plural(r.captured, "target", "targets")} captured`] : []), ...(r.repaired > 0 ? [`${r.repaired} repaired`] : [])]; return `lock file ${r.status}: ${r.path} (${parts.join(", ")}; commit it)`; } case "unreadable": return `warning: lock file ${r.path} not used: ${r.message ?? "unknown problem"}`; case "write-failed": return `warning: could not write lock file ${r.path}: ${r.message ?? "unknown problem"}`; default: return r.ignored > 0 ? `warning: ${plural(r.ignored, "entry", "entries")} in ${r.path} do not match their targets and were ignored` : undefined; } }