use std::collections::{HashMap, HashSet};
use crate::link::Link;
use crate::registry::{Field, FieldType, Kind, Registry};
use crate::storage::{match_storage, route};
use crate::value::{Value, decode};
use crate::violation::{Severity, Violation};
pub fn against_registry(
registry: &Registry,
entries: &[(String, String)],
systems: &[&str],
) -> Vec<Violation> {
let mut out = Vec::new();
let id_kinds: Vec<&str> = registry
.kinds
.iter()
.filter(|k| k.storage.contains('{'))
.map(|k| k.name.as_str())
.collect();
let singleton_kinds: Vec<&str> = registry
.kinds
.iter()
.filter(|k| !k.storage.contains('{'))
.map(|k| k.name.as_str())
.collect();
struct Parsed<'r> {
path: String,
kind: &'r Kind,
id: Option<String>,
value: std::collections::BTreeMap<String, Value>,
}
let mut parsed: Vec<Parsed> = Vec::new();
for (path, text) in entries {
if !path.starts_with("facts/") {
continue;
}
match route(registry, path) {
None => out.push(Violation {
path: path.clone(),
severity: Severity::Error,
message: format!(
"not a recognised KB path for this schema (expected one of: {})",
registry
.kinds
.iter()
.map(|k| k.storage.as_str())
.collect::<Vec<_>>()
.join(", ")
),
}),
Some((kind, id)) => match decode(kind, text) {
Err(e) => out.push(Violation {
path: path.clone(),
severity: Severity::Error,
message: format!("does not parse as a {}: {e}", kind.name),
}),
Ok(value) => parsed.push(Parsed {
path: path.clone(),
kind,
id,
value,
}),
},
}
}
let mut universe = Universe {
ids: id_kinds
.iter()
.map(|k| (k.to_string(), HashSet::new()))
.collect(),
singletons_present: HashSet::new(),
};
for p in &parsed {
match &p.id {
Some(id) => {
universe
.ids
.get_mut(p.kind.name.as_str())
.expect("seeded")
.insert(id.clone());
}
None => {
universe.singletons_present.insert(p.kind.name.clone());
}
}
}
for p in &parsed {
let mut check = Checker {
path: &p.path,
universe: &universe,
systems,
id_kinds: &id_kinds,
singleton_kinds: &singleton_kinds,
out: &mut out,
};
if let Some(id) = &p.id
&& let Some(bindings) = match_storage(&p.kind.storage, &p.path)
{
let names = placeholder_names(&p.kind.storage);
let n = names.len();
for (i, (name, segment)) in names
.iter()
.zip(&bindings)
.collect::<Vec<_>>()
.into_iter()
.enumerate()
{
let Some(v) = p.value.get(name.as_str()) else {
continue;
};
let rendered = match v {
Value::Str(s) => s.clone(),
Value::Date(d) => d.to_string(),
_ => continue,
};
if &rendered != segment {
let position = if i + 1 == n { "filename" } else { "directory" };
check.error(format!(
"{name} '{rendered}' does not match {position} '{segment}'"
));
}
}
let _ = id;
}
for field in &p.kind.fields {
if let Some(v) = p.value.get(&field.name) {
check.walk(&field.name, &field.ty, v, field.refers_to.as_deref());
}
}
}
out
}
fn placeholder_names(pattern: &str) -> Vec<String> {
pattern
.split('/')
.filter_map(|seg| {
let open = seg.find('{')?;
let close = seg.find('}')?;
(open < close).then(|| seg[open + 1..close].to_string())
})
.collect()
}
struct Universe {
ids: HashMap<String, HashSet<String>>,
singletons_present: HashSet<String>,
}
impl Universe {
fn unresolved(&self, link: &Link, systems: &[&str]) -> Option<String> {
let p = link.parts(systems);
if p.is_external() {
return None;
}
if let Some(set) = self.ids.get(p.kind) {
return match p.id {
Some(id) if set.contains(id) => None,
Some(_) => Some(format!(
"link '{link}' does not resolve to a known {}",
p.kind
)),
None => Some(format!(
"link '{link}' names kind '{}' without an id",
p.kind
)),
};
}
Some(format!("unrecognized link '{link}'"))
}
fn unresolved_singleton(&self, link: &Link, kind: &str, id: Option<&str>) -> Option<String> {
match id {
None if self.singletons_present.contains(kind) => None,
None => Some(format!(
"link '{link}' does not resolve: no {kind} record present"
)),
Some(_) => Some(format!("singleton kind '{kind}' takes no id")),
}
}
}
struct Checker<'a> {
path: &'a str,
universe: &'a Universe,
systems: &'a [&'a str],
id_kinds: &'a [&'a str],
singleton_kinds: &'a [&'a str],
out: &'a mut Vec<Violation>,
}
impl Checker<'_> {
fn push(&mut self, severity: Severity, message: String) {
self.out.push(Violation {
path: self.path.to_string(),
severity,
message,
});
}
fn error(&mut self, message: String) {
self.push(Severity::Error, message);
}
fn warn(&mut self, message: String) {
self.push(Severity::Warning, message);
}
fn resolve(&mut self, prefix: &str, link: &Link) {
let p = link.parts(self.systems);
let msg = if self.singleton_kinds.contains(&p.kind) && !p.is_external() {
self.universe.unresolved_singleton(link, p.kind, p.id)
} else {
self.universe.unresolved(link, self.systems)
};
if let Some(msg) = msg {
self.warn(format!("{prefix}{msg}"));
}
}
fn link(&mut self, field: &str, link: &Link, allowed: Option<&[String]>) {
let p = link.parts(self.systems);
if let Some(kinds) = allowed {
if p.recognized(self.id_kinds, self.singleton_kinds)
&& !kinds.iter().any(|k| k == p.namespace())
{
self.error(format!(
"{field} '{link}' has kind '{}' (allowed: {})",
p.namespace(),
kinds.join(", ")
));
return;
}
}
self.resolve(&format!("{field}: "), link);
}
fn inline(&mut self, text: &str) {
for l in Link::find_inline(text) {
let p = l.parts(self.systems);
if !p.recognized(self.id_kinds, self.singleton_kinds) {
continue;
}
self.resolve("inline ", &l);
}
}
fn walk(&mut self, field: &str, ty: &FieldType, v: &Value, refers_to: Option<&str>) {
match (ty, v) {
(FieldType::Link { allowed }, Value::Link(l)) => {
self.link(field, l, allowed.as_deref());
}
(FieldType::Markdown, Value::Str(s)) => self.inline(s),
(FieldType::Str, Value::Str(s)) => {
if let Some(target) = refers_to
&& let Some(set) = self.universe.ids.get(target)
&& !set.contains(s)
{
self.warn(format!("{field} '{s}' is not a known {target}"));
}
}
(FieldType::Option(inner), v) if !matches!(v, Value::Null) => {
self.walk(field, inner, v, refers_to);
}
(FieldType::List(inner), Value::List(vs)) => {
for v in vs {
self.walk(field, inner, v, refers_to);
}
}
(FieldType::Struct(fields), Value::Struct(map)) => {
self.walk_fields(fields, map);
}
(FieldType::Enum(variants), Value::Enum { variant, fields }) => {
if let Some(decl) = variants.iter().find(|d| &d.name == variant) {
self.walk_fields(&decl.fields, fields);
}
}
_ => {}
}
}
fn walk_fields(&mut self, decls: &[Field], values: &std::collections::BTreeMap<String, Value>) {
for f in decls {
if let Some(v) = values.get(&f.name) {
self.walk(&f.name, &f.ty, v, f.refers_to.as_deref());
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::Variant;
fn field(name: &str, ty: FieldType) -> Field {
Field {
name: name.into(),
doc: String::new(),
ty,
role: None,
refers_to: None,
}
}
fn unit(name: &str) -> Variant {
Variant {
name: name.into(),
doc: String::new(),
fields: Vec::new(),
tone: None,
}
}
fn reg() -> Registry {
Registry {
schema_hash: String::new(),
kinds: vec![
Kind {
name: "todo".into(),
doc: String::new(),
storage: "facts/todos/{id}.ron".into(),
fields: vec![
field("id", FieldType::Str),
field("title", FieldType::Str),
field("status", FieldType::Enum(vec![unit("Open"), unit("Done")])),
field("due", FieldType::Option(Box::new(FieldType::Date))),
field(
"blocked_by",
FieldType::Option(Box::new(FieldType::Link {
allowed: Some(vec!["todo".into()]),
})),
),
field(
"sources",
FieldType::List(Box::new(FieldType::Link { allowed: None })),
),
field("notes", FieldType::Markdown),
],
},
Kind {
name: "charter".into(),
doc: String::new(),
storage: "facts/charter.ron".into(),
fields: vec![
field("purpose", FieldType::Str),
field("objectives", FieldType::List(Box::new(FieldType::Markdown))),
field("notes", FieldType::Markdown),
],
},
Kind {
name: "self".into(),
doc: String::new(),
storage: "facts/self.ron".into(),
fields: vec![
field("name", FieldType::Str),
field("role", FieldType::Str),
field("notes", FieldType::Markdown),
],
},
],
queries: vec![],
roles: vec![],
}
}
const SYSTEMS: &[&str] = &["graph", "sharepoint"];
fn v(entries: &[(&str, &str)]) -> Vec<Violation> {
let entries: Vec<(String, String)> = entries
.iter()
.map(|(p, t)| (p.to_string(), t.to_string()))
.collect();
against_registry(®(), &entries, SYSTEMS)
}
fn errors(v: &[Violation]) -> Vec<&Violation> {
v.iter().filter(|x| x.severity == Severity::Error).collect()
}
fn warnings(v: &[Violation]) -> Vec<&Violation> {
v.iter()
.filter(|x| x.severity == Severity::Warning)
.collect()
}
#[test]
fn happy_path_is_clean() {
let out = v(&[
(
"facts/todos/declare-source.ron",
r#"(id: "declare-source", title: "Declare", status: Open)"#,
),
(
"facts/todos/ship-it.ron",
r#"(id: "ship-it", title: "Ship", status: Open, due: Some("2026-07-31"),
blocked_by: Some("todo:declare-source"),
sources: ["graph:email:AAMk=="],
notes: "see [[charter]] and [[todo:declare-source]]")"#,
),
("facts/charter.ron", r#"(purpose: "P", objectives: ["o"])"#),
("facts/self.ron", r#"(name: "Tom", role: "CTO")"#),
]);
assert!(out.is_empty(), "{out:?}");
}
#[test]
fn routing_parse_and_placeholder_mismatches_are_errors() {
let out = v(&[
("facts/notes/x.ron", "(id: \"x\")"),
("facts/todos/readme.txt", "hi"),
("facts/todos/broken.ron", "(id: "),
("facts/todos/ship-it.ron", r#"(id: "ship", title: "T")"#),
(
"facts/todos/bad-date.ron",
r#"(id: "bad-date", title: "T", due: Some("someday"))"#,
),
("tools/elsewhere.txt", "ignored"),
]);
let e = errors(&out);
assert_eq!(e.len(), 5, "{out:?}");
assert!(
e.iter()
.any(|x| x.message.contains("not a recognised KB path")
&& x.message.contains("facts/todos/{id}.ron"))
);
assert!(
e.iter().any(
|x| x.path.contains("broken") && x.message.contains("does not parse as a todo")
)
);
assert!(e.iter().any(|x| {
x.message
.contains("id 'ship' does not match filename 'ship-it'")
}));
assert!(
e.iter()
.any(|x| x.path.contains("bad-date") && x.message.contains("does not parse"))
);
}
#[test]
fn multi_segment_placeholders_check_each_position() {
let mut registry = reg();
registry.kinds.push(Kind {
name: "checkin".into(),
doc: String::new(),
storage: "facts/checkins/{person}/{date}.ron".into(),
fields: vec![
field("person", FieldType::Str),
field("date", FieldType::Date),
],
});
let entries = vec![(
"facts/checkins/jane-doe/2026-07-03.ron".to_string(),
r#"(person: "bob-ray", date: "2026-07-04")"#.to_string(),
)];
let out = against_registry(®istry, &entries, SYSTEMS);
let e = errors(&out);
assert!(e.iter().any(|x| {
x.message
.contains("person 'bob-ray' does not match directory 'jane-doe'")
}));
assert!(e.iter().any(|x| {
x.message
.contains("date '2026-07-04' does not match filename '2026-07-03'")
}));
}
#[test]
fn link_policy_rejects_disallowed_kinds() {
let out = v(&[
(
"facts/todos/b.ron",
r#"(id: "b", title: "B", blocked_by: Some("charter"))"#,
),
("facts/charter.ron", r#"(purpose: "P")"#),
]);
let e = errors(&out);
assert_eq!(e.len(), 1, "{out:?}");
assert!(e[0].message.contains("blocked_by") && e[0].message.contains("kind 'charter'"));
}
#[test]
fn dangling_and_unrecognized_links_warn_externals_pass() {
let out = v(&[(
"facts/todos/b.ron",
r#"(id: "b", title: "B", blocked_by: Some("todo:ghost"),
sources: ["jira:EV-12", "graph:email:fine=="])"#,
)]);
assert!(errors(&out).is_empty(), "{out:?}");
let w = warnings(&out);
assert_eq!(w.len(), 2, "{w:?}");
assert!(
w.iter()
.any(|x| x.message.contains("does not resolve to a known todo"))
);
assert!(
w.iter()
.any(|x| x.message.contains("unrecognized link 'jira:EV-12'"))
);
}
#[test]
fn singletons_resolve_iff_present_and_take_no_id() {
let todo = (
"facts/todos/b.ron",
r#"(id: "b", title: "B", notes: "per [[charter]] and [[charter:2026]]")"#,
);
let out = v(&[todo, ("facts/charter.ron", r#"(purpose: "P")"#)]);
assert!(errors(&out).is_empty());
let w = warnings(&out);
assert_eq!(w.len(), 1, "{w:?}");
assert!(
w[0].message
.contains("singleton kind 'charter' takes no id")
);
let out = v(&[todo]);
assert!(
warnings(&out)
.iter()
.any(|x| x.message.contains("no charter record present"))
);
}
#[test]
fn inline_links_are_scanned_in_every_markdown_store() {
let out = v(&[
(
"facts/todos/b.ron",
r#"(id: "b", title: "B", notes: "see [[todo:ghost1]]; [[not a link]] is prose")"#,
),
(
"facts/charter.ron",
r#"(purpose: "P", objectives: ["see [[todo:ghost2]]"], notes: "see [[todo:ghost3]]")"#,
),
(
"facts/self.ron",
r#"(name: "T", role: "R", notes: "see [[todo:ghost4]]")"#,
),
]);
assert!(errors(&out).is_empty());
let inline: Vec<_> = warnings(&out)
.into_iter()
.filter(|x| x.message.starts_with("inline"))
.collect();
assert_eq!(inline.len(), 4, "{out:?}");
}
#[test]
fn refers_to_slugs_warn_when_unknown() {
let mut registry = reg();
let mut person_field = field("person", FieldType::Str);
person_field.refers_to = Some("todo".into());
registry.kinds.push(Kind {
name: "checkin".into(),
doc: String::new(),
storage: "facts/checkins/{person}/{date}.ron".into(),
fields: vec![person_field, field("date", FieldType::Date)],
});
let entries = vec![(
"facts/checkins/ghost/2026-07-01.ron".to_string(),
r#"(person: "ghost", date: "2026-07-01")"#.to_string(),
)];
let out = against_registry(®istry, &entries, SYSTEMS);
assert!(errors(&out).is_empty(), "{out:?}");
assert!(
warnings(&out)
.iter()
.any(|x| x.message.contains("person 'ghost' is not a known todo"))
);
}
#[test]
fn enum_payload_links_are_checked() {
let mut registry = reg();
registry.kinds.push(Kind {
name: "probe".into(),
doc: String::new(),
storage: "facts/probes/{id}.ron".into(),
fields: vec![
field("id", FieldType::Str),
Field {
name: "client".into(),
doc: String::new(),
ty: FieldType::Enum(vec![
unit("Unknown"),
Variant {
name: "Known".into(),
doc: String::new(),
fields: vec![field(
"todo",
FieldType::Link {
allowed: Some(vec!["todo".into()]),
},
)],
tone: None,
},
]),
role: None,
refers_to: None,
},
],
});
let entries = vec![(
"facts/probes/p1.ron".to_string(),
r#"(id: "p1", client: Known(todo: "charter"))"#.to_string(),
)];
let out = against_registry(®istry, &entries, SYSTEMS);
assert!(
errors(&out)
.iter()
.any(|x| x.message.contains("todo") && x.message.contains("kind 'charter'")),
"{out:?}"
);
}
}