forked from EduCraft/curriculum-project-hub
Merge pull request 'feat(cli): cph init / cph add 子命令' (#101) from feat/cph-init-add into main
Reviewed-on: EduCraft/curriculum-project-hub#101
This commit is contained in:
Generated
+1
@@ -411,6 +411,7 @@ dependencies = [
|
|||||||
"cph-check",
|
"cph-check",
|
||||||
"cph-diag",
|
"cph-diag",
|
||||||
"cph-model",
|
"cph-model",
|
||||||
|
"cph-schema",
|
||||||
"cph-typst",
|
"cph-typst",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ cargo install --path crates/cph-cli --locked
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
cph --version # cph 0.0.2
|
cph --version # cph 0.0.2
|
||||||
|
cph init <工程目录> # 脚手架:manifest.toml + .cph-version + 默认 exports/student.typ + 空 kind 目录
|
||||||
|
cph add --root <工程目录> <kind> <名称> # 新增 part(segment/example/lemma/sop):建目录+空白内容文件+追加 [[children]]
|
||||||
cph check <工程目录> # 校验合法性(7 类诊断)
|
cph check <工程目录> # 校验合法性(7 类诊断)
|
||||||
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
|
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
|
||||||
```
|
```
|
||||||
@@ -29,6 +31,12 @@ cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 o
|
|||||||
大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行
|
大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行
|
||||||
`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown,
|
`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown,
|
||||||
并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。
|
并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。
|
||||||
|
`init` / `add` 是纯本地的创作脚手架(与 ADR-0013 的 `completions` 同类,不涉及
|
||||||
|
hub 语义):`init` 产出一个 `cph check` 可过的工程根;`add` 按 kind 建
|
||||||
|
`<子目录>/<名称>/` + `element.toml` + 必填内容字段(`segment→textbook.typ`、
|
||||||
|
`example→problem/solution.typ`、`lemma→stmt.typ`、`sop→sop.typ`),并把配套
|
||||||
|
`[[children]]` 追加进根 `manifest.toml`(保持数组连续,不破坏注释;ADR-0029)。缺省
|
||||||
|
`--root` 为当前目录。
|
||||||
|
|
||||||
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
|
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
|||||||
cph-check = { path = "../cph-check" }
|
cph-check = { path = "../cph-check" }
|
||||||
cph-diag = { workspace = true }
|
cph-diag = { workspace = true }
|
||||||
cph-model = { workspace = true }
|
cph-model = { workspace = true }
|
||||||
|
cph-schema = { path = "../cph-schema" }
|
||||||
cph-typst = { path = "../cph-typst" }
|
cph-typst = { path = "../cph-typst" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
|
|||||||
+399
-9
@@ -95,6 +95,32 @@ enum Command {
|
|||||||
/// Which shell to generate completions for.
|
/// Which shell to generate completions for.
|
||||||
shell: CompletionTarget,
|
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)]
|
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||||
@@ -126,22 +152,34 @@ enum CompletionTarget {
|
|||||||
fn main() -> ExitCode {
|
fn main() -> ExitCode {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let engine = match &cli.render_dir {
|
|
||||||
Some(dir) => Engine::with_render_dir(dir.clone()),
|
|
||||||
None => Engine::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
Command::Check { path } => run_check(&path, &engine),
|
Command::Check { path } => run_check(&path, &engine_from(&cli.render_dir)),
|
||||||
Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out),
|
Command::Build { path, targets, out } => {
|
||||||
Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, 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 {
|
Command::Outline {
|
||||||
path,
|
path,
|
||||||
format,
|
format,
|
||||||
out,
|
out,
|
||||||
force,
|
force,
|
||||||
} => run_outline(&path, &engine, format, out, force),
|
} => run_outline(&path, &engine_from(&cli.render_dir), format, out, force),
|
||||||
Command::Completions { shell } => run_completions(shell),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +202,358 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
|
|||||||
ExitCode::SUCCESS
|
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-0029), 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-0029).
|
||||||
|
//
|
||||||
|
// 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-0029).
|
||||||
|
//
|
||||||
|
// 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-0029): 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-0029 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-0029).
|
||||||
|
/// 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.
|
/// Print every diagnostic in `report` to stderr, followed by a summary line.
|
||||||
fn print_diagnostics(report: &CheckReport) {
|
fn print_diagnostics(report: &CheckReport) {
|
||||||
for d in &report.diagnostics {
|
for d in &report.diagnostics {
|
||||||
|
|||||||
@@ -155,6 +155,19 @@ impl KindSchema {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The names of the **required** content fields (those in the schema's
|
||||||
|
/// `required` list), in schema order. The `cph-cli add` authoring surface
|
||||||
|
/// uses this to scaffold the sibling `<field>.typ` files a new part must
|
||||||
|
/// have (ADR-0008): an optional content field (e.g. a lemma's `proof`) is
|
||||||
|
/// not created, so a freshly added part stays schema-legal.
|
||||||
|
pub fn required_content_field_names(&self) -> Vec<&str> {
|
||||||
|
self.content_fields
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.required)
|
||||||
|
.map(|f| f.name.as_str())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The names of the scalar fields (those living in `element.toml`), in
|
/// The names of the scalar fields (those living in `element.toml`), in
|
||||||
/// schema order.
|
/// schema order.
|
||||||
pub fn scalar_field_names(&self) -> Vec<&str> {
|
pub fn scalar_field_names(&self) -> Vec<&str> {
|
||||||
|
|||||||
@@ -74,3 +74,25 @@ fn example_source_non_string_is_schema_violation() {
|
|||||||
assert!(diags[0].message.contains("source"));
|
assert!(diags[0].message.contains("source"));
|
||||||
assert!(diags[0].message.contains("string"));
|
assert!(diags[0].message.contains("string"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `required_content_field_names` is the `cph-cli add` authoring contract: the
|
||||||
|
/// set of sibling `.typ` files a new part of a kind must have to be
|
||||||
|
/// schema-legal (ADR-0008). It must be the schema `required` content fields —
|
||||||
|
/// not the optional ones (e.g. a lemma's `proof`) and not the scalar fields.
|
||||||
|
#[test]
|
||||||
|
fn required_content_field_names_match_schema_required() {
|
||||||
|
let case = |kind: &str, expected: Vec<&str>| {
|
||||||
|
let mut got: Vec<&str> = cph_schema::schema_for(kind)
|
||||||
|
.unwrap()
|
||||||
|
.required_content_field_names();
|
||||||
|
got.sort_unstable();
|
||||||
|
let mut want = expected;
|
||||||
|
want.sort_unstable();
|
||||||
|
assert_eq!(got, want, "required content fields for '{kind}'");
|
||||||
|
};
|
||||||
|
|
||||||
|
case("segment", vec!["textbook"]);
|
||||||
|
case("lemma", vec!["stmt"]);
|
||||||
|
case("example", vec!["problem", "solution"]);
|
||||||
|
case("sop", vec!["sop"]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user