Skip to main content

adrs_core/
doctor.rs

1//! Health checks for ADR repositories.
2
3use crate::{Adr, Repository, Result};
4use std::collections::{HashMap, HashSet};
5use std::path::PathBuf;
6
7/// The severity level of a diagnostic.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Severity {
10    /// Informational message.
11    Info,
12    /// Warning that should be addressed.
13    Warning,
14    /// Error that needs to be fixed.
15    Error,
16}
17
18impl std::fmt::Display for Severity {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            Severity::Info => write!(f, "info"),
22            Severity::Warning => write!(f, "warning"),
23            Severity::Error => write!(f, "error"),
24        }
25    }
26}
27
28/// A diagnostic message from a health check.
29#[derive(Debug, Clone)]
30pub struct Diagnostic {
31    /// The severity of this diagnostic.
32    pub severity: Severity,
33    /// The check that produced this diagnostic.
34    pub check: Check,
35    /// A human-readable message describing the issue.
36    pub message: String,
37    /// The path to the affected file, if applicable.
38    pub path: Option<PathBuf>,
39    /// The ADR number, if applicable.
40    pub adr_number: Option<u32>,
41}
42
43impl Diagnostic {
44    /// Create a new diagnostic.
45    pub fn new(severity: Severity, check: Check, message: impl Into<String>) -> Self {
46        Self {
47            severity,
48            check,
49            message: message.into(),
50            path: None,
51            adr_number: None,
52        }
53    }
54
55    /// Set the path for this diagnostic.
56    pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
57        self.path = Some(path.into());
58        self
59    }
60
61    /// Set the ADR number for this diagnostic.
62    pub fn with_adr(mut self, number: u32) -> Self {
63        self.adr_number = Some(number);
64        self
65    }
66}
67
68/// The type of health check.
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum Check {
71    /// Check for duplicate ADR numbers.
72    DuplicateNumbers,
73    /// Check for proper file naming (4-digit padded IDs).
74    FileNaming,
75    /// Check that all ADRs have a status.
76    MissingStatus,
77    /// Check that linked ADRs exist.
78    BrokenLinks,
79    /// Check for sequential numbering gaps.
80    NumberingGaps,
81    /// Check that superseded ADRs have a superseding link.
82    SupersededLinks,
83}
84
85impl std::fmt::Display for Check {
86    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87        match self {
88            Check::DuplicateNumbers => write!(f, "duplicate-numbers"),
89            Check::FileNaming => write!(f, "file-naming"),
90            Check::MissingStatus => write!(f, "missing-status"),
91            Check::BrokenLinks => write!(f, "broken-links"),
92            Check::NumberingGaps => write!(f, "numbering-gaps"),
93            Check::SupersededLinks => write!(f, "superseded-links"),
94        }
95    }
96}
97
98/// Results from running health checks.
99#[derive(Debug, Default)]
100pub struct DoctorReport {
101    /// All diagnostics found.
102    pub diagnostics: Vec<Diagnostic>,
103}
104
105impl DoctorReport {
106    /// Create a new empty report.
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Add a diagnostic to the report.
112    pub fn add(&mut self, diagnostic: Diagnostic) {
113        self.diagnostics.push(diagnostic);
114    }
115
116    /// Check if there are any errors.
117    pub fn has_errors(&self) -> bool {
118        self.diagnostics
119            .iter()
120            .any(|d| d.severity == Severity::Error)
121    }
122
123    /// Check if there are any warnings.
124    pub fn has_warnings(&self) -> bool {
125        self.diagnostics
126            .iter()
127            .any(|d| d.severity == Severity::Warning)
128    }
129
130    /// Check if the report is clean (no warnings or errors).
131    pub fn is_healthy(&self) -> bool {
132        !self.has_errors() && !self.has_warnings()
133    }
134
135    /// Get the count of diagnostics by severity.
136    pub fn count_by_severity(&self, severity: Severity) -> usize {
137        self.diagnostics
138            .iter()
139            .filter(|d| d.severity == severity)
140            .count()
141    }
142}
143
144/// Run all health checks on a repository.
145pub fn check(repo: &Repository) -> Result<DoctorReport> {
146    let adrs = repo.list()?;
147    let mut report = DoctorReport::new();
148
149    check_duplicate_numbers(&adrs, &mut report);
150    check_file_naming(&adrs, &mut report);
151    check_missing_status(&adrs, &mut report);
152    check_broken_links(&adrs, &mut report);
153    check_numbering_gaps(&adrs, &mut report);
154    check_superseded_links(&adrs, &mut report);
155
156    // Sort diagnostics by severity (errors first)
157    report
158        .diagnostics
159        .sort_by(|a, b| b.severity.cmp(&a.severity));
160
161    Ok(report)
162}
163
164/// Check for duplicate ADR numbers.
165fn check_duplicate_numbers(adrs: &[Adr], report: &mut DoctorReport) {
166    let mut seen: HashMap<u32, Vec<&Adr>> = HashMap::new();
167
168    for adr in adrs {
169        seen.entry(adr.number).or_default().push(adr);
170    }
171
172    for (number, duplicates) in seen {
173        if duplicates.len() > 1 {
174            let paths: Vec<_> = duplicates
175                .iter()
176                .filter_map(|a| a.path.as_ref().and_then(|p| p.file_name()))
177                .map(|p| p.to_string_lossy())
178                .collect();
179
180            report.add(
181                Diagnostic::new(
182                    Severity::Error,
183                    Check::DuplicateNumbers,
184                    format!(
185                        "ADR number {} is used by multiple files: {}",
186                        number,
187                        paths.join(", ")
188                    ),
189                )
190                .with_adr(number),
191            );
192        }
193    }
194}
195
196/// Check for proper file naming (4-digit padded IDs).
197fn check_file_naming(adrs: &[Adr], report: &mut DoctorReport) {
198    for adr in adrs {
199        if let Some(path) = &adr.path
200            && let Some(filename) = path.file_name().and_then(|f| f.to_str())
201        {
202            let expected_prefix = format!("{:04}-", adr.number);
203            if !filename.starts_with(&expected_prefix) {
204                report.add(
205                    Diagnostic::new(
206                        Severity::Warning,
207                        Check::FileNaming,
208                        format!(
209                            "File '{}' should start with '{}'",
210                            filename, expected_prefix
211                        ),
212                    )
213                    .with_path(path)
214                    .with_adr(adr.number),
215                );
216            }
217        }
218    }
219}
220
221/// Check that all ADRs have a status.
222fn check_missing_status(adrs: &[Adr], report: &mut DoctorReport) {
223    use crate::AdrStatus;
224
225    for adr in adrs {
226        // Check for custom empty status
227        if let AdrStatus::Custom(s) = &adr.status
228            && s.trim().is_empty()
229        {
230            report.add(
231                Diagnostic::new(
232                    Severity::Warning,
233                    Check::MissingStatus,
234                    format!("ADR {} '{}' has an empty status", adr.number, adr.title),
235                )
236                .with_path(adr.path.clone().unwrap_or_default())
237                .with_adr(adr.number),
238            );
239        }
240    }
241}
242
243/// Check that linked ADRs exist.
244fn check_broken_links(adrs: &[Adr], report: &mut DoctorReport) {
245    let existing_numbers: HashSet<u32> = adrs.iter().map(|a| a.number).collect();
246
247    for adr in adrs {
248        for link in &adr.links {
249            if !existing_numbers.contains(&link.target) {
250                report.add(
251                    Diagnostic::new(
252                        Severity::Error,
253                        Check::BrokenLinks,
254                        format!(
255                            "ADR {} '{}' links to non-existent ADR {}",
256                            adr.number, adr.title, link.target
257                        ),
258                    )
259                    .with_path(adr.path.clone().unwrap_or_default())
260                    .with_adr(adr.number),
261                );
262            }
263        }
264    }
265}
266
267/// Check for gaps in sequential numbering.
268fn check_numbering_gaps(adrs: &[Adr], report: &mut DoctorReport) {
269    if adrs.is_empty() {
270        return;
271    }
272
273    let mut numbers: Vec<u32> = adrs.iter().map(|a| a.number).collect();
274    numbers.sort();
275    numbers.dedup();
276
277    let min = *numbers.first().unwrap();
278    let max = *numbers.last().unwrap();
279
280    let missing: Vec<u32> = (min..=max).filter(|n| !numbers.contains(n)).collect();
281
282    if !missing.is_empty() {
283        let missing_str = if missing.len() <= 5 {
284            missing
285                .iter()
286                .map(|n| n.to_string())
287                .collect::<Vec<_>>()
288                .join(", ")
289        } else {
290            format!(
291                "{}, ... ({} total)",
292                missing[..3]
293                    .iter()
294                    .map(|n| n.to_string())
295                    .collect::<Vec<_>>()
296                    .join(", "),
297                missing.len()
298            )
299        };
300
301        report.add(Diagnostic::new(
302            Severity::Info,
303            Check::NumberingGaps,
304            format!("Missing ADR numbers in sequence: {}", missing_str),
305        ));
306    }
307}
308
309/// Check that superseded ADRs have proper links.
310fn check_superseded_links(adrs: &[Adr], report: &mut DoctorReport) {
311    use crate::{AdrStatus, LinkKind};
312
313    for adr in adrs {
314        if adr.status == AdrStatus::Superseded {
315            let has_superseded_by_link = adr
316                .links
317                .iter()
318                .any(|link| link.kind == LinkKind::SupersededBy);
319
320            if !has_superseded_by_link {
321                report.add(
322                    Diagnostic::new(
323                        Severity::Warning,
324                        Check::SupersededLinks,
325                        format!(
326                            "ADR {} '{}' has status 'Superseded' but no 'Superseded by' link",
327                            adr.number, adr.title
328                        ),
329                    )
330                    .with_path(adr.path.clone().unwrap_or_default())
331                    .with_adr(adr.number),
332                );
333            }
334        }
335    }
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::{AdrLink, AdrStatus, LinkKind};
342
343    #[test]
344    fn test_duplicate_numbers() {
345        let adrs = vec![
346            {
347                let mut adr = Adr::new(1, "First");
348                adr.path = Some(PathBuf::from("0001-first.md"));
349                adr
350            },
351            {
352                let mut adr = Adr::new(1, "Duplicate");
353                adr.path = Some(PathBuf::from("0001-duplicate.md"));
354                adr
355            },
356        ];
357
358        let mut report = DoctorReport::new();
359        check_duplicate_numbers(&adrs, &mut report);
360
361        assert_eq!(report.diagnostics.len(), 1);
362        assert_eq!(report.diagnostics[0].severity, Severity::Error);
363        assert_eq!(report.diagnostics[0].check, Check::DuplicateNumbers);
364    }
365
366    #[test]
367    fn test_file_naming() {
368        let adrs = vec![{
369            let mut adr = Adr::new(1, "Test");
370            adr.path = Some(PathBuf::from("1-test.md")); // Missing padding
371            adr
372        }];
373
374        let mut report = DoctorReport::new();
375        check_file_naming(&adrs, &mut report);
376
377        assert_eq!(report.diagnostics.len(), 1);
378        assert_eq!(report.diagnostics[0].severity, Severity::Warning);
379        assert_eq!(report.diagnostics[0].check, Check::FileNaming);
380    }
381
382    #[test]
383    fn test_broken_links() {
384        let adrs = vec![{
385            let mut adr = Adr::new(1, "Test");
386            adr.links.push(AdrLink {
387                target: 99, // Doesn't exist
388                kind: LinkKind::Supersedes,
389                description: None,
390            });
391            adr
392        }];
393
394        let mut report = DoctorReport::new();
395        check_broken_links(&adrs, &mut report);
396
397        assert_eq!(report.diagnostics.len(), 1);
398        assert_eq!(report.diagnostics[0].severity, Severity::Error);
399        assert_eq!(report.diagnostics[0].check, Check::BrokenLinks);
400    }
401
402    #[test]
403    fn test_numbering_gaps() {
404        let adrs = vec![
405            Adr::new(1, "First"),
406            Adr::new(3, "Third"), // Missing 2
407            Adr::new(5, "Fifth"), // Missing 4
408        ];
409
410        let mut report = DoctorReport::new();
411        check_numbering_gaps(&adrs, &mut report);
412
413        assert_eq!(report.diagnostics.len(), 1);
414        assert_eq!(report.diagnostics[0].severity, Severity::Info);
415        assert!(report.diagnostics[0].message.contains("2"));
416        assert!(report.diagnostics[0].message.contains("4"));
417    }
418
419    #[test]
420    fn test_superseded_without_link() {
421        let adrs = vec![{
422            let mut adr = Adr::new(1, "Old Decision");
423            adr.status = AdrStatus::Superseded;
424            // No SupersededBy link
425            adr
426        }];
427
428        let mut report = DoctorReport::new();
429        check_superseded_links(&adrs, &mut report);
430
431        assert_eq!(report.diagnostics.len(), 1);
432        assert_eq!(report.diagnostics[0].severity, Severity::Warning);
433        assert_eq!(report.diagnostics[0].check, Check::SupersededLinks);
434    }
435
436    #[test]
437    fn test_healthy_repo() {
438        let adrs = vec![
439            {
440                let mut adr = Adr::new(1, "First");
441                adr.path = Some(PathBuf::from("0001-first.md"));
442                adr.status = AdrStatus::Accepted;
443                adr
444            },
445            {
446                let mut adr = Adr::new(2, "Second");
447                adr.path = Some(PathBuf::from("0002-second.md"));
448                adr.status = AdrStatus::Proposed;
449                adr
450            },
451        ];
452
453        let mut report = DoctorReport::new();
454        check_duplicate_numbers(&adrs, &mut report);
455        check_file_naming(&adrs, &mut report);
456        check_broken_links(&adrs, &mut report);
457        check_numbering_gaps(&adrs, &mut report);
458        check_superseded_links(&adrs, &mut report);
459
460        assert!(report.is_healthy());
461    }
462}