import { readFile } from "node:fs/promises"; import { join } from "node:path"; import { GenericDomainError, type DomainError } from "../../errors/generic-domain-error.js"; import type { SliceSpecReader, SliceSpecResult, } from "../../../domain/ports/slice-spec-reader.port.js"; import { debugSliceDir, Err, Ok, quickSliceDir, sliceDir, type Result } from "@tff/core"; const MILESTONE_SLICE_RE = /^M(\d+)-S\d+$/; const ADHOC_SLICE_RE = /^([QD])-\d+$/; /** * Resolve `//SPEC.md` from a slice label. Path is * deterministic — mirrors the layout written by slice:create. Falls back to * `missing: true` if the file is absent at the expected location. */ const specPathFor = (projectRoot: string, sliceLabel: string): string | null => { const milestone = MILESTONE_SLICE_RE.exec(sliceLabel); if (milestone) { const milestoneLabel = `M${milestone[1].padStart(2, "0")}`; return join(projectRoot, sliceDir(milestoneLabel, sliceLabel), "SPEC.md"); } const adhoc = ADHOC_SLICE_RE.exec(sliceLabel); if (adhoc) { const dir = adhoc[1] === "Q" ? quickSliceDir(sliceLabel) : debugSliceDir(sliceLabel); return join(projectRoot, dir, "SPEC.md"); } return null; }; export interface SliceSpecFsReaderOpts { projectRoot: string; } export class SliceSpecFsReader implements SliceSpecReader { constructor(private readonly opts: SliceSpecFsReaderOpts) {} async readSpec( sliceLabel: string, maxBytes: number, ): Promise> { const specPath = specPathFor(this.opts.projectRoot, sliceLabel); if (specPath === null) { return Err( new GenericDomainError("VALIDATION_ERROR", `invalid slice label: ${sliceLabel}`, { sliceLabel, }), ); } let raw: string; try { raw = await readFile(specPath, "utf8"); } catch { return Ok({ text: "", truncated: false, missing: true }); } if (raw.length > maxBytes) { const dropped = raw.length - maxBytes; return Ok({ text: `${raw.slice(0, maxBytes)}\n... [truncated, ${dropped} bytes dropped]`, truncated: true, missing: false, }); } return Ok({ text: raw, truncated: false, missing: false }); } }