feat(checker): cph-model manifest v2 — Artifact-with-fields + typed steps (WU-B', ADR-0011)

[targets.*] reworked: artifact is now an inline table with a type discriminator
({type="single-file",filepath} / {type="file-tree",root,outputs}) and steps is an
ordered array-of-tables ({type="typst-compile",template} / {type="shell",run}).
numbering removed entirely (presentation lives in the template per ADR-0011).

TargetConfig { name, artifact: Artifact, steps: Vec<Step> }; Artifact and Step
mirror Spec.Courseware.Artifact / Spec.Courseware.Step (doc-cited). Sensible
defaults so a bare [targets.student] still works: single-file build/<name>.pdf +
one typst-compile exports/<name>.typ. Malformed artifact/step type → non-fatal
SchemaViolation with fallback. 8 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:57:50 +08:00
parent de87cddaf1
commit d5bce1bede
4 changed files with 473 additions and 190 deletions
+363 -139
View File
@@ -36,9 +36,8 @@ pub struct Lesson {
pub parts: Vec<Part>, pub parts: Vec<Part>,
/// Declared export targets, collected from the `[targets.<name>]` tables. /// Declared export targets, collected from the `[targets.<name>]` tables.
/// ///
/// Per ADR-0009 an export target is a *build* producing a typed artifact, /// Per ADR-0009/0011 an export target is a *build* producing a typed
/// with a file-resident, overridable presentation config. This carries the /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those
/// file-resident slice of that (artifact choice + presentation overrides),
/// in declared (TOML document) order. See [`TargetConfig`]. /// in declared (TOML document) order. See [`TargetConfig`].
pub targets: Vec<TargetConfig>, pub targets: Vec<TargetConfig>,
/// Engineering-file root (absolute), for resolving part paths. /// Engineering-file root (absolute), for resolving part paths.
@@ -56,69 +55,134 @@ impl Lesson {
} }
} }
/// One declared export target's *file-resident* build config (ADR-0009). /// One declared export target's build config (ADR-0009/0011).
/// ///
/// An export target is a build producing a typed [`ArtifactKind`]; its defaults /// An export target is a **build** producing a typed [`Artifact`] via an
/// are seeded at project creation and **overridable in the engineering file**. /// **ordered** list of typed [`Step`]s. ADR-0011 fixed the *shape* of that
/// This struct models the slice of that build config that lives in the file: /// build: the artifact carries fields (where the product lands / which files it
/// the artifact choice and presentation overrides (currently heading /// is), and the build is a step list, each step a typed operation. Presentation
/// numbering). The per-kind render *map* itself stays in the render package /// (heading numbering, styling) lives in the `typstCompile` template file, not
/// (`Spec.Courseware.TargetSpec.renders`), not here. /// in the manifest — so there is no `numbering` field here anymore.
///
/// ## Defaults for a minimal manifest
///
/// - A target with **no `artifact`** key defaults to
/// [`Artifact::SingleFile`] with `filepath = "build/<name>.pdf"`, so an
/// almost-empty `[targets.student]` still produces a usable build.
/// - A target with **no `steps`** defaults to a single
/// [`Step::TypstCompile`] with `template = "exports/<name>.typ"` (the
/// framework's stock per-target template).
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TargetConfig { pub struct TargetConfig {
/// Target name, e.g. `"student"` (the `[targets.<name>]` table key). /// Target name, e.g. `"student"` (the `[targets.<name>]` table key).
pub name: String, pub name: String,
/// The artifact shape this target produces. Defaults to /// The artifact this target produces. Defaults to a [`Artifact::SingleFile`]
/// [`ArtifactKind::SingleFile`] when the `artifact` field is absent. /// at `build/<name>.pdf` when the `artifact` field is absent.
pub artifact: ArtifactKind, pub artifact: Artifact,
/// Presentation override for this target. `None` when no presentation /// The ordered build steps. Defaults to a single [`Step::TypstCompile`]
/// config is given in the file (the render layer applies its defaults). /// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub numbering: Option<NumberingConfig>, pub steps: Vec<Step>,
} }
/// The shape of the artifact an export target produces (ADR-0009). /// The artifact an export target produces (ADR-0009/0011).
/// ///
/// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master /// **Mirrors `Spec.Courseware.Artifact`** in the Lean semantic master
/// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly: /// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly:
/// ///
/// ```text /// ```text
/// inductive Artifact where /// inductive Artifact where
/// | singleFile /// | singleFile (filepath : String)
/// | fileTree /// | fileTree (root : String) (outputs : String)
/// ``` /// ```
/// ///
/// `SingleFile` is one bundled document (a 讲义 / 教案 PDF); `FileTree` is a /// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product
/// tree of files plus an index (a third-party-platform archive). The on-disk /// is" — one file at some path vs. a tree of files under some root matching a
/// `artifact` string maps `"single-file"` → [`ArtifactKind::SingleFile`] and /// glob — is the non-obvious domain semantics that must be in the type, not
/// `"file-tree"` → [`ArtifactKind::FileTree`]. /// erased behind a bare tag. `SingleFile` is one bundled document (a 讲义 / 教案
/// PDF); `FileTree` is a tree of files (a third-party-platform archive). The
/// on-disk `artifact` inline table's `type` discriminator selects the variant:
/// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` →
/// [`Artifact::FileTree`].
/// ///
/// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing this
/// alignment (see the repo constitution) — it is maintained by review, which is /// alignment (see the repo constitution) — it is maintained by review, which is
/// why the correspondence is documented here. /// why the correspondence is documented here.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum ArtifactKind {
/// One bundled document. Mirrors Lean `Artifact.singleFile`. Default.
SingleFile,
/// A tree of files plus an index. Mirrors Lean `Artifact.fileTree`.
FileTree,
}
impl ArtifactKind {
/// The default artifact shape when `artifact` is omitted: `SingleFile`.
const DEFAULT: ArtifactKind = ArtifactKind::SingleFile;
}
/// File-resident presentation overrides for a target (ADR-0009).
///
/// Each field is `Option` so **absence is distinguishable from a value**: when
/// a field is `None`, the render package applies its framework default; only an
/// explicitly-given value overrides it. This crate deliberately invents no
/// presentation defaults — that ownership stays in the render layer.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct NumberingConfig { pub enum Artifact {
/// Per-heading-level numbering patterns (index 0 = level 1, etc.), e.g. /// One bundled document landing at `filepath` (relative to the engineering
/// `["{1:一}、", "{1:1}.{2:1}"]`. `None` when `numbering.heading` is absent. /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape.
pub heading: Option<Vec<String>>, SingleFile {
/// Where the single product is written (relative to the engineering
/// root), e.g. `build/student.pdf`.
filepath: PathBuf,
},
/// A set of files under `root` matching the `outputs` glob. Mirrors Lean
/// `Artifact.fileTree`.
FileTree {
/// The output directory (relative to the engineering root).
root: PathBuf,
/// A **glob** describing which files this build produces, e.g.
/// `**/*.{html,js,json}` — kept as a string (the contract pins the
/// field, not a path/glob algebra).
outputs: String,
},
}
impl Artifact {
/// The default artifact when `artifact` is omitted: a single file at
/// `build/<name>.pdf`.
fn default_for(name: &str) -> Artifact {
Artifact::SingleFile {
filepath: PathBuf::from(format!("build/{name}.pdf")),
}
}
}
/// One typed build step (ADR-0011).
///
/// **Mirrors `Spec.Courseware.Step`** in the Lean semantic master
/// (`spec/Spec/Courseware/Render.lean`), whose definition is exactly:
///
/// ```text
/// inductive Step where
/// | typstCompile (template : String)
/// | shell (run : String)
/// ```
///
/// A step is a *typed* operation (extensible): `TypstCompile` compiles a
/// template file — typed, not a raw shell line, because the framework must wire
/// the manifest into it (`--input manifest=…`), which a bare `typst compile`
/// string cannot express. `Shell` is the escape hatch for steps that resist
/// declaration (ADR-0005's medium-only category (b): HTML interactive / npm
/// builds). The on-disk `[[steps]]` entry's `type` discriminator selects the
/// variant: `"typst-compile"` → [`Step::TypstCompile`], `"shell"` →
/// [`Step::Shell`].
///
/// As with [`Artifact`], this alignment is maintained by review, not CI.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Step {
/// Compile a template file (relative to the engineering root) into the
/// artifact; the framework injects the manifest. Mirrors Lean
/// `Step.typstCompile`.
TypstCompile {
/// The template file to compile as main, e.g. `exports/student.typ`.
template: PathBuf,
},
/// Run a shell command — the escape hatch. Mirrors Lean `Step.shell`.
Shell {
/// The command line to run.
run: String,
},
}
impl Step {
/// The default step when no `[[steps]]` are given: compile the stock
/// per-target template `exports/<name>.typ`.
fn default_for(name: &str) -> Step {
Step::TypstCompile {
template: PathBuf::from(format!("exports/{name}.typ")),
}
}
} }
/// `[project]` table. /// `[project]` table.
@@ -296,7 +360,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
}; };
// Parse each [targets.<name>] table into a structured build config // Parse each [targets.<name>] table into a structured build config
// (ADR-0009). Order is the TOML document order, as before. Malformed // (ADR-0009/0011). Order is the TOML document order, as before. Malformed
// target config is non-fatal: it is reported and the target is kept with // target config is non-fatal: it is reported and the target is kept with
// defaults so loading continues. // defaults so loading continues.
let targets: Vec<TargetConfig> = raw let targets: Vec<TargetConfig> = raw
@@ -485,13 +549,14 @@ fn load_descriptor(
} }
} }
/// Parse one `[targets.<name>]` table into a [`TargetConfig`] (ADR-0009), /// Parse one `[targets.<name>]` table into a [`TargetConfig`] (ADR-0009/0011),
/// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. /// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields.
/// ///
/// Malformed config is **non-fatal**: a bad `artifact` value falls back to the /// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the
/// default ([`ArtifactKind::SingleFile`]); a malformed `numbering.heading` /// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the
/// leaves the override absent — both reported, never dropping the target. An /// default single `typst-compile` step ([`Step::default_for`]) — all reported,
/// empty `[targets.<name>]` body is valid (all defaults, no diagnostics). /// never dropping the target. An empty `[targets.<name>]` body is valid (all
/// defaults, no diagnostics).
fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -> TargetConfig { fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -> TargetConfig {
// A target body must be a table; anything else (`student = 1`) is malformed. // A target body must be a table; anything else (`student = 1`) is malformed.
let mut table = match value { let mut table = match value {
@@ -505,127 +570,286 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec<Diagnostic>) -
.with_hint("declare a target as a `[targets.<name>]` table"), .with_hint("declare a target as a `[targets.<name>]` table"),
); );
return TargetConfig { return TargetConfig {
artifact: Artifact::default_for(&name),
steps: vec![Step::default_for(&name)],
name, name,
artifact: ArtifactKind::DEFAULT,
numbering: None,
}; };
} }
}; };
let artifact = match table.remove("artifact") { let artifact = match table.remove("artifact") {
None => ArtifactKind::DEFAULT, None => Artifact::default_for(&name),
Some(toml::Value::String(s)) => match s.as_str() { Some(value) => parse_artifact(&name, value, diags),
"single-file" => ArtifactKind::SingleFile,
"file-tree" => ArtifactKind::FileTree,
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has an invalid artifact '{other}'; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact` to \"single-file\" or \"file-tree\""),
);
ArtifactKind::DEFAULT
}
},
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-string `artifact`; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact` to \"single-file\" or \"file-tree\""),
);
ArtifactKind::DEFAULT
}
}; };
let numbering = parse_numbering(&name, &mut table, diags); let steps = match table.remove("steps") {
None => vec![Step::default_for(&name)],
Some(value) => parse_steps(&name, value, diags),
};
TargetConfig { TargetConfig {
name, name,
artifact, artifact,
numbering, steps,
} }
} }
/// Parse the optional `[targets.<name>.numbering]` sub-table into a /// Parse the `artifact` inline table into an [`Artifact`]. The `type`
/// [`NumberingConfig`]. Returns `None` when no `numbering` table is present. /// discriminator selects the variant and which fields are read. An unknown or
/// A malformed `heading` (not an array of strings) is reported as a /// malformed `type`, a non-table value, or a missing required field is reported
/// [`DiagCode::SchemaViolation`] and left absent (`None`). /// as a [`DiagCode::SchemaViolation`]; in every error case a usable default
fn parse_numbering( /// ([`Artifact::default_for`]) is returned so loading continues.
name: &str, fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Artifact {
table: &mut toml::Table, let mut table = match value {
diags: &mut Vec<Diagnostic>,
) -> Option<NumberingConfig> {
let numbering = table.remove("numbering")?;
let mut numbering_table = match numbering {
toml::Value::Table(t) => t, toml::Value::Table(t) => t,
_ => { _ => {
diags.push( diags.push(
Diagnostic::error( Diagnostic::error(
DiagCode::SchemaViolation, DiagCode::SchemaViolation,
format!("target '{name}' has a non-table `numbering`"), format!(
"target '{name}' has a non-table `artifact`; it must be an \
inline table like `{{ type = \"single-file\", filepath = \"\" }}`"
),
) )
.with_hint(format!("declare numbering as `[targets.{name}.numbering]`")), .with_hint("set `artifact` to an inline table with a `type` discriminator"),
); );
return Some(NumberingConfig { heading: None }); return Artifact::default_for(name);
} }
}; };
let heading = match numbering_table.remove("heading") { let kind = match table.remove("type") {
None => None, Some(toml::Value::String(s)) => s,
Some(toml::Value::Array(items)) => {
let mut patterns = Vec::with_capacity(items.len());
let mut malformed = false;
for item in items {
match item {
toml::Value::String(s) => patterns.push(s),
_ => {
malformed = true;
break;
}
}
}
if malformed {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a `numbering.heading` that is not an array of strings"
),
)
.with_hint(
"`numbering.heading` must be an array of per-level pattern strings",
),
);
None
} else {
Some(patterns)
}
}
Some(_) => { Some(_) => {
diags.push( diags.push(
Diagnostic::error( Diagnostic::error(
DiagCode::SchemaViolation, DiagCode::SchemaViolation,
format!( format!("target '{name}' has a non-string `artifact.type`"),
"target '{name}' has a `numbering.heading` that is not an array of strings"
),
) )
.with_hint("`numbering.heading` must be an array of per-level pattern strings"), .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""),
); );
None return Artifact::default_for(name);
}
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has an `artifact` with no `type` discriminator"),
)
.with_hint("add `type = \"single-file\"` (or `\"file-tree\"`) to `artifact`"),
);
return Artifact::default_for(name);
} }
}; };
Some(NumberingConfig { heading }) match kind.as_str() {
"single-file" => {
let filepath = match take_string_field(&mut table, "filepath") {
Some(s) => PathBuf::from(s),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' single-file `artifact` is missing a string \
`filepath`"
),
)
.with_hint("add `filepath = \"build/<name>.pdf\"` to the artifact"),
);
return Artifact::default_for(name);
}
};
Artifact::SingleFile { filepath }
}
"file-tree" => {
let root = take_string_field(&mut table, "root");
let outputs = take_string_field(&mut table, "outputs");
match (root, outputs) {
(Some(root), Some(outputs)) => Artifact::FileTree {
root: PathBuf::from(root),
outputs,
},
(root, outputs) => {
let mut missing = Vec::new();
if root.is_none() {
missing.push("root");
}
if outputs.is_none() {
missing.push("outputs");
}
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' file-tree `artifact` is missing required \
string field(s): {}",
missing.join(", ")
),
)
.with_hint(
"a file-tree artifact needs `root = \"<dir>\"` and \
`outputs = \"<glob>\"`",
),
);
Artifact::default_for(name)
}
}
}
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has an invalid `artifact.type` '{other}'; \
valid values are \"single-file\", \"file-tree\""
),
)
.with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""),
);
Artifact::default_for(name)
}
}
}
/// Parse the `steps` array-of-tables into an ordered `Vec<Step>`, preserving
/// declared order. A non-array `steps`, or any individual step with an unknown
/// or malformed `type` / missing required field, is reported as a
/// [`DiagCode::SchemaViolation`]; malformed individual steps are skipped while
/// well-formed ones are kept. If `steps` is present but yields no usable steps,
/// the default step ([`Step::default_for`]) is substituted so the build stays
/// runnable.
fn parse_steps(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Vec<Step> {
let items = match value {
toml::Value::Array(items) => items,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a non-array `steps`; declare steps as \
`[[targets.{name}.steps]]` entries"
),
)
.with_hint("each step is a `[[targets.<name>.steps]]` table with a `type`"),
);
return vec![Step::default_for(name)];
}
};
let mut steps = Vec::with_capacity(items.len());
for item in items {
if let Some(step) = parse_step(name, item, diags) {
steps.push(step);
}
}
if steps.is_empty() {
// `steps` was present but produced nothing usable: keep the build
// runnable with the default step (the malformed entries were reported).
vec![Step::default_for(name)]
} else {
steps
}
}
/// Parse one `[[steps]]` entry into a [`Step`]. The `type` discriminator selects
/// the variant. Returns `None` (after reporting a [`DiagCode::SchemaViolation`])
/// for a non-table entry, an unknown/malformed `type`, or a missing required
/// field.
fn parse_step(name: &str, value: toml::Value, diags: &mut Vec<Diagnostic>) -> Option<Step> {
let mut table = match value {
toml::Value::Table(t) => t,
_ => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a step that is not a table"),
)
.with_hint("each step is a `[[targets.<name>.steps]]` table with a `type`"),
);
return None;
}
};
let kind = match table.remove("type") {
Some(toml::Value::String(s)) => s,
Some(_) => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a step with a non-string `type`"),
)
.with_hint("set the step `type` to \"typst-compile\" or \"shell\""),
);
return None;
}
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' has a step with no `type` discriminator"),
)
.with_hint("add `type = \"typst-compile\"` (or `\"shell\"`) to the step"),
);
return None;
}
};
match kind.as_str() {
"typst-compile" => match take_string_field(&mut table, "template") {
Some(template) => Some(Step::TypstCompile {
template: PathBuf::from(template),
}),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' typst-compile step is missing a string `template`"
),
)
.with_hint("add `template = \"exports/<name>.typ\"` to the step"),
);
None
}
},
"shell" => match take_string_field(&mut table, "run") {
Some(run) => Some(Step::Shell { run }),
None => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{name}' shell step is missing a string `run`"),
)
.with_hint("add `run = \"<command>\"` to the step"),
);
None
}
},
other => {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!(
"target '{name}' has a step with an invalid `type` '{other}'; \
valid values are \"typst-compile\", \"shell\""
),
)
.with_hint("set the step `type` to \"typst-compile\" or \"shell\""),
);
None
}
}
}
/// Remove `key` from `table` and return it if it is a string; otherwise `None`
/// (a missing key and a non-string value are both treated as absent — the
/// caller reports the missing-field diagnostic with the right context).
fn take_string_field(table: &mut toml::Table, key: &str) -> Option<String> {
match table.remove(key) {
Some(toml::Value::String(s)) => Some(s),
_ => None,
}
} }
/// True if `path` contains any `..` component (parent-dir traversal). /// True if `path` contains any `..` component (parent-dir traversal).
@@ -5,12 +5,14 @@ name = "bad-target-config"
[info] [info]
title = "测试课:非法导出目标配置" title = "测试课:非法导出目标配置"
# Invalid artifact value → SchemaViolation, falls back to default (single-file). # Invalid artifact `type` → SchemaViolation, falls back to default (single-file).
[targets.student] [targets.student]
artifact = "pdf-thing" artifact = { type = "pdf-thing", filepath = "build/student.pdf" }
# numbering.heading is not an array of strings → SchemaViolation, heading None. # Invalid step `type` → SchemaViolation; the bad step is skipped and the build
# falls back to the default step.
[targets.teacher] [targets.teacher]
artifact = "file-tree" artifact = { type = "file-tree", root = "build/teacher", outputs = "**/*.html" }
[targets.teacher.numbering] [[targets.teacher.steps]]
heading = "not-an-array" type = "make-it-nice"
template = "exports/teacher.typ"
@@ -5,15 +5,24 @@ name = "target-configs"
[info] [info]
title = "测试课:导出目标的结构化 build 配置" title = "测试课:导出目标的结构化 build 配置"
# Full config: explicit artifact + numbering.heading override. # Full v2 config: explicit single-file artifact + an ordered typst-compile step.
[targets.student] [targets.student]
artifact = "single-file" artifact = { type = "single-file", filepath = "build/student.pdf" }
[targets.student.numbering] [[targets.student.steps]]
heading = ["{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}"] type = "typst-compile"
template = "exports/student.typ"
# file-tree artifact, no presentation override. # file-tree artifact + multiple steps (typst-compile then a shell step),
# exercising both step types and order preservation.
[targets.archive] [targets.archive]
artifact = "file-tree" artifact = { type = "file-tree", root = "build/archive", outputs = "**/*.{html,js,json}" }
[[targets.archive.steps]]
type = "typst-compile"
template = "exports/archive.typ"
[[targets.archive.steps]]
type = "shell"
run = "npm run build"
# Empty body → all defaults. # Empty body → all defaults (single-file build/teacher.pdf, one typst-compile
# step exports/teacher.typ).
[targets.teacher] [targets.teacher]
+86 -38
View File
@@ -48,10 +48,21 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
// Targets are collected from [targets.*], in declared order. // Targets are collected from [targets.*], in declared order.
assert_eq!(lesson.target_names(), vec!["student", "teacher"]); assert_eq!(lesson.target_names(), vec!["student", "teacher"]);
// Empty `[targets.x]` bodies → all-default build config, no diagnostics. // Empty `[targets.x]` bodies → all-default build config, no diagnostics:
// single-file artifact at build/<name>.pdf + one typst-compile step.
for target in &lesson.targets { for target in &lesson.targets {
assert_eq!(target.artifact, cph_model::ArtifactKind::SingleFile); assert_eq!(
assert_eq!(target.numbering, None); target.artifact,
cph_model::Artifact::SingleFile {
filepath: PathBuf::from(format!("build/{}.pdf", target.name)),
}
);
assert_eq!(
target.steps,
vec![cph_model::Step::TypstCompile {
template: PathBuf::from(format!("exports/{}.typ", target.name)),
}]
);
} }
// Descriptor dir is absolute, anchored under the root. // Descriptor dir is absolute, anchored under the root.
@@ -129,7 +140,7 @@ fn missing_manifest_is_a_hard_failure() {
#[test] #[test]
fn structured_target_configs_parse_into_typed_builds() { fn structured_target_configs_parse_into_typed_builds() {
use cph_model::ArtifactKind; use cph_model::{Artifact, Step};
let (lesson, diags) = load(&fixture("target-configs")); let (lesson, diags) = load(&fixture("target-configs"));
let lesson = lesson.expect("target-configs fixture must produce a Lesson"); let lesson = lesson.expect("target-configs fixture must produce a Lesson");
@@ -141,41 +152,66 @@ fn structured_target_configs_parse_into_typed_builds() {
// Declared order is preserved. // Declared order is preserved.
assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]); assert_eq!(lesson.target_names(), vec!["student", "archive", "teacher"]);
// student: explicit single-file + heading numbering override, in order. // student: explicit single-file artifact (with filepath) + one
// typst-compile step (with template).
let student = &lesson.targets[0]; let student = &lesson.targets[0];
assert_eq!(student.name, "student"); assert_eq!(student.name, "student");
assert_eq!(student.artifact, ArtifactKind::SingleFile);
let numbering = student
.numbering
.as_ref()
.expect("student declares a numbering override");
assert_eq!( assert_eq!(
numbering.heading.as_deref(), student.artifact,
Some( Artifact::SingleFile {
&[ filepath: PathBuf::from("build/student.pdf"),
"{1:一}、".to_string(), }
"{1:1}.{2:1}".to_string(), );
"{1:1}.{2:1}.{3:1}".to_string(), assert_eq!(
][..] student.steps,
) vec![Step::TypstCompile {
template: PathBuf::from("exports/student.typ"),
}]
); );
// archive: file-tree artifact, no presentation override. // archive: file-tree artifact (root + outputs glob) + two ordered steps,
// a typst-compile followed by a shell step (order preserved).
let archive = &lesson.targets[1]; let archive = &lesson.targets[1];
assert_eq!(archive.name, "archive"); assert_eq!(archive.name, "archive");
assert_eq!(archive.artifact, ArtifactKind::FileTree); assert_eq!(
assert_eq!(archive.numbering, None); archive.artifact,
Artifact::FileTree {
root: PathBuf::from("build/archive"),
outputs: "**/*.{html,js,json}".to_string(),
}
);
assert_eq!(
archive.steps,
vec![
Step::TypstCompile {
template: PathBuf::from("exports/archive.typ"),
},
Step::Shell {
run: "npm run build".to_string(),
},
]
);
// teacher: empty body → all defaults, numbering absent. // teacher: empty body → all defaults.
let teacher = &lesson.targets[2]; let teacher = &lesson.targets[2];
assert_eq!(teacher.name, "teacher"); assert_eq!(teacher.name, "teacher");
assert_eq!(teacher.artifact, ArtifactKind::SingleFile); assert_eq!(
assert_eq!(teacher.numbering, None); teacher.artifact,
Artifact::SingleFile {
filepath: PathBuf::from("build/teacher.pdf"),
}
);
assert_eq!(
teacher.steps,
vec![Step::TypstCompile {
template: PathBuf::from("exports/teacher.typ"),
}]
);
} }
#[test] #[test]
fn malformed_target_config_is_non_fatal_with_schema_violations() { fn malformed_target_config_is_non_fatal_with_schema_violations() {
use cph_model::ArtifactKind; use cph_model::{Artifact, Step};
let (lesson, diags) = load(&fixture("bad-target-config")); let (lesson, diags) = load(&fixture("bad-target-config"));
let lesson = lesson.expect("bad-target-config must still produce a Lesson"); let lesson = lesson.expect("bad-target-config must still produce a Lesson");
@@ -183,19 +219,31 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() {
// Both targets survive despite their malformed fields. // Both targets survive despite their malformed fields.
assert_eq!(lesson.target_names(), vec!["student", "teacher"]); assert_eq!(lesson.target_names(), vec!["student", "teacher"]);
// Bad `artifact` value → SchemaViolation, target kept with default artifact. // Bad `artifact.type` → SchemaViolation, target kept with default artifact.
let student = &lesson.targets[0]; let student = &lesson.targets[0];
assert_eq!(student.artifact, ArtifactKind::SingleFile); assert_eq!(
student.artifact,
Artifact::SingleFile {
filepath: PathBuf::from("build/student.pdf"),
}
);
// Bad `numbering.heading` (not an array of strings) → heading stays None, // Bad step `type` → the step is skipped; the file-tree artifact survives and
// but the numbering table itself is present. // the empty step list falls back to the default step.
let teacher = &lesson.targets[1]; let teacher = &lesson.targets[1];
assert_eq!(teacher.artifact, ArtifactKind::FileTree); assert_eq!(
let teacher_numbering = teacher teacher.artifact,
.numbering Artifact::FileTree {
.as_ref() root: PathBuf::from("build/teacher"),
.expect("teacher declares a numbering table"); outputs: "**/*.html".to_string(),
assert_eq!(teacher_numbering.heading, None); }
);
assert_eq!(
teacher.steps,
vec![Step::TypstCompile {
template: PathBuf::from("exports/teacher.typ"),
}]
);
let violations: Vec<_> = diags let violations: Vec<_> = diags
.iter() .iter()
@@ -208,12 +256,12 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() {
); );
assert!( assert!(
violations.iter().any(|d| d.message.contains("pdf-thing")), violations.iter().any(|d| d.message.contains("pdf-thing")),
"a diagnostic should name the bad artifact value, got: {diags:?}" "a diagnostic should name the bad artifact type, got: {diags:?}"
); );
assert!( assert!(
violations violations
.iter() .iter()
.any(|d| d.message.contains("numbering.heading")), .any(|d| d.message.contains("make-it-nice")),
"a diagnostic should name the malformed numbering.heading, got: {diags:?}" "a diagnostic should name the bad step type, got: {diags:?}"
); );
} }