//! `cph-check` — check-pipeline orchestration. //! //! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema` //! (validate), and `cph-typst` (compile), runs the render-coverage rule //! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics. //! //! The orchestrator owns *sequencing and gating*, not the individual checks: //! each phase is implemented by an upstream crate, and `cph-check` decides the //! order, what gates what, and how the results are merged / deduped. See //! [`check`] for the full pipeline and [`build`] for the export path. use std::path::Path; use cph_diag::{DiagCode, Diagnostic, Severity}; use cph_typst::Engine; /// The default render target when a lesson declares no `[targets.*]` at all. const DEFAULT_TARGET: &str = "student"; /// Severity of the render-coverage ("element ignored under a target") diagnostic. /// /// **PINNED to `warning` by the contract.** Mirrors the Lean master's /// `Spec.Courseware.renderIgnoredSeverity : Severity := .warning` /// (`spec/Spec/Courseware/Check/Diagnostic.lean`), itself citing ADR-0005: when a /// `(kind, target)` pair has no render rule the checker reports that the element /// is ignored under that target and **does not block the export**. Naming the /// severity as a const makes "it is a warning, not an error" a greppable, /// alignable fact rather than an inline literal. const RENDER_IGNORED_SEVERITY: Severity = Severity::Warning; /// The result of running [`check`] (or the check phases of [`build`]). #[derive(Debug, Clone, PartialEq)] pub struct CheckReport { /// Every diagnostic collected across all phases, deduplicated. pub diagnostics: Vec, /// `false` if `cph_model::load` returned `None` (a hard manifest failure /// that prevents any further phase from running). pub lesson_loaded: bool, } impl CheckReport { /// How many collected diagnostics are `Error`-severity. pub fn error_count(&self) -> usize { self.diagnostics .iter() .filter(|d| d.severity == Severity::Error) .count() } /// How many collected diagnostics are `Warning`-severity. pub fn warning_count(&self) -> usize { self.diagnostics .iter() .filter(|d| d.severity == Severity::Warning) .count() } /// Whether any collected diagnostic is `Error`-severity. /// /// **Legality decision (spec alignment).** `!has_errors()` is the /// implementation of `Spec.Courseware.Legal` (`spec/Spec/Courseware/Check/Diagnostic.lean`): /// a lesson is *legal* iff its diagnostics contain no error-level diagnostic /// (warnings are non-blocking — see `Severity` / ADR-0010). There is no CI /// gate enforcing this alignment (repo constitution); it is kept greppable /// here so a reviewer can tie the orchestrator's gate to the Lean master. pub fn has_errors(&self) -> bool { self.diagnostics .iter() .any(|d| d.severity == Severity::Error) } } /// Run the full check pipeline against the engineering file at `root`. /// /// Phases, in order, with gating: /// - **(a) load** — `cph_model::load`. Its diagnostics are collected. On a hard /// failure (`None`) the report is returned immediately with /// `lesson_loaded == false`; nothing downstream can run. /// - **(b) structural** — for each part, check `part.kind` is in /// `cph_schema::known_kinds()`; an unknown kind is an `UnknownKind` error. /// (cph-model already checked path existence, `..` traversal, and /// part/element kind *mismatch*; this complements that with the *known-set* /// check and does not duplicate it.) Parts whose folder cph-model already /// flagged as missing are skipped for the rest of the pipeline. /// - **(c) schema** — for each part whose folder exists and whose kind is /// known, `cph_schema::validate(&part.descriptor)`. /// - **(d) typst compile** — only if phases (a)–(c) produced **zero** errors /// (otherwise the compile is noise on known-broken input). Compiles each /// declared `lesson.targets` (defaulting to `"student"` if none), deduping /// identical diagnostics across targets. /// - **(e) render-coverage** — a `warning` (never an error) for each /// `(kind, target)` the target's `covers` declaration does not include. Runs /// regardless of (d) gating. /// /// The engine is taken as a parameter so the caller owns it (and tests can /// inject `Engine::with_render_dir`). pub fn check(root: &Path, engine: &Engine) -> CheckReport { let mut diags = Vec::new(); // (a) load. let (lesson, load_diags) = cph_model::load(root); diags.extend(load_diags); let Some(lesson) = lesson else { // Hard manifest failure: nothing else can run. return CheckReport { diagnostics: dedup(diags), lesson_loaded: false, }; }; let known = cph_schema::known_kinds(); // (b) structural + (c) schema. run_structural_and_schema(&lesson, known, &mut diags); // (d) typst compile — gated on zero errors from (a)–(c). let pre_compile_has_error = diags.iter().any(|d| d.severity == Severity::Error); if !pre_compile_has_error { for target in compile_targets(&lesson) { diags.extend(engine.compile_check(&lesson, target)); } } // (e) render-coverage — always runs; only a warning. Skips parts already // broken in (b) (missing folder / unknown kind). diags.extend(render_coverage(&lesson, known)); CheckReport { diagnostics: dedup(diags), lesson_loaded: true, } } /// Build a PDF for `target`. /// /// Runs the check phases **(a)–(c)** (load → structural → schema). If those /// produce any `Error`, the build is refused: returns `(None, report)` with the /// blocking diagnostics. Otherwise it compiles + exports the PDF for `target` /// via `engine.build_pdf`; on a clean compile returns `(Some(bytes), report)`, /// and on a typst error returns `(None, report)` with the compile diagnostics /// folded into the report. /// /// Note `build` does **not** run render-coverage (phase (e)): coverage warnings /// describe export *loss*, which is informational for a `check`, not a gate on /// producing a single target's PDF. pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option>, CheckReport) { let mut diags = Vec::new(); // (a) load. let (lesson, load_diags) = cph_model::load(root); diags.extend(load_diags); let Some(lesson) = lesson else { return ( None, CheckReport { diagnostics: dedup(diags), lesson_loaded: false, }, ); }; let known = cph_schema::known_kinds(); // (b) structural + (c) schema. run_structural_and_schema(&lesson, known, &mut diags); if diags.iter().any(|d| d.severity == Severity::Error) { return ( None, CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, ); } // Phases passed: compile + export the requested target. match engine.build_pdf(&lesson, target) { Ok(bytes) => ( Some(bytes), CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, ), Err(compile_diags) => { diags.extend(compile_diags); ( None, CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, ) } } } /// One shell step's execution outcome (for [`run_shell_target`]). #[derive(Debug, Clone, PartialEq)] pub struct ShellStepOutcome { /// The command line that was run (verbatim from the manifest `run` field). pub run: String, /// Process exit code (`None` if the process was killed by a signal). pub status: Option, /// Captured stdout. pub stdout: String, /// Captured stderr. pub stderr: String, } impl ShellStepOutcome { /// Whether the step exited successfully (status 0). pub fn ok(&self) -> bool { self.status == Some(0) } } /// The result of [`run_shell_target`]. #[derive(Debug, Clone, PartialEq)] pub struct ShellRunReport { /// The check phases' report (load → structural → schema). Shell steps run /// only when this has no errors. pub check: CheckReport, /// Each shell step's outcome, in declared order. Empty if the build was /// refused (check errors, unknown target, or the target has no shell steps). pub outcomes: Vec, /// `true` if the target was found, check passed, and every shell step exited 0. pub ok: bool, } /// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009 /// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI). /// /// This is the **escape-hatch executor**: unlike [`build`] (which compiles a /// typst template to a PDF), a shell target hands off to an external tool that /// writes files itself. cph only *runs the declared command* in the engineering /// root and reports what happened — it assembles nothing. /// /// Contract / safety: /// - Runs the check phases first (load → structural → schema). Any error refuses /// the run (`ok == false`, no command executed) — a shell build of a broken /// lesson is not attempted. /// - Each `Step::Shell { run }` is executed via the platform shell /// (`sh -c ` / `cmd /C `) with the **engineering root as the working /// directory**, so a manifest can use root-relative paths. /// - **Arbitrary command execution is opt-in by construction**: this function is /// only reached from `cph build --target `, never from `check`. Callers /// (the CLI) surface the command before running it. /// - `TypstCompile` steps inside the same target are skipped here (a shell target /// is not a typst build); a target with no shell steps yields `ok == false`. pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport { let mut diags = Vec::new(); let (lesson, load_diags) = cph_model::load(root); diags.extend(load_diags); let Some(lesson) = lesson else { return ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: false, }, outcomes: Vec::new(), ok: false, }; }; let known = cph_schema::known_kinds(); run_structural_and_schema(&lesson, known, &mut diags); // Suppress the unused-parameter warning while keeping the signature uniform // with `build`/`check` (the engine is not needed to run shell steps, but // callers pass it so the API is consistent and future steps may use it). let _ = engine; let has_error = diags.iter().any(|d| d.severity == Severity::Error); let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{target}' not declared in manifest"), ) .with_hint(format!( "add a `[targets.{target}]` table, or run a declared target" )), ); return ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes: Vec::new(), ok: false, }; }; let shell_steps: Vec<&str> = tc .steps .iter() .filter_map(|s| match s { cph_model::Step::Shell { run } => Some(run.as_str()), cph_model::Step::TypstCompile { .. } => None, }) .collect(); if has_error || shell_steps.is_empty() { return ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes: Vec::new(), ok: false, }; } // Run each shell step in the engineering root. First non-zero exit stops the // sequence (a failed step's successors likely depend on it). let mut outcomes = Vec::new(); let mut all_ok = true; for run in shell_steps { let outcome = run_one_shell(&lesson.root, run); let ok = outcome.ok(); outcomes.push(outcome); if !ok { all_ok = false; break; } } ShellRunReport { check: CheckReport { diagnostics: dedup(diags), lesson_loaded: true, }, outcomes, ok: all_ok, } } /// Whether the named target is a **shell-step** target (its steps contain at /// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated /// asset bundle that [`run_shell_target`] executes rather than [`build`] /// compiling to PDF. /// /// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an /// unknown target returns `false`, so the caller falls through to the normal /// (typst) build path, which then reports the real error. pub fn target_is_shell(root: &Path, target: &str) -> bool { let (Some(lesson), _) = cph_model::load(root) else { return false; }; lesson .targets .iter() .find(|t| t.name == target) .is_some_and(|t| { t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. })) && !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })) }) } /// Run one shell command line in `cwd` via the platform shell, capturing output. fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome { use std::process::Command; let output = if cfg!(target_os = "windows") { Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output() } else { Command::new("sh").arg("-c").arg(run).current_dir(cwd).output() }; match output { Ok(out) => ShellStepOutcome { run: run.to_string(), status: out.status.code(), stdout: String::from_utf8_lossy(&out.stdout).into_owned(), stderr: String::from_utf8_lossy(&out.stderr).into_owned(), }, Err(e) => ShellStepOutcome { run: run.to_string(), status: None, stdout: String::new(), stderr: format!("failed to spawn command: {e}"), }, } } /// Whether a part's folder exists on disk. cph-model emits `PartPathMissing` /// for parts whose folder is absent, so the orchestrator skips those parts in /// the structural/schema/coverage phases to avoid piling diagnostics on the /// same root cause. fn part_exists(p: &cph_model::Part) -> bool { p.descriptor.dir.is_dir() } /// Phases (b) + (c): the known-kind structural check and per-part schema /// validation. Shared by [`check`] and [`build`] so their gating stays /// identical. fn run_structural_and_schema( lesson: &cph_model::Lesson, known: &[&str], diags: &mut Vec, ) { // (b) structural: known-kind check. for part in &lesson.parts { if !part_exists(part) { continue; } if !known.contains(&part.kind.as_str()) { diags.push( Diagnostic::error( DiagCode::UnknownKind, format!( "part '{}' has unknown kind '{}'", part.path.display(), part.kind ), ) .with_hint(format!("valid kinds are: {}", known.join(", "))), ); } } // (c) schema: validate each existing part with a known kind. Skipping // unknown kinds avoids a second `UnknownKind` from `cph_schema::validate` // (already reported in (b)). for part in &lesson.parts { if !part_exists(part) || !known.contains(&part.kind.as_str()) { continue; } diags.extend(cph_schema::validate(&part.descriptor)); } } /// The targets to compile-check for [`check`]: every declared **typst-compile** /// target, or `["student"]` if the lesson declares none. /// /// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle — /// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is /// **not** typst-compiled, so it is excluded here: compile-checking it would /// wrongly route a non-typst build through the typst engine. Such targets are /// executed only on an explicit `cph build --target ` (see /// [`run_shell_target`]); `check` validates the lesson structure, not the /// outcome of running an external tool. fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { if lesson.targets.is_empty() { return vec![DEFAULT_TARGET]; } lesson .targets .iter() .filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))) .map(|t| t.name.as_str()) .collect() } /// Phase (e): for each `(part.kind, target)` over the lesson's declared targets, /// emit a `RenderIgnored` **warning** when the target does not cover that kind. /// /// Coverage is the per-target **declaration** ADR-0011 pinned (and the spec's /// `RenderConfig.covers k t` in `Export/Render.lean`): a target's /// `cph_model::TargetConfig::covers` lists the kinds it renders. A used kind not /// in that list draws the warning. An **absent** `covers` (the loader's `None`) /// is resolved here — the orchestrator owns the kind universe via `cph-schema` — /// to "covers all known kinds", so an undeclared target is fully covering rather /// than covering nothing. (An explicit `covers = []` stays empty: it covers /// nothing on purpose.) Parts already broken in (b) (missing folder / unknown /// kind) are skipped — they are reported there. fn render_coverage(lesson: &cph_model::Lesson, known: &[&str]) -> Vec { let mut out = Vec::new(); // De-dupe (kind, target) pairs so we emit one warning per pair, not per part. let mut seen: Vec<(String, String)> = Vec::new(); for part in &lesson.parts { // Skip parts already flagged structurally. if !part_exists(part) || !known.contains(&part.kind.as_str()) { continue; } for target in &lesson.targets { // Resolve the coverage set: an absent declaration defaults to the // full known-kind universe; an explicit list (possibly empty) is // taken verbatim. let covered = match &target.covers { None => true, Some(kinds) => kinds.iter().any(|k| k == &part.kind), }; if covered { continue; } let key = (part.kind.clone(), target.name.clone()); if seen.contains(&key) { continue; } seen.push(key); let mut d = Diagnostic::warning( DiagCode::RenderIgnored, format!( "kind '{}' is not covered by target '{}'; it will be omitted from that export", part.kind, target.name ), ) .with_hint(format!( "add '{}' to `covers` under target '{}', or remove the target", part.kind, target.name )); // Pin the severity via the named contract const. `Diagnostic::warning` // already produces a warning, but assigning the const keeps the // alignment to `renderIgnoredSeverity` load-bearing and greppable. d.severity = RENDER_IGNORED_SEVERITY; out.push(d); } } out } /// Deduplicate diagnostics that are identical (same code+severity+message+span+ /// hint). The same typst error can surface once per compiled target, and the /// same root cause can be reached by more than one phase; callers see each /// distinct problem once. fn dedup(diags: Vec) -> Vec { let mut out: Vec = Vec::with_capacity(diags.len()); for d in diags { if !out.contains(&d) { out.push(d); } } out }