forked from EduCraft/curriculum-project-hub
feat(checker): cph-check orchestrator + cph-cli cph binary (WU-5)
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>
This commit is contained in:
Generated
+132
@@ -17,6 +17,56 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle"
|
||||
version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-query"
|
||||
version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-wincon"
|
||||
version = "3.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
version = "0.5.1"
|
||||
@@ -222,6 +272,46 @@ dependencies = [
|
||||
"serde_path_to_error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
@@ -255,6 +345,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "comemo"
|
||||
version = "0.5.1"
|
||||
@@ -291,10 +387,22 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "cph-check"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
"cph-schema",
|
||||
"cph-typst",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cph-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"cph-check",
|
||||
"cph-diag",
|
||||
"cph-typst",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cph-diag"
|
||||
@@ -1101,6 +1209,12 @@ version = "0.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
@@ -1338,6 +1452,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -2028,6 +2148,12 @@ dependencies = [
|
||||
"float-cmp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.27.2"
|
||||
@@ -2807,6 +2933,12 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "vello_common"
|
||||
version = "0.0.8"
|
||||
|
||||
@@ -5,3 +5,7 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cph-diag = { workspace = true }
|
||||
cph-model = { workspace = true }
|
||||
cph-schema = { path = "../cph-schema" }
|
||||
cph-typst = { path = "../cph-typst" }
|
||||
|
||||
+310
-2
@@ -3,6 +3,314 @@
|
||||
//! 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.
|
||||
|
||||
// WU-5: implement the orchestrator + render-coverage rule. Empty for now so the
|
||||
// workspace compiles.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Integration tests for the `cph-check` orchestrator.
|
||||
//!
|
||||
//! These exercise phase gating and the public API against the shared `mini`
|
||||
//! fixture (`crates/cph-typst/tests/fixtures/mini/`) and small throwaway
|
||||
//! fixtures built in a temp dir. The typst Engine is pointed at the repo's real
|
||||
//! `render/` package via `Engine::with_render_dir`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_typst::Engine;
|
||||
|
||||
/// `<this crate>/../cph-typst/tests/fixtures/mini` — the known-good lesson.
|
||||
fn mini_fixture() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("cph-typst")
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join("mini")
|
||||
}
|
||||
|
||||
/// `<this crate>/../../render` — the repo render package.
|
||||
fn render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("render")
|
||||
}
|
||||
|
||||
fn engine() -> Engine {
|
||||
Engine::with_render_dir(render_dir())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn good_fixture_has_no_errors() {
|
||||
let report = cph_check::check(&mini_fixture(), &engine());
|
||||
assert!(report.lesson_loaded, "mini fixture should load");
|
||||
assert_eq!(
|
||||
report.error_count(),
|
||||
0,
|
||||
"mini fixture should have zero errors, got: {:#?}",
|
||||
report.diagnostics
|
||||
);
|
||||
assert!(!report.has_errors());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_is_an_error() {
|
||||
// Build a throwaway lesson whose part declares kind "frob".
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "broken"
|
||||
name = "broken"
|
||||
|
||||
[info]
|
||||
title = "broken"
|
||||
|
||||
[[parts]]
|
||||
kind = "frob"
|
||||
path = "elements/widget"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let part_dir = tmp.join("elements").join("widget");
|
||||
std::fs::create_dir_all(&part_dir).unwrap();
|
||||
std::fs::write(part_dir.join("element.toml"), "kind = \"frob\"\n").unwrap();
|
||||
|
||||
let report = cph_check::check(&tmp, &engine());
|
||||
assert!(report.lesson_loaded);
|
||||
assert!(report.has_errors());
|
||||
assert!(
|
||||
report
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == DiagCode::UnknownKind && d.message.contains("frob")),
|
||||
"expected an UnknownKind error mentioning 'frob', got: {:#?}",
|
||||
report.diagnostics
|
||||
);
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_manifest_failure_sets_lesson_not_loaded() {
|
||||
let tmp = tempdir();
|
||||
// No manifest.toml at all → cph_model::load returns None.
|
||||
let report = cph_check::check(&tmp, &engine());
|
||||
assert!(!report.lesson_loaded, "missing manifest is a hard failure");
|
||||
assert!(report.has_errors());
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_returns_pdf_on_good_fixture() {
|
||||
let (pdf, report) = cph_check::build(&mini_fixture(), &engine(), "student");
|
||||
assert!(report.lesson_loaded);
|
||||
assert_eq!(
|
||||
report.error_count(),
|
||||
0,
|
||||
"no check errors expected, got: {:#?}",
|
||||
report.diagnostics
|
||||
);
|
||||
let pdf = pdf.expect("expected PDF bytes for the good fixture");
|
||||
assert!(pdf.starts_with(b"%PDF"), "output should be a PDF");
|
||||
assert!(pdf.len() > 1000, "PDF should be non-trivial");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_returns_none_on_broken_fixture() {
|
||||
// A part folder is missing → cph-model flags PartPathMissing (an error),
|
||||
// so build refuses before ever compiling.
|
||||
let tmp = tempdir();
|
||||
std::fs::write(
|
||||
tmp.join("manifest.toml"),
|
||||
r#"
|
||||
[project]
|
||||
id = "broken"
|
||||
name = "broken"
|
||||
|
||||
[info]
|
||||
title = "broken"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/does-not-exist"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (pdf, report) = cph_check::build(&tmp, &engine(), "student");
|
||||
assert!(pdf.is_none(), "broken lesson must not produce a PDF");
|
||||
assert!(report.has_errors());
|
||||
cleanup(&tmp);
|
||||
}
|
||||
|
||||
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
p.push(format!(
|
||||
"cph-check-test-{nanos}-{:?}",
|
||||
std::thread::current().id()
|
||||
));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
fn cleanup(p: &PathBuf) {
|
||||
let _ = std::fs::remove_dir_all(p);
|
||||
}
|
||||
@@ -9,3 +9,7 @@ name = "cph"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
cph-check = { path = "../cph-check" }
|
||||
cph-diag = { workspace = true }
|
||||
cph-typst = { path = "../cph-typst" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
+129
-6
@@ -1,10 +1,133 @@
|
||||
//! `cph` — the command-line entrypoint for the checker.
|
||||
//!
|
||||
//! Owned by **WU-5**. Will parse args, run the `cph-check` pipeline against an
|
||||
//! engineering file, and print diagnostics. A stub for now so the workspace
|
||||
//! builds and produces a runnable binary.
|
||||
//! Owned by **WU-5**. Parses args, constructs the typst [`Engine`], runs the
|
||||
//! `cph-check` pipeline against an engineering file, and prints diagnostics.
|
||||
//!
|
||||
//! Convention: **diagnostics go to stderr, results go to stdout.** `check`
|
||||
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
|
||||
//! 0); `build` exits 1 when the PDF could not be produced.
|
||||
|
||||
// WU-5: wire up arg parsing + the check pipeline + diagnostic rendering.
|
||||
fn main() {
|
||||
println!("cph: not yet implemented");
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use cph_check::CheckReport;
|
||||
use cph_typst::Engine;
|
||||
|
||||
/// The `cph` checker for curriculum engineering files.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "cph", version, about, long_about = None)]
|
||||
struct Cli {
|
||||
/// Override the `cph-render` package directory (the folder containing
|
||||
/// `lib.typ` / `typst.toml`). Defaults to the engine's own resolution
|
||||
/// (`CPH_RENDER_DIR` env var, else the repo `render/`).
|
||||
#[arg(long, global = true, value_name = "DIR")]
|
||||
render_dir: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
enum Command {
|
||||
/// Run the full check pipeline and print diagnostics. Exits 1 on any error.
|
||||
Check {
|
||||
/// Path to the engineering-file root (the folder with `manifest.toml`).
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Build a PDF for a render target. Exits 1 if the build fails.
|
||||
Build {
|
||||
/// Path to the engineering-file root (the folder with `manifest.toml`).
|
||||
path: PathBuf,
|
||||
/// Render target to export.
|
||||
#[arg(long, default_value = "student")]
|
||||
target: String,
|
||||
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let engine = match &cli.render_dir {
|
||||
Some(dir) => Engine::with_render_dir(dir.clone()),
|
||||
None => Engine::new(),
|
||||
};
|
||||
|
||||
match cli.command {
|
||||
Command::Check { path } => run_check(&path, &engine),
|
||||
Command::Build { path, target, out } => run_build(&path, &engine, &target, out),
|
||||
}
|
||||
}
|
||||
|
||||
/// Print every diagnostic in `report` to stderr, followed by a summary line.
|
||||
fn print_diagnostics(report: &CheckReport) {
|
||||
for d in &report.diagnostics {
|
||||
eprintln!("{d}");
|
||||
}
|
||||
eprintln!(
|
||||
"{} error{}, {} warning{}",
|
||||
report.error_count(),
|
||||
plural(report.error_count()),
|
||||
report.warning_count(),
|
||||
plural(report.warning_count()),
|
||||
);
|
||||
}
|
||||
|
||||
fn plural(n: usize) -> &'static str {
|
||||
if n == 1 {
|
||||
""
|
||||
} else {
|
||||
"s"
|
||||
}
|
||||
}
|
||||
|
||||
fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
|
||||
let report = cph_check::check(path, engine);
|
||||
print_diagnostics(&report);
|
||||
|
||||
if report.has_errors() {
|
||||
ExitCode::FAILURE
|
||||
} else {
|
||||
println!("check passed: {}", path.display());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
}
|
||||
|
||||
fn run_build(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
target: &str,
|
||||
out: Option<PathBuf>,
|
||||
) -> ExitCode {
|
||||
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
|
||||
|
||||
let (pdf, report) = cph_check::build(path, engine, target);
|
||||
print_diagnostics(&report);
|
||||
|
||||
match pdf {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
eprintln!(
|
||||
"error: cannot create output directory '{}': {e}",
|
||||
parent.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
if let Err(e) = std::fs::write(&out_path, &bytes) {
|
||||
eprintln!("error: cannot write '{}': {e}", out_path.display());
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => {
|
||||
eprintln!("build failed: {} errors", report.error_count());
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user