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:
2026-06-22 01:27:32 +08:00
parent 8599f472c0
commit c810ea6137
30 changed files with 1212 additions and 0 deletions
+418
View File
@@ -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")));
}
}