forked from EduCraft/curriculum-project-hub
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:
+363
-139
@@ -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]
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user