Skip to main content

stoa_core/
validate.rs

1//! Schema validation for parsed wiki frontmatter.
2//!
3//! `validate_page` is the spine of `stoa schema --check`. It runs in two
4//! passes — first inspect the raw YAML mapping for missing required fields
5//! and invalid enum values (so error attribution stays structural, not
6//! text-matched), then attempt the typed [`Frontmatter`] parse for
7//! relationship-level checks.
8
9use serde_yaml::{Mapping, Value};
10
11use crate::error::ValidationError;
12use crate::frontmatter::{Frontmatter, KindData};
13use crate::schema::Schema;
14
15const REQUIRED_FIELDS: &[&str] = &["id", "kind", "title", "status", "created", "updated"];
16
17/// Validate a page's raw frontmatter YAML against `schema`.
18///
19/// The `path_id` is the id derived from the file path (e.g. `ent-redis`
20/// from `wiki/entities/ent-redis.md`). It's used as the page identifier
21/// in error messages so the CLI can pin-point the offending file even
22/// when the YAML is structurally broken.
23#[must_use]
24pub fn validate_page(yaml: &str, path_id: &str, schema: &Schema) -> Vec<ValidationError> {
25    let map = match parse_mapping(yaml, path_id) {
26        Ok(m) => m,
27        Err(err) => return vec![err],
28    };
29    let pid = map_str(&map, "id").map_or_else(|| path_id.to_owned(), str::to_owned);
30    let mut errors = check_required(&map, &pid);
31    errors.extend(check_enums(&map, &pid, schema));
32    if errors.is_empty() {
33        errors.extend(check_typed(yaml, &pid, schema));
34    }
35    errors
36}
37
38fn parse_mapping(yaml: &str, path_id: &str) -> Result<Mapping, ValidationError> {
39    let value: Value = serde_yaml::from_str(yaml)
40        .map_err(|e| ValidationError::new(path_id, "frontmatter", format!("yaml syntax: {e}")))?;
41    match value {
42        Value::Mapping(m) => Ok(m),
43        _ => Err(ValidationError::new(path_id, "frontmatter", "not a YAML mapping")),
44    }
45}
46
47fn check_required(map: &Mapping, pid: &str) -> Vec<ValidationError> {
48    let mut errors = Vec::new();
49    for field in REQUIRED_FIELDS {
50        if !map_has(map, field) {
51            let msg = format!("missing required field `{field}`");
52            errors.push(ValidationError::new(pid, *field, msg));
53        }
54    }
55    if matches!(map_str(map, "kind"), Some("entity")) && !map_has(map, "type") {
56        let msg = "missing required field `type` for entity".to_owned();
57        errors.push(ValidationError::new(pid, "type", msg));
58    }
59    errors
60}
61
62fn check_enums(map: &Mapping, pid: &str, schema: &Schema) -> Vec<ValidationError> {
63    let mut errors = Vec::new();
64    if let Some(k) = map_str(map, "kind").filter(|v| !schema.allows_kind(v)) {
65        let msg = format!("unknown kind `{k}` (allowed: entity, concept, synthesis)");
66        errors.push(ValidationError::new(pid, "kind", msg));
67    }
68    if let Some(s) = map_str(map, "status").filter(|v| !schema.allows_status(v)) {
69        let allowed = "active, superseded, stale, deprecated";
70        let msg = format!("unknown status `{s}` (allowed: {allowed})");
71        errors.push(ValidationError::new(pid, "status", msg));
72    }
73    if matches!(map_str(map, "kind"), Some("entity"))
74        && let Some(t) = map_str(map, "type").filter(|v| !schema.allows_entity_type(v))
75    {
76        let allowed = schema.entity_types().join(", ");
77        let msg = format!("unknown entity type `{t}` (allowed: {allowed})");
78        errors.push(ValidationError::new(pid, "type", msg));
79    }
80    errors
81}
82
83fn check_typed(yaml: &str, pid: &str, schema: &Schema) -> Vec<ValidationError> {
84    match serde_yaml::from_str::<Frontmatter>(yaml) {
85        Ok(fm) => relationship_errors(&fm, pid, schema),
86        Err(err) => vec![ValidationError::new(pid, "frontmatter", err.to_string())],
87    }
88}
89
90fn relationship_errors(fm: &Frontmatter, pid: &str, schema: &Schema) -> Vec<ValidationError> {
91    let rels: &[crate::relationship::Relationship] = match &fm.kind_data {
92        KindData::Entity(d) => &d.relationships,
93        KindData::Concept(d) => &d.relationships,
94        KindData::Synthesis(_) => return Vec::new(),
95    };
96    let mut errors = Vec::new();
97    for rel in rels {
98        if !schema.allows_relationship_type(rel.kind.as_str()) {
99            let allowed = schema.relationship_types().join(", ");
100            let msg =
101                format!("unknown relationship type `{}` (allowed: {allowed})", rel.kind.as_str());
102            errors.push(ValidationError::new(pid, "relationship.type", msg));
103        }
104    }
105    errors
106}
107
108fn map_has(map: &Mapping, key: &str) -> bool {
109    map.get(Value::String(key.to_owned())).is_some()
110}
111
112fn map_str<'a>(map: &'a Mapping, key: &str) -> Option<&'a str> {
113    map.get(Value::String(key.to_owned()))?.as_str()
114}