forked from EduCraft/curriculum-project-hub
feat(checker): cph-schema — 4 kind JSON Schemas + validation (WU-3)
Declarative JSON Schema per kind (segment/example/lemma/sop), embedded as real .json artifacts (ADR-0006) and validated by a small hand-rolled interpreter (properties/required/type/enum/additionalProperties + the `x-cph-content` marker) — no jsonschema-crate churn for a one-optional-string scalar surface. `x-cph-content: true` marks content fields (not in element.toml; require sibling <field>.typ); everything else is an element.toml scalar. `validate(desc)` is self-contained: UnknownKind for unknown kinds, MissingContentFile (path named in message) for absent required .typ, SchemaViolation for bad scalars; lemma proof optional ⇒ no diagnostic when absent. 8 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,3 +5,10 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cph-diag = { workspace = true }
|
||||
cph-model = { workspace = true }
|
||||
serde_json = "1"
|
||||
toml = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
toml = { workspace = true }
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "cph:kind/example",
|
||||
"title": "example",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"problem": { "type": "string", "x-cph-content": true },
|
||||
"solution": { "type": "string", "x-cph-content": true },
|
||||
"source": { "type": "string" }
|
||||
},
|
||||
"required": ["problem", "solution"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "cph:kind/lemma",
|
||||
"title": "lemma",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stmt": { "type": "string", "x-cph-content": true },
|
||||
"proof": { "type": "string", "x-cph-content": true }
|
||||
},
|
||||
"required": ["stmt"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "cph:kind/segment",
|
||||
"title": "segment",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"textbook": { "type": "string", "x-cph-content": true }
|
||||
},
|
||||
"required": ["textbook"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "cph:kind/sop",
|
||||
"title": "sop",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sop": { "type": "string", "x-cph-content": true }
|
||||
},
|
||||
"required": ["sop"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -4,6 +4,496 @@
|
||||
//! Schema (built-in scalars plus the `content` extension type) and validates
|
||||
//! element instance data against it, including which `content` `.typ` files
|
||||
//! must exist.
|
||||
//!
|
||||
//! # The `content` extension convention
|
||||
//!
|
||||
//! ADR-0006 fixes that a kind's data schema is a declarative **JSON Schema**
|
||||
//! (draft 2020-12 here) with one extension type — `content` — whose value is a
|
||||
//! Typst source rather than inline data. JSON Schema has no native `content`
|
||||
//! type, so this crate marks a content field with a **custom keyword**:
|
||||
//!
|
||||
//! ```json
|
||||
//! { "type": "string", "x-cph-content": true }
|
||||
//! ```
|
||||
//!
|
||||
//! The `x-cph-content: true` marker is the single convention this crate honors.
|
||||
//! A field carrying it is treated as a **content field**: it does NOT appear in
|
||||
//! `element.toml`; instead the validator checks that the sibling file
|
||||
//! `<field>.typ` exists in the element's directory (ADR-0008). A required
|
||||
//! content field whose `.typ` file is missing yields a
|
||||
//! [`cph_diag::DiagCode::MissingContentFile`]; an *optional* content field
|
||||
//! (one not in the schema's `required` list, e.g. a lemma's `proof`) may be
|
||||
//! absent with no diagnostic. All other (non-`x-cph-content`) properties are
|
||||
//! **scalar** fields that live in `element.toml` and are validated structurally.
|
||||
//!
|
||||
//! The four stdlib kind schemas live as real JSON artifacts under
|
||||
//! `crates/cph-schema/schemas/{segment,example,lemma,sop}.json` and are
|
||||
//! compiled in via `include_str!`, parsed once on first use.
|
||||
//!
|
||||
//! # Validation approach
|
||||
//!
|
||||
//! The MVP scalar surface is intentionally tiny (a single optional string on
|
||||
//! `example`), so rather than pull in a full JSON Schema engine (and its
|
||||
//! version churn), this crate uses `serde_json` to parse the schemas and a
|
||||
//! small hand-rolled interpreter that understands exactly the keywords the
|
||||
//! stdlib schemas use: `properties`, `required`, `type`, `enum`,
|
||||
//! `additionalProperties`, plus the `x-cph-content` extension marker. This
|
||||
//! keeps the schemas as declarative artifacts (ADR-0006 intent) while keeping
|
||||
//! the validator dependency-light and fully under our control.
|
||||
|
||||
// WU-3: implement the 4 stdlib kind schemas (segment / example / lemma / sop)
|
||||
// and the validation entry point. Empty for now so the workspace compiles.
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use cph_diag::{DiagCode, Diagnostic};
|
||||
use cph_model::ElementDescriptor;
|
||||
use serde_json::Value as Json;
|
||||
|
||||
/// The custom JSON Schema keyword marking a field as a `content` (Typst-source)
|
||||
/// field rather than a scalar. See the crate docs for the convention.
|
||||
const CONTENT_MARKER: &str = "x-cph-content";
|
||||
|
||||
/// The known stdlib kinds, in a stable order.
|
||||
const KNOWN_KINDS: &[&str] = &["segment", "example", "lemma", "sop"];
|
||||
|
||||
/// Raw schema JSON for each kind, embedded at compile time.
|
||||
const SCHEMA_SOURCES: &[(&str, &str)] = &[
|
||||
("segment", include_str!("../schemas/segment.json")),
|
||||
("example", include_str!("../schemas/example.json")),
|
||||
("lemma", include_str!("../schemas/lemma.json")),
|
||||
("sop", include_str!("../schemas/sop.json")),
|
||||
];
|
||||
|
||||
/// The scalar `type` a property declares (the subset the stdlib schemas use).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ScalarType {
|
||||
String,
|
||||
Number,
|
||||
Integer,
|
||||
Boolean,
|
||||
}
|
||||
|
||||
impl ScalarType {
|
||||
fn from_json(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"string" => Some(ScalarType::String),
|
||||
"number" => Some(ScalarType::Number),
|
||||
"integer" => Some(ScalarType::Integer),
|
||||
"boolean" => Some(ScalarType::Boolean),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ScalarType::String => "string",
|
||||
ScalarType::Number => "number",
|
||||
ScalarType::Integer => "integer",
|
||||
ScalarType::Boolean => "boolean",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `value` (a scalar coming from `element.toml`, already converted
|
||||
/// to JSON) satisfies this declared type.
|
||||
fn matches(self, value: &Json) -> bool {
|
||||
match self {
|
||||
ScalarType::String => value.is_string(),
|
||||
ScalarType::Number => value.is_number(),
|
||||
ScalarType::Integer => value.is_i64() || value.is_u64(),
|
||||
ScalarType::Boolean => value.is_boolean(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One scalar property of a kind (a field that lives in `element.toml`).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ScalarField {
|
||||
name: String,
|
||||
/// Declared `type`, if any. `None` means "unconstrained" (we still accept
|
||||
/// the field but apply no type check).
|
||||
ty: Option<ScalarType>,
|
||||
/// Allowed values from a JSON Schema `enum`, as JSON values.
|
||||
allowed: Option<Vec<Json>>,
|
||||
required: bool,
|
||||
}
|
||||
|
||||
/// One content property of a kind (a field whose value is a sibling `.typ`
|
||||
/// file, marked with `x-cph-content`).
|
||||
#[derive(Debug, Clone)]
|
||||
struct ContentField {
|
||||
name: String,
|
||||
required: bool,
|
||||
}
|
||||
|
||||
/// A parsed kind schema: the content fields (which drive `.typ` existence
|
||||
/// checks) and the scalar fields (validated against `element.toml`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KindSchema {
|
||||
kind: String,
|
||||
content_fields: Vec<ContentField>,
|
||||
scalar_fields: Vec<ScalarField>,
|
||||
/// Whether the schema forbids properties beyond those declared
|
||||
/// (`additionalProperties: false`).
|
||||
deny_additional: bool,
|
||||
}
|
||||
|
||||
impl KindSchema {
|
||||
/// The kind name this schema describes (e.g. `"example"`).
|
||||
pub fn kind(&self) -> &str {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// The names of the `content` fields (those requiring a `<field>.typ`
|
||||
/// file), in schema order.
|
||||
pub fn content_field_names(&self) -> Vec<&str> {
|
||||
self.content_fields
|
||||
.iter()
|
||||
.map(|f| f.name.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The names of the scalar fields (those living in `element.toml`), in
|
||||
/// schema order.
|
||||
pub fn scalar_field_names(&self) -> Vec<&str> {
|
||||
self.scalar_fields.iter().map(|f| f.name.as_str()).collect()
|
||||
}
|
||||
|
||||
/// Parse a kind schema from its raw JSON source. Panics on a malformed
|
||||
/// schema — schemas are compile-time artifacts owned by this crate, so a
|
||||
/// bad one is a build-time bug, not a runtime condition.
|
||||
fn parse(kind: &str, src: &str) -> Self {
|
||||
let root: Json = serde_json::from_str(src)
|
||||
.unwrap_or_else(|e| panic!("kind schema for '{kind}' is not valid JSON: {e}"));
|
||||
|
||||
let deny_additional = root
|
||||
.get("additionalProperties")
|
||||
.and_then(Json::as_bool)
|
||||
.map(|b| !b)
|
||||
.unwrap_or(false);
|
||||
|
||||
let required: Vec<String> = root
|
||||
.get("required")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_string))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let is_required = |name: &str| required.iter().any(|r| r == name);
|
||||
|
||||
let mut content_fields = Vec::new();
|
||||
let mut scalar_fields = Vec::new();
|
||||
|
||||
let props = root
|
||||
.get("properties")
|
||||
.and_then(Json::as_object)
|
||||
.unwrap_or_else(|| panic!("kind schema for '{kind}' has no `properties` object"));
|
||||
|
||||
for (name, prop) in props {
|
||||
let is_content = prop
|
||||
.get(CONTENT_MARKER)
|
||||
.and_then(Json::as_bool)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_content {
|
||||
content_fields.push(ContentField {
|
||||
name: name.clone(),
|
||||
required: is_required(name),
|
||||
});
|
||||
} else {
|
||||
let ty = prop
|
||||
.get("type")
|
||||
.and_then(Json::as_str)
|
||||
.and_then(ScalarType::from_json);
|
||||
let allowed = prop
|
||||
.get("enum")
|
||||
.and_then(Json::as_array)
|
||||
.map(|a| a.to_vec());
|
||||
scalar_fields.push(ScalarField {
|
||||
name: name.clone(),
|
||||
ty,
|
||||
allowed,
|
||||
required: is_required(name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
KindSchema {
|
||||
kind: kind.to_string(),
|
||||
content_fields,
|
||||
scalar_fields,
|
||||
deny_additional,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The lazily-built registry mapping kind name → parsed schema.
|
||||
fn registry() -> &'static BTreeMap<&'static str, KindSchema> {
|
||||
static REGISTRY: OnceLock<BTreeMap<&'static str, KindSchema>> = OnceLock::new();
|
||||
REGISTRY.get_or_init(|| {
|
||||
SCHEMA_SOURCES
|
||||
.iter()
|
||||
.map(|(kind, src)| (*kind, KindSchema::parse(kind, src)))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up the parsed [`KindSchema`] for `kind`, or `None` if it is unknown.
|
||||
pub fn schema_for(kind: &str) -> Option<&'static KindSchema> {
|
||||
registry().get(kind)
|
||||
}
|
||||
|
||||
/// The known stdlib kind names (`["segment", "example", "lemma", "sop"]`),
|
||||
/// for use by the orchestrator (`cph-check`).
|
||||
pub fn known_kinds() -> &'static [&'static str] {
|
||||
KNOWN_KINDS
|
||||
}
|
||||
|
||||
/// Validate an element instance against its kind's schema.
|
||||
///
|
||||
/// Self-contained: looks up the schema by `desc.kind` and, if the kind is
|
||||
/// unknown, returns a single [`cph_diag::DiagCode::UnknownKind`] diagnostic.
|
||||
/// Otherwise it checks, in order:
|
||||
///
|
||||
/// 1. **Scalar fields** (`desc.scalars`, a `toml::Table`) against the
|
||||
/// non-content part of the schema — required-ness, declared `type`, and
|
||||
/// `enum`. Violations are [`cph_diag::DiagCode::SchemaViolation`]. Under an
|
||||
/// `additionalProperties: false` schema, an unexpected scalar key is also a
|
||||
/// `SchemaViolation`.
|
||||
/// 2. **Content fields**: for each required content field, that the sibling
|
||||
/// file `desc.dir.join("<field>.typ")` exists; if not, a
|
||||
/// [`cph_diag::DiagCode::MissingContentFile`]. Optional content fields may be
|
||||
/// absent with no diagnostic.
|
||||
///
|
||||
/// Every diagnostic carries a fix `hint`. `MissingContentFile` names the
|
||||
/// expected path in its message; `span` is left `None` (there is no line/col
|
||||
/// for a file that does not exist).
|
||||
pub fn validate(desc: &ElementDescriptor) -> Vec<Diagnostic> {
|
||||
let Some(schema) = schema_for(&desc.kind) else {
|
||||
return vec![Diagnostic::error(
|
||||
DiagCode::UnknownKind,
|
||||
format!("unknown element kind '{}'", desc.kind),
|
||||
)
|
||||
.with_hint(format!("valid kinds are: {}", KNOWN_KINDS.join(", ")))];
|
||||
};
|
||||
|
||||
let mut diags = Vec::new();
|
||||
validate_scalars(schema, desc, &mut diags);
|
||||
validate_content_files(schema, desc, &mut diags);
|
||||
diags
|
||||
}
|
||||
|
||||
/// Validate the scalar fields in `desc.scalars` against the schema's scalar
|
||||
/// part.
|
||||
fn validate_scalars(schema: &KindSchema, desc: &ElementDescriptor, diags: &mut Vec<Diagnostic>) {
|
||||
let scalars = &desc.scalars;
|
||||
|
||||
// Required scalar fields must be present.
|
||||
for field in &schema.scalar_fields {
|
||||
if field.required && !scalars.contains_key(&field.name) {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"element of kind '{}' is missing required field '{}'",
|
||||
schema.kind, field.name
|
||||
),
|
||||
)
|
||||
.with_hint(format!("add `{} = ...` to element.toml", field.name)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Each present scalar: type / enum checks, and (if known) reject unexpected
|
||||
// keys when additionalProperties is false. Content fields must never appear
|
||||
// in element.toml.
|
||||
let content_names: Vec<&str> = schema
|
||||
.content_fields
|
||||
.iter()
|
||||
.map(|f| f.name.as_str())
|
||||
.collect();
|
||||
|
||||
for (key, value) in scalars {
|
||||
if content_names.contains(&key.as_str()) {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"field '{}' of kind '{}' is a content field and must not appear in element.toml",
|
||||
key, schema.kind
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"remove '{key}' from element.toml; put its content in the sibling file '{key}.typ'"
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(field) = schema.scalar_fields.iter().find(|f| f.name == *key) else {
|
||||
if schema.deny_additional {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!("unexpected field '{}' for kind '{}'", key, schema.kind),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"remove '{key}' from element.toml (it is not part of the '{}' schema)",
|
||||
schema.kind
|
||||
)),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
let json = toml_to_json(value);
|
||||
|
||||
if let Some(ty) = field.ty {
|
||||
if !ty.matches(&json) {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"field '{}' of kind '{}' must be a {}, got {}",
|
||||
key,
|
||||
schema.kind,
|
||||
ty.label(),
|
||||
json_type_label(&json)
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"set '{key}' to a {} value in element.toml",
|
||||
ty.label()
|
||||
)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(allowed) = &field.allowed {
|
||||
if !allowed.contains(&json) {
|
||||
let choices: Vec<String> = allowed.iter().map(render_json_choice).collect();
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::SchemaViolation,
|
||||
format!(
|
||||
"field '{}' of kind '{}' has value {} which is not one of the allowed values",
|
||||
key,
|
||||
schema.kind,
|
||||
render_json_choice(&json)
|
||||
),
|
||||
)
|
||||
.with_hint(format!("'{key}' must be one of: {}", choices.join(", "))),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// For each required content field, check that its `<field>.typ` file exists in
|
||||
/// the element directory.
|
||||
fn validate_content_files(
|
||||
schema: &KindSchema,
|
||||
desc: &ElementDescriptor,
|
||||
diags: &mut Vec<Diagnostic>,
|
||||
) {
|
||||
for field in &schema.content_fields {
|
||||
if !field.required {
|
||||
// Optional content (e.g. lemma's `proof`): absence is fine.
|
||||
continue;
|
||||
}
|
||||
let file_name = format!("{}.typ", field.name);
|
||||
let path = desc.dir.join(&file_name);
|
||||
if !path.is_file() {
|
||||
diags.push(
|
||||
Diagnostic::error(
|
||||
DiagCode::MissingContentFile,
|
||||
format!(
|
||||
"kind '{}' requires the content file '{}', which is missing",
|
||||
schema.kind,
|
||||
path.display()
|
||||
),
|
||||
)
|
||||
.with_hint(format!(
|
||||
"create '{file_name}' in the element folder with the typst source for '{}'",
|
||||
field.name
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a `toml::Value` scalar into the equivalent `serde_json::Value` for
|
||||
/// type checking. Composite TOML values (arrays / tables) are converted
|
||||
/// structurally too, though the MVP stdlib schemas only use scalars.
|
||||
fn toml_to_json(value: &toml::Value) -> Json {
|
||||
match value {
|
||||
toml::Value::String(s) => Json::String(s.clone()),
|
||||
toml::Value::Integer(i) => Json::from(*i),
|
||||
toml::Value::Float(f) => Json::from(*f),
|
||||
toml::Value::Boolean(b) => Json::Bool(*b),
|
||||
toml::Value::Datetime(dt) => Json::String(dt.to_string()),
|
||||
toml::Value::Array(arr) => Json::Array(arr.iter().map(toml_to_json).collect()),
|
||||
toml::Value::Table(tbl) => Json::Object(
|
||||
tbl.iter()
|
||||
.map(|(k, v)| (k.clone(), toml_to_json(v)))
|
||||
.collect(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// A short label for a JSON value's runtime type, for diagnostic messages.
|
||||
fn json_type_label(value: &Json) -> &'static str {
|
||||
match value {
|
||||
Json::Null => "null",
|
||||
Json::Bool(_) => "boolean",
|
||||
Json::Number(n) => {
|
||||
if n.is_i64() || n.is_u64() {
|
||||
"integer"
|
||||
} else {
|
||||
"number"
|
||||
}
|
||||
}
|
||||
Json::String(_) => "string",
|
||||
Json::Array(_) => "array",
|
||||
Json::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a JSON value for an enum-choice list (strings unquoted-ish, others as
|
||||
/// JSON).
|
||||
fn render_json_choice(value: &Json) -> String {
|
||||
match value {
|
||||
Json::String(s) => format!("\"{s}\""),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_has_all_known_kinds() {
|
||||
for kind in known_kinds() {
|
||||
assert!(schema_for(kind).is_some(), "missing schema for {kind}");
|
||||
}
|
||||
assert!(schema_for("frob").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_field_split() {
|
||||
let s = schema_for("example").unwrap();
|
||||
assert_eq!(s.content_field_names(), vec!["problem", "solution"]);
|
||||
assert_eq!(s.scalar_field_names(), vec!["source"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lemma_proof_is_optional_content() {
|
||||
let s = schema_for("lemma").unwrap();
|
||||
let proof = s.content_fields.iter().find(|f| f.name == "proof").unwrap();
|
||||
assert!(!proof.required);
|
||||
let stmt = s.content_fields.iter().find(|f| f.name == "stmt").unwrap();
|
||||
assert!(stmt.required);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
= Problem
|
||||
only the problem here
|
||||
@@ -0,0 +1,2 @@
|
||||
= Problem
|
||||
What is 2+2?
|
||||
@@ -0,0 +1,2 @@
|
||||
= Solution
|
||||
4.
|
||||
@@ -0,0 +1,2 @@
|
||||
= Statement
|
||||
A lemma.
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Integration tests for `cph-schema::validate`, driven by tiny fixtures under
|
||||
//! `tests/fixtures/<case>/` and in-code `ElementDescriptor`s.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use cph_diag::DiagCode;
|
||||
use cph_model::ElementDescriptor;
|
||||
use cph_schema::validate;
|
||||
|
||||
/// Absolute path to a fixture directory.
|
||||
fn fixture(case: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests")
|
||||
.join("fixtures")
|
||||
.join(case)
|
||||
}
|
||||
|
||||
fn desc(kind: &str, dir: PathBuf, scalars: toml::Table) -> ElementDescriptor {
|
||||
ElementDescriptor {
|
||||
kind: kind.to_string(),
|
||||
dir,
|
||||
scalars,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_example_no_diagnostics() {
|
||||
let mut scalars = toml::Table::new();
|
||||
scalars.insert("source".into(), toml::Value::String("Textbook §3".into()));
|
||||
let d = desc("example", fixture("example_valid"), scalars);
|
||||
let diags = validate(&d);
|
||||
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_missing_solution_typ_is_missing_content_file() {
|
||||
let d = desc(
|
||||
"example",
|
||||
fixture("example_missing_solution"),
|
||||
toml::Table::new(),
|
||||
);
|
||||
let diags = validate(&d);
|
||||
assert_eq!(diags.len(), 1, "got {diags:?}");
|
||||
assert_eq!(diags[0].code, DiagCode::MissingContentFile);
|
||||
assert!(diags[0].message.contains("solution.typ"));
|
||||
assert!(diags[0].hint.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lemma_without_proof_typ_is_ok() {
|
||||
// stmt.typ exists, proof.typ does not — proof is optional, so no diagnostic.
|
||||
let d = desc("lemma", fixture("lemma_no_proof"), toml::Table::new());
|
||||
let diags = validate(&d);
|
||||
assert!(diags.is_empty(), "expected no diagnostics, got {diags:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kind_is_reported() {
|
||||
let d = desc("frob", fixture("example_valid"), toml::Table::new());
|
||||
let diags = validate(&d);
|
||||
assert_eq!(diags.len(), 1, "got {diags:?}");
|
||||
assert_eq!(diags[0].code, DiagCode::UnknownKind);
|
||||
assert!(diags[0].message.contains("frob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn example_source_non_string_is_schema_violation() {
|
||||
let mut scalars = toml::Table::new();
|
||||
scalars.insert("source".into(), toml::Value::Integer(42));
|
||||
let d = desc("example", fixture("example_valid"), scalars);
|
||||
let diags = validate(&d);
|
||||
assert_eq!(diags.len(), 1, "got {diags:?}");
|
||||
assert_eq!(diags[0].code, DiagCode::SchemaViolation);
|
||||
assert!(diags[0].message.contains("source"));
|
||||
assert!(diags[0].message.contains("string"));
|
||||
}
|
||||
Reference in New Issue
Block a user