1use crate::{Adr, Repository, Result};
4use std::collections::{HashMap, HashSet};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
9pub enum Severity {
10 Info,
12 Warning,
14 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#[derive(Debug, Clone)]
30pub struct Diagnostic {
31 pub severity: Severity,
33 pub check: Check,
35 pub message: String,
37 pub path: Option<PathBuf>,
39 pub adr_number: Option<u32>,
41}
42
43impl Diagnostic {
44 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 pub fn with_path(mut self, path: impl Into<PathBuf>) -> Self {
57 self.path = Some(path.into());
58 self
59 }
60
61 pub fn with_adr(mut self, number: u32) -> Self {
63 self.adr_number = Some(number);
64 self
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
70pub enum Check {
71 DuplicateNumbers,
73 FileNaming,
75 MissingStatus,
77 BrokenLinks,
79 NumberingGaps,
81 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#[derive(Debug, Default)]
100pub struct DoctorReport {
101 pub diagnostics: Vec<Diagnostic>,
103}
104
105impl DoctorReport {
106 pub fn new() -> Self {
108 Self::default()
109 }
110
111 pub fn add(&mut self, diagnostic: Diagnostic) {
113 self.diagnostics.push(diagnostic);
114 }
115
116 pub fn has_errors(&self) -> bool {
118 self.diagnostics
119 .iter()
120 .any(|d| d.severity == Severity::Error)
121 }
122
123 pub fn has_warnings(&self) -> bool {
125 self.diagnostics
126 .iter()
127 .any(|d| d.severity == Severity::Warning)
128 }
129
130 pub fn is_healthy(&self) -> bool {
132 !self.has_errors() && !self.has_warnings()
133 }
134
135 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
144pub 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 report
158 .diagnostics
159 .sort_by(|a, b| b.severity.cmp(&a.severity));
160
161 Ok(report)
162}
163
164fn 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
196fn 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
221fn check_missing_status(adrs: &[Adr], report: &mut DoctorReport) {
223 use crate::AdrStatus;
224
225 for adr in adrs {
226 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
243fn 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
267fn 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
309fn 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")); 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, 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"), Adr::new(5, "Fifth"), ];
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 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}