use std::collections::{BTreeMap, BTreeSet};
use rto_graph::{Edge, EdgeKind, Store, StoreError};
use serde::Serialize;
use crate::adr::{AdrDoc, AdrStatus};
use crate::annotate::Annotation;
use crate::blueprint::BlueprintDoc;
use crate::layer::AuthoredDocs;
use crate::site::SitePage;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViolationKind {
MalformedAdr,
BrokenLink,
UnknownAdr,
InactiveAdr,
DuplicateAdrId,
AdrVersionDrift,
MalformedSitePage,
DuplicateSiteSlug,
}
impl ViolationKind {
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::MalformedAdr => "malformed-adr",
Self::BrokenLink => "broken-link",
Self::UnknownAdr => "unknown-adr",
Self::InactiveAdr => "inactive-adr",
Self::DuplicateAdrId => "duplicate-adr-id",
Self::AdrVersionDrift => "adr-version-drift",
Self::MalformedSitePage => "malformed-site-page",
Self::DuplicateSiteSlug => "duplicate-site-slug",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Violation {
pub kind: ViolationKind,
pub message: String,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct CheckReport {
pub adrs: usize,
pub blueprints: usize,
pub site_pages: usize,
pub links_ok: usize,
pub annotations_ok: usize,
pub violations: Vec<Violation>,
}
impl CheckReport {
#[must_use]
pub fn has_violations(&self) -> bool {
!self.violations.is_empty()
}
}
fn duplicate_adr_ids(docs: &[AdrDoc]) -> Vec<Violation> {
let mut by_id: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for doc in docs {
by_id
.entry(doc.meta.id.as_str())
.or_default()
.push(doc.path.as_str());
}
by_id
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(id, mut paths)| {
paths.sort_unstable();
Violation {
kind: ViolationKind::DuplicateAdrId,
message: format!(
"adr-id {id} is declared by {} files: {} — all of them collapse \
into the single node `adr:{id}`, so only one decision survives \
and every @rto:{id} annotation binds to it",
paths.len(),
paths.join(", "),
),
}
})
.collect()
}
fn duplicate_site_slugs(pages: &[SitePage]) -> Vec<Violation> {
let mut by_slug: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for page in pages {
by_slug
.entry(page.slug.as_str())
.or_default()
.push(page.path.as_str());
}
by_slug
.into_iter()
.filter(|(_, paths)| paths.len() > 1)
.map(|(slug, mut paths)| {
paths.sort_unstable();
Violation {
kind: ViolationKind::DuplicateSiteSlug,
message: format!(
"site-page slug `{slug}` is declared by {} files: {} — all of \
them collapse into the single node `site:{slug}` and the single \
published page `{slug}.html`, so only one document survives",
paths.len(),
paths.join(", "),
),
}
})
.collect()
}
fn adr_version_drift(docs: &[AdrDoc]) -> Vec<Violation> {
let mut out = Vec::new();
for doc in docs {
let path = &doc.path;
let facts = &doc.versions;
if let (Some(front), Some(row)) = (doc.meta.version, facts.summary_row)
&& front != row
{
out.push(Violation {
kind: ViolationKind::AdrVersionDrift,
message: format!(
"{path}: frontmatter says version {front} but the summary \
table's **Document version** row says {row}"
),
});
}
for pair in facts.history.windows(2) {
let (prev, next) = (pair[0], pair[1]);
if next > prev {
continue;
}
let why = if next == prev {
"twice"
} else {
"out of order"
};
out.push(Violation {
kind: ViolationKind::AdrVersionDrift,
message: format!(
"{path}: version history lists {next} after {prev} — {why}; the \
rows must ascend so the document reads as its own changelog"
),
});
}
if facts.history.is_empty() {
continue;
}
for reference in &facts.inline_refs {
if facts.history.contains(&reference.version) {
continue;
}
let known = facts
.history
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
out.push(Violation {
kind: ViolationKind::AdrVersionDrift,
message: format!(
"{path}:{}: an inline note cites (Update, v{}), a version this \
document has never had — its history records {known}",
reference.line, reference.version
),
});
}
}
out
}
#[derive(Debug, Clone, Default)]
pub struct Validation {
pub report: CheckReport,
pub edges: Vec<Edge>,
}
#[derive(Debug, Default)]
struct AuthoredOverlay {
keys: BTreeSet<String>,
adr_status: BTreeMap<String, AdrStatus>,
}
fn authored_overlay(
docs: &[AdrDoc],
blueprints: &[BlueprintDoc],
site: &[SitePage],
) -> AuthoredOverlay {
let mut overlay = AuthoredOverlay::default();
for doc in docs {
overlay
.keys
.extend(doc.facts().nodes.into_iter().map(|n| n.key));
overlay.adr_status.insert(doc.key(), doc.meta.status);
}
for bp in blueprints {
overlay
.keys
.extend(bp.facts().nodes.into_iter().map(|n| n.key));
}
for page in site {
overlay
.keys
.extend(page.facts().nodes.into_iter().map(|n| n.key));
}
overlay
}
pub fn validate(
store: &Store,
docs: &[AdrDoc],
blueprints: &[BlueprintDoc],
annotations: &[Annotation],
) -> Result<Validation, StoreError> {
validate_all(store, docs, blueprints, &[], annotations)
}
pub fn validate_layer(store: &Store, docs: &AuthoredDocs) -> Result<Validation, StoreError> {
validate_all(
store,
&docs.layer.docs,
&docs.layer.blueprints,
&docs.site,
&docs.layer.annotations,
)
}
fn validate_all(
store: &Store,
docs: &[AdrDoc],
blueprints: &[BlueprintDoc],
site: &[SitePage],
annotations: &[Annotation],
) -> Result<Validation, StoreError> {
let mut report = CheckReport {
adrs: docs.len(),
blueprints: blueprints.len(),
site_pages: site.len(),
violations: duplicate_adr_ids(docs),
..CheckReport::default()
};
report.violations.extend(duplicate_site_slugs(site));
report.violations.extend(adr_version_drift(docs));
let overlay = authored_overlay(docs, blueprints, site);
let mut edges = Vec::new();
let links = docs
.iter()
.flat_map(|d| &d.links)
.chain(blueprints.iter().flat_map(|b| &b.links))
.chain(site.iter().flat_map(|p| &p.links));
for link in links {
if store.get_node(&link.target_key)?.is_some() || overlay.keys.contains(&link.target_key) {
edges.push(Edge::authored(
link.from.clone(),
link.target_key.clone(),
EdgeKind::References,
));
report.links_ok += 1;
} else {
report.violations.push(Violation {
kind: ViolationKind::BrokenLink,
message: format!(
"{}: authored link [[{}]] does not resolve ({} not found in graph)",
link.from, link.raw, link.target_key
),
});
}
}
for ann in annotations {
let key = ann.target_key();
let status = match overlay.adr_status.get(&key) {
Some(status) => Some(*status),
None => match store.get_node(&key)? {
Some(adr) => Some(
adr.meta
.get("status")
.and_then(|s| s.as_str())
.and_then(|s| s.parse::<AdrStatus>().ok())
.unwrap_or(AdrStatus::Accepted),
),
None => None,
},
};
let Some(status) = status else {
report.violations.push(Violation {
kind: ViolationKind::UnknownAdr,
message: format!(
"{}:{}: @rto:{} references unknown ADR",
ann.path, ann.line, ann.adr_id
),
});
continue;
};
if !status.is_active() {
report.violations.push(Violation {
kind: ViolationKind::InactiveAdr,
message: format!(
"{}:{}: @rto:{} references non-active ADR ({})",
ann.path,
ann.line,
ann.adr_id,
status.as_str()
),
});
continue;
}
let file_key = format!("file:{}", ann.path);
if store.get_node(&file_key)?.is_some() {
edges.push(Edge::authored(file_key, key, EdgeKind::References));
}
report.annotations_ok += 1;
}
Ok(Validation { report, edges })
}
pub fn run(
store: &mut Store,
docs: &[AdrDoc],
blueprints: &[BlueprintDoc],
annotations: &[Annotation],
) -> Result<CheckReport, StoreError> {
run_all(store, docs, blueprints, &[], annotations)
}
pub fn run_layer(store: &mut Store, docs: &AuthoredDocs) -> Result<CheckReport, StoreError> {
run_all(
store,
&docs.layer.docs,
&docs.layer.blueprints,
&docs.site,
&docs.layer.annotations,
)
}
fn run_all(
store: &mut Store,
docs: &[AdrDoc],
blueprints: &[BlueprintDoc],
site: &[SitePage],
annotations: &[Annotation],
) -> Result<CheckReport, StoreError> {
for doc in docs {
store.apply_factset(&doc.facts())?;
}
for bp in blueprints {
store.apply_factset(&bp.facts())?;
}
for page in site {
store.apply_factset(&page.facts())?;
}
let validation = validate_all(store, docs, blueprints, site, annotations)?;
for edge in &validation.edges {
store.insert_edge(edge)?;
}
Ok(validation.report)
}
#[cfg(test)]
mod tests {
use super::{ViolationKind, run, run_layer};
use crate::adr::parse_adr;
use crate::annotate::scan_annotations;
use crate::layer::{AuthoredDocs, AuthoredLayer};
use crate::site::parse_site_page;
use rto_graph::{Node, NodeKind, Store};
fn site_layer(pages: Vec<crate::site::SitePage>) -> AuthoredDocs {
AuthoredDocs {
site: pages,
..AuthoredDocs::default()
}
}
fn seed_graph(store: &Store) {
store
.upsert_node(&Node::new("file:src/store.rs", NodeKind::File, "store.rs"))
.expect("file");
store
.upsert_node(&Node::new(
"sym:rust:src/store.rs#Store",
NodeKind::Struct,
"Store",
))
.expect("sym");
}
#[test]
fn resolvable_links_and_annotations_pass() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let adr = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n## Design\n\nUses [[src/store.rs#Store]].\n";
let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
let anns = scan_annotations("src/store.rs", "//! @rto:0001\n");
let report = run(&mut store, &[doc], &[], &anns).expect("run");
assert!(!report.has_violations(), "{:?}", report.violations);
assert_eq!(report.links_ok, 1);
assert_eq!(report.annotations_ok, 1);
let edges = store.edges_from("adr:0001#design").expect("edges");
assert!(edges.iter().any(|e| e.dst == "sym:rust:src/store.rs#Store"));
}
#[test]
fn a_site_page_s_links_are_drift_checked_like_an_adr_s() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let ok = parse_site_page(
"docs/site/modes.md",
"---\nsite-page: modes\n---\n\n# Modes\n\n## Offline\n\nSee [[src/store.rs#Store]].\n",
)
.expect("parse");
let report = run_layer(&mut store, &site_layer(vec![ok])).expect("run");
assert!(!report.has_violations(), "{:?}", report.violations);
assert_eq!(report.site_pages, 1);
assert_eq!(report.links_ok, 1);
let edges = store.edges_from("site:modes#offline").expect("edges");
assert!(edges.iter().any(|e| e.dst == "sym:rust:src/store.rs#Store"));
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let stale = parse_site_page(
"docs/site/modes.md",
"---\nsite-page: modes\n---\n\n# Modes\n\nSee [[src/store.rs#Ghost]].\n",
)
.expect("parse");
let report = run_layer(&mut store, &site_layer(vec![stale])).expect("run");
assert_eq!(report.violations.len(), 1);
assert_eq!(report.violations[0].kind, ViolationKind::BrokenLink);
}
#[test]
fn two_pages_sharing_a_slug_are_a_violation_naming_both_files() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let one = parse_site_page(
"docs/site/config.md",
"---\nsite-page: config\n---\n\n# Configuration\n",
)
.expect("one");
let two = parse_site_page(
"docs/OFFLINE_SETUP.md",
"---\nsite-page: config\n---\n\n# Offline setup\n",
)
.expect("two");
let report = run_layer(&mut store, &site_layer(vec![one, two])).expect("run");
let dupes: Vec<_> = report
.violations
.iter()
.filter(|v| v.kind == ViolationKind::DuplicateSiteSlug)
.collect();
assert_eq!(dupes.len(), 1, "one finding for the one colliding slug");
let msg = &dupes[0].message;
assert!(msg.contains("config"), "names the slug: {msg}");
assert!(
msg.contains("docs/site/config.md"),
"names the first: {msg}"
);
assert!(
msg.contains("docs/OFFLINE_SETUP.md"),
"names the second: {msg}"
);
}
#[test]
fn the_three_slice_entry_point_still_reaches_the_same_verdict_today() {
let mut a = Store::open_in_memory().expect("store");
seed_graph(&a);
let mut b = Store::open_in_memory().expect("store");
seed_graph(&b);
let adr = "---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# ADR-0001\n\n## Design\n\nUses [[src/store.rs#Store]].\n";
let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
let old = run(&mut a, std::slice::from_ref(&doc), &[], &[]).expect("run");
let new = run_layer(
&mut b,
&AuthoredDocs {
layer: AuthoredLayer {
docs: vec![doc],
..AuthoredLayer::default()
},
..AuthoredDocs::default()
},
)
.expect("run_layer");
assert_eq!(old.adrs, new.adrs);
assert_eq!(old.links_ok, new.links_ok);
assert_eq!(old.violations.len(), new.violations.len());
assert_eq!(old.site_pages, 0, "no site pages via the three-slice form");
assert_eq!(new.site_pages, 0);
}
#[test]
fn broken_link_is_a_violation() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let adr =
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n## Design\n\n[[src/store.rs#Ghost]]\n";
let doc = parse_adr("docs/adr/0001.md", adr).expect("parse");
let report = run(&mut store, &[doc], &[], &[]).expect("run");
assert_eq!(report.violations.len(), 1);
assert_eq!(report.violations[0].kind, ViolationKind::BrokenLink);
}
#[test]
fn two_adrs_sharing_an_id_are_a_violation_naming_both_files() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let one = parse_adr(
"docs/adr/0016-audio-metadata.md",
"---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n# Audio metadata\n\n## Decision\n\nbody\n",
)
.expect("parse one");
let two = parse_adr(
"docs/adr/0016-speculative-decoding.md",
"---\nadr-id: \"0016\"\nstatus: Accepted\n---\n\n# Speculative decoding\n\n## Decision\n\nbody\n",
)
.expect("parse two");
let report = run(&mut store, &[one, two], &[], &[]).expect("run");
let dupes: Vec<_> = report
.violations
.iter()
.filter(|v| v.kind == ViolationKind::DuplicateAdrId)
.collect();
assert_eq!(dupes.len(), 1, "one finding for the one colliding id");
let msg = &dupes[0].message;
assert!(msg.contains("0016"), "names the shared id: {msg}");
assert!(
msg.contains("docs/adr/0016-audio-metadata.md"),
"names the first file: {msg}"
);
assert!(
msg.contains("docs/adr/0016-speculative-decoding.md"),
"names the second file: {msg}"
);
assert!(report.has_violations(), "the gate must fail");
}
#[test]
fn distinct_adr_ids_are_not_a_duplicate_violation() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let one = parse_adr(
"docs/adr/0001-a.md",
"---\nadr-id: \"0001\"\nstatus: Accepted\n---\n\n# A\n\n## Decision\n\nbody\n",
)
.expect("parse one");
let two = parse_adr(
"docs/adr/0002-b.md",
"---\nadr-id: \"0002\"\nstatus: Accepted\n---\n\n# B\n\n## Decision\n\nbody\n",
)
.expect("parse two");
let report = run(&mut store, &[one, two], &[], &[]).expect("run");
assert!(!report.has_violations(), "{:?}", report.violations);
}
#[test]
fn three_files_on_one_id_report_once_and_name_all_three() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let docs: Vec<_> = ["c.md", "a.md", "b.md"]
.iter()
.map(|name| {
parse_adr(
&format!("docs/adr/{name}"),
"---\nadr-id: \"0007\"\nstatus: Accepted\n---\n\n# X\n\n## Decision\n\nbody\n",
)
.expect("parse")
})
.collect();
let report = run(&mut store, &docs, &[], &[]).expect("run");
assert_eq!(report.violations.len(), 1, "one finding, not one per file");
let msg = &report.violations[0].message;
assert!(
msg.contains("docs/adr/a.md, docs/adr/b.md, docs/adr/c.md"),
"names all three in a stable order: {msg}"
);
}
#[test]
fn annotation_to_unknown_and_superseded_adrs() {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let superseded =
"---\nadr-id: \"0002\"\nstatus: Superseded\n---\n\n# Old\n\n## X\n\nbody\n";
let doc = parse_adr("docs/adr/0002.md", superseded).expect("parse");
let anns = scan_annotations("src/store.rs", "// @rto:0002\n// @rto:9999\n");
let report = run(&mut store, &[doc], &[], &anns).expect("run");
let kinds: Vec<_> = report.violations.iter().map(|v| v.kind).collect();
assert!(kinds.contains(&ViolationKind::InactiveAdr));
assert!(kinds.contains(&ViolationKind::UnknownAdr));
assert_eq!(report.annotations_ok, 0);
}
const VERSIONED: &str = "\
---
adr-id: \"0006\"
status: Accepted
version: \"1.4\"
---
# ADR-0006
| Field | Value |
|---|---|
| **Document version** | 1.4 |
## Consequences
The server moved. *(Update, v1.2: it moved again.)*
Taken with `axum` v1.13.0, and boxlite v0.9.7 alongside it.
## Document version history
| Version | Date | Notes |
|---------|------|-------|
| 1.0 | 2026-08-09 | Accepted. |
| 1.1 | 2026-08-09 | Revised. |
| 1.2 | 2026-08-15 | Consequence added. |
| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |
";
fn drift(adr: &str) -> Vec<String> {
let mut store = Store::open_in_memory().expect("store");
seed_graph(&store);
let doc = parse_adr("docs/adr/0006-local-model-serving.md", adr).expect("parse");
let report = run(&mut store, &[doc], &[], &[]).expect("run");
report
.violations
.into_iter()
.inspect(|v| assert_eq!(v.kind, ViolationKind::AdrVersionDrift, "{}", v.message))
.map(|v| v.message)
.collect()
}
#[test]
fn a_self_consistent_adr_reports_nothing() {
assert!(drift(VERSIONED).is_empty());
}
#[test]
fn frontmatter_disagreeing_with_the_summary_row_is_a_violation() {
let msg = &drift(&VERSIONED.replace("version: \"1.4\"", "version: \"1.2\""))[0];
assert!(msg.contains("0006-local-model-serving.md"), "{msg}");
assert!(msg.contains("frontmatter says version 1.2"), "{msg}");
assert!(msg.contains("row says 1.4"), "{msg}");
}
#[test]
fn history_rows_out_of_order_are_a_violation() {
let swapped = VERSIONED.replace(
"| 1.1 | 2026-08-09 | Revised. |",
"| 1.3 | 2026-08-09 | Revised. |",
);
let msg = &drift(&swapped)[0];
assert!(msg.contains("lists 1.2 after 1.3"), "{msg}");
assert!(msg.contains("out of order"), "{msg}");
}
#[test]
fn a_version_listed_twice_is_a_violation() {
let dup = VERSIONED.replace(
"| 1.1 | 2026-08-09 | Revised. |",
"| 1.0 | 2026-08-09 | Revised. |",
);
let msg = &drift(&dup)[0];
assert!(msg.contains("lists 1.0 after 1.0"), "{msg}");
assert!(msg.contains("twice"), "{msg}");
}
#[test]
fn an_inline_note_citing_an_unrecorded_version_is_a_violation() {
let msg = &drift(&VERSIONED.replace("(Update, v1.2:", "(Update, v1.5:"))[0];
assert!(msg.contains("0006-local-model-serving.md:15"), "{msg}");
assert!(msg.contains("(Update, v1.5)"), "{msg}");
assert!(msg.contains("never had"), "{msg}");
assert!(msg.contains("1.0, 1.1, 1.2, 1.4"), "{msg}");
}
#[test]
fn software_versions_in_prose_are_not_document_versions() {
assert!(drift(VERSIONED).is_empty());
let extra = VERSIONED.replace(
"Taken with",
"Released in v1.11.0 and v1.12.0, superseding v0.9. Taken with",
);
assert!(drift(&extra).is_empty(), "{:?}", drift(&extra));
}
#[test]
fn a_history_row_quoting_a_bad_note_is_not_itself_one() {
let quoting = VERSIONED.replace(
"| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
"| 1.4 | 2026-08-18 | An inline note cited *(Update, v1.5)*, now removed. |",
);
assert!(drift("ing).is_empty(), "{:?}", drift("ing));
}
#[test]
fn ten_is_a_later_revision_than_nine() {
let long = VERSIONED.replace(
"| 1.4 | 2026-08-18 | HTTP/2 is a non-goal. |",
"| 1.9 | 2026-08-12 | Step 8b. |\n| 1.10 | 2026-08-12 | Step 8c. |\n| 1.11 | 2026-08-13 | Config keys. |",
);
let long = long.replace("version: \"1.4\"", "version: \"1.11\"");
let long = long.replace(
"| **Document version** | 1.4 |",
"| **Document version** | 1.11 |",
);
assert!(drift(&long).is_empty(), "{:?}", drift(&long));
}
#[test]
fn an_adr_with_no_history_table_is_not_a_violation() {
let none = VERSIONED
.split("## Document version history")
.next()
.expect("body")
.to_owned();
assert!(drift(&none).is_empty(), "{:?}", drift(&none));
}
}