forked from EduCraft/curriculum-project-hub
b86ee24a26
Closes the core pipeline: the `cph` CLI checks an engineering file and compiles a PDF. cph-check — phases (a) load → (b) structural (known-kind set) → (c) schema → (d) typst compile → (e) render-coverage. Compile is gated: skipped if (a)-(c) produced any Error (broken input would only add noise); `check` compiles every declared target (dedup identical diags), `build` compiles the one requested. Render-coverage emits a non-blocking RenderIgnored *warning* when a (kind, target) has no render rule — severity pinned via RENDER_IGNORED_SEVERITY const citing spec Diagnostic.renderIgnoredSeverity + ADR-0005. No double-emit: unknown/missing parts are skipped downstream. API: check(root, engine) -> CheckReport, build(root, engine, target) -> (Option<pdf>, CheckReport). cph-cli — clap: `cph check <path>` (exit 1 on any Error, warnings → 0) and `cph build <path> --target <name> [-o out]` (default target student, default out <path>/build/<target>.pdf). Diagnostics → stderr via Display, results → stdout. --render-dir override. End-to-end on examples/TH-141 (39 parts): `check` → 0 errors/0 warnings; `build` → real PDF student 9pp/395KB, teacher 12pp/474KB. Broken-element check (unclosed delimiter in a stmt.typ) → span-accurate `error[E-TYPST-COMPILE]: unclosed delimiter --> lemmas/…/stmt.typ:7:8`, exit 1. Workspace: fmt + clippy -D warnings + test all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
317 lines
12 KiB
Rust
317 lines
12 KiB
Rust
//! `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<Diagnostic>,
|
||
/// `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<Vec<u8>>, 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<Diagnostic>,
|
||
) {
|
||
// (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<Diagnostic> {
|
||
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<Diagnostic>) -> Vec<Diagnostic> {
|
||
let mut out: Vec<Diagnostic> = Vec::with_capacity(diags.len());
|
||
for d in diags {
|
||
if !out.contains(&d) {
|
||
out.push(d);
|
||
}
|
||
}
|
||
out
|
||
}
|