//! `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, /// 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, /// Declared export targets, collected from the root manifest's /// `[targets.]` tables (ADR-0009/0011). pub targets: Vec, /// 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, /// 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, /// Ordered root children, preserving nested sections. pub children: Vec, } /// 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, /// Nested children; empty for an element leaf. pub children: Vec, } 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, 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/.pdf"`, so an /// almost-empty `[targets.student]` still produces a usable build. /// - A target with **no `steps`** defaults to a single /// [`Step::TypstCompile`] with `template = "exports/.typ"` (the /// framework's stock per-target template). /// - A target with **no `covers`** key leaves [`TargetConfig::covers`] `None`, /// meaning "coverage not declared". The consumer (`cph-check`) resolves that /// to the full known-kind universe — see the field doc. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TargetConfig { /// Target name, e.g. `"student"` (the `[targets.]` table key). pub name: String, /// The artifact this target produces. Defaults to a [`Artifact::SingleFile`] /// at `build/.pdf` when the `artifact` field is absent. pub artifact: Artifact, /// The ordered build steps. Defaults to a single [`Step::TypstCompile`] /// with template `exports/.typ` when no `[[steps]]` are given. pub steps: Vec, /// The **render-coverage declaration**: which element kinds this target /// renders. Realizes ADR-0011's "render /// coverage is a declaration, not a payload": the declaration keeps *which /// kinds a target renders* (used by the `renderIgnored` seed diagnostic), /// while the rendering "how" lives in the template/steps. /// /// `Some(kinds)` is the explicit manifest list (`covers = ["segment", …]`). /// `None` means the `covers` key was **absent**; the loader does not own the /// kind universe (it must not depend on `cph-schema`), so it records the /// absence and lets `cph-check` default it to all known kinds. An empty /// `Some(vec![])` is distinct: a target that explicitly covers nothing. pub covers: Option>, } /// The artifact an export target produces (ADR-0009/0011). /// /// **Pinned by ADR-0011** as an ADT with fields: /// /// ```text /// Artifact = singleFile (filepath) | fileTree (root, outputs) /// ``` /// /// ADR-0011 pinned the artifact as an ADT **with fields**: "what the product /// is" — one file at some path vs. a tree of files under some root matching a /// glob — is the non-obvious domain semantics that must be in the type, not /// erased behind a bare tag. `SingleFile` is one bundled document (a 讲义 / 教案 /// PDF); `FileTree` is a tree of files (a third-party-platform archive). The /// on-disk `artifact` inline table's `type` discriminator selects the variant: /// `"single-file"` → [`Artifact::SingleFile`], `"file-tree"` → /// [`Artifact::FileTree`]. /// /// As with `cph-diag`'s `Severity`, there is no CI gate enforcing ADR↔ /// implementation alignment (see the repo constitution) — it is maintained by /// review, which is why the decision is documented here. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum Artifact { /// One bundled document landing at `filepath` (relative to the engineering /// root). ADR-0011 `singleFile`. The default artifact shape. SingleFile { /// Where the single product is written (relative to the engineering /// root), e.g. `build/student.pdf`. filepath: PathBuf, }, /// A set of files under `root` matching the `outputs` glob. ADR-0011 /// `fileTree`. FileTree { /// The output directory (relative to the engineering root). root: PathBuf, /// A **glob** describing which files this build produces, e.g. /// `**/*.{html,js,json}` — kept as a string (the contract pins the /// field, not a path/glob algebra). outputs: String, }, } impl Artifact { /// The default artifact when `artifact` is omitted: a single file at /// `build/.pdf`. fn default_for(name: &str) -> Artifact { Artifact::SingleFile { filepath: PathBuf::from(format!("build/{name}.pdf")), } } } /// One typed build step (ADR-0011). /// /// **Pinned by ADR-0011** as an ADT: /// /// ```text /// Step = typstCompile (template) | shell (run) | assembleMarkdown (field) /// ``` /// /// A step is a *typed* operation (extensible): `TypstCompile` compiles a /// template file — typed, not a raw shell line, because the framework must wire /// the manifest into it (`--input manifest=…`), which a bare `typst compile` /// string cannot express. `Shell` is the escape hatch for steps that resist /// declaration (ADR-0005's medium-only category (b): HTML interactive / npm /// builds). `AssembleMarkdown` concatenates a per-element markdown content /// field into a single-file markdown deliverable (ADR-0015: the slides outline /// and 逐字稿 transcript surfaces, authored directly in markdown+KaTeX to /// sidestep the typst→md conversion). The on-disk `[[steps]]` entry's `type` /// discriminator selects the variant: `"typst-compile"` → /// [`Step::TypstCompile`], `"shell"` → [`Step::Shell`], `"assemble-markdown"` /// → [`Step::AssembleMarkdown`]. /// /// As with [`Artifact`], this alignment is maintained by review, not CI. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum Step { /// Compile a template file (relative to the engineering root) into the /// artifact; the framework injects the manifest. ADR-0011 /// `typstCompile`. TypstCompile { /// The template file to compile as main, e.g. `exports/student.typ`. template: PathBuf, }, /// Run a shell command — the escape hatch. ADR-0011 `shell`. Shell { /// The command line to run. run: String, }, /// Assemble a single-file markdown deliverable by concatenating each /// element's `field` markdown content file in `parts` order. ADR-0011 /// `assembleMarkdown` (ADR-0015). Not a typst build — the /// framework owns the read/concatenate/write itself. AssembleMarkdown { /// The per-element markdown content field to assemble (e.g. `slides`, /// `transcript`). field: String, }, } impl Step { /// The default step when no `[[steps]]` are given: compile the stock /// per-target template `exports/.typ`. fn default_for(name: &str) -> Step { Step::TypstCompile { template: PathBuf::from(format!("exports/{name}.typ")), } } } /// `[project]` table (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, } /// 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, /// 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, info: Option, #[serde(default)] targets: toml::Table, group: Option, #[serde(default)] children: Vec, } #[derive(Debug, Deserialize)] struct RawProject { id: String, name: String, } /// The authoring-surface `[info]`: the raw form that exists for /// fill-in convenience, normalized into the canonical [`Info`] at the load /// boundary. Not the form the rest of the model traffics in. #[derive(Debug, Deserialize)] struct RawInfo { title: String, author: Option, } /// On-disk `author`: either a single name (`author = "…"`) or a list /// (`author = ["…", "…"]`). A fill-in convenience whose /// string-or-array union lives **only** at the load boundary — [`RawAuthor::into_vec`] /// folds it into the canonical [`Info::authors`] `Vec`, after which it never appears. #[derive(Debug, Deserialize)] #[serde(untagged)] enum RawAuthor { One(String), Many(Vec), } impl RawAuthor { /// Flatten to the ordered author list: a single /// name becomes a one-element list; a list passes through verbatim. fn into_vec(self) -> Vec { match self { RawAuthor::One(s) => vec![s], RawAuthor::Many(v) => v, } } } /// 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, } /// 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, } /// Load the engineering file at `root` into a [`Lesson`]. /// /// Returns `(Option, Vec)`: /// - `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, Vec) { let mut diags = Vec::new(); let manifest_path = root.join("manifest.toml"); let manifest_src = match std::fs::read_to_string(&manifest_path) { Ok(s) => s, Err(e) => { diags.push( Diagnostic::error( DiagCode::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.] table into a structured build config // (ADR-0009/0011). Order is the TOML document order, as before. Malformed // target config is non-fatal: it is reported and the target is kept with // defaults so loading continues. let targets: Vec = raw .targets .into_iter() .map(|(name, value)| parse_target(name, value, &mut diags)) .collect(); // 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, next_section_depth: u32, diags: &mut Vec, parts: &mut Vec, outline: &mut Vec, ) { 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) -> Option { 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, outline: &mut Vec, kind: String, path: PathBuf, root: &Path, notes: Option, 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) { let path = root.join(".cph-version"); let Ok(src) = std::fs::read_to_string(&path) else { return; // missing `.cph-version` — skipped (ADR-0016 OPEN). }; let file_version = src.trim(); if file_version.is_empty() { diags.push( Diagnostic::error( DiagCode::CphVersionMismatch, format!(".cph-version is empty; expected cph version {CPH_VERSION}"), ) .with_hint(format!( "write the cph version this file targets into {} (currently {CPH_VERSION})", path.display() )), ); return; } if !versions_compatible(file_version, CPH_VERSION) { diags.push( Diagnostic::error( DiagCode::CphVersionMismatch, format!( ".cph-version declares {file_version}, but this cph is {CPH_VERSION} (incompatible)" ), ) .with_hint(format!( "align them: set .cph-version to {CPH_VERSION}, or install cph {file_version}" )), ); } } /// Whether an engineering file's declared cph version is compatible with the /// running CLI's version (ADR-0016). **The rule is exact equality** — the /// strictest choice, deliberately, while the format is young (0.0.x). This is /// the single seam for future relaxation to a semver range: relaxing the /// format does not change the diagnostic class, the spec, or the CLI — only /// this predicate. fn versions_compatible(file_version: &str, cli_version: &str) -> bool { file_version == cli_version } /// Load one element's `element.toml` into an [`ElementDescriptor`], collecting /// diagnostics. On a missing/malformed `element.toml` the descriptor falls back /// to the part's declared kind with empty scalars so the lesson stays buildable. fn load_descriptor( dir: &Path, rel_path: &Path, part_kind: &str, diags: &mut Vec, ) -> ElementDescriptor { let element_toml = dir.join("element.toml"); let rel_element_toml = rel_path.join("element.toml"); let src = match std::fs::read_to_string(&element_toml) { Ok(s) => s, Err(e) => { diags.push( Diagnostic::error( DiagCode::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.]` table into a [`TargetConfig`] (ADR-0009/0011), /// collecting [`DiagCode::SchemaViolation`] diagnostics for malformed fields. /// /// Malformed config is **non-fatal**: a bad/absent `artifact` falls back to the /// default ([`Artifact::default_for`]); a bad/absent `steps` falls back to the /// default single `typst-compile` step ([`Step::default_for`]) — all reported, /// never dropping the target. An empty `[targets.]` body is valid (all /// defaults, no diagnostics). fn parse_target(name: String, value: toml::Value, diags: &mut Vec) -> TargetConfig { // A target body must be a table; anything else (`student = 1`) is malformed. let mut table = match value { toml::Value::Table(t) => t, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' must be a table, e.g. `[targets.{name}]`"), ) .with_hint("declare a target as a `[targets.]` table"), ); return TargetConfig { artifact: Artifact::default_for(&name), steps: vec![Step::default_for(&name)], covers: None, name, }; } }; let artifact = match table.remove("artifact") { None => Artifact::default_for(&name), Some(value) => parse_artifact(&name, value, diags), }; let steps = match table.remove("steps") { None => vec![Step::default_for(&name)], Some(value) => parse_steps(&name, value, diags), }; let covers = match table.remove("covers") { None => None, Some(value) => parse_covers(&name, value, diags), }; TargetConfig { name, artifact, steps, covers, } } /// Parse a target's optional `covers` array into the render-coverage declaration /// (ADR-0011). Each entry is a kind name (a string). A non-array `covers`, or any /// non-string entry, is reported as a [`DiagCode::SchemaViolation`]; in that case /// the declaration falls back to `None` ("not declared"), so `cph-check` defaults /// it to the full known-kind universe rather than silently covering nothing. /// /// A well-formed but empty `covers = []` yields `Some(vec![])`: a target that /// explicitly renders no kind (every used kind then draws a `renderIgnored` /// warning) — distinct from an absent key. fn parse_covers( name: &str, value: toml::Value, diags: &mut Vec, ) -> Option> { let items = match value { toml::Value::Array(items) => items, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a non-array `covers`; declare it as a list of \ kind names, e.g. `covers = [\"segment\", \"example\"]`" ), ) .with_hint("set `covers` to an array of kind-name strings, or omit it"), ); return None; } }; let mut kinds = Vec::with_capacity(items.len()); for item in items { match item { toml::Value::String(s) => kinds.push(s), other => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a non-string entry in `covers`: {}", json_like_type(&other) ), ) .with_hint("each `covers` entry is a kind name (a string)"), ); return None; } } } Some(kinds) } /// A short type label for a TOML value, for the `covers` non-string diagnostic. fn json_like_type(v: &toml::Value) -> &'static str { match v { toml::Value::String(_) => "string", toml::Value::Integer(_) => "integer", toml::Value::Float(_) => "float", toml::Value::Boolean(_) => "boolean", toml::Value::Datetime(_) => "datetime", toml::Value::Array(_) => "array", toml::Value::Table(_) => "table", } } /// Parse the `artifact` inline table into an [`Artifact`]. The `type` /// discriminator selects the variant and which fields are read. An unknown or /// malformed `type`, a non-table value, or a missing required field is reported /// as a [`DiagCode::SchemaViolation`]; in every error case a usable default /// ([`Artifact::default_for`]) is returned so loading continues. fn parse_artifact(name: &str, value: toml::Value, diags: &mut Vec) -> Artifact { let mut table = match value { toml::Value::Table(t) => t, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a non-table `artifact`; it must be an \ inline table like `{{ type = \"single-file\", filepath = \"…\" }}`" ), ) .with_hint("set `artifact` to an inline table with a `type` discriminator"), ); return Artifact::default_for(name); } }; let kind = match table.remove("type") { Some(toml::Value::String(s)) => s, Some(_) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' has a non-string `artifact.type`"), ) .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), ); return Artifact::default_for(name); } None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' has an `artifact` with no `type` discriminator"), ) .with_hint("add `type = \"single-file\"` (or `\"file-tree\"`) to `artifact`"), ); return Artifact::default_for(name); } }; match kind.as_str() { "single-file" => { let filepath = match take_string_field(&mut table, "filepath") { Some(s) => PathBuf::from(s), None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' single-file `artifact` is missing a string \ `filepath`" ), ) .with_hint("add `filepath = \"build/.pdf\"` to the artifact"), ); return Artifact::default_for(name); } }; Artifact::SingleFile { filepath } } "file-tree" => { let root = take_string_field(&mut table, "root"); let outputs = take_string_field(&mut table, "outputs"); match (root, outputs) { (Some(root), Some(outputs)) => Artifact::FileTree { root: PathBuf::from(root), outputs, }, (root, outputs) => { let mut missing = Vec::new(); if root.is_none() { missing.push("root"); } if outputs.is_none() { missing.push("outputs"); } diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' file-tree `artifact` is missing required \ string field(s): {}", missing.join(", ") ), ) .with_hint( "a file-tree artifact needs `root = \"\"` and \ `outputs = \"\"`", ), ); Artifact::default_for(name) } } } other => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has an invalid `artifact.type` '{other}'; \ valid values are \"single-file\", \"file-tree\"" ), ) .with_hint("set `artifact.type` to \"single-file\" or \"file-tree\""), ); Artifact::default_for(name) } } } /// Parse the `steps` array-of-tables into an ordered `Vec`, preserving /// declared order. A non-array `steps`, or any individual step with an unknown /// or malformed `type` / missing required field, is reported as a /// [`DiagCode::SchemaViolation`]; malformed individual steps are skipped while /// well-formed ones are kept. If `steps` is present but yields no usable steps, /// the default step ([`Step::default_for`]) is substituted so the build stays /// runnable. fn parse_steps(name: &str, value: toml::Value, diags: &mut Vec) -> Vec { let items = match value { toml::Value::Array(items) => items, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a non-array `steps`; declare steps as \ `[[targets.{name}.steps]]` entries" ), ) .with_hint("each step is a `[[targets..steps]]` table with a `type`"), ); return vec![Step::default_for(name)]; } }; let mut steps = Vec::with_capacity(items.len()); for item in items { if let Some(step) = parse_step(name, item, diags) { steps.push(step); } } if steps.is_empty() { // `steps` was present but produced nothing usable: keep the build // runnable with the default step (the malformed entries were reported). vec![Step::default_for(name)] } else { steps } } /// Parse one `[[steps]]` entry into a [`Step`]. The `type` discriminator selects /// the variant. Returns `None` (after reporting a [`DiagCode::SchemaViolation`]) /// for a non-table entry, an unknown/malformed `type`, or a missing required /// field. fn parse_step(name: &str, value: toml::Value, diags: &mut Vec) -> Option { let mut table = match value { toml::Value::Table(t) => t, _ => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' has a step that is not a table"), ) .with_hint("each step is a `[[targets..steps]]` table with a `type`"), ); return None; } }; let kind = match table.remove("type") { Some(toml::Value::String(s)) => s, Some(_) => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' has a step with a non-string `type`"), ) .with_hint( "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"", ), ); return None; } None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' has a step with no `type` discriminator"), ) .with_hint( "add `type = \"typst-compile\"` (or `\"shell\"`, `\"assemble-markdown\"`) to the step", ), ); return None; } }; match kind.as_str() { "typst-compile" => match take_string_field(&mut table, "template") { Some(template) => Some(Step::TypstCompile { template: PathBuf::from(template), }), None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' typst-compile step is missing a string `template`" ), ) .with_hint("add `template = \"exports/.typ\"` to the step"), ); None } }, "shell" => match take_string_field(&mut table, "run") { Some(run) => Some(Step::Shell { run }), None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!("target '{name}' shell step is missing a string `run`"), ) .with_hint("add `run = \"\"` to the step"), ); None } }, "assemble-markdown" => match take_string_field(&mut table, "field") { Some(field) => Some(Step::AssembleMarkdown { field }), None => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' assemble-markdown step is missing a string `field`" ), ) .with_hint("add `field = \"slides\"` (or `\"transcript\"`) to the step"), ); None } }, other => { diags.push( Diagnostic::error( DiagCode::SchemaViolation, format!( "target '{name}' has a step with an invalid `type` '{other}'; \ valid values are \"typst-compile\", \"shell\", \"assemble-markdown\"" ), ) .with_hint( "set the step `type` to \"typst-compile\", \"shell\", or \"assemble-markdown\"", ), ); None } } } /// Remove `key` from `table` and return it if it is a string; otherwise `None` /// (a missing key and a non-string value are both treated as absent — the /// caller reports the missing-field diagnostic with the right context). fn take_string_field(table: &mut toml::Table, key: &str) -> Option { match table.remove(key) { Some(toml::Value::String(s)) => Some(s), _ => None, } } /// True if `path` contains any `..` component (parent-dir traversal). fn has_parent_traversal(path: &Path) -> bool { path.components().any(|c| matches!(c, Component::ParentDir)) } // --- 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, /// Declared export targets, collected from `bundle.toml`'s /// `[targets.]` tables (ADR-0009/0011) — reusing the exact same /// build/artifact/step shape a lesson's targets use. pub targets: Vec, /// 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 `/`. pub lesson: Lesson, } #[derive(Debug, Deserialize)] struct RawBundleManifest { info: Option, #[serde(default)] targets: toml::Table, #[serde(default)] lessons: Vec, } #[derive(Debug, Deserialize)] struct RawBundleLesson { path: String, target: Option, } /// Load the bundle at `root` (a directory containing `bundle.toml`) into a /// [`Bundle`]. /// /// Returns `(Option, Vec)`, 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, Vec) { 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 = 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("导言簇/开场白") ); } }