//! `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"; /// The render targets the MVP render package actually covers (student handout + /// teacher plan). Used by the render-coverage rule (phase (e)). const COVERED_TARGETS: &[&str] = &["student", "teacher"]; /// 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/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. 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)` lacking a render rule. 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, }, ) } } } /// 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 target, or /// `["student"]` if the lesson declares none. fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> { if lesson.targets.is_empty() { vec![DEFAULT_TARGET] } else { lesson.targets.iter().map(String::as_str).collect() } } /// Phase (e): for each `(part.kind, target)` over the lesson's declared targets, /// emit a `RenderIgnored` **warning** when the pair has no render rule. /// /// The render matrix lives in the render package and is not introspectable from /// Rust in the MVP, so coverage is defined operationally: a kind is covered when /// it is a known stdlib kind (`known`) AND the target is one the render package /// handles (`COVERED_TARGETS` = student/teacher). A declared target outside that /// set, or a kind not in the known set, yields the warning. 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 { let covered = COVERED_TARGETS.contains(&target.as_str()); if covered { continue; } let key = (part.kind.clone(), target.clone()); if seen.contains(&key) { continue; } seen.push(key); let mut d = Diagnostic::warning( DiagCode::RenderIgnored, format!( "kind '{}' has no render rule for target '{}'; it will be omitted from that export", part.kind, target ), ) .with_hint(format!( "add a render rule for kind '{}' under target '{}', or remove the target", part.kind, target )); // 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 }