From c810ea613766c43d910e4b3f863a387c7b4a0ad3 Mon Sep 17 00:00:00 2001 From: sjfhsjfh Date: Mon, 22 Jun 2026 01:27:32 +0800 Subject: [PATCH] feat(checker): scaffold repo-root cargo workspace + cph-diag + cph-model (WU-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, Vec)`: 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) --- .gitignore | 5 + Cargo.lock | 187 ++++++++ Cargo.toml | 23 + crates/README.md | 34 ++ crates/cph-check/Cargo.toml | 7 + crates/cph-check/src/lib.rs | 8 + crates/cph-cli/Cargo.toml | 11 + crates/cph-cli/src/main.rs | 10 + crates/cph-diag/Cargo.toml | 8 + crates/cph-diag/src/lib.rs | 275 ++++++++++++ crates/cph-model/Cargo.toml | 10 + crates/cph-model/src/lib.rs | 418 ++++++++++++++++++ .../fixtures/kind-mismatch/manifest.toml | 12 + .../kind-mismatch/segments/intro/element.toml | 1 + .../kind-mismatch/segments/intro/textbook.typ | 1 + .../fixtures/malformed-manifest/manifest.toml | 3 + .../tests/fixtures/missing-part/manifest.toml | 16 + .../missing-part/segments/intro/element.toml | 1 + .../missing-part/segments/intro/textbook.typ | 1 + .../fixtures/valid/lemmas/young/element.toml | 2 + .../fixtures/valid/lemmas/young/proof.typ | 1 + .../fixtures/valid/lemmas/young/stmt.typ | 1 + .../tests/fixtures/valid/manifest.toml | 18 + .../valid/segments/intro/element.toml | 1 + .../valid/segments/intro/textbook.typ | 1 + crates/cph-model/tests/load.rs | 125 ++++++ crates/cph-schema/Cargo.toml | 7 + crates/cph-schema/src/lib.rs | 9 + crates/cph-typst/Cargo.toml | 7 + crates/cph-typst/src/lib.rs | 9 + 30 files changed, 1212 insertions(+) create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 crates/README.md create mode 100644 crates/cph-check/Cargo.toml create mode 100644 crates/cph-check/src/lib.rs create mode 100644 crates/cph-cli/Cargo.toml create mode 100644 crates/cph-cli/src/main.rs create mode 100644 crates/cph-diag/Cargo.toml create mode 100644 crates/cph-diag/src/lib.rs create mode 100644 crates/cph-model/Cargo.toml create mode 100644 crates/cph-model/src/lib.rs create mode 100644 crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml create mode 100644 crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/missing-part/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml create mode 100644 crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml create mode 100644 crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ create mode 100644 crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ create mode 100644 crates/cph-model/tests/fixtures/valid/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/valid/segments/intro/element.toml create mode 100644 crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ create mode 100644 crates/cph-model/tests/load.rs create mode 100644 crates/cph-schema/Cargo.toml create mode 100644 crates/cph-schema/src/lib.rs create mode 100644 crates/cph-typst/Cargo.toml create mode 100644 crates/cph-typst/src/lib.rs diff --git a/.gitignore b/.gitignore index ec17f5f..3acbb49 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,10 @@ .lake/ **/.lake/ +# Rust / Cargo build artifacts (repo-wide cargo workspace at root) +/target +**/*.pdf +.cph/ + # OS / editor .DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b21cc36 --- /dev/null +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ca2868d --- /dev/null +++ b/Cargo.toml @@ -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" } diff --git a/crates/README.md b/crates/README.md new file mode 100644 index 0000000..11eb86a --- /dev/null +++ b/crates/README.md @@ -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 +``` diff --git a/crates/cph-check/Cargo.toml b/crates/cph-check/Cargo.toml new file mode 100644 index 0000000..cb1c011 --- /dev/null +++ b/crates/cph-check/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-check" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs new file mode 100644 index 0000000..947c084 --- /dev/null +++ b/crates/cph-check/src/lib.rs @@ -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. diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml new file mode 100644 index 0000000..58ecd0a --- /dev/null +++ b/crates/cph-cli/Cargo.toml @@ -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] diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs new file mode 100644 index 0000000..67255fa --- /dev/null +++ b/crates/cph-cli/src/main.rs @@ -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"); +} diff --git a/crates/cph-diag/Cargo.toml b/crates/cph-diag/Cargo.toml new file mode 100644 index 0000000..fe44e86 --- /dev/null +++ b/crates/cph-diag/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cph-diag" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } diff --git a/crates/cph-diag/src/lib.rs b/crates/cph-diag/src/lib.rs new file mode 100644 index 0000000..c6fabd7 --- /dev/null +++ b/crates/cph-diag/src/lib.rs @@ -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, + /// 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, + /// Optional fix hint — the product's core value. + pub hint: Option, +} + +impl Diagnostic { + /// Create an `error`-severity diagnostic. + pub fn error(code: DiagCode, message: impl Into) -> 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) -> 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) -> 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]: `, 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'" + ); + } +} diff --git a/crates/cph-model/Cargo.toml b/crates/cph-model/Cargo.toml new file mode 100644 index 0000000..6953601 --- /dev/null +++ b/crates/cph-model/Cargo.toml @@ -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 } diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs new file mode 100644 index 0000000..13263ce --- /dev/null +++ b/crates/cph-model/src/lib.rs @@ -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 +//! `/manifest.toml` (project / info / ordered `[[parts]]` / declared +//! `[targets.*]`) and each part's `//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, + /// Declared export target names, e.g. `["student", "teacher"]`, collected + /// from the `[targets.]` tables. + pub targets: Vec, + /// 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, +} + +/// 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, + info: Option, + #[serde(default)] + parts: Vec, + #[serde(default)] + targets: toml::Table, +} + +#[derive(Debug, Deserialize)] +struct RawProject { + id: String, + name: String, +} + +#[derive(Debug, Deserialize)] +struct RawInfo { + title: String, + author: Option, +} + +#[derive(Debug, Deserialize)] +struct RawPart { + kind: String, + path: String, +} + +/// Load the engineering file at `root` into a [`Lesson`]. +/// +/// Returns `(Option, Vec)`: +/// - `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, Vec) { + let mut diags = Vec::new(); + + let manifest_path = root.join("manifest.toml"); + + let manifest_src = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::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 = 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, +) -> 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"))); + } +} diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml new file mode 100644 index 0000000..fedd3aa --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-kind-mismatch" +name = "kind-mismatch" + +[info] +title = "kind 不一致测试" + +[[parts]] +kind = "segment" +path = "segments/intro" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/kind-mismatch/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml b/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml new file mode 100644 index 0000000..4f1b473 --- /dev/null +++ b/crates/cph-model/tests/fixtures/malformed-manifest/manifest.toml @@ -0,0 +1,3 @@ +[project +id = "broken" +this is not valid toml = = diff --git a/crates/cph-model/tests/fixtures/missing-part/manifest.toml b/crates/cph-model/tests/fixtures/missing-part/manifest.toml new file mode 100644 index 0000000..d54f585 --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/manifest.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] diff --git a/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml b/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/missing-part/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml b/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml new file mode 100644 index 0000000..7dcea61 --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/element.toml @@ -0,0 +1,2 @@ +kind = "lemma" +source = "测试引理" diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ b/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/proof.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ b/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/lemmas/young/stmt.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml new file mode 100644 index 0000000..227556b --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -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] diff --git a/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml b/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/segments/intro/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ b/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ new file mode 100644 index 0000000..bdd69ac --- /dev/null +++ b/crates/cph-model/tests/fixtures/valid/segments/intro/textbook.typ @@ -0,0 +1 @@ +这是一段测试。 diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs new file mode 100644 index 0000000..3ab446b --- /dev/null +++ b/crates/cph-model/tests/load.rs @@ -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); +} diff --git a/crates/cph-schema/Cargo.toml b/crates/cph-schema/Cargo.toml new file mode 100644 index 0000000..b8c32aa --- /dev/null +++ b/crates/cph-schema/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-schema" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/cph-schema/src/lib.rs b/crates/cph-schema/src/lib.rs new file mode 100644 index 0000000..c27fd1f --- /dev/null +++ b/crates/cph-schema/src/lib.rs @@ -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. diff --git a/crates/cph-typst/Cargo.toml b/crates/cph-typst/Cargo.toml new file mode 100644 index 0000000..0b53ffa --- /dev/null +++ b/crates/cph-typst/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "cph-typst" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs new file mode 100644 index 0000000..db52dcf --- /dev/null +++ b/crates/cph-typst/src/lib.rs @@ -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.