Files
curriculum-project-hub/crates/cph-cli/src/main.rs
T
sjfhsjfh 4a9eb59aa9 feat(cli): cph completions <shell> 子命令(clap_complete)
用 clap_complete 4 给 cph 生成 shell 补全脚本(bash/zsh/fish/powershell/elvish)。
clap ValueEnum 枚举 Shell;run_completions 从 Cli::command() 派生,自动跟随
子命令/flag 演进。用法:`cph completions zsh > ~/.zfunc/_cph`。

- 依赖经 `cargo add clap_complete@4 -p cph-cli` 添加(4.6.5,默认 features 无额外依赖)
- README 补补全安装一节

测试:全 workspace 49 passed。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:21:46 +08:00

268 lines
9.2 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::path::PathBuf;
use std::process::ExitCode;
use clap::{Parser, Subcommand};
use cph_check::CheckReport;
use cph_typst::Engine;
/// The `cph` checker for curriculum engineering files.
#[derive(Debug, Parser)]
#[command(name = "cph", version, about, long_about = None)]
struct Cli {
/// Override the `cph-render` package directory (the folder containing
/// `lib.typ` / `typst.toml`). Defaults to the engine's own resolution
/// (`CPH_RENDER_DIR` env var, else the repo `render/`).
#[arg(long, global = true, value_name = "DIR")]
render_dir: Option<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
/// Run the full check pipeline and print diagnostics. Exits 1 on any error.
Check {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
},
/// Build a PDF for a render target. Exits 1 if the build fails.
Build {
/// Path to the engineering-file root (the folder with `manifest.toml`).
path: PathBuf,
/// Render target to export.
#[arg(long, default_value = "student")]
target: String,
/// Output PDF path. Defaults to `<PATH>/build/<target>.pdf`.
#[arg(short = 'o', long, value_name = "OUT")]
out: Option<PathBuf>,
},
/// 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: Shell,
},
}
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
enum Shell {
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: Shell) -> ExitCode {
use clap::CommandFactory;
use clap_complete::Shell as CompShell;
let sh = match shell {
Shell::Bash => CompShell::Bash,
Shell::Zsh => CompShell::Zsh,
Shell::Fish => CompShell::Fish,
Shell::PowerShell => CompShell::PowerShell,
Shell::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<PathBuf>,
) -> 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 `<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);
}
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 <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
}
}