Files
curriculum-project-hub/crates/cph-cli/src/main.rs
T
ecc92c9a87 docs(adr): cph 的 nested-manifest / batch-export 改号 0036/0037
上游 0029/0030 与本地 filelib 侧同号 ADR 相撞,cph 两份改到本地空闲号段,
代码锚点引用一并跟随。
2026-08-06 00:51:33 +08:00

1012 lines
36 KiB
Rust

//! `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<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 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<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target (ambiguous: which target would it name?).
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// 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<String>,
/// Output path for a *single*-target build. Defaults to
/// `<PATH>/build/<target>.pdf`. Rejected when building more than one
/// target.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// 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 `<PATH>/outline.<format>`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
/// 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<String>,
},
/// 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<std::path::PathBuf>) -> 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 <eng-root> --input manifest=<path-rel-to-root> exports/student.typ <out>
//
// 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 `/<part.path>/<field>.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 <path>/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: <path>/<field>.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 <path>/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-<a>-<b>` 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<PathBuf>,
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<String>,
out: Option<PathBuf>,
) -> 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<PathBuf>,
) -> 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 `<field>.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<String>,
out: Option<PathBuf>,
) -> 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<PathBuf>,
) -> 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 <name>`, 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 `<field>.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 <name>`,
/// 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
}
}