forked from bai/curriculum-project-hub
1751 lines
65 KiB
Rust
1751 lines
65 KiB
Rust
//! `cph-model` — load the ADR-0029 nested outline manifest into an in-memory
|
|
//! lesson.
|
|
//!
|
|
//! This crate is the **loader**, not the full checker. A lesson's structure is
|
|
//! a folder tree (ADR-0007): every folder that groups children carries a
|
|
//! `manifest.toml` (root or internal — see [`load`]); every leaf carries an
|
|
//! `element.toml` (ADR-0008's element descriptor, unchanged). The loader walks
|
|
//! that tree depth-first and produces:
|
|
//!
|
|
//! - [`Lesson::parts`] — the **ordered element sequence** (ADR-0005): pure
|
|
//! elements, no containers. This is what `cph-check`/`cph-schema`/the render
|
|
//! pipeline validate and include content from — unaffected by nesting.
|
|
//! - [`Lesson::outline`] — the **full rendering-order sequence**: elements
|
|
//! interleaved with section headings, at the depth-first traversal position
|
|
//! they open at (ADR-0029). This is what the augmented manifest (built by
|
|
//! `cph-typst`) walks to hand the template a rendering order that includes
|
|
//! headings.
|
|
//!
|
|
//! Scope boundaries (deliberately staying in lane):
|
|
//! - It validates **structure** only: manifest/outline shape, element.toml
|
|
//! shape, the leaf/container discriminator, and the cross-check
|
|
//! `part.kind == element.toml kind`.
|
|
//! - It does **not** validate instance data against a kind's JSON Schema (that
|
|
//! is `cph-schema`), does **not** check that `content` `.typ` files exist
|
|
//! (also schema-driven), does **not** compile typst, and does **not**
|
|
//! validate a container's declared kind against the known container-kind set
|
|
//! (that is `cph-check`'s job, mirroring how it also owns the known
|
|
//! *element*-kind check).
|
|
//!
|
|
//! Entry point: [`load`].
|
|
|
|
use std::path::{Component, Path, PathBuf};
|
|
|
|
use cph_diag::{DiagCode, Diagnostic, Severity};
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// An in-memory lesson loaded from an engineering file (ADR-0029).
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
pub struct Lesson {
|
|
/// `[project]` from the root manifest (id, name).
|
|
pub project: Project,
|
|
/// `[info]` from the root manifest (title, optional author).
|
|
pub info: Info,
|
|
/// The ordered **element** sequence — the lesson's element order (ADR-0005).
|
|
/// Contains only leaves; containers never appear here (a container
|
|
/// "contributes no element of its own", ADR-0029). Index-stable: an
|
|
/// [`OutlineEntry::Element`] names a position in this `Vec` by index.
|
|
pub parts: Vec<Part>,
|
|
/// The full depth-first rendering order: elements (by index into `parts`)
|
|
/// interleaved with section headings, at the position they open in the
|
|
/// tree (ADR-0029). Consumed by the augmented-manifest builder so a
|
|
/// template can render headings in their real position; `cph-check`'s
|
|
/// structural/schema/coverage phases do not need it (they use `parts`).
|
|
pub outline: Vec<OutlineEntry>,
|
|
/// Declared export targets, collected from the root manifest's
|
|
/// `[targets.<name>]` tables (ADR-0009/0011).
|
|
pub targets: Vec<TargetConfig>,
|
|
/// Engineering-file root (absolute), for resolving part/container paths.
|
|
pub root: PathBuf,
|
|
}
|
|
|
|
impl Lesson {
|
|
/// Project the nested depth-first outline into the target-independent
|
|
/// document shape used by `cph outline`.
|
|
pub fn outline_document(&self) -> OutlineDocument {
|
|
let (children, _) = consume_outline_children(self, 0, 0);
|
|
OutlineDocument {
|
|
title: self.info.title.clone(),
|
|
authors: self.info.authors.clone(),
|
|
children,
|
|
}
|
|
}
|
|
|
|
/// The declared export-target names, in declared order.
|
|
pub fn target_names(&self) -> Vec<&str> {
|
|
self.targets.iter().map(|t| t.name.as_str()).collect()
|
|
}
|
|
}
|
|
|
|
/// One entry in the lesson's full rendering-order sequence (ADR-0029).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub enum OutlineEntry {
|
|
/// An element at this position: `part_index` into [`Lesson::parts`].
|
|
Element {
|
|
/// Index into `Lesson::parts`.
|
|
part_index: usize,
|
|
/// Container depth: 0 for a root child, 1 inside a direct section, etc.
|
|
depth: u32,
|
|
},
|
|
/// A section container opens here. Contributes no element; it is a
|
|
/// heading, not a part (ADR-0029). `depth` is the section's nesting depth
|
|
/// (1 = a section directly under the engineering-file root).
|
|
Section {
|
|
/// The container's declared kind (MVP: always `"section"`; validated
|
|
/// against the known container-kind set by `cph-check`, not here).
|
|
kind: String,
|
|
/// Heading text: the container's `[group].title` if present and
|
|
/// non-empty, else the folder's basename.
|
|
title: String,
|
|
/// Optional teacher-facing planning note from the `children` entry.
|
|
notes: Option<String>,
|
|
/// Nesting depth (1-based) — the heading level a renderer should use.
|
|
depth: u32,
|
|
/// The container folder's path, relative to the engineering-file root.
|
|
path: PathBuf,
|
|
},
|
|
}
|
|
|
|
/// A target-independent outline projection of a nested lesson.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct OutlineDocument {
|
|
/// Course title from the root `[info].title`.
|
|
pub title: String,
|
|
/// Authors from the root `[info].author`.
|
|
pub authors: Vec<String>,
|
|
/// Ordered root children, preserving nested sections.
|
|
pub children: Vec<OutlineNode>,
|
|
}
|
|
|
|
/// One section or element in the exported outline tree.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct OutlineNode {
|
|
/// Heading text shown to a teacher.
|
|
pub title: String,
|
|
/// Element or container kind.
|
|
pub kind: String,
|
|
/// Root-relative source path.
|
|
pub path: PathBuf,
|
|
/// Optional teacher-facing planning note.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub notes: Option<String>,
|
|
/// Nested children; empty for an element leaf.
|
|
pub children: Vec<OutlineNode>,
|
|
}
|
|
|
|
impl OutlineNode {
|
|
fn from_part(part: &Part) -> Self {
|
|
let title = part
|
|
.path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| !name.is_empty())
|
|
.map(str::to_owned)
|
|
.unwrap_or_else(|| part.path.to_string_lossy().into_owned());
|
|
Self {
|
|
title,
|
|
kind: part.kind.clone(),
|
|
path: part.path.clone(),
|
|
notes: part.notes.clone().filter(|notes| !notes.trim().is_empty()),
|
|
children: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Convert the flat depth-first sequence into nested JSON/format nodes.
|
|
fn consume_outline_children(
|
|
lesson: &Lesson,
|
|
start: usize,
|
|
minimum_section_depth: u32,
|
|
) -> (Vec<OutlineNode>, usize) {
|
|
let mut children = Vec::new();
|
|
let mut index = start;
|
|
while index < lesson.outline.len() {
|
|
match &lesson.outline[index] {
|
|
OutlineEntry::Element { part_index, depth } => {
|
|
if *depth < minimum_section_depth {
|
|
break;
|
|
}
|
|
children.push(OutlineNode::from_part(&lesson.parts[*part_index]));
|
|
index += 1;
|
|
}
|
|
OutlineEntry::Section {
|
|
kind,
|
|
title,
|
|
notes,
|
|
depth,
|
|
path,
|
|
} => {
|
|
if *depth < minimum_section_depth {
|
|
break;
|
|
}
|
|
let section_depth = *depth;
|
|
let (nested, next) = consume_outline_children(lesson, index + 1, section_depth);
|
|
children.push(OutlineNode {
|
|
title: title.clone(),
|
|
kind: kind.clone(),
|
|
path: path.clone(),
|
|
notes: notes.clone().filter(|note| !note.trim().is_empty()),
|
|
children: nested,
|
|
});
|
|
index = next;
|
|
}
|
|
}
|
|
}
|
|
(children, index)
|
|
}
|
|
|
|
/// 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 (root manifest only).
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
pub struct Project {
|
|
/// Stable project id.
|
|
pub id: String,
|
|
/// Folder / display name.
|
|
pub name: String,
|
|
}
|
|
|
|
/// `[info]` table (root manifest only; 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 element leaf, plus its loaded `element.toml` descriptor.
|
|
///
|
|
/// `path` is the element folder's path relative to the engineering-file root,
|
|
/// regardless of how deeply nested it is in the outline tree (ADR-0029) — the
|
|
/// loader accumulates full root-relative paths during the depth-first walk, so
|
|
/// every downstream consumer (schema validation, augmented-manifest include
|
|
/// paths) keeps working against a root-relative path exactly as before ADR-0029.
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
pub struct Part {
|
|
/// Declared kind from the containing outline entry (ADR-0006). This is the
|
|
/// **declared** kind, which may disagree with the `element.toml` kind (a
|
|
/// disagreement is reported as [`DiagCode::UnknownKind`], but the declared
|
|
/// kind — not the descriptor's — is what `cph-check`'s known-kind check
|
|
/// tests, matching pre-ADR-0029 behavior).
|
|
pub kind: String,
|
|
/// Element folder path, root-relative, kept verbatim (forward/back slashes
|
|
/// as the OS provides) for diagnostics/display.
|
|
pub path: PathBuf,
|
|
/// Optional teacher-facing planning note from the `children` entry.
|
|
/// This is outline metadata, not a content field in `element.toml`.
|
|
pub notes: Option<String>,
|
|
/// 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 declared `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 `cph-schema`'s job, not this loader's.
|
|
pub scalars: toml::Table,
|
|
}
|
|
|
|
// --- raw deserialization shapes (mirror the on-disk TOML) ---------------------
|
|
|
|
/// One `manifest.toml`, root or container (ADR-0029). Root-only tables
|
|
/// (`project`/`info`/`targets`) and the container-only `group` table coexist in
|
|
/// one shape; [`load`]/[`load_children`] enforce which is expected where and
|
|
/// flag misplacement rather than rejecting parse outright (non-fatal, like
|
|
/// every other structural defect this loader collects).
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawManifest {
|
|
project: Option<RawProject>,
|
|
info: Option<RawInfo>,
|
|
#[serde(default)]
|
|
targets: toml::Table,
|
|
group: Option<RawGroup>,
|
|
#[serde(default)]
|
|
children: Vec<RawChild>,
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A container's optional `[group]` table (ADR-0029): presentation metadata for
|
|
/// a section, kept minimal per the ADR's recommended default.
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawGroup {
|
|
title: Option<String>,
|
|
}
|
|
|
|
/// One `children` entry (ADR-0029): a `kind` + a **parent-relative** `path`.
|
|
/// The loader resolves on disk whether the named folder is a leaf
|
|
/// (`element.toml`) or a container (`manifest.toml`) — `kind` is the declared
|
|
/// label, cross-checked against the leaf's `element.toml` kind (unchanged from
|
|
/// ADR-0008) but never used to pick the leaf/container branch itself.
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawChild {
|
|
kind: String,
|
|
path: String,
|
|
#[serde(default)]
|
|
notes: Option<String>,
|
|
}
|
|
|
|
/// Load the engineering file at `root` into a [`Lesson`].
|
|
///
|
|
/// Returns `(Option<Lesson>, Vec<Diagnostic>)`:
|
|
/// - `Some(lesson)` whenever the root manifest parses into a `Lesson` at all.
|
|
/// The structure is produced even when individual parts/containers have
|
|
/// problems, so the orchestrator gets **both** the partial lesson and the
|
|
/// collected diagnostics.
|
|
/// - `None` only on a hard failure where no `Lesson` can be built: the root
|
|
/// `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 child path that does not exist
|
|
/// or escapes its container via `..`, a folder that is neither a container nor
|
|
/// a leaf (or ambiguously both), a container manifest misplacing root-only
|
|
/// tables, a missing/malformed `element.toml`, and a
|
|
/// `part.kind != element.toml kind` mismatch.
|
|
///
|
|
/// ## Diagnostic-code mapping
|
|
///
|
|
/// [`DiagCode::ManifestMalformed`] is for the **carrier document's own**
|
|
/// structure being broken: unreadable/invalid-TOML `manifest.toml` (root or
|
|
/// container), a required root table missing, a container manifest misplacing
|
|
/// `[project]`/`[info]`/`[targets]`, a folder that is neither/both a
|
|
/// container and a leaf, and an unreadable/invalid/kindless `element.toml`.
|
|
/// [`DiagCode::SchemaViolation`] stays reserved for *instance data* not
|
|
/// conforming to a schema (a kind's JSON Schema, or a target's
|
|
/// artifact/step config shape) — this loader never emits it. A genuinely
|
|
/// missing **child path** uses [`DiagCode::PartPathMissing`]; a declared kind
|
|
/// disagreeing with `element.toml`'s kind uses [`DiagCode::UnknownKind`].
|
|
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::ManifestMalformed,
|
|
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::ManifestMalformed,
|
|
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::ManifestMalformed,
|
|
"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::ManifestMalformed,
|
|
"manifest.toml is missing the required [info] table",
|
|
)
|
|
.with_hint("add an [info] table with at least `title`"),
|
|
);
|
|
return (None, diags);
|
|
}
|
|
};
|
|
|
|
// The root is the implicit top container (ADR-0029): it must not declare
|
|
// `[group]` (that is container-only presentation metadata).
|
|
if raw.group.is_some() {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
"the root manifest.toml must not declare [group]; [group] is \
|
|
container-only presentation metadata",
|
|
)
|
|
.with_hint(
|
|
"remove [group] from the root manifest.toml, or move this content into a container",
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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();
|
|
|
|
// Walk the outline tree depth-first (ADR-0029), starting at the root's own
|
|
// children. A direct child section of the root opens at depth 1.
|
|
let mut parts = Vec::new();
|
|
let mut outline = Vec::new();
|
|
load_children(
|
|
root,
|
|
root,
|
|
Path::new(""),
|
|
raw.children,
|
|
1,
|
|
&mut diags,
|
|
&mut parts,
|
|
&mut outline,
|
|
);
|
|
|
|
let lesson = Lesson {
|
|
project,
|
|
info,
|
|
parts,
|
|
outline,
|
|
targets,
|
|
root: root.to_path_buf(),
|
|
};
|
|
(Some(lesson), diags)
|
|
}
|
|
|
|
/// Recursively load one container's ordered `children` into `parts`/`outline`
|
|
/// (ADR-0029). `container_dir` is the container's own absolute directory;
|
|
/// `rel_prefix` is that container's own root-relative path (empty for the
|
|
/// engineering-file root). `next_section_depth` is the depth a **direct**
|
|
/// section child of this container would open at (1 for the root's children).
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn load_children(
|
|
container_dir: &Path,
|
|
root: &Path,
|
|
rel_prefix: &Path,
|
|
children: Vec<RawChild>,
|
|
next_section_depth: u32,
|
|
diags: &mut Vec<Diagnostic>,
|
|
parts: &mut Vec<Part>,
|
|
outline: &mut Vec<OutlineEntry>,
|
|
) {
|
|
for child in children {
|
|
let element_depth = next_section_depth.saturating_sub(1);
|
|
let local_path = PathBuf::from(&child.path);
|
|
let full_rel_path = join_rel(rel_prefix, &local_path);
|
|
|
|
// Reject `..` traversal: a child path must stay within its container.
|
|
if has_parent_traversal(&local_path) {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::PartPathMissing,
|
|
format!("child path '{}' escapes its container via '..'", child.path),
|
|
)
|
|
.with_hint("child paths must be relative folders inside the containing folder"),
|
|
);
|
|
push_broken_leaf(
|
|
parts,
|
|
outline,
|
|
child.kind,
|
|
full_rel_path,
|
|
root,
|
|
child.notes,
|
|
element_depth,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let abs_dir = container_dir.join(&local_path);
|
|
|
|
if !abs_dir.is_dir() {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::PartPathMissing,
|
|
format!("child folder '{}' does not exist", full_rel_path.display()),
|
|
)
|
|
.with_hint(format!(
|
|
"create the folder '{}' or fix the `path` in its container's manifest.toml",
|
|
full_rel_path.display()
|
|
)),
|
|
);
|
|
push_broken_leaf(
|
|
parts,
|
|
outline,
|
|
child.kind,
|
|
full_rel_path,
|
|
root,
|
|
child.notes,
|
|
element_depth,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let has_manifest = abs_dir.join("manifest.toml").is_file();
|
|
let has_element = abs_dir.join("element.toml").is_file();
|
|
|
|
match (has_manifest, has_element) {
|
|
// A leaf: exactly ADR-0008's element folder.
|
|
(false, true) => {
|
|
let descriptor = load_descriptor(&abs_dir, &full_rel_path, &child.kind, diags);
|
|
let idx = parts.len();
|
|
parts.push(Part {
|
|
kind: child.kind,
|
|
path: full_rel_path,
|
|
notes: child.notes,
|
|
descriptor,
|
|
});
|
|
outline.push(OutlineEntry::Element {
|
|
part_index: idx,
|
|
depth: element_depth,
|
|
});
|
|
}
|
|
// A container: recurse into its own manifest.toml.
|
|
(true, false) => {
|
|
let Some(raw_container) = read_container_manifest(&abs_dir, diags) else {
|
|
push_broken_leaf(
|
|
parts,
|
|
outline,
|
|
child.kind,
|
|
full_rel_path,
|
|
root,
|
|
child.notes,
|
|
element_depth,
|
|
);
|
|
continue;
|
|
};
|
|
|
|
if raw_container.project.is_some()
|
|
|| raw_container.info.is_some()
|
|
|| !raw_container.targets.is_empty()
|
|
{
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!(
|
|
"container manifest.toml at '{}' must not declare \
|
|
[project]/[info]/[targets]; those are root-only",
|
|
full_rel_path.display()
|
|
),
|
|
)
|
|
.with_hint(
|
|
"remove [project]/[info]/[targets] from this container's \
|
|
manifest.toml — they belong only at the engineering-file root",
|
|
),
|
|
);
|
|
}
|
|
|
|
let title = raw_container
|
|
.group
|
|
.and_then(|g| g.title)
|
|
.filter(|t| !t.trim().is_empty())
|
|
.unwrap_or_else(|| folder_name(&abs_dir));
|
|
|
|
outline.push(OutlineEntry::Section {
|
|
kind: child.kind,
|
|
title,
|
|
notes: child.notes,
|
|
depth: next_section_depth,
|
|
path: full_rel_path.clone(),
|
|
});
|
|
|
|
load_children(
|
|
&abs_dir,
|
|
root,
|
|
&full_rel_path,
|
|
raw_container.children,
|
|
next_section_depth + 1,
|
|
diags,
|
|
parts,
|
|
outline,
|
|
);
|
|
}
|
|
// Ambiguous: both a container and a leaf.
|
|
(true, true) => {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!(
|
|
"folder '{}' has both manifest.toml and element.toml; a folder \
|
|
must be exactly one of a container or a leaf",
|
|
full_rel_path.display()
|
|
),
|
|
)
|
|
.with_hint("remove one of manifest.toml or element.toml from this folder"),
|
|
);
|
|
push_broken_leaf(
|
|
parts,
|
|
outline,
|
|
child.kind,
|
|
full_rel_path,
|
|
root,
|
|
child.notes,
|
|
element_depth,
|
|
);
|
|
}
|
|
// Incomplete: neither a container nor a leaf.
|
|
(false, false) => {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!(
|
|
"folder '{}' has neither manifest.toml nor element.toml",
|
|
full_rel_path.display()
|
|
),
|
|
)
|
|
.with_hint(
|
|
"add an element.toml (leaf) or a manifest.toml with `children` \
|
|
(container) to this folder",
|
|
),
|
|
);
|
|
push_broken_leaf(
|
|
parts,
|
|
outline,
|
|
child.kind,
|
|
full_rel_path,
|
|
root,
|
|
child.notes,
|
|
element_depth,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read and parse a container's own `manifest.toml`. On any read/parse failure,
|
|
/// reports a [`DiagCode::ManifestMalformed`] diagnostic and returns `None` (the
|
|
/// caller falls back to a broken-leaf placeholder so order/count stays stable).
|
|
fn read_container_manifest(dir: &Path, diags: &mut Vec<Diagnostic>) -> Option<RawManifest> {
|
|
let path = dir.join("manifest.toml");
|
|
let src = match std::fs::read_to_string(&path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!("cannot read {}: {e}", path.display()),
|
|
)
|
|
.with_hint("expected a container manifest.toml with a `children` array"),
|
|
);
|
|
return None;
|
|
}
|
|
};
|
|
match toml::from_str(&src) {
|
|
Ok(r) => Some(r),
|
|
Err(e) => {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!("{} is not valid TOML: {e}", path.display()),
|
|
)
|
|
.with_hint("fix the TOML syntax in this container's manifest.toml"),
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Record a broken child as a placeholder leaf (empty descriptor) so that
|
|
/// order/count stays observable even when the folder could not be resolved to
|
|
/// either a leaf or a container. Mirrors the pre-ADR-0029 behavior for a
|
|
/// missing part folder.
|
|
fn push_broken_leaf(
|
|
parts: &mut Vec<Part>,
|
|
outline: &mut Vec<OutlineEntry>,
|
|
kind: String,
|
|
path: PathBuf,
|
|
root: &Path,
|
|
notes: Option<String>,
|
|
depth: u32,
|
|
) {
|
|
let idx = parts.len();
|
|
let dir = root.join(&path);
|
|
parts.push(Part {
|
|
kind: kind.clone(),
|
|
path,
|
|
notes,
|
|
descriptor: ElementDescriptor {
|
|
kind,
|
|
dir,
|
|
scalars: toml::Table::new(),
|
|
},
|
|
});
|
|
outline.push(OutlineEntry::Element {
|
|
part_index: idx,
|
|
depth,
|
|
});
|
|
}
|
|
|
|
/// Join a container-relative child path onto that container's own
|
|
/// root-relative prefix, producing a full root-relative path. An empty prefix
|
|
/// (the engineering-file root itself) returns `local` unchanged.
|
|
fn join_rel(prefix: &Path, local: &Path) -> PathBuf {
|
|
if prefix.as_os_str().is_empty() {
|
|
local.to_path_buf()
|
|
} else {
|
|
prefix.join(local)
|
|
}
|
|
}
|
|
|
|
/// A folder's basename as a `String` (used as a section's default title when
|
|
/// `[group].title` is absent). Falls back to the folder's full path string in
|
|
/// the (pathological) case it has no file-name component.
|
|
fn folder_name(dir: &Path) -> String {
|
|
dir.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| dir.display().to_string())
|
|
}
|
|
|
|
/// 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::ManifestMalformed,
|
|
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::ManifestMalformed,
|
|
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::ManifestMalformed,
|
|
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::ManifestMalformed,
|
|
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))
|
|
}
|
|
|
|
// --- bundle: an ordered arrangement of lessons (ADR-0030) --------------------
|
|
|
|
/// A **bundle** (ADR-0030): a directory carrying `bundle.toml`, which arranges
|
|
/// an ordered list of already-authored, self-contained lessons into one export
|
|
/// unit. Discharges ADR-0005's deferred "course = arrangement of lessons" for
|
|
/// the export purpose, without inventing a full course-authoring model.
|
|
///
|
|
/// A bundle target (declared exactly like a lesson's `[targets.*]`,
|
|
/// ADR-0009/0011) assembles its member lessons at build time; this crate only
|
|
/// loads the *arrangement* — which lessons, in what order, with which
|
|
/// per-lesson overrides. See [`load_bundle`].
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
pub struct Bundle {
|
|
/// `[info]` from `bundle.toml` — the 合集's own title/author.
|
|
pub info: Info,
|
|
/// The ordered member lessons.
|
|
pub lessons: Vec<BundleLesson>,
|
|
/// Declared export targets, collected from `bundle.toml`'s
|
|
/// `[targets.<name>]` tables (ADR-0009/0011) — reusing the exact same
|
|
/// build/artifact/step shape a lesson's targets use.
|
|
pub targets: Vec<TargetConfig>,
|
|
/// Bundle root (absolute) — the directory containing `bundle.toml`.
|
|
pub root: PathBuf,
|
|
}
|
|
|
|
impl Bundle {
|
|
/// The declared export-target names, in declared order (mirrors
|
|
/// [`Lesson::target_names`]).
|
|
pub fn target_names(&self) -> Vec<&str> {
|
|
self.targets.iter().map(|t| t.name.as_str()).collect()
|
|
}
|
|
}
|
|
|
|
/// One `[[lessons]]` entry in `bundle.toml`, plus that lesson's loaded
|
|
/// [`Lesson`] (loaded exactly as [`load`] would from its own directory — a
|
|
/// bundle never re-derives lesson-loading logic).
|
|
#[derive(Debug, Clone, PartialEq, Serialize)]
|
|
pub struct BundleLesson {
|
|
/// The lesson directory's path, relative to the bundle root, as written in
|
|
/// `bundle.toml` (kept verbatim for diagnostics/display).
|
|
pub path: PathBuf,
|
|
/// Which of that lesson's own declared targets to render into the bundle
|
|
/// (ADR-0030: the bundle assembles a lesson's already-authored content, it
|
|
/// does not re-target it). Defaults to the lesson's first declared target,
|
|
/// or `"student"` if the lesson declares none.
|
|
pub target: String,
|
|
/// The member lesson, loaded from `<bundle root>/<path>`.
|
|
pub lesson: Lesson,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawBundleManifest {
|
|
info: Option<RawInfo>,
|
|
#[serde(default)]
|
|
targets: toml::Table,
|
|
#[serde(default)]
|
|
lessons: Vec<RawBundleLesson>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RawBundleLesson {
|
|
path: String,
|
|
target: Option<String>,
|
|
}
|
|
|
|
/// Load the bundle at `root` (a directory containing `bundle.toml`) into a
|
|
/// [`Bundle`].
|
|
///
|
|
/// Returns `(Option<Bundle>, Vec<Diagnostic>)`, mirroring [`load`]'s contract:
|
|
/// - `Some(bundle)` whenever `bundle.toml` parses at all — even when a member
|
|
/// lesson fails to load, so the orchestrator sees both the partial bundle
|
|
/// and every collected diagnostic (that member's `Lesson` is still present,
|
|
/// just with its own load errors alongside).
|
|
/// - `None` only on a hard failure: `bundle.toml` missing/unreadable, not
|
|
/// valid TOML, or missing the required `[info]` table.
|
|
///
|
|
/// Each `[[lessons]]` entry's path must stay within the bundle root (no `..`
|
|
/// traversal) and must resolve to a real directory; violations are
|
|
/// [`DiagCode::PartPathMissing`], matching a lesson's own child-path
|
|
/// diagnostics. A member lesson's own load diagnostics are folded in verbatim
|
|
/// (they already carry their own context).
|
|
pub fn load_bundle(root: &Path) -> (Option<Bundle>, Vec<Diagnostic>) {
|
|
let mut diags = Vec::new();
|
|
|
|
let manifest_path = root.join("bundle.toml");
|
|
let manifest_src = match std::fs::read_to_string(&manifest_path) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!("cannot read bundle.toml: {e}"),
|
|
)
|
|
.with_hint(format!(
|
|
"expected a bundle manifest at {}",
|
|
manifest_path.display()
|
|
)),
|
|
);
|
|
return (None, diags);
|
|
}
|
|
};
|
|
|
|
let raw: RawBundleManifest = match toml::from_str(&manifest_src) {
|
|
Ok(r) => r,
|
|
Err(e) => {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::ManifestMalformed,
|
|
format!("bundle.toml is not valid TOML: {e}"),
|
|
)
|
|
.with_hint("fix the TOML syntax in bundle.toml"),
|
|
);
|
|
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::ManifestMalformed,
|
|
"bundle.toml is missing the required [info] table",
|
|
)
|
|
.with_hint("add an [info] table with at least `title`"),
|
|
);
|
|
return (None, diags);
|
|
}
|
|
};
|
|
|
|
let targets: Vec<TargetConfig> = raw
|
|
.targets
|
|
.into_iter()
|
|
.map(|(name, value)| parse_target(name, value, &mut diags))
|
|
.collect();
|
|
|
|
let mut lessons = Vec::with_capacity(raw.lessons.len());
|
|
for raw_lesson in raw.lessons {
|
|
let rel_path = PathBuf::from(&raw_lesson.path);
|
|
|
|
if has_parent_traversal(&rel_path) {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::PartPathMissing,
|
|
format!(
|
|
"bundle lesson path '{}' escapes the bundle root via '..'",
|
|
raw_lesson.path
|
|
),
|
|
)
|
|
.with_hint("bundle lesson paths must be relative folders inside the bundle root"),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let abs_dir = root.join(&rel_path);
|
|
if !abs_dir.is_dir() {
|
|
diags.push(
|
|
Diagnostic::error(
|
|
DiagCode::PartPathMissing,
|
|
format!(
|
|
"bundle lesson folder '{}' does not exist",
|
|
rel_path.display()
|
|
),
|
|
)
|
|
.with_hint(format!(
|
|
"create the folder '{}' or fix the `path` in bundle.toml",
|
|
rel_path.display()
|
|
)),
|
|
);
|
|
continue;
|
|
}
|
|
|
|
let (lesson, lesson_diags) = load(&abs_dir);
|
|
diags.extend(lesson_diags);
|
|
let Some(lesson) = lesson else {
|
|
// The member lesson's own hard-failure diagnostic already explains
|
|
// why; skip it from the bundle rather than fabricate a placeholder
|
|
// Lesson (unlike a broken element child, there is no lighter-weight
|
|
// stand-in for "an entire unloadable lesson").
|
|
continue;
|
|
};
|
|
|
|
let target = raw_lesson.target.unwrap_or_else(|| {
|
|
lesson
|
|
.targets
|
|
.first()
|
|
.map(|t| t.name.clone())
|
|
.unwrap_or_else(|| DEFAULT_LESSON_TARGET.to_string())
|
|
});
|
|
|
|
lessons.push(BundleLesson {
|
|
path: rel_path,
|
|
target,
|
|
lesson,
|
|
});
|
|
}
|
|
|
|
let bundle = Bundle {
|
|
info,
|
|
lessons,
|
|
targets,
|
|
root: root.to_path_buf(),
|
|
};
|
|
(Some(bundle), diags)
|
|
}
|
|
|
|
/// The target name a bundle member falls back to when its `bundle.toml` entry
|
|
/// gives no explicit `target` and the member lesson itself declares no targets
|
|
/// (mirrors [`Step::default_for`]'s "student" convention).
|
|
const DEFAULT_LESSON_TARGET: &str = "student";
|
|
|
|
/// Whether any diagnostic in `diags` is `Error`-severity — the shared
|
|
/// legality predicate `load`/`load_bundle` callers use (mirrors
|
|
/// `cph-check::CheckReport::has_errors`, kept local here so this crate never
|
|
/// depends on `cph-check`).
|
|
pub fn has_error_diagnostic(diags: &[Diagnostic]) -> bool {
|
|
diags.iter().any(|d| d.severity == Severity::Error)
|
|
}
|
|
|
|
#[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")));
|
|
}
|
|
|
|
#[test]
|
|
fn join_rel_empty_prefix_returns_local() {
|
|
assert_eq!(
|
|
join_rel(Path::new(""), Path::new("a/b")),
|
|
PathBuf::from("a/b")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn join_rel_nonempty_prefix_joins() {
|
|
assert_eq!(
|
|
join_rel(Path::new("导言簇"), Path::new("开场白")),
|
|
PathBuf::from("导言簇/开场白")
|
|
);
|
|
}
|
|
}
|