Files
curriculum-project-hub/crates/cph-model/src/lib.rs
T
hongjr03 3f9b60f692 chore: remove Lean spec; ADRs are the single source of truth
The spec/ Lean semantic master had no conformance gate, no codegen, and
no CI tie to implementations — alignment was carried entirely by human
review, the same mechanism that carries the ADRs. In practice the ADRs
plus greppable code comments were already the load-bearing artifacts,
so spec/ was the most expensive kind of stale documentation.

- delete spec/ and the spec-check CI workflow
- README: constitution rewritten around ADRs as decision truth
- AGENTS.md/CLAUDE.md: discipline re-anchored (new decisions -> new ADR,
  never rewrite ADR history; supersede instead)
- code comments: re-anchor 'Mirrors Spec.X' invariants to ADR numbers
  (cph-diag, cph-check, cph-model, hub runner/capacity/org, prisma)
- leave ADR bodies and .scratch audit snapshots untouched (history);
  fix live references in open readiness tickets
2026-07-20 09:07:26 +00:00

1080 lines
41 KiB
Rust

//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson.
//!
//! This crate is the **loader**, not the full checker. It reads
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
//! `[targets.*]`) and each part's `<root>/<path>/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<Part>,
/// Declared export targets, collected from the `[targets.<name>]` 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<TargetConfig>,
/// 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/<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).
/// - 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.<name>]` table key).
pub name: String,
/// The artifact this target produces. Defaults to a [`Artifact::SingleFile`]
/// at `build/<name>.pdf` when the `artifact` field is absent.
pub artifact: Artifact,
/// The ordered build steps. Defaults to a single [`Step::TypstCompile`]
/// with template `exports/<name>.typ` when no `[[steps]]` are given.
pub steps: Vec<Step>,
/// 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<Vec<String>>,
}
/// 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/<name>.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/<name>.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<String>,
}
/// 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<RawProject>,
info: Option<RawInfo>,
#[serde(default)]
parts: Vec<RawPart>,
#[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<RawAuthor>,
}
/// 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<String>),
}
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<String> {
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<Lesson>, Vec<Diagnostic>)`:
/// - `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<Lesson>, Vec<Diagnostic>) {
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.<name>] 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<TargetConfig> = 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<Diagnostic>) {
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<Diagnostic>,
) -> 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.<name>]` 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.<name>]` body is valid (all
/// defaults, no diagnostics).
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.
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.<name>]` 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<Diagnostic>,
) -> Option<Vec<String>> {
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<Diagnostic>) -> 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/<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\", \"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/<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
}
},
"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<String> {
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")));
}
}