//! `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::fs::OpenOptions; use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; use clap::{Parser, Subcommand}; use cph_check::CheckReport; use cph_model::OutlineDocument; 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 one or more render targets. Exits 1 if any target fails. /// /// With no `--target`, batches every target the lesson declares /// (ADR-0037): each target builds independently — one failing does not /// stop the rest — and the exit code is non-zero if any target failed. /// Repeat `--target` to build an explicit ordered subset instead. Build { /// Path to the engineering-file root (the folder with `manifest.toml`). path: PathBuf, /// Render target(s) to export. Repeatable. Defaults to every target /// the lesson declares (or `student` if it declares none). #[arg(long = "target")] targets: Vec, /// Output path for a *single*-target build. Defaults to /// `/build/.pdf`. Rejected when building more than one /// target (ambiguous: which target would it name?). #[arg(short = 'o', long, value_name = "OUT")] out: Option, }, /// Build one or more bundle targets (ADR-0037): combine an ordered /// arrangement of self-contained lessons (`bundle.toml`) into one /// artifact. Same batching/exit-code contract as `build`. Bundle { /// Path to the bundle root (the folder with `bundle.toml`). path: PathBuf, /// Bundle target(s) to export. Repeatable. Defaults to every target /// the bundle declares (or `student` if it declares none). #[arg(long = "target")] targets: Vec, /// Output path for a *single*-target build. Defaults to /// `/build/.pdf`. Rejected when building more than one /// target. #[arg(short = 'o', long, value_name = "OUT")] out: Option, }, /// Export the teacher-facing outline as Markdown, PDF, or JSON. Outline { /// Path to the engineering-file root. Defaults to the current directory. #[arg(default_value = ".")] path: PathBuf, /// Output format. Defaults to PDF. #[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)] format: OutlineFormat, /// Output path. Defaults to `/outline.`. #[arg(short = 'o', long, value_name = "OUT")] out: Option, /// Allow replacing an existing output file. #[arg(long)] force: bool, }, /// 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, }, /// Scaffold a new, check-clean engineering-file root under `path`. Owns the /// `manifest.toml` (with a generated `[project].id`), a pinning /// `.cph-version` (ADR-0016), the stock `exports/student.typ` render /// template, and the empty per-kind part folders. A local authoring /// convenience — no hub semantics involved. Init { /// Directory to create the engineering file in. Created recursively if /// missing; refused if it already holds a `manifest.toml`. path: PathBuf, /// Project name / lesson title. Defaults to the directory's base name. #[arg(long, value_name = "NAME")] name: Option, }, /// Add a new part to an engineering file: create its folder, its /// element.toml, and the blank required content files, then append a /// [[children]] entry to the root manifest.toml. A local authoring /// convenience, not a hub write. Add { /// Engineering-file root (the folder with `manifest.toml`). #[arg(long, default_value = ".", value_name = "DIR")] root: PathBuf, /// The element kind. kind: String, /// Display name of the new part (also its folder name). name: String, }, } #[derive(Debug, Clone, Copy, clap::ValueEnum)] enum OutlineFormat { Md, Pdf, Json, } impl OutlineFormat { fn extension(self) -> &'static str { match self { Self::Md => "md", Self::Pdf => "pdf", Self::Json => "json", } } } #[derive(Debug, Clone, Copy, clap::ValueEnum)] enum CompletionTarget { Bash, Zsh, Fish, PowerShell, Elvish, } fn main() -> ExitCode { let cli = Cli::parse(); match cli.command { Command::Check { path } => run_check(&path, &engine_from(&cli.render_dir)), Command::Build { path, targets, out } => { run_build_command(&path, &engine_from(&cli.render_dir), targets, out) } Command::Bundle { path, targets, out } => { run_bundle_command(&path, &engine_from(&cli.render_dir), targets, out) } Command::Outline { path, format, out, force, } => run_outline(&path, &engine_from(&cli.render_dir), format, out, force), Command::Completions { shell } => run_completions(shell), Command::Init { path, name } => run_init(&path, name.as_deref()), Command::Add { root, kind, name } => run_add(&root, &kind, &name), } } /// Build the typst [`Engine`], honoring a `--render-dir` override. Constructed /// lazily — only the render-touching commands (`check`, `build`, `bundle`, /// `outline`) need it; the authoring ones (`init`, `add`, `completions`) skip /// the render-package extraction cost entirely. fn engine_from(render_dir: &Option) -> Engine { match render_dir { Some(dir) => Engine::with_render_dir(dir.clone()), None => Engine::new(), } } /// 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 } // =========================================================================== // Authoring subcommands (`init`, `add`): local engineering-file scaffolding. // They never touch the hub and are deliberately local — they only create // dirs/files and append to the local `manifest.toml`. `check` stays // authoritative: whatever these write, `cph check` must accept. /// The stock `exports/student.typ` written by `init` — the framework's default /// render template (ADR-0011, outline-shape ADR-0036), the same file the /// examples ship. Kept verbatim so a freshly scaffolded engineering file /// renders out of the box; it imports `@local/cph-render:0.1.0`, which the /// engine resolves from the embedded package. Presentation (heading numbering, /// styling) is editable here per engineering file, not in the manifest. const DEFAULT_STUDENT_TEMPLATE: &str = r##"// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0036). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the // manifest injected: // typst compile --root --input manifest= exports/student.typ // // It is intentionally self-contained (no shared helper import) so it can be // copied verbatim into a new engineering file's `exports/`. Presentation — // heading numbering — lives HERE (editable per engineering file), not in the // manifest and not hardcoded in the cph-render package. // // WHY THE INCLUDE LOOP IS HERE AND NOT IN cph-render: typst resolves a dynamic // `include` path relative to the file it lexically appears in, and a package has // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the // template loads content and hands cph-render an already-assembled `outline` // array (elements interleaved with section headings, ADR-0036). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, // via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded // unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. #import "@local/cph-render:0.1.0": render-lesson, part-fields, default-heading-numbering // This template IS the student build, so the target is fixed. #let target = "student" // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) #let raw-outline = manifest.at("outline", default: ()) // Assemble each outline entry: // - an "element" entry: include its content fields (computed absolute paths, // resolved against --root) and read scalar fields from /element.toml. // `part-fields` (from cph-render) is the single source of truth for // kind->fields. // - a "section" entry (ADR-0036): pass its title/depth straight through — no // content to load, it is a heading. #let outline = raw-outline.map(raw => { if raw.at("type", default: "element") == "section" { ( entry-type: "section", kind: raw.at("kind", default: none), title: raw.at("title", default: ""), depth: raw.at("depth", default: 1), ) } else { let kind = raw.at("kind", default: none) let path = raw.at("path", default: none) let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) // Which optional content fields are present on disk (manifest-declared). let present = raw.at("fields", default: ()) let entry = (entry-type: "element", kind: kind) // Required content fields: /.typ (absolute, root-relative). for field in spec.content { entry.insert(field, include "/" + path + "/" + field + ".typ") } // Optional content fields: only when the manifest says the file exists. for field in spec.optional-content { if field in present { entry.insert(field, include "/" + path + "/" + field + ".typ") } } // Scalar fields come from /element.toml. if spec.scalars.len() > 0 { let element = toml("/" + path + "/element.toml") for field in spec.scalars { let v = element.at(field, default: none) if v != none and v != "" { entry.insert(field, v) } } } entry } }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes // from cph-render; override here per engineering file if desired. #render-lesson( info: info, target: target, outline: outline, heading-numbering: default-heading-numbering, ) "##; /// The on-disk folder a part of `kind` lives under (a convention shared by the /// examples and the hub, not derivable from the kind schema, so it's pinned /// here alongside the initializer). `None` for unknown kinds — the same set /// `cph_schema::known_kinds()` reports. fn kind_dir(kind: &str) -> Option<&'static str> { match kind { "segment" => Some("segments"), "example" => Some("examples"), "lemma" => Some("lemmas"), "sop" => Some("sops"), _ => None, } } /// Encode `v` as lowercase base-36 for use in a generated project id. fn encode_id(mut v: u64) -> String { const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; if v == 0 { return "0".into(); } let mut s = String::new(); while v > 0 { s.push(ALPHABET[(v % 36) as usize] as char); v /= 36; } s } /// Generate a `local-…` project id for a new engineering file (mirrors the /// examples' `local--` shape). Not security entropy — enough to be unique /// per init, derived from time + pid + a per-process counter. fn new_project_id() -> String { static COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); let counter = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let mix = now.rotate_left(17) ^ (std::process::id() as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ counter.wrapping_mul(0xBF58_476D_1CE4_E5B9); format!("local-{}-{}", encode_id(mix), encode_id(counter)) } /// Scaffold a new engineering-file root under `path`. Refuses to clobber an /// existing `manifest.toml`, so a repeat run is safe. fn run_init(path: &std::path::Path, name: Option<&str>) -> ExitCode { if path.join("manifest.toml").exists() { eprintln!( "error: '{}' already contains manifest.toml; refusing to init over it", path.display() ); return ExitCode::FAILURE; } if let Err(e) = std::fs::create_dir_all(path) { eprintln!("error: cannot create '{}': {e}", path.display()); return ExitCode::FAILURE; } let display_name = match name { Some(n) => n.to_string(), None => path .file_name() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_else(|| "untitled".into()), }; let manifest = format!( r#"[project] id = "{id}" name = "{name}" [info] title = "{name}" # Export target (ADR-0009/0011): a typed build. The stock template at # exports/student.typ imports @local/cph-render:0.1.0 (embedded in cph). [targets.student] artifact = {{ type = "single-file", filepath = "build/student.pdf" }} [[targets.student.steps]] type = "typst-compile" template = "exports/student.typ" "#, id = new_project_id(), name = display_name, ); let files: &[(&str, &str)] = &[ ("manifest.toml", &manifest), (".cph-version", &format!("{}\n", env!("CARGO_PKG_VERSION"))), ("exports/student.typ", DEFAULT_STUDENT_TEMPLATE), ]; for (rel, content) in files { let full = path.join(rel); if let Some(parent) = full.parent() { if let Err(e) = std::fs::create_dir_all(parent) { eprintln!("error: cannot create '{}': {e}", parent.display()); return ExitCode::FAILURE; } } if let Err(e) = std::fs::write(&full, content) { eprintln!("error: cannot write '{}': {e}", full.display()); return ExitCode::FAILURE; } } for dir in ["segments", "lemmas", "examples", "sops"] { if let Err(e) = std::fs::create_dir_all(path.join(dir)) { eprintln!("error: cannot create '{}': {e}", path.join(dir).display()); return ExitCode::FAILURE; } } println!("initialized engineering file at {}", path.display()); println!( " next: cph check {} | cph build {} --target student", path.display(), path.display() ); ExitCode::SUCCESS } /// Add a new part to the engineering file at `root`: create its folder with /// `element.toml` + blank required content files, then append its `[[children]]` /// entry to the root `manifest.toml` (ADR-0036 root children). Rejects unknown /// kinds, unsafe names, and anything that would double-register an existing part. fn run_add(root: &std::path::Path, kind: &str, name: &str) -> ExitCode { let dir = match kind_dir(kind) { Some(d) => d, None => { eprintln!( "error: unknown kind '{kind}'; expected one of: {}", cph_schema::known_kinds().join(", ") ); return ExitCode::FAILURE; } }; let trimmed = name.trim(); if trimmed.is_empty() || trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains('"') { eprintln!("error: invalid part name {name:?}; use a plain folder name (no / \\ or quotes)"); return ExitCode::FAILURE; } let rel = format!("{dir}/{trimmed}"); let part_dir = root.join(&rel); let manifest_path = root.join("manifest.toml"); let manifest_src = match std::fs::read_to_string(&manifest_path) { Ok(s) => s, Err(e) => { eprintln!( "error: cannot read '{}': {e} (run `cph init` here first?)", manifest_path.display() ); return ExitCode::FAILURE; } }; if part_dir.exists() { eprintln!("error: '{}' already exists", part_dir.display()); return ExitCode::FAILURE; } if manifest_has_child(&manifest_src, &rel) { eprintln!("error: manifest.toml already declares a part at '{rel}'"); return ExitCode::FAILURE; } if let Err(e) = std::fs::create_dir_all(&part_dir) { eprintln!("error: cannot create '{}': {e}", part_dir.display()); return ExitCode::FAILURE; } let element_toml = format!("kind = \"{kind}\"\n"); if let Err(e) = std::fs::write(part_dir.join("element.toml"), element_toml) { eprintln!("error: cannot write element.toml for '{rel}': {e}"); return ExitCode::FAILURE; } let required = cph_schema::schema_for(kind) .map(|s| s.required_content_field_names()) .unwrap_or_default(); for field in &required { let f = part_dir.join(format!("{field}.typ")); if let Err(e) = std::fs::write(&f, "") { eprintln!("error: cannot write '{}': {e}", f.display()); return ExitCode::FAILURE; } } let updated = insert_child(&manifest_src, kind, &rel); if let Err(e) = std::fs::write(&manifest_path, updated) { eprintln!("error: cannot update '{}': {e}", manifest_path.display()); return ExitCode::FAILURE; } println!("added {kind} '{trimmed}' → {rel} (folder + [[children]] entry)"); if required.is_empty() { println!(" note: kind '{kind}' declares no required content fields"); } else { println!(" content files created: {}", required.join(", ")); } ExitCode::SUCCESS } /// Whether `manifest` already declares a child whose `path` line equals `rel`. fn manifest_has_child(manifest: &str, rel: &str) -> bool { let needle = format!("path = \"{rel}\""); manifest.lines().any(|l| l.trim() == needle) } /// Insert a new `[[children]]` block into `manifest`, keeping the array of /// tables contiguous (a TOML requirement: all elements of `[[children]]` must be /// adjacent). The block goes immediately before the first section header that is /// neither `[project]`/`[info]` nor an existing `[[children]]` entry (i.e. before /// `[targets.*]`), or at end-of-file if none — either way it lands at the tail /// of the root-children run, after `[info]` and any existing children (ADR-0036). /// Comment blocks are preserved. fn insert_child(manifest: &str, kind: &str, rel: &str) -> String { let block = format!("[[children]]\nkind = \"{kind}\"\npath = \"{rel}\"\n"); let lines: Vec<&str> = manifest.lines().collect(); let insert_at = lines .iter() .position(|l| { let t = l.trim_start(); t.starts_with('[') && !t.starts_with("[[children]]") && t != "[project]" && t != "[info]" }) .unwrap_or(lines.len()); let mut out = String::new(); for (i, line) in lines.iter().enumerate() { if i == insert_at { out.push_str(&block); } out.push_str(line); out.push('\n'); } if insert_at == lines.len() { out.push_str(&block); } 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_outline( path: &std::path::Path, engine: &Engine, format: OutlineFormat, out: Option, force: bool, ) -> ExitCode { let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension()))); if out_path.exists() && !force { eprintln!( "warning: output '{}' already exists; pass --force to overwrite", out_path.display() ); return ExitCode::FAILURE; } let (outline, report) = cph_check::outline(path); print_diagnostics(&report); let Some(outline) = outline else { eprintln!("outline failed: fix the lesson before exporting"); return ExitCode::FAILURE; }; let bytes = match format { OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(), OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) { Ok(mut bytes) => { bytes.push(b'\n'); bytes } Err(e) => { eprintln!("outline failed: cannot serialize JSON: {e}"); return ExitCode::FAILURE; } }, OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) { Ok(bytes) => bytes, Err(diags) => { for diagnostic in &diags { eprintln!("{diagnostic}"); } eprintln!("outline failed: PDF compilation failed"); return ExitCode::FAILURE; } }, }; if force && out_path.exists() { eprintln!( "warning: overwriting existing output '{}'", out_path.display() ); } if let Err(e) = write_outline_output(&out_path, &bytes, force) { eprintln!("outline failed: {e}"); return ExitCode::FAILURE; } println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); ExitCode::SUCCESS } fn render_outline_markdown(outline: &OutlineDocument) -> String { let mut body = format!("# {}\n\n", outline.title.trim()); if !outline.authors.is_empty() { body.push_str("作者:"); body.push_str(&outline.authors.join("、")); body.push_str("\n\n"); } for child in &outline.children { append_outline_markdown(&mut body, child, 2); } body } fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) { let level = level.min(6); body.push_str(&"#".repeat(level)); body.push(' '); body.push_str(&node.title); if !node.kind.is_empty() { body.push_str(" `["); body.push_str(&node.kind); body.push_str("]`"); } body.push_str("\n\n"); if let Some(notes) = node.notes.as_deref() { body.push_str("> 教学提示:\n"); for line in notes.lines() { body.push_str("> "); body.push_str(line); body.push('\n'); } body.push('\n'); } for child in &node.children { append_outline_markdown(body, child, level + 1); } } fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> { if let Some(parent) = path .parent() .filter(|parent| !parent.as_os_str().is_empty()) { std::fs::create_dir_all(parent) .map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?; } if force { std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display())) } else { let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { Ok(file) => file, Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { return Err(format!( "output '{}' already exists; pass --force to overwrite", path.display() )); } Err(e) => return Err(format!("cannot create '{}': {e}", path.display())), }; file.write_all(bytes) .map_err(|e| format!("cannot write '{}': {e}", path.display())) } } /// Dispatch `cph build` (ADR-0037): with an explicit `--target` (repeatable), /// build exactly that ordered set; with none, batch every target the lesson /// declares. Each target builds **independently** — one failing does not stop /// the rest — and prints a per-target ledger when building more than one. /// Exits non-zero iff **any** target failed to produce its artifact (a build /// failure is a real defect, distinct from the non-blocking `renderIgnored` /// warning class — ADR-0037). fn run_build_command( path: &std::path::Path, engine: &Engine, targets: Vec, out: Option, ) -> ExitCode { let target_list = if targets.is_empty() { cph_check::declared_target_names(path) } else { targets }; if target_list.len() > 1 && out.is_some() { eprintln!( "error: -o/--out only applies to a single-target build; pass exactly one --target with -o" ); return ExitCode::FAILURE; } let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len()); for target in &target_list { if target_list.len() > 1 { eprintln!("=== target '{target}' ==="); } let ok = run_build_one(path, engine, target, out.clone()); results.push((target.clone(), ok)); } if target_list.len() > 1 { eprintln!("--- build summary ---"); for (target, ok) in &results { eprintln!("{target}: {}", if *ok { "ok" } else { "failed" }); } } if results.iter().any(|(_, ok)| !ok) { ExitCode::FAILURE } else { ExitCode::SUCCESS } } /// Build one target, returning whether it succeeded. Routes to the shell, /// markdown-assemble, or typst-compile path per the target's step shape. fn run_build_one( path: &std::path::Path, engine: &Engine, target: &str, out: Option, ) -> bool { // 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) == ExitCode::SUCCESS; } // 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) == ExitCode::SUCCESS; } 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 false; } } if let Err(e) = std::fs::write(&out_path, &bytes) { eprintln!("error: cannot write '{}': {e}", out_path.display()); return false; } println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); true } None => { eprintln!("build failed: {} errors", report.error_count()); false } } } /// Dispatch `cph bundle` (ADR-0037) — same batching/exit-code contract as /// [`run_build_command`], over a bundle's own declared targets. MVP bundle /// targets are `typst-compile` only (no shell/markdown-assemble routing — /// ADR-0037 did not extend those step kinds to bundles). fn run_bundle_command( path: &std::path::Path, engine: &Engine, targets: Vec, out: Option, ) -> ExitCode { let target_list = if targets.is_empty() { cph_check::declared_bundle_target_names(path) } else { targets }; if target_list.len() > 1 && out.is_some() { eprintln!( "error: -o/--out only applies to a single-target build; pass exactly one --target with -o" ); return ExitCode::FAILURE; } let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len()); for target in &target_list { if target_list.len() > 1 { eprintln!("=== target '{target}' ==="); } let ok = run_bundle_one(path, engine, target, out.clone()); results.push((target.clone(), ok)); } if target_list.len() > 1 { eprintln!("--- build summary ---"); for (target, ok) in &results { eprintln!("{target}: {}", if *ok { "ok" } else { "failed" }); } } if results.iter().any(|(_, ok)| !ok) { ExitCode::FAILURE } else { ExitCode::SUCCESS } } /// Build one bundle target, returning whether it succeeded. fn run_bundle_one( path: &std::path::Path, engine: &Engine, target: &str, out: Option, ) -> bool { let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); let (pdf, report) = cph_check::build_bundle(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 false; } } if let Err(e) = std::fs::write(&out_path, &bytes) { eprintln!("error: cannot write '{}': {e}", out_path.display()); return false; } println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); true } None => { eprintln!("build failed: {} errors", report.error_count()); false } } } /// 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 } }