import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join, resolve } from "node:path"; import { formatId, parsePrd } from "./parse.js"; import type { PrdCollection, PrdDocument, PrdStatus } from "./types.js"; const TEMPLATE_FILE = "0000-template.md"; const INDEX_FILE = "README.md"; export class PrdCollectionError extends Error { readonly code = "OP-L-COLLECTION"; constructor(message: string) { super(message); this.name = "PrdCollectionError"; } } /** Load every `NNNN-*.md` in a `prd/` directory, plus the template and index. */ export function loadPrdCollection(dir: string): PrdCollection { const base = resolve(dir); if (!existsSync(base)) { throw new PrdCollectionError(`No PRD collection at ${base} — run \`logicsrc prd init\` first`); } const files = readdirSync(base) .filter((file) => file.endsWith(".md") && file !== INDEX_FILE) .sort(); const documents: PrdDocument[] = []; const unparsed: PrdCollection["unparsed"] = []; let template: PrdDocument | null = null; for (const file of files) { const path = join(base, file); try { const doc = parsePrd(readFileSync(path, "utf8"), path); if (file === TEMPLATE_FILE) template = doc; else documents.push(doc); } catch (error) { unparsed.push({ file, reason: (error as Error).message }); } } documents.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0)); const indexPath = join(base, INDEX_FILE); return { dir: base, template, documents, unparsed, indexRaw: existsSync(indexPath) ? readFileSync(indexPath, "utf8") : null }; } /** The next free number: highest existing + 1, never reserved in advance. */ export function nextPrdNumber(collection: PrdCollection): string { const highest = collection.documents.reduce((max, doc) => { const n = Number.parseInt(doc.filePrefix ?? "", 10); return Number.isInteger(n) ? Math.max(max, n) : max; }, 0); return formatId(highest + 1); } export function findPrd(collection: PrdCollection, ref: string): PrdDocument | undefined { const normalized = /^\d+$/.test(ref) ? formatId(Number.parseInt(ref, 10)) : ref; return collection.documents.find( (doc) => doc.frontMatter.id === normalized || doc.filePrefix === normalized || doc.file === ref || doc.slug === ref ); } export interface PrdSummary { id: string; title: string; status: PrdStatus | string; file: string; authors: string; tags: string; requirements: number; updated: string; } export function summarize(doc: PrdDocument): PrdSummary { const fm = doc.frontMatter; return { id: fm.id ?? doc.filePrefix ?? "????", title: fm.title ?? "(untitled)", status: fm.status ?? "(none)", file: doc.file, authors: (fm.authors ?? []).join(", "), tags: (fm.tags ?? []).join(", "), requirements: doc.requirements.length, updated: fm.updated ?? fm.created ?? "" }; } /** * Render the `prd/README.md` index the standard calls for. Deterministic, so * `prd index` is idempotent and CI can diff it. */ export function renderIndex(collection: PrdCollection, options: { title?: string } = {}): string { const rows = collection.documents.map(summarize); const lines = [ `# ${options.title ?? "LogicSRC PRDs"}`, "", "Numbered [OpenPRD](../docs/openprd.md) product requirements documents for this repo. One file", "per PRD at `prd/-.md`, four-digit ids, no gaps. `0000-template.md` is the copy-paste", "starting point.", "", "Status lives in each file's front-matter and is the source of truth:", "`Draft → Review → Accepted → Final`, or `Rejected` / `Withdrawn` / `Superseded`.", "", "", "", "| ID | Title | Status | Tags |", "| --- | --- | --- | --- |" ]; if (rows.length === 0) { lines.push("| — | _No PRDs yet. Run `logicsrc prd new \"Title\"`._ | — | — |"); } for (const row of rows) { const escape = (value: string) => value.replace(/\|/g, "\\|"); lines.push( `| [${row.id}](./${row.file}) | ${escape(row.title)} | ${row.status} | ${escape(row.tags)} |` ); } return `${lines.join("\n")}\n`; } export { TEMPLATE_FILE, INDEX_FILE };