import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); /** Course directory; override with COURSE_ROOT (used by tests). */ export function courseRoot() { return process.env.COURSE_ROOT ? resolve(process.env.COURSE_ROOT) : join(here, "..", "course"); } /** Module directories `course/NN-slug/`, sorted by number. */ export function moduleDirs() { const root = courseRoot(); if (!existsSync(root)) return []; return readdirSync(root) .map((name) => ({ name, m: /^(\d\d)-(.+)$/.exec(name) })) .filter(({ name, m }) => m && statSync(join(root, name)).isDirectory()) .map(({ name, m }) => ({ num: m[1], slug: m[2], path: join(root, name) })) .sort((a, b) => a.num.localeCompare(b.num)); } export function testFiles(path, lang) { const dir = join(path, "tests", lang === "rust" ? "rust" : "ts"); if (!existsSync(dir)) return []; const ext = lang === "rust" ? /\.rs$/ : /\.test\.ts$/; return readdirSync(dir).filter((f) => ext.test(f)).sort().map((f) => join(dir, f)); } /** Test names declared by a module's tests for `lang` ("rust" | "ts"). */ export function testNames(path, lang) { const re = lang === "rust" ? /fn\s+(test_\w+)/g : /test\(\s*["'](test_\w+)/g; const names = new Set(); for (const f of testFiles(path, lang)) { for (const m of readFileSync(f, "utf8").matchAll(re)) names.add(m[1]); } return [...names].sort(); } /** `### test_…` headings in a module's scenarios.md (or another file in `path`). */ export function scenarioNames(path, file = "scenarios.md") { const f = join(path, file); if (!existsSync(f)) return []; const names = new Set(); for (const m of readFileSync(f, "utf8").matchAll(/^### (test_\w+)/gm)) names.add(m[1]); return [...names].sort(); } /** First module whose workspace gets course/cards (Module 5 introduces the card data format). */ export const COURSE_CARDS_FROM = 5; /** * The card data a workspace at module `upTo` has: course/cards (every module's) from * Module 5 only, since Modules 1-4 keep their cards in the learner's own code (spec * §6.2a), then the cards/ of each module up to `upTo` that has its own (Module 10's * SpellGrid cards), so earlier modules never see later modules' cards. */ export function cardDirs(upTo) { const shared = Number(upTo) >= COURSE_CARDS_FROM ? [join(courseRoot(), "cards")] : []; const local = moduleDirs() .filter((m) => Number(m.num) <= Number(upTo)) .map((m) => join(m.path, "cards")); return [...shared, ...local].filter((d) => existsSync(d)); } /** Read /.course.json, failing with a clear message if it is missing or invalid. */ export function readCourseConfig(ws) { const f = join(ws, ".course.json"); if (!existsSync(f)) { fail(`No .course.json in ${resolve(ws)}.\n` + "Create your workspace first: copy workspace-template/rust or workspace-template/ts " + "to a new directory and run `git init` there (see README.md, Quick start)."); } try { return JSON.parse(readFileSync(f, "utf8")); } catch (e) { fail(`Cannot parse ${f}: ${e.message}`); } } /** True when this module is the script node was asked to run (works on every Node version). */ export function isMain(metaUrl) { return Boolean(process.argv[1]) && fileURLToPath(metaUrl) === resolve(process.argv[1]); } export function loadCatalog() { const f = join(courseRoot(), "catalog.json"); if (!existsSync(f)) throw new Error(`catalog not found: ${f}`); return JSON.parse(readFileSync(f, "utf8")); } export function fail(msg) { console.error(msg); process.exit(1); }