forked from EduCraft/curriculum-project-hub
feat(cph): implement nested outline manifest and batch/combined export
ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]:
- cph-model: recursive loader over manifest.toml containers / element.toml
leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements
interleaved with section headings at their DFS-open position); rejects
ambiguous/incomplete folders and root-vs-container table misplacement
- cph-diag: new DiagCode::ManifestMalformed for carrier-document structure
errors (discharges an existing TODO)
- cph-typst: augmented manifest now serializes the outline (element/section
entries) instead of a flat parts array
- render/lib.typ: render-lesson renders section headings at their depth
- examples/TH-141 migrated to 5 nested section containers + 3 root segments,
byte-identical element order; smoke-verified via cph check/build + pdftotext
ADR-0030 — batch & combined export, extends ADR-0009/0011:
- cph build with no --target batches every declared target (repeatable
--target for an explicit subset); any target failure => non-zero exit,
per-target ledger, independent per-target execution
- cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered
lessons with per-lesson target overrides)
- cph-typst: augmented bundle manifest (path-prefixed member outlines),
Engine::{compile_check_bundle,build_bundle_pdf}
- render/lib.typ: render-bundle assembles member lessons under per-lesson
headings, depth-shifts their own section headings, resets example/lemma
counters at each lesson boundary by default
- cph-cli: `cph bundle <path> --target <name>` subcommand, same batching
contract as `cph build`
- new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF
compile), smoke-verified via a real 2-lesson merged PDF
Verification: cargo fmt/clippy/test clean across the workspace (68 tests);
real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF
content inspected via pdftotext.
This commit is contained in:
+635
-125
@@ -1,58 +1,96 @@
|
||||
//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson.
|
||||
//! `cph-model` — load the ADR-0029 nested outline manifest 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.
|
||||
//! 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 shape, element.toml shape, and
|
||||
//! the cross-check `part.kind == element.toml kind`.
|
||||
//! - 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 WU-3 / `cph-schema`), does **not** check that `content` `.typ` files
|
||||
//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4).
|
||||
//! 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};
|
||||
use cph_diag::{DiagCode, Diagnostic, Severity};
|
||||
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).
|
||||
/// An in-memory lesson loaded from an engineering file (ADR-0029).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Lesson {
|
||||
/// `[project]` from the manifest (id, name).
|
||||
/// `[project]` from the root manifest (id, name).
|
||||
pub project: Project,
|
||||
/// `[info]` from the manifest (title, optional author).
|
||||
/// `[info]` from the root manifest (title, optional author).
|
||||
pub info: Info,
|
||||
/// The ordered parts — the lesson's element sequence.
|
||||
/// 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>,
|
||||
/// 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`].
|
||||
/// 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 paths.
|
||||
/// Engineering-file root (absolute), for resolving part/container 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 entry in the lesson's full rendering-order sequence (ADR-0029).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub enum OutlineEntry {
|
||||
/// An element at this position: `part_index` into [`Lesson::parts`].
|
||||
Element {
|
||||
/// Index into `Lesson::parts`.
|
||||
part_index: usize,
|
||||
},
|
||||
/// 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,
|
||||
/// 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,
|
||||
},
|
||||
}
|
||||
|
||||
/// One declared export target's build config (ADR-0009/0011).
|
||||
///
|
||||
/// An export target is a **build** producing a typed [`Artifact`] via an
|
||||
@@ -185,7 +223,7 @@ pub enum Step {
|
||||
run: String,
|
||||
},
|
||||
/// Assemble a single-file markdown deliverable by concatenating each
|
||||
/// element's `field` markdown content file in `[[parts]]` order. ADR-0011
|
||||
/// 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 {
|
||||
@@ -205,7 +243,7 @@ impl Step {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[project]` table.
|
||||
/// `[project]` table (root manifest only).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Project {
|
||||
/// Stable project id.
|
||||
@@ -214,7 +252,8 @@ pub struct Project {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// `[info]` table (passed through to render targets verbatim).
|
||||
/// `[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
|
||||
@@ -232,13 +271,23 @@ pub struct Info {
|
||||
pub authors: Vec<String>,
|
||||
}
|
||||
|
||||
/// One `[[parts]]` entry plus its loaded element descriptor.
|
||||
/// 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 manifest (one of the known kinds; ADR-0006).
|
||||
/// 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 **as written in the manifest** (relative to root),
|
||||
/// kept verbatim for diagnostics / display.
|
||||
/// Element folder path, root-relative, kept verbatim (forward/back slashes
|
||||
/// as the OS provides) for diagnostics/display.
|
||||
pub path: PathBuf,
|
||||
/// The element's self-description loaded from its `element.toml`.
|
||||
pub descriptor: ElementDescriptor,
|
||||
@@ -250,26 +299,32 @@ pub struct Part {
|
||||
/// 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`]).
|
||||
/// `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 WU-3's job, not this loader's.
|
||||
/// 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)]
|
||||
parts: Vec<RawPart>,
|
||||
#[serde(default)]
|
||||
targets: toml::Table,
|
||||
group: Option<RawGroup>,
|
||||
#[serde(default)]
|
||||
children: Vec<RawChild>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -309,8 +364,20 @@ impl RawAuthor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 RawPart {
|
||||
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,
|
||||
}
|
||||
@@ -318,30 +385,33 @@ struct RawPart {
|
||||
/// 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
|
||||
/// - `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 part path that does not exist
|
||||
/// or escapes the root via `..`, a missing/malformed `element.toml`, and a
|
||||
/// 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 (judgment call, WU-1)
|
||||
/// ## Diagnostic-code mapping
|
||||
///
|
||||
/// `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`.
|
||||
/// [`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();
|
||||
|
||||
@@ -352,7 +422,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
format!("cannot read manifest.toml: {e}"),
|
||||
)
|
||||
.with_hint(format!(
|
||||
@@ -369,7 +439,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
format!("manifest.toml is not valid TOML: {e}"),
|
||||
)
|
||||
.with_hint("fix the TOML syntax in manifest.toml"),
|
||||
@@ -395,7 +465,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
None => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
"manifest.toml is missing the required [project] table",
|
||||
)
|
||||
.with_hint("add a [project] table with `id` and `name`"),
|
||||
@@ -412,7 +482,7 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
None => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
"manifest.toml is missing the required [info] table",
|
||||
)
|
||||
.with_hint("add an [info] table with at least `title`"),
|
||||
@@ -421,6 +491,21 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -431,76 +516,263 @@ pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
.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,
|
||||
});
|
||||
}
|
||||
// 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 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);
|
||||
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);
|
||||
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,
|
||||
descriptor,
|
||||
});
|
||||
outline.push(OutlineEntry::Element { part_index: idx });
|
||||
}
|
||||
// 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);
|
||||
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,
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
) {
|
||||
let idx = parts.len();
|
||||
let dir = root.join(&path);
|
||||
parts.push(Part {
|
||||
kind: kind.clone(),
|
||||
path,
|
||||
descriptor: ElementDescriptor {
|
||||
kind,
|
||||
dir,
|
||||
scalars: toml::Table::new(),
|
||||
},
|
||||
});
|
||||
outline.push(OutlineEntry::Element { part_index: idx });
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -570,7 +842,7 @@ fn load_descriptor(
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
format!(
|
||||
"cannot read element.toml for part '{}': {e}",
|
||||
rel_path.display()
|
||||
@@ -594,7 +866,7 @@ fn load_descriptor(
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
format!(
|
||||
"element.toml for part '{}' is not valid TOML: {e}",
|
||||
rel_path.display()
|
||||
@@ -617,7 +889,7 @@ fn load_descriptor(
|
||||
Some(_) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
format!(
|
||||
"element.toml for part '{}' has a non-string `kind`",
|
||||
rel_path.display()
|
||||
@@ -630,7 +902,7 @@ fn load_descriptor(
|
||||
None => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
DiagCode::ManifestMalformed,
|
||||
format!(
|
||||
"element.toml for part '{}' is missing the required `kind` key",
|
||||
rel_path.display()
|
||||
@@ -1065,6 +1337,228 @@ 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::*;
|
||||
@@ -1076,4 +1570,20 @@ mod tests {
|
||||
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("导言簇/开场白")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Integration tests for `cph_model::load_bundle` (ADR-0030): an ordered
|
||||
//! arrangement of self-contained lessons, loaded from `bundle.toml`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::load_bundle;
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_bundle_loads_lessons_in_order_with_overrides() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-valid"));
|
||||
let bundle = bundle.expect("valid bundle fixture must produce a Bundle");
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"valid bundle fixture must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
assert_eq!(bundle.info.title, "测试合集");
|
||||
assert_eq!(
|
||||
bundle.info.authors,
|
||||
vec!["张老师".to_string(), "李老师".to_string()]
|
||||
);
|
||||
|
||||
assert_eq!(bundle.lessons.len(), 2);
|
||||
|
||||
// lesson-a: no explicit `target` in bundle.toml -> falls back to the
|
||||
// lesson's own first declared target ("student").
|
||||
assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a"));
|
||||
assert_eq!(bundle.lessons[0].target, "student");
|
||||
assert_eq!(bundle.lessons[0].lesson.info.title, "课时A");
|
||||
|
||||
// lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the
|
||||
// lesson's own single declared target (also "teacher" here, but the point
|
||||
// is the bundle entry's `target` wins regardless).
|
||||
assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b"));
|
||||
assert_eq!(bundle.lessons[1].target, "teacher");
|
||||
assert_eq!(bundle.lessons[1].lesson.info.title, "课时B");
|
||||
|
||||
// The bundle's own targets are collected exactly like a lesson's.
|
||||
assert_eq!(bundle.target_names(), vec!["merged"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson"));
|
||||
let bundle = bundle.expect("must still produce a best-effort Bundle");
|
||||
assert!(
|
||||
bundle.lessons.is_empty(),
|
||||
"the missing lesson is skipped, not placeholder'd"
|
||||
);
|
||||
|
||||
let missing: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::PartPathMissing)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing.len(),
|
||||
1,
|
||||
"exactly one PartPathMissing expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bundle_toml_is_a_hard_failure() {
|
||||
let (bundle, diags) = load_bundle(&fixture("bundle-malformed"));
|
||||
assert!(bundle.is_none(), "malformed bundle.toml is a hard failure");
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_bundle_toml_is_a_hard_failure() {
|
||||
let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all"));
|
||||
assert!(bundle.is_none());
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-both"
|
||||
name = "both"
|
||||
|
||||
[info]
|
||||
title = "文件夹既是容器又是叶子"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/broken"
|
||||
|
||||
[targets.student]
|
||||
+1
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
+1
@@ -0,0 +1 @@
|
||||
[[children]]
|
||||
@@ -0,0 +1,2 @@
|
||||
[info
|
||||
this is broken
|
||||
@@ -0,0 +1,5 @@
|
||||
[info]
|
||||
title = "缺失课时的合集"
|
||||
|
||||
[[lessons]]
|
||||
path = "does-not-exist"
|
||||
@@ -0,0 +1,16 @@
|
||||
[info]
|
||||
title = "测试合集"
|
||||
author = ["张老师", "李老师"]
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-a"
|
||||
|
||||
[[lessons]]
|
||||
path = "lesson-b"
|
||||
target = "teacher"
|
||||
|
||||
[targets.merged]
|
||||
artifact = { type = "single-file", filepath = "build/merged.pdf" }
|
||||
[[targets.merged.steps]]
|
||||
type = "typst-compile"
|
||||
template = "exports/merged.typ"
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-a"
|
||||
name = "lesson-a"
|
||||
|
||||
[info]
|
||||
title = "课时A"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
A.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "lesson-b"
|
||||
name = "lesson-b"
|
||||
|
||||
[info]
|
||||
title = "课时B"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/b"
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
B.
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-container-root-tables"
|
||||
name = "container-root-tables"
|
||||
|
||||
[info]
|
||||
title = "容器错误声明了根级表"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "section"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1,5 @@
|
||||
[project]
|
||||
id = "should-not-be-here"
|
||||
name = "should-not-be-here"
|
||||
|
||||
children = []
|
||||
@@ -5,7 +5,7 @@ name = "kind-mismatch"
|
||||
[info]
|
||||
title = "kind 不一致测试"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@ name = "missing-part"
|
||||
[info]
|
||||
title = "缺部件测试"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/does-not-exist"
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-neither"
|
||||
name = "neither"
|
||||
|
||||
[info]
|
||||
title = "文件夹既不是容器也不是叶子"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/broken"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
id = "fixture-nested"
|
||||
name = "nested"
|
||||
|
||||
[info]
|
||||
title = "嵌套结构测试"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/开场白"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "导言簇"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
开场白。
|
||||
@@ -0,0 +1,14 @@
|
||||
[group]
|
||||
title = "导言簇"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/子段一"
|
||||
|
||||
[[children]]
|
||||
kind = "section"
|
||||
path = "嵌套子节"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/子段二"
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
子段一。
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
子段二。
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1 @@
|
||||
子引理陈述。
|
||||
@@ -0,0 +1,3 @@
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/子引理"
|
||||
@@ -0,0 +1,15 @@
|
||||
[project]
|
||||
id = "fixture-root-group"
|
||||
name = "root-group"
|
||||
|
||||
[info]
|
||||
title = "根级 manifest 错误声明了 group"
|
||||
|
||||
[group]
|
||||
title = "不该在根级"
|
||||
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/a"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
a.
|
||||
+2
-2
@@ -6,11 +6,11 @@ name = "valid-2-part"
|
||||
title = "测试课:两个部件"
|
||||
author = "范式教育教研组"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/young"
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! Integration tests for `cph_model::load`, driven by static fixtures under
|
||||
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008
|
||||
//! on-disk format.
|
||||
//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0029
|
||||
//! on-disk format (a nested outline manifest; supersedes ADR-0008's flat
|
||||
//! `[[parts]]`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::load;
|
||||
use cph_model::{load, OutlineEntry};
|
||||
|
||||
/// Absolute path to a fixture engineering-file root.
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
@@ -39,6 +40,16 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
|
||||
assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
|
||||
|
||||
// The outline is a flat sequence of elements-by-index when there are no
|
||||
// containers.
|
||||
assert_eq!(
|
||||
lesson.outline,
|
||||
vec![
|
||||
OutlineEntry::Element { part_index: 0 },
|
||||
OutlineEntry::Element { part_index: 1 },
|
||||
]
|
||||
);
|
||||
|
||||
// `source` scalar survives on the lemma descriptor; `kind` is removed.
|
||||
let scalars = &lesson.parts[1].descriptor.scalars;
|
||||
assert_eq!(
|
||||
@@ -74,6 +85,58 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_sections_flatten_depth_first_with_correct_depths() {
|
||||
let (lesson, diags) = load(&fixture("nested"));
|
||||
let lesson = lesson.expect("nested fixture must produce a Lesson");
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"nested fixture must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
// DFS pre-order element sequence (ADR-0029): containers contribute no
|
||||
// element of their own.
|
||||
let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect();
|
||||
assert_eq!(
|
||||
paths,
|
||||
vec![
|
||||
PathBuf::from("segments/开场白"),
|
||||
PathBuf::from("导言簇/segments/子段一"),
|
||||
PathBuf::from("导言簇/嵌套子节/lemmas/子引理"),
|
||||
PathBuf::from("导言簇/segments/子段二"),
|
||||
],
|
||||
"root-relative paths must accumulate through every nesting level"
|
||||
);
|
||||
|
||||
// The outline interleaves section headings at their DFS-open position,
|
||||
// with depth 1 for a section directly under the root and depth 2 for one
|
||||
// nested inside another section.
|
||||
assert_eq!(
|
||||
lesson.outline,
|
||||
vec![
|
||||
OutlineEntry::Element { part_index: 0 }, // segments/开场白
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "导言簇".to_string(),
|
||||
depth: 1,
|
||||
path: PathBuf::from("导言簇"),
|
||||
},
|
||||
OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一
|
||||
OutlineEntry::Section {
|
||||
kind: "section".to_string(),
|
||||
title: "嵌套子节".to_string(),
|
||||
depth: 2,
|
||||
path: PathBuf::from("导言簇/嵌套子节"),
|
||||
},
|
||||
OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理
|
||||
OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二
|
||||
]
|
||||
);
|
||||
|
||||
// The outer section declares [group].title = "导言簇"; the inner section
|
||||
// has no [group] at all, so its title falls back to the folder basename.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_part_folder_yields_part_path_missing() {
|
||||
let (lesson, diags) = load(&fixture("missing-part"));
|
||||
@@ -126,7 +189,7 @@ fn malformed_manifest_is_a_hard_failure() {
|
||||
"malformed manifest must be a hard failure (None)"
|
||||
);
|
||||
assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected");
|
||||
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
assert_eq!(diags[0].severity, cph_diag::Severity::Error);
|
||||
}
|
||||
|
||||
@@ -136,7 +199,98 @@ fn missing_manifest_is_a_hard_failure() {
|
||||
let (lesson, diags) = load(&fixture("does-not-exist-at-all"));
|
||||
assert!(lesson.is_none());
|
||||
assert_eq!(diags.len(), 1);
|
||||
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
|
||||
assert_eq!(diags[0].code, DiagCode::ManifestMalformed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_with_both_manifest_and_element_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("both-manifest-and-element"));
|
||||
let lesson = lesson.expect("must still produce a best-effort Lesson");
|
||||
assert_eq!(
|
||||
lesson.parts.len(),
|
||||
1,
|
||||
"the ambiguous child is a placeholder"
|
||||
);
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one ManifestMalformed expected, got: {diags:?}"
|
||||
);
|
||||
assert!(
|
||||
malformed[0]
|
||||
.message
|
||||
.contains("both manifest.toml and element.toml"),
|
||||
"message should explain the ambiguity, got: {}",
|
||||
malformed[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_with_neither_manifest_nor_element_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("neither-manifest-nor-element"));
|
||||
let lesson = lesson.expect("must still produce a best-effort Lesson");
|
||||
assert_eq!(
|
||||
lesson.parts.len(),
|
||||
1,
|
||||
"the incomplete child is a placeholder"
|
||||
);
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one ManifestMalformed expected, got: {diags:?}"
|
||||
);
|
||||
assert!(
|
||||
malformed[0]
|
||||
.message
|
||||
.contains("neither manifest.toml nor element.toml"),
|
||||
"message should explain the gap, got: {}",
|
||||
malformed[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_declaring_root_only_tables_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("container-root-tables"));
|
||||
assert!(
|
||||
lesson.is_some(),
|
||||
"a container misplacing root tables is non-fatal"
|
||||
);
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one root-only-table diagnostic expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_manifest_declaring_group_is_manifest_malformed() {
|
||||
let (lesson, diags) = load(&fixture("root-group-declared"));
|
||||
assert!(lesson.is_some(), "the root declaring [group] is non-fatal");
|
||||
|
||||
let malformed: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
malformed.len(),
|
||||
1,
|
||||
"exactly one root-[group] diagnostic expected, got: {diags:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -290,7 +444,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir {
|
||||
let p = tmp.path();
|
||||
std::fs::write(
|
||||
p.join("manifest.toml"),
|
||||
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n",
|
||||
"[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let seg = p.join("segments").join("a");
|
||||
|
||||
Reference in New Issue
Block a user