Skip to main content

nodex_core/rules/
schema.rs

1use chrono::NaiveDate;
2use serde_json::Value;
3
4use crate::config::{Config, FieldType, WhenPredicate, parse_when};
5use crate::model::{Graph, Node};
6
7use super::{Rule, Severity, Violation};
8
9/// Check that nodes have all required frontmatter fields.
10pub struct RequiredFieldRule;
11
12impl Rule for RequiredFieldRule {
13    fn id(&self) -> &str {
14        "required_field"
15    }
16
17    fn severity(&self) -> Severity {
18        Severity::Error
19    }
20
21    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
22        let mut violations = Vec::new();
23
24        for node in graph.nodes().values() {
25            let required = config.required_for(node.kind.as_str());
26
27            for field in required {
28                if is_field_missing(node, field) {
29                    violations.push(Violation {
30                        rule_id: self.id().to_string(),
31                        severity: self.severity(),
32                        node_id: Some(node.id.clone()),
33                        path: Some(node.path.to_string_lossy().to_string()),
34                        message: format!("missing required field: {field}"),
35                    });
36                }
37            }
38        }
39
40        violations
41    }
42}
43
44/// Check that `attrs` field values conform to configured types.
45///
46/// Built-in fields (`status`, `created`, etc.) are strongly typed in `Node`
47/// so the parser catches their type errors. This rule targets
48/// project-specific frontmatter keys that land in `Node::attrs` as
49/// `serde_json::Value`.
50pub struct FieldTypeRule;
51
52impl Rule for FieldTypeRule {
53    fn id(&self) -> &str {
54        "field_type"
55    }
56
57    fn severity(&self) -> Severity {
58        Severity::Error
59    }
60
61    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
62        let mut violations = Vec::new();
63
64        for node in graph.nodes().values() {
65            let types = config.types_for(node.kind.as_str());
66            if types.is_empty() {
67                continue;
68            }
69
70            for (field, expected) in &types {
71                let Some(value) = node.attrs.get(field) else {
72                    continue; // missing fields belong to `required_field`
73                };
74                if let Some(msg) = validate_type(value, *expected) {
75                    violations.push(Violation {
76                        rule_id: self.id().to_string(),
77                        severity: self.severity(),
78                        node_id: Some(node.id.clone()),
79                        path: Some(node.path.to_string_lossy().to_string()),
80                        message: format!("field {field:?}: {msg}"),
81                    });
82                }
83            }
84        }
85
86        violations
87    }
88}
89
90/// Check that field values are members of the configured enumeration.
91///
92/// Handles project-specific fields declared under
93/// `schema.enums` / `schema.overrides.enums` AND the two built-in
94/// scalar fields (`kind`, `status`) which are implicitly constrained
95/// by the global `kinds.allowed` / `statuses.allowed`. An override
96/// enum on `kind` or `status` supersedes the implicit backstop — the
97/// override is always a subset of the global (`Config::validate`
98/// enforces that), so the stricter rule wins without drift.
99pub struct FieldEnumRule;
100
101impl Rule for FieldEnumRule {
102    fn id(&self) -> &str {
103        "field_enum"
104    }
105
106    fn severity(&self) -> Severity {
107        Severity::Error
108    }
109
110    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
111        let mut violations = Vec::new();
112
113        for node in graph.nodes().values() {
114            let mut enums = config.enums_for(node.kind.as_str());
115
116            // Back-fill kind/status with the global allowed lists when no
117            // explicit enum was declared for them. Declaring `kinds.allowed`
118            // in `nodex.toml` must mean "these and only these kinds are
119            // valid"; silently accepting out-of-vocabulary kinds/statuses
120            // because the user didn't also write `schema.enums.kind = [...]`
121            // would defeat the purpose of the allowed list.
122            enums
123                .entry("kind".to_string())
124                .or_insert_with(|| config.kinds.allowed.clone());
125            enums
126                .entry("status".to_string())
127                .or_insert_with(|| config.statuses.allowed.clone());
128
129            for (field, allowed) in &enums {
130                let actual = read_field_as_string(node, field);
131                let Some(actual) = actual else {
132                    continue; // missing fields belong to `required_field`
133                };
134                if !allowed.iter().any(|v| v == &actual) {
135                    violations.push(Violation {
136                        rule_id: self.id().to_string(),
137                        severity: self.severity(),
138                        node_id: Some(node.id.clone()),
139                        path: Some(node.path.to_string_lossy().to_string()),
140                        message: format!(
141                            "field {field:?} has value {actual:?}; expected one of {allowed:?}"
142                        ),
143                    });
144                }
145            }
146        }
147
148        violations
149    }
150}
151
152/// Check cross-field conditional requirements.
153///
154/// "When predicate holds, `require` field must be present."
155pub struct CrossFieldRule;
156
157impl Rule for CrossFieldRule {
158    fn id(&self) -> &str {
159        "cross_field"
160    }
161
162    fn severity(&self) -> Severity {
163        Severity::Error
164    }
165
166    fn check(&self, graph: &Graph, config: &Config) -> Vec<Violation> {
167        let mut violations = Vec::new();
168
169        for node in graph.nodes().values() {
170            let cross_fields = config.cross_field_for(node.kind.as_str());
171            if cross_fields.is_empty() {
172                continue;
173            }
174
175            for cf in &cross_fields {
176                let Ok(predicate) = parse_when(&cf.when) else {
177                    continue; // already rejected by Config::validate
178                };
179                if !predicate_matches_node(&predicate, node) {
180                    continue;
181                }
182                if is_field_missing(node, &cf.require) {
183                    violations.push(Violation {
184                        rule_id: self.id().to_string(),
185                        severity: self.severity(),
186                        node_id: Some(node.id.clone()),
187                        path: Some(node.path.to_string_lossy().to_string()),
188                        message: format!("when {}, field {:?} is required", cf.when, cf.require),
189                    });
190                }
191            }
192        }
193
194        violations
195    }
196}
197
198// ─── helpers ────────────────────────────────────────────────────────────
199
200/// Return true when `field` has no value on the node. Handles both
201/// built-in scalar/vector fields and `attrs`.
202fn is_field_missing(node: &Node, field: &str) -> bool {
203    match field {
204        "id" => node.id.is_empty(),
205        "title" => node.title.is_empty(),
206        "kind" => node.kind.as_str().is_empty(),
207        "status" => node.status.as_str().is_empty(),
208        "created" => node.created.is_none(),
209        "updated" => node.updated.is_none(),
210        "reviewed" => node.reviewed.is_none(),
211        "owner" => node.owner.is_none(),
212        "superseded_by" => node.superseded_by.is_none(),
213        "supersedes" => node.supersedes.is_empty(),
214        "implements" => node.implements.is_empty(),
215        "related" => node.related.is_empty(),
216        "tags" => node.tags.is_empty(),
217        other => match node.attrs.get(other) {
218            None | Some(Value::Null) => true,
219            Some(Value::String(s)) => s.is_empty(),
220            Some(Value::Array(a)) => a.is_empty(),
221            _ => false,
222        },
223    }
224}
225
226/// Read a field's value as a `String` for enum comparison. Returns
227/// `None` when the field is absent or cannot be represented as a scalar
228/// string (arrays, objects, etc. are not enum candidates).
229fn read_field_as_string(node: &Node, field: &str) -> Option<String> {
230    match field {
231        "id" => none_if_empty(&node.id),
232        "title" => none_if_empty(&node.title),
233        "kind" => none_if_empty(node.kind.as_str()),
234        "status" => none_if_empty(node.status.as_str()),
235        "owner" => node.owner.clone(),
236        "superseded_by" => node.superseded_by.clone(),
237        // Date-valued built-ins were previously missing here. Without
238        // them, `cross_field.when = "reviewed=2026-01-01"` validated
239        // at load (the field is recognised as built-in) but never
240        // fired at runtime — `read_field_as_string` returned `None`
241        // so the predicate always saw "field absent". Format as the
242        // canonical YAML date string so equality comparisons against
243        // user-written predicate values round-trip.
244        "created" => node.created.map(|d| d.format("%Y-%m-%d").to_string()),
245        "updated" => node.updated.map(|d| d.format("%Y-%m-%d").to_string()),
246        "reviewed" => node.reviewed.map(|d| d.format("%Y-%m-%d").to_string()),
247        other => match node.attrs.get(other)? {
248            Value::String(s) if !s.is_empty() => Some(s.clone()),
249            Value::Number(n) => Some(n.to_string()),
250            Value::Bool(b) => Some(b.to_string()),
251            _ => None,
252        },
253    }
254}
255
256fn none_if_empty(s: &str) -> Option<String> {
257    if s.is_empty() {
258        None
259    } else {
260        Some(s.to_string())
261    }
262}
263
264/// Validate a JSON value against an expected field type. Returns a
265/// human-readable error message on mismatch, `None` on success.
266///
267/// Written as `match expected { Variant => match value { ... } }` so
268/// that adding a new `FieldType` variant is a compile error here —
269/// silent acceptance of unknown types would defeat the validation.
270fn validate_type(value: &Value, expected: FieldType) -> Option<String> {
271    match expected {
272        FieldType::String => match value {
273            Value::String(_) => None,
274            other => Some(format!("expected string, got {}", describe_value(other))),
275        },
276        FieldType::Integer => match value {
277            Value::Number(n) if n.is_i64() || n.is_u64() => None,
278            other => Some(format!("expected integer, got {}", describe_value(other))),
279        },
280        FieldType::Bool => match value {
281            Value::Bool(_) => None,
282            other => Some(format!("expected bool, got {}", describe_value(other))),
283        },
284        FieldType::Date => match value {
285            Value::String(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
286                .ok()
287                .map(|_| None)
288                .unwrap_or_else(|| Some(format!("invalid date {s:?}, expected YYYY-MM-DD"))),
289            other => Some(format!(
290                "expected date (YYYY-MM-DD), got {}",
291                describe_value(other)
292            )),
293        },
294    }
295}
296
297fn describe_value(v: &Value) -> &'static str {
298    match v {
299        Value::Null => "null",
300        Value::Bool(_) => "bool",
301        Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
302        Value::Number(_) => "float",
303        Value::String(_) => "string",
304        Value::Array(_) => "array",
305        Value::Object(_) => "object",
306    }
307}
308
309/// Evaluate whether a `when` predicate holds for a given node.
310///
311/// Public so scaffold can evaluate cross_field predicates against a
312/// synthetic default node without reimplementing the predicate logic.
313pub fn predicate_matches_node(predicate: &WhenPredicate, node: &Node) -> bool {
314    match predicate {
315        WhenPredicate::Equals { field, value } => read_field_as_string(node, field)
316            .as_deref()
317            .map(|actual| actual == value)
318            .unwrap_or(false),
319    }
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::config::{
326        CrossFieldSpec, FieldType, KindsConfig, SchemaConfig, SchemaOverride, StatusesConfig,
327    };
328    use crate::model::{Kind, Status};
329    use std::collections::BTreeMap;
330    use std::path::PathBuf;
331
332    fn test_config() -> Config {
333        Config {
334            kinds: KindsConfig {
335                allowed: vec!["adr".to_string(), "guide".to_string()],
336            },
337            statuses: StatusesConfig {
338                allowed: vec![
339                    "draft".to_string(),
340                    "active".to_string(),
341                    "superseded".to_string(),
342                ],
343                terminal: vec!["superseded".to_string()],
344            },
345            schema: SchemaConfig {
346                required: vec!["id".to_string(), "title".to_string()],
347                overrides: vec![SchemaOverride {
348                    kinds: vec!["adr".to_string()],
349                    required: vec!["id".to_string(), "title".to_string(), "status".to_string()],
350                    types: [("decision_date".to_string(), FieldType::Date)]
351                        .into_iter()
352                        .collect(),
353                    enums: [(
354                        "status".to_string(),
355                        vec![
356                            "draft".to_string(),
357                            "active".to_string(),
358                            "superseded".to_string(),
359                        ],
360                    )]
361                    .into_iter()
362                    .collect(),
363                    cross_field: vec![CrossFieldSpec {
364                        when: "status=superseded".to_string(),
365                        require: "superseded_by".to_string(),
366                    }],
367                }],
368                ..Default::default()
369            },
370            ..Config::default()
371        }
372    }
373
374    fn make_node(id: &str, kind: &str, status: &str) -> Node {
375        Node {
376            id: id.to_string(),
377            path: PathBuf::from(format!("{id}.md")),
378            title: id.to_string(),
379            kind: Kind::new(kind),
380            status: Status::new(status),
381            created: None,
382            updated: None,
383            reviewed: None,
384            owner: None,
385            supersedes: vec![],
386            superseded_by: None,
387            implements: vec![],
388            related: vec![],
389            tags: vec![],
390            orphan_ok: false,
391            attrs: BTreeMap::new(),
392        }
393    }
394
395    fn make_graph(nodes: Vec<Node>) -> Graph {
396        use indexmap::IndexMap;
397        let mut map = IndexMap::new();
398        for n in nodes {
399            map.insert(n.id.clone(), n);
400        }
401        Graph::new(map, vec![])
402    }
403
404    #[test]
405    fn field_types_accepts_valid_date() {
406        let mut node = make_node("adr-1", "adr", "active");
407        node.attrs.insert(
408            "decision_date".to_string(),
409            Value::String("2026-04-19".to_string()),
410        );
411        let graph = make_graph(vec![node]);
412        let v = FieldTypeRule.check(&graph, &test_config());
413        assert!(v.is_empty());
414    }
415
416    #[test]
417    fn field_types_rejects_invalid_date() {
418        let mut node = make_node("adr-1", "adr", "active");
419        node.attrs.insert(
420            "decision_date".to_string(),
421            Value::String("yesterday".to_string()),
422        );
423        let graph = make_graph(vec![node]);
424        let v = FieldTypeRule.check(&graph, &test_config());
425        assert_eq!(v.len(), 1);
426        assert_eq!(v[0].rule_id, "field_type");
427    }
428
429    #[test]
430    fn field_types_skip_missing_field() {
431        let node = make_node("adr-1", "adr", "active");
432        let graph = make_graph(vec![node]);
433        let v = FieldTypeRule.check(&graph, &test_config());
434        assert!(v.is_empty()); // required_field handles missing
435    }
436
437    #[test]
438    fn field_enums_rejects_typo() {
439        let node = make_node("adr-1", "adr", "actives");
440        let graph = make_graph(vec![node]);
441        let v = FieldEnumRule.check(&graph, &test_config());
442        assert_eq!(v.len(), 1);
443        assert_eq!(v[0].rule_id, "field_enum");
444    }
445
446    #[test]
447    fn field_enums_accepts_valid() {
448        let node = make_node("adr-1", "adr", "active");
449        let graph = make_graph(vec![node]);
450        let v = FieldEnumRule.check(&graph, &test_config());
451        assert!(v.is_empty());
452    }
453
454    #[test]
455    fn field_enums_fall_back_to_global_allowed() {
456        // A "guide" doc has no per-kind enum override, but the global
457        // `statuses.allowed` still constrains its `status` field —
458        // declaring an allowed list has to mean "these and only these,
459        // everywhere," otherwise the list is a lie.
460        let node = make_node("guide-1", "guide", "actives");
461        let graph = make_graph(vec![node]);
462        let v = FieldEnumRule.check(&graph, &test_config());
463        assert_eq!(v.len(), 1);
464        assert_eq!(v[0].rule_id, "field_enum");
465        assert!(v[0].message.contains("\"actives\""));
466    }
467
468    #[test]
469    fn field_enums_rejects_unknown_kind() {
470        // Symmetric to the status check: a kind value outside
471        // `kinds.allowed` is flagged even when no explicit enum
472        // override on `kind` was declared.
473        let node = make_node("x-1", "unlisted-kind", "active");
474        let graph = make_graph(vec![node]);
475        let v = FieldEnumRule.check(&graph, &test_config());
476        assert!(v.iter().any(|v| v.message.contains("\"unlisted-kind\"")));
477    }
478
479    #[test]
480    fn cross_field_fires_when_predicate_matches() {
481        let node = make_node("adr-1", "adr", "superseded");
482        // missing superseded_by
483        let graph = make_graph(vec![node]);
484        let v = CrossFieldRule.check(&graph, &test_config());
485        assert_eq!(v.len(), 1);
486        assert!(v[0].message.contains("superseded_by"));
487    }
488
489    #[test]
490    fn cross_field_silent_when_predicate_false() {
491        let node = make_node("adr-1", "adr", "draft");
492        let graph = make_graph(vec![node]);
493        let v = CrossFieldRule.check(&graph, &test_config());
494        assert!(v.is_empty());
495    }
496
497    #[test]
498    fn cross_field_fires_on_date_valued_builtin_predicate() {
499        // Regression: `read_field_as_string` used to return `None` for
500        // every date-valued built-in (`created`, `updated`,
501        // `reviewed`), so predicates like `when = "reviewed=2026-01-01"`
502        // validated at config load but silently never fired at
503        // runtime. Now the predicate matches and the cross_field rule
504        // flags the missing `require` field.
505        use chrono::NaiveDate;
506        let mut config = test_config();
507        config.schema.overrides[0].cross_field = vec![CrossFieldSpec {
508            when: "reviewed=2026-01-01".to_string(),
509            require: "owner".to_string(),
510        }];
511        let mut node = make_node("adr-1", "adr", "active");
512        node.reviewed = Some(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap());
513        // missing owner
514        let graph = make_graph(vec![node]);
515        let v = CrossFieldRule.check(&graph, &config);
516        assert_eq!(v.len(), 1, "expected one violation, got: {v:?}");
517        assert!(v[0].message.contains("owner"));
518    }
519
520    #[test]
521    fn cross_field_silent_when_required_field_present() {
522        let mut node = make_node("adr-1", "adr", "superseded");
523        node.superseded_by = Some("adr-2".to_string());
524        let graph = make_graph(vec![node]);
525        let v = CrossFieldRule.check(&graph, &test_config());
526        assert!(v.is_empty());
527    }
528
529    #[test]
530    fn type_and_cross_field_rules_early_return_on_empty_override() {
531        // `FieldTypeRule` and `CrossFieldRule` are purely config-driven
532        // — no declared constraints, no violations. `FieldEnumRule` is
533        // now stricter: even with no override, `kind` and `status` are
534        // validated against the global allowed lists, so it is no
535        // longer part of this "no constraints configured" test.
536        let mut config = test_config();
537        config.schema.overrides[0].types.clear();
538        config.schema.overrides[0].enums.clear();
539        config.schema.overrides[0].cross_field.clear();
540        // Use a valid status so the global-backstop enum check stays silent.
541        let node = make_node("adr-1", "adr", "active");
542        let graph = make_graph(vec![node]);
543        assert!(FieldTypeRule.check(&graph, &config).is_empty());
544        assert!(CrossFieldRule.check(&graph, &config).is_empty());
545    }
546}