diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 2b46004..8d9b6f4 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -36,9 +36,8 @@ pub struct Lesson { pub parts: Vec, /// Declared export targets, collected from the `[targets.]` tables. /// - /// Per ADR-0009 an export target is a *build* producing a typed artifact, - /// with a file-resident, overridable presentation config. This carries the - /// file-resident slice of that (artifact choice + presentation overrides), + /// Per ADR-0009/0011 an export target is a *build* producing a typed + /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those /// in declared (TOML document) order. See [`TargetConfig`]. pub targets: Vec, /// 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 -/// are seeded at project creation and **overridable in the engineering file**. -/// This struct models the slice of that build config that lives in the file: -/// the artifact choice and presentation overrides (currently heading -/// numbering). The per-kind render *map* itself stays in the render package -/// (`Spec.Courseware.TargetSpec.renders`), not here. +/// An export target is a **build** producing a typed [`Artifact`] via an +/// **ordered** list of typed [`Step`]s. ADR-0011 fixed the *shape* of that +/// build: the artifact carries fields (where the product lands / which files it +/// is), and the build is a step list, each step a typed operation. Presentation +/// (heading numbering, styling) lives in the `typstCompile` template file, not +/// 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/.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/.typ"` (the +/// framework's stock per-target template). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TargetConfig { /// Target name, e.g. `"student"` (the `[targets.]` table key). pub name: String, - /// The artifact shape this target produces. Defaults to - /// [`ArtifactKind::SingleFile`] when the `artifact` field is absent. - pub artifact: ArtifactKind, - /// Presentation override for this target. `None` when no presentation - /// config is given in the file (the render layer applies its defaults). - pub numbering: Option, + /// The artifact this target produces. Defaults to a [`Artifact::SingleFile`] + /// at `build/.pdf` when the `artifact` field is absent. + pub artifact: Artifact, + /// The ordered build steps. Defaults to a single [`Step::TypstCompile`] + /// with template `exports/.typ` when no `[[steps]]` are given. + pub steps: Vec, } -/// 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 /// (`spec/Spec/Courseware/Artifact.lean`), whose definition is exactly: /// /// ```text /// inductive Artifact where -/// | singleFile -/// | fileTree +/// | singleFile (filepath : String) +/// | fileTree (root : String) (outputs : String) /// ``` /// -/// `SingleFile` is one bundled document (a 讲义 / 教案 PDF); `FileTree` is a -/// tree of files plus an index (a third-party-platform archive). The on-disk -/// `artifact` string maps `"single-file"` → [`ArtifactKind::SingleFile`] and -/// `"file-tree"` → [`ArtifactKind::FileTree`]. +/// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product +/// is" — one file at some path vs. a tree of files under some root matching a +/// glob — is the non-obvious domain semantics that must be in the type, not +/// 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 /// alignment (see the repo constitution) — it is maintained by review, which is /// 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)] -pub struct NumberingConfig { - /// Per-heading-level numbering patterns (index 0 = level 1, etc.), e.g. - /// `["{1:一}、", "{1:1}.{2:1}"]`. `None` when `numbering.heading` is absent. - pub heading: Option>, +pub enum Artifact { + /// One bundled document landing at `filepath` (relative to the engineering + /// root). Mirrors Lean `Artifact.singleFile`. The default artifact shape. + 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/.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/.typ`. + fn default_for(name: &str) -> Step { + Step::TypstCompile { + template: PathBuf::from(format!("exports/{name}.typ")), + } + } } /// `[project]` table. @@ -296,7 +360,7 @@ pub fn load(root: &Path) -> (Option, Vec) { }; // Parse each [targets.] 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 // defaults so loading continues. let targets: Vec = raw @@ -485,13 +549,14 @@ fn load_descriptor( } } -/// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009), +/// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009/0011), /// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. /// -/// Malformed config is **non-fatal**: a bad `artifact` value falls back to the -/// default ([`ArtifactKind::SingleFile`]); a malformed `numbering.heading` -/// leaves the override absent — both reported, never dropping the target. An -/// empty `[targets.]` body is valid (all defaults, no diagnostics). +/// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the +/// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the +/// default single `typst-compile` step ([`Step::default_for`]) — all reported, +/// never dropping the target. An empty `[targets.]` body is valid (all +/// defaults, no diagnostics). fn parse_target(name: String, value: toml::Value, diags: &mut Vec) -> TargetConfig { // A target body must be a table; anything else (`student = 1`) is malformed. let mut table = match value { @@ -505,127 +570,286 @@ fn parse_target(name: String, value: toml::Value, diags: &mut Vec) - .with_hint("declare a target as a `[targets.]` table"), ); return TargetConfig { + artifact: Artifact::default_for(&name), + steps: vec![Step::default_for(&name)], name, - artifact: ArtifactKind::DEFAULT, - numbering: None, }; } }; let artifact = match table.remove("artifact") { - None => ArtifactKind::DEFAULT, - Some(toml::Value::String(s)) => match s.as_str() { - "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 - } + None => Artifact::default_for(&name), + Some(value) => parse_artifact(&name, value, diags), }; - 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 { name, artifact, - numbering, + steps, } } -/// Parse the optional `[targets..numbering]` sub-table into a -/// [`NumberingConfig`]. Returns `None` when no `numbering` table is present. -/// A malformed `heading` (not an array of strings) is reported as a -/// [`DiagCode::SchemaViolation`] and left absent (`None`). -fn parse_numbering( - name: &str, - table: &mut toml::Table, - diags: &mut Vec, -) -> Option { - let numbering = table.remove("numbering")?; - - let mut numbering_table = match numbering { +/// Parse the `artifact` inline table into an [`Artifact`]. The `type` +/// discriminator selects the variant and which fields are read. An unknown or +/// malformed `type`, a non-table value, or a missing required field is reported +/// as a [`DiagCode::SchemaViolation`]; in every error case a usable default +/// ([`Artifact::default_for`]) is returned so loading continues. +fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec) -> Artifact { + let mut table = match value { toml::Value::Table(t) => t, _ => { diags.push( Diagnostic::error( 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") { - None => None, - 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) - } - } + let kind = match table.remove("type") { + Some(toml::Value::String(s)) => s, Some(_) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, - format!( - "target '{name}' has a `numbering.heading` that is not an array of strings" - ), + format!("target '{name}' has a non-string `artifact.type`"), ) - .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/.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 = \"\"` and \ + `outputs = \"\"`", + ), + ); + 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`, 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) -> Vec { + 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..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) -> Option { + 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..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/.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 = \"\"` 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 { + match table.remove(key) { + Some(toml::Value::String(s)) => Some(s), + _ => None, + } } /// True if `path` contains any `..` component (parent-dir traversal). diff --git a/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml index d47aa1b..cacd692 100644 --- a/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml +++ b/crates/cph-model/tests/fixtures/bad-target-config/manifest.toml @@ -5,12 +5,14 @@ name = "bad-target-config" [info] title = "测试课:非法导出目标配置" -# Invalid artifact value → SchemaViolation, falls back to default (single-file). +# Invalid artifact `type` → SchemaViolation, falls back to default (single-file). [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] -artifact = "file-tree" -[targets.teacher.numbering] -heading = "not-an-array" +artifact = { type = "file-tree", root = "build/teacher", outputs = "**/*.html" } +[[targets.teacher.steps]] +type = "make-it-nice" +template = "exports/teacher.typ" diff --git a/crates/cph-model/tests/fixtures/target-configs/manifest.toml b/crates/cph-model/tests/fixtures/target-configs/manifest.toml index fe06232..48d0125 100644 --- a/crates/cph-model/tests/fixtures/target-configs/manifest.toml +++ b/crates/cph-model/tests/fixtures/target-configs/manifest.toml @@ -5,15 +5,24 @@ name = "target-configs" [info] title = "测试课:导出目标的结构化 build 配置" -# Full config: explicit artifact + numbering.heading override. +# Full v2 config: explicit single-file artifact + an ordered typst-compile step. [targets.student] -artifact = "single-file" -[targets.student.numbering] -heading = ["{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}"] +artifact = { type = "single-file", filepath = "build/student.pdf" } +[[targets.student.steps]] +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] -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] diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 511e53e..d26b8f2 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -48,10 +48,21 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { // Targets are collected from [targets.*], in declared order. 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/.pdf + one typst-compile step. for target in &lesson.targets { - assert_eq!(target.artifact, cph_model::ArtifactKind::SingleFile); - assert_eq!(target.numbering, None); + assert_eq!( + 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. @@ -129,7 +140,7 @@ fn missing_manifest_is_a_hard_failure() { #[test] 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 = 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. 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]; 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!( - numbering.heading.as_deref(), - Some( - &[ - "{1:一}、".to_string(), - "{1:1}.{2:1}".to_string(), - "{1:1}.{2:1}.{3:1}".to_string(), - ][..] - ) + student.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/student.pdf"), + } + ); + 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]; assert_eq!(archive.name, "archive"); - assert_eq!(archive.artifact, ArtifactKind::FileTree); - assert_eq!(archive.numbering, None); + assert_eq!( + 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]; assert_eq!(teacher.name, "teacher"); - assert_eq!(teacher.artifact, ArtifactKind::SingleFile); - assert_eq!(teacher.numbering, None); + assert_eq!( + teacher.artifact, + Artifact::SingleFile { + filepath: PathBuf::from("build/teacher.pdf"), + } + ); + assert_eq!( + teacher.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/teacher.typ"), + }] + ); } #[test] 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 = 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. 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]; - 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, - // but the numbering table itself is present. + // Bad step `type` → the step is skipped; the file-tree artifact survives and + // the empty step list falls back to the default step. let teacher = &lesson.targets[1]; - assert_eq!(teacher.artifact, ArtifactKind::FileTree); - let teacher_numbering = teacher - .numbering - .as_ref() - .expect("teacher declares a numbering table"); - assert_eq!(teacher_numbering.heading, None); + assert_eq!( + teacher.artifact, + Artifact::FileTree { + root: PathBuf::from("build/teacher"), + outputs: "**/*.html".to_string(), + } + ); + assert_eq!( + teacher.steps, + vec![Step::TypstCompile { + template: PathBuf::from("exports/teacher.typ"), + }] + ); let violations: Vec<_> = diags .iter() @@ -208,12 +256,12 @@ fn malformed_target_config_is_non_fatal_with_schema_violations() { ); assert!( 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!( violations .iter() - .any(|d| d.message.contains("numbering.heading")), - "a diagnostic should name the malformed numbering.heading, got: {diags:?}" + .any(|d| d.message.contains("make-it-nice")), + "a diagnostic should name the bad step type, got: {diags:?}" ); }