1use std::collections::{HashMap, HashSet};
20
21use crate::link::Link;
22use crate::registry::{Field, FieldType, Kind, Registry};
23use crate::storage::{match_storage, route};
24use crate::value::{Value, decode};
25use crate::violation::{Severity, Violation};
26
27pub fn against_registry(
30 registry: &Registry,
31 entries: &[(String, String)],
32 systems: &[&str],
33) -> Vec<Violation> {
34 let mut out = Vec::new();
35 let id_kinds: Vec<&str> = registry
36 .kinds
37 .iter()
38 .filter(|k| k.storage.contains('{'))
39 .map(|k| k.name.as_str())
40 .collect();
41 let singleton_kinds: Vec<&str> = registry
42 .kinds
43 .iter()
44 .filter(|k| !k.storage.contains('{'))
45 .map(|k| k.name.as_str())
46 .collect();
47
48 struct Parsed<'r> {
52 path: String,
53 kind: &'r Kind,
54 id: Option<String>,
55 value: std::collections::BTreeMap<String, Value>,
56 }
57 let mut parsed: Vec<Parsed> = Vec::new();
58 for (path, text) in entries {
59 if !path.starts_with("facts/") {
60 continue;
61 }
62 match route(registry, path) {
63 None => out.push(Violation {
64 path: path.clone(),
65 severity: Severity::Error,
66 message: format!(
67 "not a recognised KB path for this schema (expected one of: {})",
68 registry
69 .kinds
70 .iter()
71 .map(|k| k.storage.as_str())
72 .collect::<Vec<_>>()
73 .join(", ")
74 ),
75 }),
76 Some((kind, id)) => match decode(kind, text) {
77 Err(e) => out.push(Violation {
78 path: path.clone(),
79 severity: Severity::Error,
80 message: format!("does not parse as a {}: {e}", kind.name),
81 }),
82 Ok(value) => parsed.push(Parsed {
83 path: path.clone(),
84 kind,
85 id,
86 value,
87 }),
88 },
89 }
90 }
91
92 let mut universe = Universe {
95 ids: id_kinds
96 .iter()
97 .map(|k| (k.to_string(), HashSet::new()))
98 .collect(),
99 singletons_present: HashSet::new(),
100 };
101 for p in &parsed {
102 match &p.id {
103 Some(id) => {
104 universe
105 .ids
106 .get_mut(p.kind.name.as_str())
107 .expect("seeded")
108 .insert(id.clone());
109 }
110 None => {
111 universe.singletons_present.insert(p.kind.name.clone());
112 }
113 }
114 }
115
116 for p in &parsed {
118 let mut check = Checker {
119 path: &p.path,
120 universe: &universe,
121 systems,
122 id_kinds: &id_kinds,
123 singleton_kinds: &singleton_kinds,
124 out: &mut out,
125 };
126 if let Some(id) = &p.id
129 && let Some(bindings) = match_storage(&p.kind.storage, &p.path)
130 {
131 let names = placeholder_names(&p.kind.storage);
132 let n = names.len();
133 for (i, (name, segment)) in names
134 .iter()
135 .zip(&bindings)
136 .collect::<Vec<_>>()
137 .into_iter()
138 .enumerate()
139 {
140 let Some(v) = p.value.get(name.as_str()) else {
141 continue;
142 };
143 let rendered = match v {
144 Value::Str(s) => s.clone(),
145 Value::Date(d) => d.to_string(),
146 _ => continue,
147 };
148 if &rendered != segment {
149 let position = if i + 1 == n { "filename" } else { "directory" };
150 check.error(format!(
151 "{name} '{rendered}' does not match {position} '{segment}'"
152 ));
153 }
154 }
155 let _ = id;
156 }
157 for field in &p.kind.fields {
158 if let Some(v) = p.value.get(&field.name) {
159 check.walk(&field.name, &field.ty, v, field.refers_to.as_deref());
160 }
161 }
162 }
163 out
164}
165
166fn placeholder_names(pattern: &str) -> Vec<String> {
168 pattern
169 .split('/')
170 .filter_map(|seg| {
171 let open = seg.find('{')?;
172 let close = seg.find('}')?;
173 (open < close).then(|| seg[open + 1..close].to_string())
174 })
175 .collect()
176}
177
178struct Universe {
180 ids: HashMap<String, HashSet<String>>,
181 singletons_present: HashSet<String>,
182}
183
184impl Universe {
185 fn unresolved(&self, link: &Link, systems: &[&str]) -> Option<String> {
188 let p = link.parts(systems);
189 if p.is_external() {
190 return None;
191 }
192 if let Some(set) = self.ids.get(p.kind) {
193 return match p.id {
194 Some(id) if set.contains(id) => None,
195 Some(_) => Some(format!(
196 "link '{link}' does not resolve to a known {}",
197 p.kind
198 )),
199 None => Some(format!(
200 "link '{link}' names kind '{}' without an id",
201 p.kind
202 )),
203 };
204 }
205 Some(format!("unrecognized link '{link}'"))
206 }
207
208 fn unresolved_singleton(&self, link: &Link, kind: &str, id: Option<&str>) -> Option<String> {
209 match id {
210 None if self.singletons_present.contains(kind) => None,
211 None => Some(format!(
212 "link '{link}' does not resolve: no {kind} record present"
213 )),
214 Some(_) => Some(format!("singleton kind '{kind}' takes no id")),
215 }
216 }
217}
218
219struct Checker<'a> {
221 path: &'a str,
222 universe: &'a Universe,
223 systems: &'a [&'a str],
224 id_kinds: &'a [&'a str],
225 singleton_kinds: &'a [&'a str],
226 out: &'a mut Vec<Violation>,
227}
228
229impl Checker<'_> {
230 fn push(&mut self, severity: Severity, message: String) {
231 self.out.push(Violation {
232 path: self.path.to_string(),
233 severity,
234 message,
235 });
236 }
237
238 fn error(&mut self, message: String) {
239 self.push(Severity::Error, message);
240 }
241
242 fn warn(&mut self, message: String) {
243 self.push(Severity::Warning, message);
244 }
245
246 fn resolve(&mut self, prefix: &str, link: &Link) {
248 let p = link.parts(self.systems);
249 let msg = if self.singleton_kinds.contains(&p.kind) && !p.is_external() {
250 self.universe.unresolved_singleton(link, p.kind, p.id)
251 } else {
252 self.universe.unresolved(link, self.systems)
253 };
254 if let Some(msg) = msg {
255 self.warn(format!("{prefix}{msg}"));
256 }
257 }
258
259 fn link(&mut self, field: &str, link: &Link, allowed: Option<&[String]>) {
262 let p = link.parts(self.systems);
263 if let Some(kinds) = allowed {
264 if p.recognized(self.id_kinds, self.singleton_kinds)
268 && !kinds.iter().any(|k| k == p.namespace())
269 {
270 self.error(format!(
271 "{field} '{link}' has kind '{}' (allowed: {})",
272 p.namespace(),
273 kinds.join(", ")
274 ));
275 return;
276 }
277 }
278 self.resolve(&format!("{field}: "), link);
279 }
280
281 fn inline(&mut self, text: &str) {
285 for l in Link::find_inline(text) {
286 let p = l.parts(self.systems);
287 if !p.recognized(self.id_kinds, self.singleton_kinds) {
288 continue;
289 }
290 self.resolve("inline ", &l);
291 }
292 }
293
294 fn walk(&mut self, field: &str, ty: &FieldType, v: &Value, refers_to: Option<&str>) {
298 match (ty, v) {
299 (FieldType::Link { allowed }, Value::Link(l)) => {
300 self.link(field, l, allowed.as_deref());
301 }
302 (FieldType::Markdown, Value::Str(s)) => self.inline(s),
303 (FieldType::Str, Value::Str(s)) => {
304 if let Some(target) = refers_to
305 && let Some(set) = self.universe.ids.get(target)
306 && !set.contains(s)
307 {
308 self.warn(format!("{field} '{s}' is not a known {target}"));
309 }
310 }
311 (FieldType::Option(inner), v) if !matches!(v, Value::Null) => {
312 self.walk(field, inner, v, refers_to);
313 }
314 (FieldType::List(inner), Value::List(vs)) => {
315 for v in vs {
316 self.walk(field, inner, v, refers_to);
317 }
318 }
319 (FieldType::Struct(fields), Value::Struct(map)) => {
320 self.walk_fields(fields, map);
321 }
322 (FieldType::Enum(variants), Value::Enum { variant, fields }) => {
323 if let Some(decl) = variants.iter().find(|d| &d.name == variant) {
324 self.walk_fields(&decl.fields, fields);
325 }
326 }
327 _ => {}
328 }
329 }
330
331 fn walk_fields(&mut self, decls: &[Field], values: &std::collections::BTreeMap<String, Value>) {
332 for f in decls {
333 if let Some(v) = values.get(&f.name) {
334 self.walk(&f.name, &f.ty, v, f.refers_to.as_deref());
335 }
336 }
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343 use crate::registry::Variant;
344
345 fn field(name: &str, ty: FieldType) -> Field {
346 Field {
347 name: name.into(),
348 doc: String::new(),
349 ty,
350 role: None,
351 refers_to: None,
352 }
353 }
354
355 fn unit(name: &str) -> Variant {
356 Variant {
357 name: name.into(),
358 doc: String::new(),
359 fields: Vec::new(),
360 tone: None,
361 }
362 }
363
364 fn reg() -> Registry {
367 Registry {
368 schema_hash: String::new(),
369 kinds: vec![
370 Kind {
371 name: "todo".into(),
372 doc: String::new(),
373 storage: "facts/todos/{id}.ron".into(),
374 fields: vec![
375 field("id", FieldType::Str),
376 field("title", FieldType::Str),
377 field("status", FieldType::Enum(vec![unit("Open"), unit("Done")])),
378 field("due", FieldType::Option(Box::new(FieldType::Date))),
379 field(
380 "blocked_by",
381 FieldType::Option(Box::new(FieldType::Link {
382 allowed: Some(vec!["todo".into()]),
383 })),
384 ),
385 field(
386 "sources",
387 FieldType::List(Box::new(FieldType::Link { allowed: None })),
388 ),
389 field("notes", FieldType::Markdown),
390 ],
391 },
392 Kind {
393 name: "charter".into(),
394 doc: String::new(),
395 storage: "facts/charter.ron".into(),
396 fields: vec![
397 field("purpose", FieldType::Str),
398 field("objectives", FieldType::List(Box::new(FieldType::Markdown))),
399 field("notes", FieldType::Markdown),
400 ],
401 },
402 Kind {
403 name: "self".into(),
404 doc: String::new(),
405 storage: "facts/self.ron".into(),
406 fields: vec![
407 field("name", FieldType::Str),
408 field("role", FieldType::Str),
409 field("notes", FieldType::Markdown),
410 ],
411 },
412 ],
413 queries: vec![],
414 roles: vec![],
415 }
416 }
417
418 const SYSTEMS: &[&str] = &["graph", "sharepoint"];
419
420 fn v(entries: &[(&str, &str)]) -> Vec<Violation> {
421 let entries: Vec<(String, String)> = entries
422 .iter()
423 .map(|(p, t)| (p.to_string(), t.to_string()))
424 .collect();
425 against_registry(®(), &entries, SYSTEMS)
426 }
427
428 fn errors(v: &[Violation]) -> Vec<&Violation> {
429 v.iter().filter(|x| x.severity == Severity::Error).collect()
430 }
431
432 fn warnings(v: &[Violation]) -> Vec<&Violation> {
433 v.iter()
434 .filter(|x| x.severity == Severity::Warning)
435 .collect()
436 }
437
438 #[test]
439 fn happy_path_is_clean() {
440 let out = v(&[
441 (
442 "facts/todos/declare-source.ron",
443 r#"(id: "declare-source", title: "Declare", status: Open)"#,
444 ),
445 (
446 "facts/todos/ship-it.ron",
447 r#"(id: "ship-it", title: "Ship", status: Open, due: Some("2026-07-31"),
448 blocked_by: Some("todo:declare-source"),
449 sources: ["graph:email:AAMk=="],
450 notes: "see [[charter]] and [[todo:declare-source]]")"#,
451 ),
452 ("facts/charter.ron", r#"(purpose: "P", objectives: ["o"])"#),
453 ("facts/self.ron", r#"(name: "Tom", role: "CTO")"#),
454 ]);
455 assert!(out.is_empty(), "{out:?}");
456 }
457
458 #[test]
459 fn routing_parse_and_placeholder_mismatches_are_errors() {
460 let out = v(&[
461 ("facts/notes/x.ron", "(id: \"x\")"),
462 ("facts/todos/readme.txt", "hi"),
463 ("facts/todos/broken.ron", "(id: "),
464 ("facts/todos/ship-it.ron", r#"(id: "ship", title: "T")"#),
465 (
466 "facts/todos/bad-date.ron",
467 r#"(id: "bad-date", title: "T", due: Some("someday"))"#,
468 ),
469 ("tools/elsewhere.txt", "ignored"),
470 ]);
471 let e = errors(&out);
472 assert_eq!(e.len(), 5, "{out:?}");
473 assert!(
474 e.iter()
475 .any(|x| x.message.contains("not a recognised KB path")
476 && x.message.contains("facts/todos/{id}.ron"))
477 );
478 assert!(
479 e.iter().any(
480 |x| x.path.contains("broken") && x.message.contains("does not parse as a todo")
481 )
482 );
483 assert!(e.iter().any(|x| {
484 x.message
485 .contains("id 'ship' does not match filename 'ship-it'")
486 }));
487 assert!(
488 e.iter()
489 .any(|x| x.path.contains("bad-date") && x.message.contains("does not parse"))
490 );
491 }
492
493 #[test]
494 fn multi_segment_placeholders_check_each_position() {
495 let mut registry = reg();
496 registry.kinds.push(Kind {
497 name: "checkin".into(),
498 doc: String::new(),
499 storage: "facts/checkins/{person}/{date}.ron".into(),
500 fields: vec![
501 field("person", FieldType::Str),
502 field("date", FieldType::Date),
503 ],
504 });
505 let entries = vec![(
506 "facts/checkins/jane-doe/2026-07-03.ron".to_string(),
507 r#"(person: "bob-ray", date: "2026-07-04")"#.to_string(),
508 )];
509 let out = against_registry(®istry, &entries, SYSTEMS);
510 let e = errors(&out);
511 assert!(e.iter().any(|x| {
512 x.message
513 .contains("person 'bob-ray' does not match directory 'jane-doe'")
514 }));
515 assert!(e.iter().any(|x| {
516 x.message
517 .contains("date '2026-07-04' does not match filename '2026-07-03'")
518 }));
519 }
520
521 #[test]
522 fn link_policy_rejects_disallowed_kinds() {
523 let out = v(&[
524 (
525 "facts/todos/b.ron",
526 r#"(id: "b", title: "B", blocked_by: Some("charter"))"#,
527 ),
528 ("facts/charter.ron", r#"(purpose: "P")"#),
529 ]);
530 let e = errors(&out);
531 assert_eq!(e.len(), 1, "{out:?}");
532 assert!(e[0].message.contains("blocked_by") && e[0].message.contains("kind 'charter'"));
533 }
534
535 #[test]
536 fn dangling_and_unrecognized_links_warn_externals_pass() {
537 let out = v(&[(
538 "facts/todos/b.ron",
539 r#"(id: "b", title: "B", blocked_by: Some("todo:ghost"),
540 sources: ["jira:EV-12", "graph:email:fine=="])"#,
541 )]);
542 assert!(errors(&out).is_empty(), "{out:?}");
543 let w = warnings(&out);
544 assert_eq!(w.len(), 2, "{w:?}");
545 assert!(
546 w.iter()
547 .any(|x| x.message.contains("does not resolve to a known todo"))
548 );
549 assert!(
550 w.iter()
551 .any(|x| x.message.contains("unrecognized link 'jira:EV-12'"))
552 );
553 }
554
555 #[test]
556 fn singletons_resolve_iff_present_and_take_no_id() {
557 let todo = (
558 "facts/todos/b.ron",
559 r#"(id: "b", title: "B", notes: "per [[charter]] and [[charter:2026]]")"#,
560 );
561 let out = v(&[todo, ("facts/charter.ron", r#"(purpose: "P")"#)]);
562 assert!(errors(&out).is_empty());
563 let w = warnings(&out);
564 assert_eq!(w.len(), 1, "{w:?}");
565 assert!(
566 w[0].message
567 .contains("singleton kind 'charter' takes no id")
568 );
569
570 let out = v(&[todo]);
571 assert!(
572 warnings(&out)
573 .iter()
574 .any(|x| x.message.contains("no charter record present"))
575 );
576 }
577
578 #[test]
579 fn inline_links_are_scanned_in_every_markdown_store() {
580 let out = v(&[
581 (
582 "facts/todos/b.ron",
583 r#"(id: "b", title: "B", notes: "see [[todo:ghost1]]; [[not a link]] is prose")"#,
584 ),
585 (
586 "facts/charter.ron",
587 r#"(purpose: "P", objectives: ["see [[todo:ghost2]]"], notes: "see [[todo:ghost3]]")"#,
588 ),
589 (
590 "facts/self.ron",
591 r#"(name: "T", role: "R", notes: "see [[todo:ghost4]]")"#,
592 ),
593 ]);
594 assert!(errors(&out).is_empty());
595 let inline: Vec<_> = warnings(&out)
596 .into_iter()
597 .filter(|x| x.message.starts_with("inline"))
598 .collect();
599 assert_eq!(inline.len(), 4, "{out:?}");
600 }
601
602 #[test]
603 fn refers_to_slugs_warn_when_unknown() {
604 let mut registry = reg();
605 let mut person_field = field("person", FieldType::Str);
606 person_field.refers_to = Some("todo".into());
607 registry.kinds.push(Kind {
608 name: "checkin".into(),
609 doc: String::new(),
610 storage: "facts/checkins/{person}/{date}.ron".into(),
611 fields: vec![person_field, field("date", FieldType::Date)],
612 });
613 let entries = vec![(
614 "facts/checkins/ghost/2026-07-01.ron".to_string(),
615 r#"(person: "ghost", date: "2026-07-01")"#.to_string(),
616 )];
617 let out = against_registry(®istry, &entries, SYSTEMS);
618 assert!(errors(&out).is_empty(), "{out:?}");
619 assert!(
620 warnings(&out)
621 .iter()
622 .any(|x| x.message.contains("person 'ghost' is not a known todo"))
623 );
624 }
625
626 #[test]
629 fn enum_payload_links_are_checked() {
630 let mut registry = reg();
631 registry.kinds.push(Kind {
632 name: "probe".into(),
633 doc: String::new(),
634 storage: "facts/probes/{id}.ron".into(),
635 fields: vec![
636 field("id", FieldType::Str),
637 Field {
638 name: "client".into(),
639 doc: String::new(),
640 ty: FieldType::Enum(vec![
641 unit("Unknown"),
642 Variant {
643 name: "Known".into(),
644 doc: String::new(),
645 fields: vec![field(
646 "todo",
647 FieldType::Link {
648 allowed: Some(vec!["todo".into()]),
649 },
650 )],
651 tone: None,
652 },
653 ]),
654 role: None,
655 refers_to: None,
656 },
657 ],
658 });
659 let entries = vec![(
660 "facts/probes/p1.ron".to_string(),
661 r#"(id: "p1", client: Known(todo: "charter"))"#.to_string(),
662 )];
663 let out = against_registry(®istry, &entries, SYSTEMS);
664 assert!(
665 errors(&out)
666 .iter()
667 .any(|x| x.message.contains("todo") && x.message.contains("kind 'charter'")),
668 "{out:?}"
669 );
670 }
671}