/** * TrailLibrary — load + index a set of Trails, look up by id/family/chain. * * Two construction paths: * • `TrailLibrary.fromArray(trails)` — pass in already-parsed Trail objects (browser-friendly) * • `TrailLibrary.fromDirectory(path)` — read *.trail.json from a directory (Node-only) * * Validation happens at construction time; an invalid file throws with the field path. Once * loaded, lookups are O(1) by id and O(N) by family/chain (acceptable for libraries up to * ~thousands). * * Authoring rule of thumb: one Trail per file, filename matches `id` with dots → dashes * (`trail.morpho.supply` → `trail.morpho.supply.json`, OR `trail-morpho-supply.json`; the loader * doesn't care, only the contents matter). */ import { Trail, parseTrail, type ChainName } from "./trail.js"; export interface TrailLibraryStats { total: number; byFamily: Record; byChain: Record; } export interface TrailFilter { family?: string; chain?: ChainName; /** Substring match against id, name, or summary (case-insensitive). */ query?: string; } export class TrailLibrary { private readonly trails: Map; private constructor(trails: Trail[]) { this.trails = new Map(); for (const t of trails) { if (this.trails.has(t.id)) { throw new Error(`TrailLibrary: duplicate trail id "${t.id}"`); } this.trails.set(t.id, t); } } /** Build a library from an array of already-parsed trails. Throws on duplicate ids. */ static fromArray(trails: Trail[]): TrailLibrary { return new TrailLibrary(trails); } /** * Build a library from an array of raw JSON objects — runs them all through `parseTrail` so * each one is fully validated. Useful when loading from a bundle / fetch / static import. */ static fromJsonArray(items: unknown[]): TrailLibrary { return new TrailLibrary(items.map((j, i) => { try { return parseTrail(j); } catch (e) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`TrailLibrary.fromJsonArray: item ${i} failed validation: ${msg}`); } })); } /** * Load all *.trail.json files from a directory (Node-only — uses dynamic import of fs). * The async import keeps the SDK browser-safe; callers that don't use fromDirectory will not * pull node:fs at runtime. */ static async fromDirectory(dir: string): Promise { const fs = await import("node:fs/promises"); const path = await import("node:path"); const entries = await fs.readdir(dir); const jsonFiles = entries.filter((f) => /\.trail\.json$/i.test(f) || /\.json$/i.test(f)); if (jsonFiles.length === 0) { throw new Error(`TrailLibrary.fromDirectory: no *.json or *.trail.json files in ${dir}`); } const trails: Trail[] = []; for (const f of jsonFiles) { const full = path.join(dir, f); const raw = await fs.readFile(full, "utf8"); let json: unknown; try { json = JSON.parse(raw); } catch (e) { throw new Error(`TrailLibrary.fromDirectory: ${f} is not valid JSON: ${(e as Error).message}`); } try { trails.push(parseTrail(json)); } catch (e) { const msg = e instanceof Error ? e.message : String(e); throw new Error(`TrailLibrary.fromDirectory: ${f} failed Trail schema validation: ${msg}`); } } return new TrailLibrary(trails); } /** O(1) lookup by id. Returns undefined if not present. */ get(id: string): Trail | undefined { return this.trails.get(id); } /** Strict variant — throws if the id isn't in the library. */ require(id: string): Trail { const t = this.trails.get(id); if (!t) throw new Error(`TrailLibrary: no trail with id "${id}". Did you mean: ${this.suggest(id).slice(0, 3).join(", ") || ""}?`); return t; } /** All trails. */ all(): Trail[] { return Array.from(this.trails.values()); } /** Number of trails in the library. */ get size(): number { return this.trails.size; } /** Filter trails by family / chain / substring query. */ filter(opts: TrailFilter): Trail[] { const q = opts.query?.toLowerCase(); return this.all().filter((t) => { if (opts.family && t.family !== opts.family) return false; if (opts.chain && !t.chains.includes(opts.chain)) return false; if (q && !(t.id.toLowerCase().includes(q) || t.name.toLowerCase().includes(q) || t.summary.toLowerCase().includes(q))) { return false; } return true; }); } /** Quick stats — useful for `trail.list` MCP/CLI tools + dashboards. */ stats(): TrailLibraryStats { const byFamily: Record = {}; const byChain: Record = {}; for (const t of this.trails.values()) { const f = t.family ?? "(none)"; byFamily[f] = (byFamily[f] ?? 0) + 1; for (const c of t.chains) byChain[c] = (byChain[c] ?? 0) + 1; } return { total: this.trails.size, byFamily, byChain }; } /** Closest-prefix suggestions for typos — used in `require()` error messages. */ private suggest(typo: string): string[] { const t = typo.toLowerCase(); return this.all() .map((tr) => ({ id: tr.id, score: commonPrefixLen(tr.id.toLowerCase(), t) })) .filter((x) => x.score >= 6) .sort((a, b) => b.score - a.score) .map((x) => x.id); } } function commonPrefixLen(a: string, b: string): number { const n = Math.min(a.length, b.length); let i = 0; while (i < n && a[i] === b[i]) i++; return i; }