use crate::domain::model::check::{CheckViolation, CheckViolationKind, Severity, Violations};
use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::load_defect::LoadDefect;
use crate::domain::model::malformed_entry::MalformedEntry;
use crate::domain::usecases::check::{dr_rules, DrCheckCtx};
use crate::domain::usecases::decision_record::{
DecisionRecordCheckResult, DecisionRecordRepository,
};
use crate::domain::usecases::entry_defect_scanner::EntryDefectScanner;
const SCAN_RULE_ID: &str = "decision-record/scan";
fn locator_to_path(loc: &EntryLocator) -> std::path::PathBuf {
let s = loc.as_str();
let bare = s.strip_prefix("file://").unwrap_or(s);
std::path::PathBuf::from(bare)
}
fn render_defect(defect: &LoadDefect) -> String {
match defect {
LoadDefect::SourceUnreadable { reason } => reason.clone(),
LoadDefect::InvalidFrontmatter { reason } => reason.clone(),
LoadDefect::MissingId => "missing 'id'".to_string(),
LoadDefect::IdPrefixMismatch { expected, found } => {
format!("id '{found}' does not start with prefix '{expected}'")
}
LoadDefect::InvalidStatus { value } => format!("invalid status '{value}'"),
LoadDefect::MissingEventsLog => "missing 'events.jsonl' sibling".to_string(),
LoadDefect::StatusJournalMismatch {
frontmatter,
terminal,
} => format!(
"frontmatter status '{frontmatter}' diverges from event-log terminal '{terminal}'"
),
}
}
pub fn check_decision_records(
repo: &dyn DecisionRecordRepository,
defect_scanner: &dyn EntryDefectScanner,
known_refs: &crate::domain::model::entity_ref::KnownRefs,
tag_descriptors: &crate::domain::model::tag_descriptor::TagDescriptors,
) -> anyhow::Result<Vec<DecisionRecordCheckResult>> {
let mut per_file: Vec<(std::path::PathBuf, Violations)> = Vec::new();
let mut pairs: Vec<(std::path::PathBuf, DecisionRecord)> = Vec::new();
for MalformedEntry {
location, defect, ..
} in defect_scanner.scan()?
{
let path = locator_to_path(&location);
let mut bag = Violations::new();
bag.push(scan_violation(
path.clone(),
Severity::Error,
render_defect(&defect),
));
per_file.push((path, bag));
}
for record in repo.list()? {
let path = locator_to_path(&record.location);
pairs.push((path, record));
}
let dr_ctx = DrCheckCtx {
repo,
records: &pairs,
known_refs,
tag_descriptors,
};
for rule in dr_rules() {
for finding in rule.find(&dr_ctx)? {
let v = finding.violation;
if let Some(entry) = per_file.iter_mut().find(|(p, _)| p == &v.path) {
entry.1.push(v);
} else {
let mut bag = Violations::new();
let path = v.path.clone();
bag.push(v);
per_file.push((path, bag));
}
}
}
Ok(per_file
.into_iter()
.map(|(path, violations)| DecisionRecordCheckResult { path, violations })
.collect())
}
fn scan_violation(path: std::path::PathBuf, severity: Severity, detail: String) -> CheckViolation {
CheckViolation {
rule_id: SCAN_RULE_ID,
path,
severity,
kind: CheckViolationKind::ScanIssue { detail },
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::check::render;
use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::entity_ref::KnownRefs;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::decision_record::tests::{
adr, FakeDecisionRecordRepository, RecordFixture,
};
use std::path::PathBuf;
fn p(s: &str) -> PathBuf {
PathBuf::from(s)
}
fn build(fix: RecordFixture, id: u32) -> DecisionRecord {
use crate::domain::model::record_ref::DecisionRecordRef;
fix.build(DecisionRecordRef::new(format!("ADR-{id:04}")).unwrap())
}
fn run(entries: Vec<(PathBuf, DecisionRecord)>) -> String {
let repo = FakeDecisionRecordRepository::with_pairs(entries);
let results =
check_decision_records(&repo, &repo, &KnownRefs::new(), &TagDescriptors::default())
.expect("check_decision_records failed unexpectedly");
results
.iter()
.flat_map(|r| {
r.violations.iter().map(|v| {
let severity = if v.severity == Severity::Error {
"error"
} else {
"warning"
};
format!("{}: {severity}: {}", r.path.display(), render(&v.kind))
})
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn all_valid_records_produce_no_output() {
let v = run(vec![
(
p("docs/adr/0001-use-rust/index.md"),
build(adr("Use Rust").with_id("ADR-0001").status("accepted"), 1),
),
(
p("docs/adr/0002-use-postgres/index.md"),
build(
adr("Use Postgres").with_id("ADR-0002").status("proposed"),
2,
),
),
]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn no_files_produce_no_output() {
let v = run(vec![]);
assert!(v.is_empty(), "expected no violations, got:\n{v}");
}
#[test]
fn duplicate_record_ids_are_flagged_on_both_files() {
let v = run(vec![
(
p("docs/adr/0001-use-rust/index.md"),
build(adr("Use Rust").with_id("ADR-0001").status("accepted"), 1),
),
(
p("docs/adr/0001-use-rust-copy/index.md"),
build(
adr("Use Rust copy").with_id("ADR-0001").status("accepted"),
1,
),
),
]);
assert!(v.contains("DuplicateId"), "got:\n{v}");
assert_eq!(
v.lines().filter(|l| l.contains("DuplicateId")).count(),
2,
"expected both files flagged, got:\n{v}"
);
}
#[test]
fn supersedes_without_back_link_is_flagged_on_the_target() {
let v = run(vec![
(
p("docs/adr/0001-old/index.md"),
build(adr("Old design").with_id("ADR-0001").status("accepted"), 1),
),
(
p("docs/adr/0002-new/index.md"),
build(
adr("New design")
.with_id("ADR-0002")
.status("accepted")
.with_link("ADR-0001", "supersedes"),
2,
),
),
]);
assert!(
v.contains("0001-old")
&& v.contains("MissingBackPointer")
&& v.contains("back=superseded-by"),
"got:\n{v}"
);
}
#[test]
fn amends_without_back_link_is_flagged_on_the_target() {
let v = run(vec![
(
p("docs/adr/0001-base/index.md"),
build(adr("Base").with_id("ADR-0001").status("accepted"), 1),
),
(
p("docs/adr/0002-amend/index.md"),
build(
adr("Amendment")
.with_id("ADR-0002")
.status("accepted")
.with_link("ADR-0001", "amends"),
2,
),
),
]);
assert!(
v.contains("0001-base")
&& v.contains("MissingBackPointer")
&& v.contains("back=amended-by"),
"got:\n{v}"
);
}
#[test]
fn superseded_by_without_forward_link_is_flagged_on_the_source() {
let v = run(vec![
(
p("docs/adr/0001-old/index.md"),
build(
adr("Old design")
.with_id("ADR-0001")
.status("superseded")
.with_link("ADR-0002", "superseded-by"),
1,
),
),
(
p("docs/adr/0002-new/index.md"),
build(adr("New design").with_id("ADR-0002").status("accepted"), 2),
),
]);
assert!(
v.contains("0002-new")
&& v.contains("MissingForwardLink")
&& v.contains("forward=supersedes"),
"got:\n{v}"
);
}
#[test]
fn fully_paired_supersedes_link_is_silent() {
let v = run(vec![
(
p("docs/adr/0001-old/index.md"),
build(
adr("Old")
.with_id("ADR-0001")
.status("superseded")
.with_link("ADR-0002", "superseded-by"),
1,
),
),
(
p("docs/adr/0002-new/index.md"),
build(
adr("New")
.with_id("ADR-0002")
.status("accepted")
.with_link("ADR-0001", "supersedes"),
2,
),
),
]);
assert!(v.is_empty(), "expected silent, got:\n{v}");
}
}