forked from EduCraft/curriculum-project-hub
ecc92c9a87
上游 0029/0030 与本地 filelib 侧同号 ADR 相撞,cph 两份改到本地空闲号段, 代码锚点引用一并跟随。
1012 lines
36 KiB
Rust
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
|
|
}
|
|
}
|