//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson. //! //! This crate is the **loader**, not the full checker. It reads //! `/manifest.toml` (project / info / ordered `[[parts]]` / declared //! `[targets.*]`) and each part's `//element.toml`, and produces an //! ordered [`Lesson`], where the order of `parts` carries teaching semantics. //! //! Scope boundaries (deliberately staying in lane): //! - It validates **structure** only: manifest shape, element.toml shape, and //! the cross-check `part.kind == element.toml kind`. //! - It does **not** validate instance data against a kind's JSON Schema (that //! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files //! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4). //! //! Entry point: [`load`]. use std::path::{Component, Path, PathBuf}; use cph_diag::{DiagCode, Diagnostic}; use serde::{Deserialize, Serialize}; /// An ordered, in-memory lesson loaded from an engineering file. /// /// `parts` is an ordered /// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest /// is declarative" — the `[[parts]]` array order is the single source of truth). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Lesson { /// `[project]` from the manifest (id, name). pub project: Project, /// `[info]` from the manifest (title, optional author). pub info: Info, /// The ordered parts — the lesson's element sequence. pub parts: Vec, /// Declared export targets, collected from the `[targets.]` tables. /// /// 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. pub root: PathBuf, } impl Lesson { /// The declared export-target names, in declared order. /// /// Convenience for callers that only need the names (the shape this crate /// exposed before ADR-0009 turned `targets` into structured /// [`TargetConfig`]s). pub fn target_names(&self) -> Vec<&str> { self.targets.iter().map(|t| t.name.as_str()).collect() } } /// One declared export target's build config (ADR-0009/0011). /// /// 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). /// - A target with **no `covers`** key leaves [`TargetConfig::covers`] `None`, /// meaning "coverage not declared". The consumer (`cph-check`) resolves that /// to the full known-kind universe — see the field doc. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TargetConfig { /// Target name, e.g. `"student"` (the `[targets.]` table key). pub name: String, /// 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 **render-coverage declaration**: which element kinds this target /// renders. Realizes ADR-0011's "render /// coverage is a declaration, not a payload": the declaration keeps *which /// kinds a target renders* (used by the `renderIgnored` seed diagnostic), /// while the rendering "how" lives in the template/steps. /// /// `Some(kinds)` is the explicit manifest list (`covers = ["segment", …]`). /// `None` means the `covers` key was **absent**; the loader does not own the /// kind universe (it must not depend on `cph-schema`), so it records the /// absence and lets `cph-check` default it to all known kinds. An empty /// `Some(vec![])` is distinct: a target that explicitly covers nothing. pub covers: Option>, } /// The artifact an export target produces (ADR-0009/0011). /// /// **Pinned by ADR-0011** as an ADT with fields: /// /// ```text /// Artifact = singleFile (filepath) | fileTree (root, outputs) /// ``` /// /// 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 ADR↔ /// implementation alignment (see the repo constitution) — it is maintained by /// review, which is why the decision is documented here. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum Artifact { /// One bundled document landing at `filepath` (relative to the engineering /// root). ADR-0011 `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. ADR-0011 /// `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). /// /// **Pinned by ADR-0011** as an ADT: /// /// ```text /// Step = typstCompile (template) | shell (run) | assembleMarkdown (field) /// ``` /// /// 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). `AssembleMarkdown` concatenates a per-element markdown content /// field into a single-file markdown deliverable (ADR-0015: the slides outline /// and 逐字稿 transcript surfaces, authored directly in markdown+KaTeX to /// sidestep the typst→md conversion). The on-disk `[[steps]]` entry's `type` /// discriminator selects the variant: `"typst-compile"` → /// [`Step::TypstCompile`], `"shell"` → [`Step::Shell`], `"assemble-markdown"` /// → [`Step::AssembleMarkdown`]. /// /// 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. ADR-0011 /// `typstCompile`. TypstCompile { /// The template file to compile as main, e.g. `exports/student.typ`. template: PathBuf, }, /// Run a shell command — the escape hatch. ADR-0011 `shell`. Shell { /// The command line to run. run: String, }, /// Assemble a single-file markdown deliverable by concatenating each /// element's `field` markdown content file in `[[parts]]` order. ADR-0011 /// `assembleMarkdown` (ADR-0015). Not a typst build — the /// framework owns the read/concatenate/write itself. AssembleMarkdown { /// The per-element markdown content field to assemble (e.g. `slides`, /// `transcript`). field: 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. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Project { /// Stable project id. pub id: String, /// Folder / display name. pub name: String, } /// `[info]` table (passed through to render targets verbatim). /// /// The *canonical* model whose /// `authors` is always a list. The authoring-surface form (string-or-array /// `author`) is the separate [`RawInfo`] / [`RawAuthor`], normalized into this /// at the load boundary. No /// CI gate enforces ADR↔implementation alignment (repo constitution); it is kept greppable. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Info { /// Lesson title. pub title: String, /// Authors, in declared order. A lesson can have several (a teaching group), /// so this is a list, not a single name. Empty when `[info]` declares no /// `author`. The on-disk `author` accepts either a bare string (one author) /// or an array of strings (see [`RawAuthor`]); both load into this `Vec`. pub authors: Vec, } /// One `[[parts]]` entry plus its loaded element descriptor. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Part { /// Declared kind from the manifest (one of the known kinds; ADR-0006). pub kind: String, /// Element folder path **as written in the manifest** (relative to root), /// kept verbatim for diagnostics / display. pub path: PathBuf, /// The element's self-description loaded from its `element.toml`. pub descriptor: ElementDescriptor, } /// An element's `element.toml`, parsed into kind + remaining scalar fields. /// /// ADR-0008: `kind` is explicit in the descriptor (not inferred from the parent /// directory), so a folder is self-describing. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct ElementDescriptor { /// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is /// reported as a diagnostic (see [`load`]). pub kind: String, /// Element folder, absolute path. pub dir: PathBuf, /// The remaining `element.toml` keys (the scalar fields). Schema validation /// of these is WU-3's job, not this loader's. pub scalars: toml::Table, } // --- raw deserialization shapes (mirror the on-disk TOML) --------------------- #[derive(Debug, Deserialize)] struct RawManifest { project: Option, info: Option, #[serde(default)] parts: Vec, #[serde(default)] targets: toml::Table, } #[derive(Debug, Deserialize)] struct RawProject { id: String, name: String, } /// The authoring-surface `[info]`: the raw form that exists for /// fill-in convenience, normalized into the canonical [`Info`] at the load /// boundary. Not the form the rest of the model traffics in. #[derive(Debug, Deserialize)] struct RawInfo { title: String, author: Option, } /// On-disk `author`: either a single name (`author = "…"`) or a list /// (`author = ["…", "…"]`). A fill-in convenience whose /// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] /// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears. #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawAuthor { One(String), Many(Vec), } impl RawAuthor { /// Flatten to the ordered author list: a single /// name becomes a one-element list; a list passes through verbatim. fn into_vec(self) -> Vec { match self { RawAuthor::One(s) => vec![s], RawAuthor::Many(v) => v, } } } #[derive(Debug, Deserialize)] struct RawPart { kind: String, path: String, } /// Load the engineering file at `root` into a [`Lesson`]. /// /// Returns `(Option, Vec)`: /// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The /// structure is produced even when individual parts have problems, so the /// WU-5 orchestrator gets **both** the partial lesson and the collected /// diagnostics. /// - `None` only on a hard failure where no `Lesson` can be built: the /// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the /// required `[project]` / `[info]` tables. In that case the diagnostics /// describe the hard failure. /// /// Collected (non-fatal) diagnostics include: a part path that does not exist /// or escapes the root via `..`, a missing/malformed `element.toml`, and a /// `part.kind != element.toml kind` mismatch. /// /// ## Diagnostic-code mapping (judgment call, WU-1) /// /// `cph-diag`'s code set is closed and has **no** dedicated "manifest /// malformed" code. We deliberately do not invent one here. Until such a code /// is added, manifest-level structural errors (bad TOML, missing `[project]` / /// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`], /// with a message making the real cause clear. A genuinely missing **part /// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing / /// unreadable / malformed `element.toml` also maps to `SchemaViolation`. // TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so // manifest-structure errors don't overload `SchemaViolation`. pub fn load(root: &Path) -> (Option, Vec) { let mut diags = Vec::new(); let manifest_path = root.join("manifest.toml"); let manifest_src = match std::fs::read_to_string(&manifest_path) { Ok(s) => s, Err(e) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("cannot read manifest.toml: {e}"), ) .with_hint(format!( "expected an engineering-file manifest at {}", manifest_path.display() )), ); return (None, diags); } }; let raw: RawManifest = match toml::from_str(&manifest_src) { Ok(r) => r, Err(e) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("manifest.toml is not valid TOML: {e}"), ) .with_hint("fix the TOML syntax in manifest.toml"), ); return (None, diags); } }; // `.cph-version` compatibility (ADR-0016). Decided at load time, before // any structural/schema/compile work. An incompatible version is an // error diagnostic (`CphVersionMismatch`); it does not halt loading, so // any other defects surface alongside it — but an error diagnostic alone // already makes the lesson illegal (ADR-0010), so `check`/`build` refuse. // A missing `.cph-version` is skipped for now (ADR-0016 OPEN). check_cph_version(root, &mut diags); // [project] and [info] are required to build a Lesson at all. let project = match raw.project { Some(p) => Project { id: p.id, name: p.name, }, None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, "manifest.toml is missing the required [project] table", ) .with_hint("add a [project] table with `id` and `name`"), ); return (None, diags); } }; let info = match raw.info { Some(i) => Info { title: i.title, authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(), }, None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, "manifest.toml is missing the required [info] table", ) .with_hint("add an [info] table with at least `title`"), ); return (None, diags); } }; // Parse each [targets.] table into a structured build config // (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 .targets .into_iter() .map(|(name, value)| parse_target(name, value, &mut diags)) .collect(); // Load each part. Problems are collected, not fatal: we still build the // Part (with a best-effort descriptor) so order/membership is observable. let mut parts = Vec::with_capacity(raw.parts.len()); for raw_part in raw.parts { let rel_path = PathBuf::from(&raw_part.path); // Reject `..` traversal: a part path must stay within the root. if has_parent_traversal(&rel_path) { diags.push( Diagnostic::error( DiagCode::PartPathMissing, format!( "part path '{}' escapes the engineering-file root via '..'", raw_part.path ), ) .with_hint("part paths must be relative folders inside the engineering file"), ); // Still record the part with an empty descriptor so order is kept. parts.push(Part { kind: raw_part.kind.clone(), path: rel_path.clone(), descriptor: ElementDescriptor { kind: raw_part.kind, dir: root.join(&rel_path), scalars: toml::Table::new(), }, }); continue; } let dir = root.join(&rel_path); let descriptor = if !dir.is_dir() { diags.push( Diagnostic::error( DiagCode::PartPathMissing, format!("part folder '{}' does not exist", raw_part.path), ) .with_hint(format!( "create the folder '{}' or fix the `path` in manifest.toml", raw_part.path )), ); ElementDescriptor { kind: raw_part.kind.clone(), dir, scalars: toml::Table::new(), } } else { load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags) }; parts.push(Part { kind: raw_part.kind, path: rel_path, descriptor, }); } let lesson = Lesson { project, info, parts, targets, root: root.to_path_buf(), }; (Some(lesson), diags) } /// The cph version the running CLI was built with (ADR-0016). Pulled from the /// crate's `CARGO_PKG_VERSION` at compile time. pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Read the engineering file's `.cph-version` and, if present, push a /// `CphVersionMismatch` error when it is not compatible with [`CPH_VERSION`] /// (ADR-0016). A missing file is skipped for now (migration period; OPEN in /// ADR-0016). fn check_cph_version(root: &Path, diags: &mut Vec) { let path = root.join(".cph-version"); let Ok(src) = std::fs::read_to_string(&path) else { return; // missing `.cph-version` — skipped (ADR-0016 OPEN). }; let file_version = src.trim(); if file_version.is_empty() { diags.push( Diagnostic::error( DiagCode::CphVersionMismatch, format!(".cph-version is empty; expected cph version {CPH_VERSION}"), ) .with_hint(format!( "write the cph version this file targets into {} (currently {CPH_VERSION})", path.display() )), ); return; } if !versions_compatible(file_version, CPH_VERSION) { diags.push( Diagnostic::error( DiagCode::CphVersionMismatch, format!( ".cph-version declares {file_version}, but this cph is {CPH_VERSION} (incompatible)" ), ) .with_hint(format!( "align them: set .cph-version to {CPH_VERSION}, or install cph {file_version}" )), ); } } /// Whether an engineering file's declared cph version is compatible with the /// running CLI's version (ADR-0016). **The rule is exact equality** — the /// strictest choice, deliberately, while the format is young (0.0.x). This is /// the single seam for future relaxation to a semver range: relaxing the /// format does not change the diagnostic class, the spec, or the CLI — only /// this predicate. fn versions_compatible(file_version: &str, cli_version: &str) -> bool { file_version == cli_version } /// Load one element's `element.toml` into an [`ElementDescriptor`], collecting /// diagnostics. On a missing/malformed `element.toml` the descriptor falls back /// to the part's declared kind with empty scalars so the lesson stays buildable. fn load_descriptor( dir: &Path, rel_path: &Path, part_kind: &str, diags: &mut Vec, ) -> ElementDescriptor { let element_toml = dir.join("element.toml"); let rel_element_toml = rel_path.join("element.toml"); let src = match std::fs::read_to_string(&element_toml) { Ok(s) => s, Err(e) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "cannot read element.toml for part '{}': {e}", rel_path.display() ), ) .with_hint(format!( "add an element.toml at {} with at least `kind = \"{part_kind}\"`", rel_element_toml.display() )), ); return ElementDescriptor { kind: part_kind.to_string(), dir: dir.to_path_buf(), scalars: toml::Table::new(), }; } }; let mut table: toml::Table = match toml::from_str(&src) { Ok(t) => t, Err(e) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "element.toml for part '{}' is not valid TOML: {e}", rel_path.display() ), ) .with_hint("fix the TOML syntax in element.toml"), ); return ElementDescriptor { kind: part_kind.to_string(), dir: dir.to_path_buf(), scalars: toml::Table::new(), }; } }; // `kind` is required and must be a string. Pull it out of the scalar map so // `scalars` ends up being "the remaining keys". let kind = match table.remove("kind") { Some(toml::Value::String(k)) => k, Some(_) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "element.toml for part '{}' has a non-string `kind`", rel_path.display() ), ) .with_hint("`kind` must be a string, e.g. `kind = \"lemma\"`"), ); part_kind.to_string() } None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "element.toml for part '{}' is missing the required `kind` key", rel_path.display() ), ) .with_hint(format!("add `kind = \"{part_kind}\"` to element.toml")), ); part_kind.to_string() } }; // Cross-check: the descriptor's kind must equal the part's declared kind. if kind != part_kind { diags.push( Diagnostic::error( DiagCode::UnknownKind, format!( "kind mismatch for part '{}': manifest says '{part_kind}', element.toml says '{kind}'", rel_path.display() ), ) .with_hint("make the manifest `kind` and the element.toml `kind` agree"), ); } ElementDescriptor { kind, dir: dir.to_path_buf(), scalars: table, } } /// Parse one `[targets.]` table into a [`TargetConfig`] (ADR-0009/0011), /// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. /// /// 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 { toml::Value::Table(t) => t, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' must be a table, e.g. `[targets.{name}]`"), ) .with_hint("declare a target as a `[targets.]` table"), ); return TargetConfig { artifact: Artifact::default_for(&name), steps: vec![Step::default_for(&name)], covers: None, name, }; } }; let artifact = match table.remove("artifact") { None => Artifact::default_for(&name), Some(value) => parse_artifact(&name, value, diags), }; let steps = match table.remove("steps") { None => vec![Step::default_for(&name)], Some(value) => parse_steps(&name, value, diags), }; let covers = match table.remove("covers") { None => None, Some(value) => parse_covers(&name, value, diags), }; TargetConfig { name, artifact, steps, covers, } } /// Parse a target's optional `covers` array into the render-coverage declaration /// (ADR-0011). Each entry is a kind name (a string). A non-array `covers`, or any /// non-string entry, is reported as a [`DiagCode::SchemaViolation`]; in that case /// the declaration falls back to `None` ("not declared"), so `cph-check` defaults /// it to the full known-kind universe rather than silently covering nothing. /// /// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that /// explicitly renders no kind (every used kind then draws a `renderIgnored` /// warning) — distinct from an absent key. fn parse_covers( name: &str, value: toml::Value, diags: &mut Vec, ) -> Option> { let items = match value { toml::Value::Array(items) => items, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a non-array `covers`; declare it as a list of \ kind names, e.g. `covers = [\"segment\", \"example\"]`" ), ) .with_hint("set `covers` to an array of kind-name strings, or omit it"), ); return None; } }; let mut kinds = Vec::with_capacity(items.len()); for item in items { match item { toml::Value::String(s) => kinds.push(s), other => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a non-string entry in `covers`: {}", json_like_type(&other) ), ) .with_hint("each `covers` entry is a kind name (a string)"), ); return None; } } } Some(kinds) } /// A short type label for a TOML value, for the `covers` non-string diagnostic. fn json_like_type(v: &toml::Value) -> &'static str { match v { toml::Value::String(_) => "string", toml::Value::Integer(_) => "integer", toml::Value::Float(_) => "float", toml::Value::Boolean(_) => "boolean", toml::Value::Datetime(_) => "datetime", toml::Value::Array(_) => "array", toml::Value::Table(_) => "table", } } /// 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 `artifact`; it must be an \ inline table like `{{ type = \"single-file\", filepath = \"…\" }}`" ), ) .with_hint("set `artifact` to an inline table with a `type` discriminator"), ); return Artifact::default_for(name); } }; let kind = match table.remove("type") { Some(toml::Value::String(s)) => s, Some(_) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' has a non-string `artifact.type`"), ) .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), ); 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); } }; 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\", \"shell\", or \"assemble-markdown\"", ), ); 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\"`, `\"assemble-markdown\"`) 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 } }, "assemble-markdown" => match take_string_field(&mut table, "field") { Some(field) => Some(Step::AssembleMarkdown { field }), None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' assemble-markdown step is missing a string `field`" ), ) .with_hint("add `field = \"slides\"` (or `\"transcript\"`) 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\", \"assemble-markdown\"" ), ) .with_hint( "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"", ), ); 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). fn has_parent_traversal(path: &Path) -> bool { path.components().any(|c| matches!(c, Component::ParentDir)) } #[cfg(test)] mod tests { use super::*; #[test] fn parent_traversal_detection() { assert!(has_parent_traversal(Path::new("../x"))); assert!(has_parent_traversal(Path::new("a/../b"))); assert!(!has_parent_traversal(Path::new("a/b/c"))); assert!(!has_parent_traversal(Path::new("segments/intro"))); } }