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
+5
View File
@@ -2,5 +2,10 @@
.lake/
**/.lake/
# Rust / Cargo build artifacts (repo-wide cargo workspace at root)
/target
**/*.pdf
.cph/
# OS / editor
.DS_Store
Generated
+187
View File
@@ -0,0 +1,187 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "cph-check"
version = "0.1.0"
[[package]]
name = "cph-cli"
version = "0.1.0"
[[package]]
name = "cph-diag"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "cph-model"
version = "0.1.0"
dependencies = [
"cph-diag",
"serde",
"toml",
]
[[package]]
name = "cph-schema"
version = "0.1.0"
[[package]]
name = "cph-typst"
version = "0.1.0"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "memchr"
version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
"toml_datetime",
"toml_edit",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
+23
View File
@@ -0,0 +1,23 @@
[workspace]
resolver = "2"
members = [
"crates/cph-diag",
"crates/cph-model",
"crates/cph-schema",
"crates/cph-typst",
"crates/cph-check",
"crates/cph-cli",
]
# Shared metadata for all workspace crates. Individual crates inherit these via
# `version.workspace = true` / `edition.workspace = true`.
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
toml = "0.8"
cph-diag = { path = "crates/cph-diag" }
cph-model = { path = "crates/cph-model" }
+34
View File
@@ -0,0 +1,34 @@
# crates/
These crates implement the rule-based lesson checker that aligns to the
semantic master in `spec/`: it reads an engineering-file (one lesson, ADR-0005)
laid out per ADR-0008 (declarative `manifest.toml` + per-element
`element.toml`), validates structure and content, and emits diagnostics.
`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader),
and `cph-typst` (the typst `World` / compile / span-mapping layer) are
deliberately reusable by future components such as an `exporter`, which is why
they live in this repo-wide `crates/` directory rather than under any single
component; `cph-schema` (kind JSON Schemas + validation), `cph-check`
(orchestration + render-coverage) and `cph-cli` (the `cph` command-line
entrypoint) are the checker proper.
## Crate map
| crate | owner | role |
|---------------|-------|------|
| `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable |
| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable |
| `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation |
| `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable |
| `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline |
| `cph-cli` | WU-5 | the `cph` command-line entrypoint |
## Build
```sh
# from the repository root (the cargo workspace root)
cargo build # all crates
cargo test # cph-diag + cph-model unit/integration tests
cargo clippy --all-targets -- -D warnings
cargo fmt --check
```
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cph-check"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+8
View File
@@ -0,0 +1,8 @@
//! `cph-check` — check-pipeline orchestration.
//!
//! Owned by **WU-5**. Ties together `cph-model` (load), `cph-schema`
//! (validate), and `cph-typst` (compile), runs the render-coverage rule
//! (ADR-0005's "missing render ⇒ warning"), and collects all diagnostics.
// WU-5: implement the orchestrator + render-coverage rule. Empty for now so the
// workspace compiles.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "cph-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "cph"
path = "src/main.rs"
[dependencies]
+10
View File
@@ -0,0 +1,10 @@
//! `cph` — the command-line entrypoint for the checker.
//!
//! Owned by **WU-5**. Will parse args, run the `cph-check` pipeline against an
//! engineering file, and print diagnostics. A stub for now so the workspace
//! builds and produces a runnable binary.
// WU-5: wire up arg parsing + the check pipeline + diagnostic rendering.
fn main() {
println!("cph: not yet implemented");
}
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "cph-diag"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
+275
View File
@@ -0,0 +1,275 @@
//! `cph-diag` — the shared diagnostic vocabulary for the checker.
//!
//! Every other crate in the workspace depends on these types to report
//! problems. The vocabulary is intentionally small and stable: a [`Severity`]
//! (mirroring the Lean master), a closed set of machine-stable [`DiagCode`]s, an
//! optional [`SourceSpan`] pointing back at the offending source, and a
//! [`Diagnostic`] tying them together with a human message and a fix hint.
//!
//! All public types derive `Serialize` (downstream prints diagnostics as JSON),
//! plus `Debug`, `Clone`, `PartialEq`, `Eq`.
use std::fmt;
use std::ops::Range;
use std::path::PathBuf;
use serde::Serialize;
/// Severity of a diagnostic.
///
/// **Mirrors `Spec.Courseware.Diagnostic.Severity`** in the Lean semantic
/// master (`spec/Spec/Courseware/Diagnostic.lean`), whose definition is
/// exactly:
///
/// ```text
/// inductive Severity where
/// | warning
/// | error
/// ```
///
/// This two-valued shape is a **contract decision**, not an accident: the Lean
/// module pins `Severity` to exactly `warning | error` and states the finer
/// levels (`info` / `hint` / `note`) are deliberately undecided. We therefore
/// do **not** add an info/note level here. `error` blocks (the artifact is
/// invalid); `warning` does not block (the artifact still exports, but with
/// loss / an ignored element — e.g. ADR-0005's "missing render ⇒ warning").
///
/// There is no CI gate enforcing this alignment (see the repo constitution);
/// it is maintained by review, which is why this correspondence is documented
/// here rather than only in the spec.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Severity {
/// Non-blocking: the artifact still exports, but is lossy / has an ignored
/// element. Mirrors Lean `Severity.warning`.
Warning,
/// Blocking: the artifact is invalid. Mirrors Lean `Severity.error`.
Error,
}
/// A location in a source file within the engineering-file tree.
///
/// `file` is relative to the engineering-file root so spans are portable and
/// diffable. `line` / `col` are 1-based (as editors display them); `byte_range`
/// is a 0-based byte offset range into the file.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SourceSpan {
/// Path to the file, relative to the engineering-file root.
pub file: PathBuf,
/// Byte offset range into the file (0-based).
pub byte_range: Range<usize>,
/// 1-based line number.
pub line: u32,
/// 1-based column number.
pub col: u32,
}
/// Stable, machine-readable diagnostic codes.
///
/// This set is **closed**: codes are part of the checker's public contract, so
/// downstream tooling can match on the string form (see [`DiagCode::as_str`]).
/// Do not invent codes outside this enum without a deliberate decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiagCode {
/// A `[[parts]]` entry references a folder/path that does not exist.
PartPathMissing,
/// An element declares a `kind` that is not a known kind.
UnknownKind,
/// A required `content` `.typ` file (per the kind schema) is missing.
MissingContentFile,
/// A reference (e.g. a relative import or cross-element link) does not
/// resolve.
DanglingReference,
/// Instance data does not conform to its kind's JSON Schema.
SchemaViolation,
/// A typst source failed to compile.
TypstCompile,
/// An element is ignored under a render target (ADR-0005: warning).
RenderIgnored,
}
impl DiagCode {
/// The stable string form of this code, e.g. `"E-PART-PATH"`.
///
/// The `E-` / `W-` prefix reflects the *typical* severity of the code but
/// is not authoritative — the actual severity lives on the [`Diagnostic`].
pub fn as_str(&self) -> &'static str {
match self {
DiagCode::PartPathMissing => "E-PART-PATH",
DiagCode::UnknownKind => "E-UNKNOWN-KIND",
DiagCode::MissingContentFile => "E-MISSING-CONTENT",
DiagCode::DanglingReference => "E-DANGLING-REF",
DiagCode::SchemaViolation => "E-SCHEMA",
DiagCode::TypstCompile => "E-TYPST-COMPILE",
DiagCode::RenderIgnored => "W-RENDER-IGNORED",
}
}
}
impl fmt::Display for DiagCode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// A single diagnostic emitted by the checker.
///
/// Construct via [`Diagnostic::error`] / [`Diagnostic::warning`] and attach an
/// optional [`SourceSpan`] / hint with the builder methods
/// [`Diagnostic::with_span`] / [`Diagnostic::with_hint`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Diagnostic {
/// How serious this diagnostic is.
pub severity: Severity,
/// The stable machine code.
pub code: DiagCode,
/// Human-readable message.
pub message: String,
/// Optional location this diagnostic points at.
pub span: Option<SourceSpan>,
/// Optional fix hint — the product's core value.
pub hint: Option<String>,
}
impl Diagnostic {
/// Create an `error`-severity diagnostic.
pub fn error(code: DiagCode, message: impl Into<String>) -> Self {
Diagnostic {
severity: Severity::Error,
code,
message: message.into(),
span: None,
hint: None,
}
}
/// Create a `warning`-severity diagnostic.
pub fn warning(code: DiagCode, message: impl Into<String>) -> Self {
Diagnostic {
severity: Severity::Warning,
code,
message: message.into(),
span: None,
hint: None,
}
}
/// Attach a source span (builder-style).
pub fn with_span(mut self, span: SourceSpan) -> Self {
self.span = Some(span);
self
}
/// Attach a fix hint (builder-style).
pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
self.hint = Some(hint.into());
self
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Severity::Warning => "warning",
Severity::Error => "error",
})
}
}
impl fmt::Display for Diagnostic {
/// Renders one line `error[E-PART-PATH]: <msg>`, plus an indented
/// `--> file:line:col` line when a span is present, and a `hint: ...` line
/// when a hint is present.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}[{}]: {}", self.severity, self.code, self.message)?;
if let Some(span) = &self.span {
write!(
f,
"\n --> {}:{}:{}",
span.file.display(),
span.line,
span.col
)?;
}
if let Some(hint) = &self.hint {
write!(f, "\n hint: {hint}")?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn code_as_str_is_stable() {
assert_eq!(DiagCode::PartPathMissing.as_str(), "E-PART-PATH");
assert_eq!(DiagCode::RenderIgnored.as_str(), "W-RENDER-IGNORED");
assert_eq!(DiagCode::SchemaViolation.as_str(), "E-SCHEMA");
// Display matches as_str.
assert_eq!(DiagCode::UnknownKind.to_string(), "E-UNKNOWN-KIND");
}
#[test]
fn error_constructor() {
let d = Diagnostic::error(DiagCode::UnknownKind, "no such kind 'frob'");
assert_eq!(d.severity, Severity::Error);
assert_eq!(d.code, DiagCode::UnknownKind);
assert_eq!(d.message, "no such kind 'frob'");
assert!(d.span.is_none());
assert!(d.hint.is_none());
}
#[test]
fn warning_constructor() {
let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
assert_eq!(d.severity, Severity::Warning);
assert_eq!(d.code, DiagCode::RenderIgnored);
}
#[test]
fn builders_attach_span_and_hint() {
let span = SourceSpan {
file: PathBuf::from("segments/intro/textbook.typ"),
byte_range: 10..20,
line: 3,
col: 5,
};
let d = Diagnostic::error(DiagCode::TypstCompile, "unclosed bracket")
.with_span(span.clone())
.with_hint("close the '[' opened on line 2");
assert_eq!(d.span, Some(span));
assert_eq!(d.hint.as_deref(), Some("close the '[' opened on line 2"));
}
#[test]
fn display_bare() {
let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found");
assert_eq!(d.to_string(), "error[E-PART-PATH]: folder not found");
}
#[test]
fn display_with_span_and_hint() {
let d = Diagnostic::error(DiagCode::PartPathMissing, "folder not found")
.with_span(SourceSpan {
file: PathBuf::from("manifest.toml"),
byte_range: 0..0,
line: 12,
col: 3,
})
.with_hint("create the folder 'segments/intro' or fix the path");
assert_eq!(
d.to_string(),
"error[E-PART-PATH]: folder not found\n --> manifest.toml:12:3\n hint: create the folder 'segments/intro' or fix the path"
);
}
#[test]
fn display_warning() {
let d = Diagnostic::warning(DiagCode::RenderIgnored, "ignored under 'teacher'");
assert_eq!(
d.to_string(),
"warning[W-RENDER-IGNORED]: ignored under 'teacher'"
);
}
}
+10
View File
@@ -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 }
+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")));
}
}
@@ -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 @@
这是一段测试。
+18
View File
@@ -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 @@
这是一段测试。
+125
View File
@@ -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);
}
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cph-schema"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+9
View File
@@ -0,0 +1,9 @@
//! `cph-schema` — kind JSON Schemas (ADR-0006) + structural validation.
//!
//! Owned by **WU-3**. The schema layer reads each kind's declarative JSON
//! Schema (built-in scalars plus the `content` extension type) and validates
//! element instance data against it, including which `content` `.typ` files
//! must exist.
// WU-3: implement the 4 stdlib kind schemas (segment / example / lemma / sop)
// and the validation entry point. Empty for now so the workspace compiles.
+7
View File
@@ -0,0 +1,7 @@
[package]
name = "cph-typst"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
+9
View File
@@ -0,0 +1,9 @@
//! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping.
//!
//! Owned by **WU-4**. Builds the typst compiler `World` over the engineering
//! file's virtual file system (ADR-0006/0007), generates the driver entrypoint
//! from the manifest, compiles to PDF, and maps typst spans back to source
//! locations for diagnostics.
// WU-4: implement the World + driver-gen + compile + span mapping. Empty for
// now so the workspace compiles.