use crate::{Adr, Repository, Result};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity {
Info,
Warning,
Error,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Severity::Info => write!(f, "info"),
Severity::Warning => write!(f, "warning"),
Severity::Error => write!(f, "error"),
}
}
}
#[derive(Debug, Clone)]
pub struct Diagnostic {
pub severity: Severity,
pub check: Check,
pub message: String,
pub path: Option<PathBuf>,
pub adr_number: Option<u32>,
}
impl Diagnostic {
pub fn new(severity: Severity, check: Check, message: impl Into<String>) -> Self {
Self {
severity,
check,
message: message.into(),
path: None,
adr_number: None,
}
}
pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
self.path = Some(path.into());
self
}
pub fn with_adr(mut self, number: u32) -> Self {
self.adr_number = Some(number);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Check {
DuplicateNumbers,
FileNaming,
MissingStatus,
BrokenLinks,
NumberingGaps,
SupersededLinks,
}
impl std::fmt::Display for Check {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Check::DuplicateNumbers => write!(f, "duplicate-numbers"),
Check::FileNaming => write!(f, "file-naming"),
Check::MissingStatus => write!(f, "missing-status"),
Check::BrokenLinks => write!(f, "broken-links"),
Check::NumberingGaps => write!(f, "numbering-gaps"),
Check::SupersededLinks => write!(f, "superseded-links"),
}
}
}
#[derive(Debug, Default)]
pub struct DoctorReport {
pub diagnostics: Vec<Diagnostic>,
}
impl DoctorReport {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, diagnostic: Diagnostic) {
self.diagnostics.push(diagnostic);
}
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Error)
}
pub fn has_warnings(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.severity == Severity::Warning)
}
pub fn is_healthy(&self) -> bool {
!self.has_errors() && !self.has_warnings()
}
pub fn count_by_severity(&self, severity: Severity) -> usize {
self.diagnostics
.iter()
.filter(|d| d.severity == severity)
.count()
}
}
pub fn check(repo: &Repository) -> Result<DoctorReport> {
let adrs = repo.list()?;
let mut report = DoctorReport::new();
check_duplicate_numbers(&adrs, &mut report);
check_file_naming(&adrs, &mut report);
check_missing_status(&adrs, &mut report);
check_broken_links(&adrs, &mut report);
check_numbering_gaps(&adrs, &mut report);
check_superseded_links(&adrs, &mut report);
report
.diagnostics
.sort_by(|a, b| b.severity.cmp(&a.severity));
Ok(report)
}
fn check_duplicate_numbers(adrs: &[Adr], report: &mut DoctorReport) {
let mut seen: HashMap<u32, Vec<&Adr>> = HashMap::new();
for adr in adrs {
seen.entry(adr.number).or_default().push(adr);
}
for (number, duplicates) in seen {
if duplicates.len() > 1 {
let paths: Vec<_> = duplicates
.iter()
.filter_map(|a| a.path.as_ref().and_then(|p| p.file_name()))
.map(|p| p.to_string_lossy())
.collect();
report.add(
Diagnostic::new(
Severity::Error,
Check::DuplicateNumbers,
format!(
"ADR number {} is used by multiple files: {}",
number,
paths.join(", ")
),
)
.with_adr(number),
);
}
}
}
fn check_file_naming(adrs: &[Adr], report: &mut DoctorReport) {
for adr in adrs {
if let Some(path) = &adr.path
&& let Some(filename) = path.file_name().and_then(|f| f.to_str())
{
let expected_prefix = format!("{:04}-", adr.number);
if !filename.starts_with(&expected_prefix) {
report.add(
Diagnostic::new(
Severity::Warning,
Check::FileNaming,
format!(
"File '{}' should start with '{}'",
filename, expected_prefix
),
)
.with_path(path)
.with_adr(adr.number),
);
}
}
}
}
fn check_missing_status(adrs: &[Adr], report: &mut DoctorReport) {
use crate::AdrStatus;
for adr in adrs {
if let AdrStatus::Custom(s) = &adr.status
&& s.trim().is_empty()
{
report.add(
Diagnostic::new(
Severity::Warning,
Check::MissingStatus,
format!("ADR {} '{}' has an empty status", adr.number, adr.title),
)
.with_path(adr.path.clone().unwrap_or_default())
.with_adr(adr.number),
);
}
}
}
fn check_broken_links(adrs: &[Adr], report: &mut DoctorReport) {
let existing_numbers: HashSet<u32> = adrs.iter().map(|a| a.number).collect();
for adr in adrs {
for link in &adr.links {
if !existing_numbers.contains(&link.target) {
report.add(
Diagnostic::new(
Severity::Error,
Check::BrokenLinks,
format!(
"ADR {} '{}' links to non-existent ADR {}",
adr.number, adr.title, link.target
),
)
.with_path(adr.path.clone().unwrap_or_default())
.with_adr(adr.number),
);
}
}
}
}
fn check_numbering_gaps(adrs: &[Adr], report: &mut DoctorReport) {
if adrs.is_empty() {
return;
}
let mut numbers: Vec<u32> = adrs.iter().map(|a| a.number).collect();
numbers.sort();
numbers.dedup();
let min = *numbers.first().unwrap();
let max = *numbers.last().unwrap();
let missing: Vec<u32> = (min..=max).filter(|n| !numbers.contains(n)).collect();
if !missing.is_empty() {
let missing_str = if missing.len() <= 5 {
missing
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(", ")
} else {
format!(
"{}, ... ({} total)",
missing[..3]
.iter()
.map(|n| n.to_string())
.collect::<Vec<_>>()
.join(", "),
missing.len()
)
};
report.add(Diagnostic::new(
Severity::Info,
Check::NumberingGaps,
format!("Missing ADR numbers in sequence: {}", missing_str),
));
}
}
fn check_superseded_links(adrs: &[Adr], report: &mut DoctorReport) {
use crate::{AdrStatus, LinkKind};
for adr in adrs {
if adr.status == AdrStatus::Superseded {
let has_superseded_by_link = adr
.links
.iter()
.any(|link| link.kind == LinkKind::SupersededBy);
if !has_superseded_by_link {
report.add(
Diagnostic::new(
Severity::Warning,
Check::SupersededLinks,
format!(
"ADR {} '{}' has status 'Superseded' but no 'Superseded by' link",
adr.number, adr.title
),
)
.with_path(adr.path.clone().unwrap_or_default())
.with_adr(adr.number),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AdrLink, AdrStatus, LinkKind};
#[test]
fn test_duplicate_numbers() {
let adrs = vec![
{
let mut adr = Adr::new(1, "First");
adr.path = Some(PathBuf::from("0001-first.md"));
adr
},
{
let mut adr = Adr::new(1, "Duplicate");
adr.path = Some(PathBuf::from("0001-duplicate.md"));
adr
},
];
let mut report = DoctorReport::new();
check_duplicate_numbers(&adrs, &mut report);
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, Severity::Error);
assert_eq!(report.diagnostics[0].check, Check::DuplicateNumbers);
}
#[test]
fn test_file_naming() {
let adrs = vec![{
let mut adr = Adr::new(1, "Test");
adr.path = Some(PathBuf::from("1-test.md")); adr
}];
let mut report = DoctorReport::new();
check_file_naming(&adrs, &mut report);
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, Severity::Warning);
assert_eq!(report.diagnostics[0].check, Check::FileNaming);
}
#[test]
fn test_broken_links() {
let adrs = vec![{
let mut adr = Adr::new(1, "Test");
adr.links.push(AdrLink {
target: 99, kind: LinkKind::Supersedes,
description: None,
});
adr
}];
let mut report = DoctorReport::new();
check_broken_links(&adrs, &mut report);
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, Severity::Error);
assert_eq!(report.diagnostics[0].check, Check::BrokenLinks);
}
#[test]
fn test_numbering_gaps() {
let adrs = vec![
Adr::new(1, "First"),
Adr::new(3, "Third"), Adr::new(5, "Fifth"), ];
let mut report = DoctorReport::new();
check_numbering_gaps(&adrs, &mut report);
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, Severity::Info);
assert!(report.diagnostics[0].message.contains("2"));
assert!(report.diagnostics[0].message.contains("4"));
}
#[test]
fn test_superseded_without_link() {
let adrs = vec![{
let mut adr = Adr::new(1, "Old Decision");
adr.status = AdrStatus::Superseded;
adr
}];
let mut report = DoctorReport::new();
check_superseded_links(&adrs, &mut report);
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].severity, Severity::Warning);
assert_eq!(report.diagnostics[0].check, Check::SupersededLinks);
}
#[test]
fn test_healthy_repo() {
let adrs = vec![
{
let mut adr = Adr::new(1, "First");
adr.path = Some(PathBuf::from("0001-first.md"));
adr.status = AdrStatus::Accepted;
adr
},
{
let mut adr = Adr::new(2, "Second");
adr.path = Some(PathBuf::from("0002-second.md"));
adr.status = AdrStatus::Proposed;
adr
},
];
let mut report = DoctorReport::new();
check_duplicate_numbers(&adrs, &mut report);
check_file_naming(&adrs, &mut report);
check_broken_links(&adrs, &mut report);
check_numbering_gaps(&adrs, &mut report);
check_superseded_links(&adrs, &mut report);
assert!(report.is_healthy());
}
}