1use 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#[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}