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;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViolationKind {
MalformedAdr,
BrokenLink,
UnknownAdr,
InactiveAdr,
DuplicateAdrId,
}
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",
}
}
}
#[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 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()
}
#[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]) -> 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));
}
overlay
}
pub fn validate(
store: &Store,
docs: &[AdrDoc],
blueprints: &[BlueprintDoc],
annotations: &[Annotation],
) -> Result<Validation, StoreError> {
let mut report = CheckReport {
adrs: docs.len(),
blueprints: blueprints.len(),
violations: duplicate_adr_ids(docs),
..CheckReport::default()
};
let overlay = authored_overlay(docs, blueprints);
let mut edges = Vec::new();
let links = docs
.iter()
.flat_map(|d| &d.links)
.chain(blueprints.iter().flat_map(|b| &b.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> {
for doc in docs {
store.apply_factset(&doc.facts())?;
}
for bp in blueprints {
store.apply_factset(&bp.facts())?;
}
let validation = validate(store, docs, blueprints, annotations)?;
for edge in &validation.edges {
store.insert_edge(edge)?;
}
Ok(validation.report)
}
#[cfg(test)]
mod tests {
use super::{ViolationKind, run};
use crate::adr::parse_adr;
use crate::annotate::scan_annotations;
use rto_graph::{Node, NodeKind, Store};
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 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);
}
}