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
9pub 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
44pub 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; };
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
90pub 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 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; };
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
152pub 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; };
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
198fn 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
226fn 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 "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
264fn 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
309pub 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()); }
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 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 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 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 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 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 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 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}