From b86ee24a261c9eafb0e50f2bd0db7f8532ad764d Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:57:34 +0800 Subject: [PATCH] feat(checker): cph-check orchestrator + cph-cli `cph` binary (WU-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, CheckReport). cph-cli — clap: `cph check ` (exit 1 on any Error, warnings → 0) and `cph build --target [-o out]` (default target student, default out /build/.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) --- Cargo.lock | 132 ++++++++++++ crates/cph-check/Cargo.toml | 4 + crates/cph-check/src/lib.rs | 312 ++++++++++++++++++++++++++++- crates/cph-check/tests/pipeline.rs | 157 +++++++++++++++ crates/cph-cli/Cargo.toml | 4 + crates/cph-cli/src/main.rs | 135 ++++++++++++- 6 files changed, 736 insertions(+), 8 deletions(-) create mode 100644 crates/cph-check/tests/pipeline.rs diff --git a/Cargo.lock b/Cargo.lock index e1af56a..4475d92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/cph-check/Cargo.toml b/crates/cph-check/Cargo.toml index cb1c011..5408e07 100644 --- a/crates/cph-check/Cargo.toml +++ b/crates/cph-check/Cargo.toml @@ -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" } diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 947c084..5926a22 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -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, + /// `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 +} diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs new file mode 100644 index 0000000..e9a3be5 --- /dev/null +++ b/crates/cph-check/tests/pipeline.rs @@ -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; + +/// `/../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") +} + +/// `/../../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); +} diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml index 58ecd0a..ba6f955 100644 --- a/crates/cph-cli/Cargo.toml +++ b/crates/cph-cli/Cargo.toml @@ -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"] } diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index 67255fa..b564458 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -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, + + #[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 `/build/.pdf`. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + }, +} + +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, +) -> 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 + } + } }