//! `cph` — the command-line entrypoint for the checker. //! //! 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. 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, }, /// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// sibling: a local convenience, no lesson involved). Pipe to your shell's /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. Completions { /// Which shell to generate completions for. shell: CompletionTarget, }, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] enum CompletionTarget { Bash, Zsh, Fish, PowerShell, Elvish, } 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), Command::Completions { shell } => run_completions(shell), } } /// Emit a shell-completion script for `shell` to stdout. The script is built /// from the same `Cli` clap definition above, so it tracks subcommands/flags as /// they evolve. fn run_completions(shell: CompletionTarget) -> ExitCode { use clap::CommandFactory; use clap_complete::Shell as CompShell; let sh = match shell { CompletionTarget::Bash => CompShell::Bash, CompletionTarget::Zsh => CompShell::Zsh, CompletionTarget::Fish => CompShell::Fish, CompletionTarget::PowerShell => CompShell::PowerShell, CompletionTarget::Elvish => CompShell::Elvish, }; let mut cmd = Cli::command(); clap_complete::generate(sh, &mut cmd, "cph", &mut std::io::stdout()); ExitCode::SUCCESS } /// 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 { // A target whose steps are shell commands (a tool-generated asset bundle, // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by // executing those commands, not by compiling a typst template. Detect that // shape up front and route accordingly. if cph_check::target_is_shell(path, target) { return run_shell_build(path, engine, target); } // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿 // transcript surfaces) is built by concatenating per-element `.md` // files in parts order, not by compiling a typst template. if cph_check::target_is_markdown_assemble(path, target) { return run_markdown_assemble_build(path, engine, target); } 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 } } } /// Run a shell-step target: execute its declared commands in the engineering /// root. Arbitrary command execution is **opt-in** — it only happens on an /// explicit `cph build --target `, and each command is printed before it /// runs so the user sees exactly what is executed. fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { eprintln!( "running shell-step target '{target}' (commands execute in {})", path.display() ); let report = cph_check::run_shell_target(path, engine, target); print_diagnostics(&report.check); if report.outcomes.is_empty() && !report.ok { // Refused before running anything (check errors / unknown target / no // shell steps): the diagnostics above explain why. if report.check.has_errors() { eprintln!( "build refused: {} errors (fix the lesson first)", report.check.error_count() ); } else { eprintln!("build failed: target '{target}' has no shell steps to run"); } return ExitCode::FAILURE; } for outcome in &report.outcomes { eprintln!("$ {}", outcome.run); if !outcome.stdout.trim().is_empty() { print!("{}", outcome.stdout); } if !outcome.ok() { eprint!("{}", outcome.stderr); eprintln!( "step failed (exit {})", outcome .status .map(|c| c.to_string()) .unwrap_or_else(|| "signal".into()) ); } } if report.ok { println!( "shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len() ); ExitCode::SUCCESS } else { eprintln!("shell-step target '{target}' failed"); ExitCode::FAILURE } } /// Run an assemble-markdown target: concatenate each element's `.md` /// (in parts order) and write the single-file artifact (ADR-0015). Like the /// shell path, this is opt-in — only on an explicit `cph build --target `, /// and never in `check`. fn run_markdown_assemble_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode { eprintln!( "assembling markdown target '{target}' (reading parts under {})", path.display() ); let report = cph_check::run_markdown_assemble_target(path, engine, target); print_diagnostics(&report.check); if report.outcomes.is_empty() && !report.ok { if report.check.has_errors() { eprintln!( "build refused: {} errors (fix the lesson first)", report.check.error_count() ); } else { eprintln!("build failed: target '{target}' has no assemble-markdown steps to run"); } return ExitCode::FAILURE; } for outcome in &report.outcomes { eprintln!( "assembled field '{}' from {} part(s)", outcome.field, outcome.parts_read ); if let Some(out_rel) = &outcome.written { println!( "wrote {} ({} bytes)", path.join(out_rel).display(), outcome.body.len() ); } if let Some(err) = &outcome.error { eprintln!("assemble failed: {err}"); } } if report.ok { println!( "assemble-markdown target '{target}' completed: {} step(s) ok", report.outcomes.len() ); ExitCode::SUCCESS } else { eprintln!("assemble-markdown target '{target}' failed"); ExitCode::FAILURE } }