forked from bai/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:
@@ -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
@@ -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
@@ -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" }
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cph-check"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
@@ -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");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
[package]
|
||||
name = "cph-diag"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
@@ -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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cph-schema"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cph-typst"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user