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-diag",
|
||||
"cph-model",
|
||||
"cph-schema",
|
||||
"cph-typst",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -16,6 +16,8 @@ cargo install --path crates/cph-cli --locked
|
||||
|
||||
```sh
|
||||
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 build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
|
||||
```
|
||||
@@ -29,6 +31,12 @@ cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 o
|
||||
大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行
|
||||
`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown,
|
||||
并在 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)。
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ path = "src/main.rs"
|
||||
cph-check = { path = "../cph-check" }
|
||||
cph-diag = { workspace = true }
|
||||
cph-model = { workspace = true }
|
||||
cph-schema = { path = "../cph-schema" }
|
||||
cph-typst = { path = "../cph-typst" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
|
||||
+399
-9
@@ -95,6 +95,32 @@ enum Command {
|
||||
/// 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)]
|
||||
@@ -126,22 +152,34 @@ enum CompletionTarget {
|
||||
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, targets, out } => run_build_command(&path, &engine, targets, out),
|
||||
Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out),
|
||||
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, 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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,6 +202,358 @@ fn run_completions(shell: CompletionTarget) -> ExitCode {
|
||||
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.
|
||||
fn print_diagnostics(report: &CheckReport) {
|
||||
for d in &report.diagnostics {
|
||||
|
||||
@@ -155,6 +155,19 @@ impl KindSchema {
|
||||
.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
|
||||
/// schema order.
|
||||
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("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