forked from EduCraft/curriculum-project-hub
feat(checker): scaffold repo-root cargo workspace + cph-diag + cph-model (WU-1)
Stand up the Rust implementation of the rule-based checker (the thing that
"stands in Lean's position" at product runtime). Single repo-wide cargo
workspace at the repo root so future components (e.g. an exporter) can reuse
shared crates.
- workspace: 6 member crates under crates/; cph-schema/cph-typst/cph-check/
cph-cli are stubs for later WUs that compile clean today.
- cph-diag: shared diagnostic vocabulary. Severity { Warning, Error } mirrors
Spec.Courseware.Diagnostic.Severity exactly (two-valued, no info/note — a
contract decision documented in-code since there's no CI gate for alignment).
Closed DiagCode set with stable string forms; Diagnostic with optional span +
fix hint; Display renders `error[CODE]: msg / --> file:line:col / hint: …`.
- cph-model: the ADR-0008 loader. Reads manifest.toml ([project]/[info]/ordered
[[parts]]/[targets.*]) + each part's element.toml into an ordered Lesson
(mirrors Lean `Lesson = List (Element P)`). Structure-only: cross-checks
part.kind == element.toml kind, rejects `..` traversal; does NOT do schema
validation (WU-3), content-file existence (WU-3), or typst (WU-4).
`load(root) -> (Option<Lesson>, Vec<Diagnostic>)`: Some even on collectible
part errors, None only on hard manifest failure.
- 13 tests (7 cph-diag, 6 cph-model incl. static fixtures doubling as format
docs). build + test + clippy -D warnings + fmt --check all green.
Judgment call flagged for review: cph-diag has no ManifestMalformed code, so
manifest-structure errors map to SchemaViolation (TODO noted in-code).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "cph-model"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cph-diag = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
@@ -0,0 +1,418 @@
|
||||
//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson.
|
||||
//!
|
||||
//! This crate is the **loader**, not the full checker. It reads
|
||||
//! `<root>/manifest.toml` (project / info / ordered `[[parts]]` / declared
|
||||
//! `[targets.*]`) and each part's `<root>/<path>/element.toml`, and produces an
|
||||
//! ordered [`Lesson`] — mirroring the Lean master's `Lesson = List (Element P)`
|
||||
//! (`spec/Spec/Courseware/Lesson.lean`), where the order of `parts` carries
|
||||
//! teaching semantics.
|
||||
//!
|
||||
//! Scope boundaries (deliberately staying in lane):
|
||||
//! - It validates **structure** only: manifest shape, element.toml shape, and
|
||||
//! the cross-check `part.kind == element.toml kind`.
|
||||
//! - It does **not** validate instance data against a kind's JSON Schema (that
|
||||
//! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files
|
||||
//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4).
|
||||
//!
|
||||
//! Entry point: [`load`].
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use cph_diag::{DiagCode, Diagnostic};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An ordered, in-memory lesson loaded from an engineering file.
|
||||
///
|
||||
/// Mirrors the Lean master's `Lesson = List (Element P)`: `parts` is an ordered
|
||||
/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest
|
||||
/// is declarative" — the `[[parts]]` array order is the single source of truth).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Lesson {
|
||||
/// `[project]` from the manifest (id, name).
|
||||
pub project: Project,
|
||||
/// `[info]` from the manifest (title, optional author).
|
||||
pub info: Info,
|
||||
/// The ordered parts — the lesson's element sequence.
|
||||
pub parts: Vec<Part>,
|
||||
/// Declared export target names, e.g. `["student", "teacher"]`, collected
|
||||
/// from the `[targets.<name>]` tables.
|
||||
pub targets: Vec<String>,
|
||||
/// Engineering-file root (absolute), for resolving part paths.
|
||||
pub root: PathBuf,
|
||||
}
|
||||
|
||||
/// `[project]` table.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Project {
|
||||
/// Stable project id.
|
||||
pub id: String,
|
||||
/// Folder / display name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// `[info]` table (passed through to render targets verbatim).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct Info {
|
||||
/// Lesson title.
|
||||
pub title: String,
|
||||
/// Author, optional.
|
||||
pub author: Option<String>,
|
||||
}
|
||||
|
||||
/// One `[[parts]]` entry plus its loaded element descriptor.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct Part {
|
||||
/// Declared kind from the manifest (one of the known kinds; ADR-0006).
|
||||
pub kind: String,
|
||||
/// Element folder path **as written in the manifest** (relative to root),
|
||||
/// kept verbatim for diagnostics / display.
|
||||
pub path: PathBuf,
|
||||
/// The element's self-description loaded from its `element.toml`.
|
||||
pub descriptor: ElementDescriptor,
|
||||
}
|
||||
|
||||
/// An element's `element.toml`, parsed into kind + remaining scalar fields.
|
||||
///
|
||||
/// ADR-0008: `kind` is explicit in the descriptor (not inferred from the parent
|
||||
/// directory), so a folder is self-describing.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct ElementDescriptor {
|
||||
/// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is
|
||||
/// reported as a diagnostic (see [`load`]).
|
||||
pub kind: String,
|
||||
/// Element folder, absolute path.
|
||||
pub dir: PathBuf,
|
||||
/// The remaining `element.toml` keys (the scalar fields). Schema validation
|
||||
/// of these is WU-3's job, not this loader's.
|
||||
pub scalars: toml::Table,
|
||||
}
|
||||
|
||||
// --- raw deserialization shapes (mirror the on-disk TOML) ---------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawManifest {
|
||||
project: Option<RawProject>,
|
||||
info: Option<RawInfo>,
|
||||
#[serde(default)]
|
||||
parts: Vec<RawPart>,
|
||||
#[serde(default)]
|
||||
targets: toml::Table,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawProject {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawInfo {
|
||||
title: String,
|
||||
author: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RawPart {
|
||||
kind: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// Load the engineering file at `root` into a [`Lesson`].
|
||||
///
|
||||
/// Returns `(Option<Lesson>, Vec<Diagnostic>)`:
|
||||
/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The
|
||||
/// structure is produced even when individual parts have problems, so the
|
||||
/// WU-5 orchestrator gets **both** the partial lesson and the collected
|
||||
/// diagnostics.
|
||||
/// - `None` only on a hard failure where no `Lesson` can be built: the
|
||||
/// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the
|
||||
/// required `[project]` / `[info]` tables. In that case the diagnostics
|
||||
/// describe the hard failure.
|
||||
///
|
||||
/// Collected (non-fatal) diagnostics include: a part path that does not exist
|
||||
/// or escapes the root via `..`, a missing/malformed `element.toml`, and a
|
||||
/// `part.kind != element.toml kind` mismatch.
|
||||
///
|
||||
/// ## Diagnostic-code mapping (judgment call, WU-1)
|
||||
///
|
||||
/// `cph-diag`'s code set is closed and has **no** dedicated "manifest
|
||||
/// malformed" code. We deliberately do not invent one here. Until such a code
|
||||
/// is added, manifest-level structural errors (bad TOML, missing `[project]` /
|
||||
/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`],
|
||||
/// with a message making the real cause clear. A genuinely missing **part
|
||||
/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing /
|
||||
/// unreadable / malformed `element.toml` also maps to `SchemaViolation`.
|
||||
// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so
|
||||
// manifest-structure errors don't overload `SchemaViolation`.
|
||||
pub fn load(root: &Path) -> (Option<Lesson>, Vec<Diagnostic>) {
|
||||
let mut diags = Vec::new();
|
||||
|
||||
let manifest_path = root.join("manifest.toml");
|
||||
|
||||
let manifest_src = match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!("cannot read manifest.toml: {e}"),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"expected an engineering-file manifest at {}",
|
||||
manifest_path.display()
|
||||
)),
|
||||
);
|
||||
return (None, diags);
|
||||
}
|
||||
};
|
||||
|
||||
let raw: RawManifest = match toml::from_str(&manifest_src) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!("manifest.toml is not valid TOML: {e}"),
|
||||
)
|
||||
.with_hint("fix the TOML syntax in manifest.toml"),
|
||||
);
|
||||
return (None, diags);
|
||||
}
|
||||
};
|
||||
|
||||
// [project] and [info] are required to build a Lesson at all.
|
||||
let project = match raw.project {
|
||||
Some(p) => Project {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
},
|
||||
None => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
"manifest.toml is missing the required [project] table",
|
||||
)
|
||||
.with_hint("add a [project] table with `id` and `name`"),
|
||||
);
|
||||
return (None, diags);
|
||||
}
|
||||
};
|
||||
|
||||
let info = match raw.info {
|
||||
Some(i) => Info {
|
||||
title: i.title,
|
||||
author: i.author,
|
||||
},
|
||||
None => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
"manifest.toml is missing the required [info] table",
|
||||
)
|
||||
.with_hint("add an [info] table with at least `title`"),
|
||||
);
|
||||
return (None, diags);
|
||||
}
|
||||
};
|
||||
|
||||
// Target names are just the keys of the [targets.*] table; order is not
|
||||
// semantically meaningful, but we preserve the TOML document order.
|
||||
let targets: Vec<String> = raw.targets.keys().cloned().collect();
|
||||
|
||||
// Load each part. Problems are collected, not fatal: we still build the
|
||||
// Part (with a best-effort descriptor) so order/membership is observable.
|
||||
let mut parts = Vec::with_capacity(raw.parts.len());
|
||||
for raw_part in raw.parts {
|
||||
let rel_path = PathBuf::from(&raw_part.path);
|
||||
|
||||
// Reject `..` traversal: a part path must stay within the root.
|
||||
if has_parent_traversal(&rel_path) {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::PartPathMissing,
|
||||
format!(
|
||||
"part path '{}' escapes the engineering-file root via '..'",
|
||||
raw_part.path
|
||||
),
|
||||
)
|
||||
.with_hint("part paths must be relative folders inside the engineering file"),
|
||||
);
|
||||
// Still record the part with an empty descriptor so order is kept.
|
||||
parts.push(Part {
|
||||
kind: raw_part.kind.clone(),
|
||||
path: rel_path.clone(),
|
||||
descriptor: ElementDescriptor {
|
||||
kind: raw_part.kind,
|
||||
dir: root.join(&rel_path),
|
||||
scalars: toml::Table::new(),
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
let dir = root.join(&rel_path);
|
||||
|
||||
let descriptor = if !dir.is_dir() {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::PartPathMissing,
|
||||
format!("part folder '{}' does not exist", raw_part.path),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"create the folder '{}' or fix the `path` in manifest.toml",
|
||||
raw_part.path
|
||||
)),
|
||||
);
|
||||
ElementDescriptor {
|
||||
kind: raw_part.kind.clone(),
|
||||
dir,
|
||||
scalars: toml::Table::new(),
|
||||
}
|
||||
} else {
|
||||
load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags)
|
||||
};
|
||||
|
||||
parts.push(Part {
|
||||
kind: raw_part.kind,
|
||||
path: rel_path,
|
||||
descriptor,
|
||||
});
|
||||
}
|
||||
|
||||
let lesson = Lesson {
|
||||
project,
|
||||
info,
|
||||
parts,
|
||||
targets,
|
||||
root: root.to_path_buf(),
|
||||
};
|
||||
(Some(lesson), diags)
|
||||
}
|
||||
|
||||
/// Load one element's `element.toml` into an [`ElementDescriptor`], collecting
|
||||
/// diagnostics. On a missing/malformed `element.toml` the descriptor falls back
|
||||
/// to the part's declared kind with empty scalars so the lesson stays buildable.
|
||||
fn load_descriptor(
|
||||
dir: &Path,
|
||||
rel_path: &Path,
|
||||
part_kind: &str,
|
||||
diags: &mut Vec<Diagnostic>,
|
||||
) -> ElementDescriptor {
|
||||
let element_toml = dir.join("element.toml");
|
||||
let rel_element_toml = rel_path.join("element.toml");
|
||||
|
||||
let src = match std::fs::read_to_string(&element_toml) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"cannot read element.toml for part '{}': {e}",
|
||||
rel_path.display()
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"add an element.toml at {} with at least `kind = \"{part_kind}\"`",
|
||||
rel_element_toml.display()
|
||||
)),
|
||||
);
|
||||
return ElementDescriptor {
|
||||
kind: part_kind.to_string(),
|
||||
dir: dir.to_path_buf(),
|
||||
scalars: toml::Table::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut table: toml::Table = match toml::from_str(&src) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"element.toml for part '{}' is not valid TOML: {e}",
|
||||
rel_path.display()
|
||||
),
|
||||
)
|
||||
.with_hint("fix the TOML syntax in element.toml"),
|
||||
);
|
||||
return ElementDescriptor {
|
||||
kind: part_kind.to_string(),
|
||||
dir: dir.to_path_buf(),
|
||||
scalars: toml::Table::new(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// `kind` is required and must be a string. Pull it out of the scalar map so
|
||||
// `scalars` ends up being "the remaining keys".
|
||||
let kind = match table.remove("kind") {
|
||||
Some(toml::Value::String(k)) => k,
|
||||
Some(_) => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"element.toml for part '{}' has a non-string `kind`",
|
||||
rel_path.display()
|
||||
),
|
||||
)
|
||||
.with_hint("`kind` must be a string, e.g. `kind = \"lemma\"`"),
|
||||
);
|
||||
part_kind.to_string()
|
||||
}
|
||||
None => {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"element.toml for part '{}' is missing the required `kind` key",
|
||||
rel_path.display()
|
||||
),
|
||||
)
|
||||
.with_hint(format!("add `kind = \"{part_kind}\"` to element.toml")),
|
||||
);
|
||||
part_kind.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// Cross-check: the descriptor's kind must equal the part's declared kind.
|
||||
if kind != part_kind {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::UnknownKind,
|
||||
format!(
|
||||
"kind mismatch for part '{}': manifest says '{part_kind}', element.toml says '{kind}'",
|
||||
rel_path.display()
|
||||
),
|
||||
)
|
||||
.with_hint("make the manifest `kind` and the element.toml `kind` agree"),
|
||||
);
|
||||
}
|
||||
|
||||
ElementDescriptor {
|
||||
kind,
|
||||
dir: dir.to_path_buf(),
|
||||
scalars: table,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `path` contains any `..` component (parent-dir traversal).
|
||||
fn has_parent_traversal(path: &Path) -> bool {
|
||||
path.components().any(|c| matches!(c, Component::ParentDir))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_detection() {
|
||||
assert!(has_parent_traversal(Path::new("../x")));
|
||||
assert!(has_parent_traversal(Path::new("a/../b")));
|
||||
assert!(!has_parent_traversal(Path::new("a/b/c")));
|
||||
assert!(!has_parent_traversal(Path::new("segments/intro")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
id = "fixture-kind-mismatch"
|
||||
name = "kind-mismatch"
|
||||
|
||||
[info]
|
||||
title = "kind 不一致测试"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1 @@
|
||||
这是一段测试。
|
||||
@@ -0,0 +1,3 @@
|
||||
[project
|
||||
id = "broken"
|
||||
this is not valid toml = =
|
||||
@@ -0,0 +1,16 @@
|
||||
[project]
|
||||
id = "fixture-missing-part"
|
||||
name = "missing-part"
|
||||
|
||||
[info]
|
||||
title = "缺部件测试"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/does-not-exist"
|
||||
|
||||
[targets.student]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
这是一段测试。
|
||||
@@ -0,0 +1,2 @@
|
||||
kind = "lemma"
|
||||
source = "测试引理"
|
||||
@@ -0,0 +1 @@
|
||||
这是一段测试。
|
||||
@@ -0,0 +1 @@
|
||||
这是一段测试。
|
||||
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
id = "fixture-valid"
|
||||
name = "valid-2-part"
|
||||
|
||||
[info]
|
||||
title = "测试课:两个部件"
|
||||
author = "范式教育教研组"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/young"
|
||||
|
||||
[targets.student]
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1 @@
|
||||
这是一段测试。
|
||||
@@ -0,0 +1,125 @@
|
||||
//! 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.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::load;
|
||||
|
||||
/// Absolute path to a fixture engineering-file root.
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_two_part_lesson_loads_in_order_with_no_errors() {
|
||||
let (lesson, diags) = load(&fixture("valid"));
|
||||
|
||||
let lesson = lesson.expect("valid fixture must produce a Lesson");
|
||||
assert!(
|
||||
diags.is_empty(),
|
||||
"valid fixture must have no diagnostics, got: {diags:?}"
|
||||
);
|
||||
|
||||
assert_eq!(lesson.project.id, "fixture-valid");
|
||||
assert_eq!(lesson.project.name, "valid-2-part");
|
||||
assert_eq!(lesson.info.title, "测试课:两个部件");
|
||||
assert_eq!(lesson.info.author.as_deref(), Some("范式教育教研组"));
|
||||
|
||||
// Parts preserve declared order: segment first, lemma second.
|
||||
assert_eq!(lesson.parts.len(), 2);
|
||||
assert_eq!(lesson.parts[0].kind, "segment");
|
||||
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
|
||||
assert_eq!(lesson.parts[0].descriptor.kind, "segment");
|
||||
assert_eq!(lesson.parts[1].kind, "lemma");
|
||||
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
|
||||
assert_eq!(lesson.parts[1].descriptor.kind, "lemma");
|
||||
|
||||
// `source` scalar survives on the lemma descriptor; `kind` is removed.
|
||||
let scalars = &lesson.parts[1].descriptor.scalars;
|
||||
assert_eq!(
|
||||
scalars.get("source").and_then(|v| v.as_str()),
|
||||
Some("测试引理")
|
||||
);
|
||||
assert!(scalars.get("kind").is_none());
|
||||
|
||||
// Targets are collected from [targets.*].
|
||||
let mut targets = lesson.targets.clone();
|
||||
targets.sort();
|
||||
assert_eq!(targets, vec!["student".to_string(), "teacher".to_string()]);
|
||||
|
||||
// Descriptor dir is absolute, anchored under the root.
|
||||
assert!(lesson.parts[0].descriptor.dir.is_absolute());
|
||||
assert_eq!(
|
||||
lesson.parts[0].descriptor.dir,
|
||||
lesson.root.join("segments/intro")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_part_folder_yields_part_path_missing() {
|
||||
let (lesson, diags) = load(&fixture("missing-part"));
|
||||
|
||||
// Still produces a Lesson (loader stays best-effort).
|
||||
let lesson = lesson.expect("missing-part fixture must still produce a Lesson");
|
||||
assert_eq!(lesson.parts.len(), 2, "both parts are recorded for order");
|
||||
|
||||
let missing: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::PartPathMissing)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing.len(),
|
||||
1,
|
||||
"exactly one PartPathMissing expected, got diags: {diags:?}"
|
||||
);
|
||||
assert_eq!(missing[0].severity, cph_diag::Severity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_mismatch_yields_unknown_kind_diagnostic() {
|
||||
let (lesson, diags) = load(&fixture("kind-mismatch"));
|
||||
|
||||
let lesson = lesson.expect("kind-mismatch fixture must still produce a Lesson");
|
||||
assert_eq!(lesson.parts.len(), 1);
|
||||
|
||||
let mismatch: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.code == DiagCode::UnknownKind)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mismatch.len(),
|
||||
1,
|
||||
"exactly one kind-mismatch diagnostic expected, got: {diags:?}"
|
||||
);
|
||||
assert!(
|
||||
mismatch[0].message.contains("segment") && mismatch[0].message.contains("lemma"),
|
||||
"message should name both kinds, got: {}",
|
||||
mismatch[0].message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_manifest_is_a_hard_failure() {
|
||||
let (lesson, diags) = load(&fixture("malformed-manifest"));
|
||||
|
||||
assert!(
|
||||
lesson.is_none(),
|
||||
"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].severity, cph_diag::Severity::Error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_manifest_is_a_hard_failure() {
|
||||
// A directory with no manifest.toml at all.
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user