1use crate::{Adr, Repository, Result};
8use mdbook_lint_core::Document;
9use mdbook_lint_core::rule::{CollectionRule, Rule};
10use mdbook_lint_rulesets::adr::{
11 Adr001, Adr002, Adr003, Adr004, Adr005, Adr006, Adr007, Adr008, Adr009, Adr010, Adr011, Adr012,
12 Adr013, Adr014, Adr015, Adr016, Adr017,
13};
14use std::path::PathBuf;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum IssueSeverity {
19 Info,
21 Warning,
23 Error,
25}
26
27impl std::fmt::Display for IssueSeverity {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 IssueSeverity::Info => write!(f, "info"),
31 IssueSeverity::Warning => write!(f, "warning"),
32 IssueSeverity::Error => write!(f, "error"),
33 }
34 }
35}
36
37impl From<mdbook_lint_core::Severity> for IssueSeverity {
38 fn from(severity: mdbook_lint_core::Severity) -> Self {
39 match severity {
40 mdbook_lint_core::Severity::Error => IssueSeverity::Error,
41 mdbook_lint_core::Severity::Warning => IssueSeverity::Warning,
42 mdbook_lint_core::Severity::Info => IssueSeverity::Info,
43 }
44 }
45}
46
47#[derive(Debug, Clone)]
49pub struct Issue {
50 pub rule_id: String,
52 pub rule_name: String,
54 pub severity: IssueSeverity,
56 pub message: String,
58 pub path: Option<PathBuf>,
60 pub line: Option<usize>,
62 pub column: Option<usize>,
64 pub adr_number: Option<u32>,
66 pub related_adrs: Vec<u32>,
68}
69
70impl Issue {
71 fn from_violation(
73 violation: mdbook_lint_core::Violation,
74 path: Option<PathBuf>,
75 adr_number: Option<u32>,
76 ) -> Self {
77 Self {
78 rule_id: violation.rule_id,
79 rule_name: violation.rule_name,
80 severity: violation.severity.into(),
81 message: violation.message,
82 path,
83 line: Some(violation.line),
84 column: Some(violation.column),
85 adr_number,
86 related_adrs: Vec::new(),
87 }
88 }
89}
90
91#[derive(Debug, Default)]
93pub struct LintReport {
94 pub issues: Vec<Issue>,
96}
97
98impl LintReport {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn add(&mut self, issue: Issue) {
106 self.issues.push(issue);
107 }
108
109 pub fn has_errors(&self) -> bool {
111 self.issues
112 .iter()
113 .any(|i| i.severity == IssueSeverity::Error)
114 }
115
116 pub fn has_warnings(&self) -> bool {
118 self.issues
119 .iter()
120 .any(|i| i.severity == IssueSeverity::Warning)
121 }
122
123 pub fn is_clean(&self) -> bool {
125 !self.has_errors() && !self.has_warnings()
126 }
127
128 pub fn count_by_severity(&self, severity: IssueSeverity) -> usize {
130 self.issues
131 .iter()
132 .filter(|i| i.severity == severity)
133 .count()
134 }
135
136 pub fn sort(&mut self) {
138 self.issues.sort_by(|a, b| {
139 b.severity
140 .cmp(&a.severity)
141 .then_with(|| a.path.cmp(&b.path))
142 .then_with(|| a.line.cmp(&b.line))
143 });
144 }
145}
146
147pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
151 let mut report = LintReport::new();
152
153 let Some(path) = &adr.path else {
155 return Ok(report); };
157
158 let content = std::fs::read_to_string(path)?;
159
160 let doc = match Document::new(content, path.clone()) {
162 Ok(d) => d,
163 Err(e) => {
164 report.add(Issue {
165 rule_id: "parse-error".to_string(),
166 rule_name: "parse-error".to_string(),
167 severity: IssueSeverity::Error,
168 message: format!("Failed to parse document: {e}"),
169 path: Some(path.clone()),
170 line: None,
171 column: None,
172 adr_number: Some(adr.number),
173 related_adrs: Vec::new(),
174 });
175 return Ok(report);
176 }
177 };
178
179 let rules: Vec<Box<dyn Rule>> = vec![
181 Box::new(Adr001::default()),
182 Box::new(Adr002::default()),
183 Box::new(Adr003::default()),
184 Box::new(Adr004::default()),
185 Box::new(Adr005::default()),
186 Box::new(Adr006::default()),
187 Box::new(Adr007::default()),
188 Box::new(Adr008::default()),
189 Box::new(Adr009::default()),
190 Box::new(Adr014::default()),
191 Box::new(Adr015::default()),
192 Box::new(Adr016::default()),
193 Box::new(Adr017::default()),
194 ];
195
196 for rule in rules {
197 match rule.check(&doc) {
198 Ok(violations) => {
199 for violation in violations {
200 report.add(Issue::from_violation(
201 violation,
202 Some(path.clone()),
203 Some(adr.number),
204 ));
205 }
206 }
207 Err(e) => {
208 report.add(Issue {
209 rule_id: rule.id().to_string(),
210 rule_name: rule.name().to_string(),
211 severity: IssueSeverity::Error,
212 message: format!("Rule failed: {e}"),
213 path: Some(path.clone()),
214 line: None,
215 column: None,
216 adr_number: Some(adr.number),
217 related_adrs: Vec::new(),
218 });
219 }
220 }
221 }
222
223 Ok(report)
224}
225
226pub fn lint_all(repo: &Repository) -> Result<LintReport> {
228 let mut report = LintReport::new();
229 let adrs = repo.list()?;
230
231 for adr in &adrs {
232 let adr_report = lint_adr(adr)?;
233 report.issues.extend(adr_report.issues);
234 }
235
236 report.sort();
237 Ok(report)
238}
239
240pub fn check_repository(repo: &Repository) -> Result<LintReport> {
248 let mut report = LintReport::new();
249 let adrs = repo.list()?;
250
251 let mut documents = Vec::new();
253 for adr in &adrs {
254 if let Some(path) = &adr.path {
255 let content = std::fs::read_to_string(path)?;
256 if let Ok(doc) = Document::new(content, path.clone()) {
257 documents.push(doc);
258 }
259 }
260 }
261
262 let collection_rules: Vec<Box<dyn CollectionRule>> = vec![
264 Box::new(Adr010),
265 Box::new(Adr011),
266 Box::new(Adr012),
267 Box::new(Adr013),
268 ];
269
270 for rule in collection_rules {
271 match rule.check_collection(&documents) {
272 Ok(violations) => {
273 for violation in violations {
274 report.add(Issue {
277 rule_id: rule.id().to_string(),
278 rule_name: rule.name().to_string(),
279 severity: violation.severity.into(),
280 message: violation.message,
281 path: None, line: if violation.line > 0 {
283 Some(violation.line)
284 } else {
285 None
286 },
287 column: if violation.column > 0 {
288 Some(violation.column)
289 } else {
290 None
291 },
292 adr_number: None,
293 related_adrs: Vec::new(),
294 });
295 }
296 }
297 Err(e) => {
298 report.add(Issue {
299 rule_id: rule.id().to_string(),
300 rule_name: rule.name().to_string(),
301 severity: IssueSeverity::Error,
302 message: format!("Rule failed: {e}"),
303 path: None,
304 line: None,
305 column: None,
306 adr_number: None,
307 related_adrs: Vec::new(),
308 });
309 }
310 }
311 }
312
313 report.sort();
314 Ok(report)
315}
316
317pub fn check_all_filtered(
325 repo: &Repository,
326 extra_ignore: &[String],
327) -> Result<(LintReport, usize)> {
328 let mut report = LintReport::new();
329
330 let (adrs, parse_errors) = repo.list_with_errors()?;
332
333 for (path, error) in &parse_errors {
335 report.add(Issue {
336 rule_id: "parse-error".to_string(),
337 rule_name: "adr-parse-error".to_string(),
338 severity: IssueSeverity::Error,
339 message: format!("Failed to parse ADR: {error}"),
340 path: Some(path.clone()),
341 line: None,
342 column: None,
343 adr_number: None,
344 related_adrs: Vec::new(),
345 });
346 }
347
348 for adr in &adrs {
350 let adr_report = lint_adr(adr)?;
351 report.issues.extend(adr_report.issues);
352 }
353
354 let repo_report = check_repository(repo)?;
357 report.issues.extend(repo_report.issues);
358
359 report.sort();
360
361 let ignore_set: std::collections::HashSet<String> = repo
362 .config()
363 .doctor
364 .ignore
365 .iter()
366 .chain(extra_ignore.iter())
367 .map(|s| s.to_lowercase())
368 .collect();
369
370 if ignore_set.is_empty() {
371 return Ok((report, 0));
372 }
373
374 let before = report.issues.len();
375 report.issues.retain(|issue| {
376 !ignore_set.contains(&issue.rule_id.to_lowercase())
377 && !ignore_set.contains(&issue.rule_name.to_lowercase())
378 });
379 let suppressed = before - report.issues.len();
380
381 Ok((report, suppressed))
382}
383
384pub fn check_all(repo: &Repository) -> Result<LintReport> {
389 check_all_filtered(repo, &[]).map(|(report, _)| report)
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395 use crate::Adr;
396
397 #[test]
398 fn test_issue_severity_ordering() {
399 assert!(IssueSeverity::Error > IssueSeverity::Warning);
400 assert!(IssueSeverity::Warning > IssueSeverity::Info);
401 }
402
403 #[test]
404 fn test_lint_report_empty() {
405 let report = LintReport::new();
406 assert!(report.is_clean());
407 assert!(!report.has_errors());
408 assert!(!report.has_warnings());
409 }
410
411 #[test]
412 fn test_lint_report_with_issues() {
413 let mut report = LintReport::new();
414 report.add(Issue {
415 rule_id: "ADR001".to_string(),
416 rule_name: "adr-title-format".to_string(),
417 severity: IssueSeverity::Error,
418 message: "Title format invalid".to_string(),
419 path: Some(PathBuf::from("0001-test.md")),
420 line: Some(1),
421 column: Some(1),
422 adr_number: Some(1),
423 related_adrs: Vec::new(),
424 });
425
426 assert!(report.has_errors());
427 assert!(!report.is_clean());
428 assert_eq!(report.count_by_severity(IssueSeverity::Error), 1);
429 }
430
431 #[test]
432 fn test_lint_valid_nygard_adr() {
433 let content = format!(
436 r#"# 1. Record architecture decisions
437
438Date: 2024-03-04
439
440## Status
441
442Accepted
443
444## Context
445
446{}
447
448## Decision
449
450{}
451
452## Consequences
453
454{}
455"#,
456 crate::init_adr::CONTEXT,
457 crate::init_adr::DECISION,
458 crate::init_adr::CONSEQUENCES,
459 );
460 let temp_dir = tempfile::tempdir().unwrap();
461 let path = temp_dir
462 .path()
463 .join("adr")
464 .join("0001-record-architecture-decisions.md");
465 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
466 std::fs::write(&path, content).unwrap();
467
468 let mut adr = Adr::new(1, "Record architecture decisions");
469 adr.path = Some(path);
470
471 let report = lint_adr(&adr).unwrap();
472
473 for issue in &report.issues {
475 println!(
476 "{}: {} ({}:{})",
477 issue.rule_id,
478 issue.message,
479 issue.line.unwrap_or(0),
480 issue.column.unwrap_or(0)
481 );
482 }
483
484 assert!(report.is_clean(), "Expected no issues for valid Nygard ADR");
485 }
486
487 #[test]
488 fn test_lint_invalid_adr_missing_status() {
489 let content = r#"# 1. Test decision
490
491Date: 2024-03-04
492
493## Context
494
495Some context.
496
497## Decision
498
499Some decision.
500
501## Consequences
502
503Some consequences.
504"#;
505 let temp_dir = tempfile::tempdir().unwrap();
506 let path = temp_dir.path().join("adr").join("0001-test-decision.md");
507 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
508 std::fs::write(&path, content).unwrap();
509
510 let mut adr = Adr::new(1, "Test decision");
511 adr.path = Some(path);
512
513 let report = lint_adr(&adr).unwrap();
514
515 assert!(
517 !report.is_clean(),
518 "Expected issues for ADR missing status section"
519 );
520 assert!(
521 report.issues.iter().any(|i| i.rule_id == "ADR002"),
522 "Expected ADR002 (missing status) violation"
523 );
524 }
525
526 #[test]
527 fn test_nygard_bare_minimal_template_passes_doctor() {
528 use crate::{Adr, Config, Repository, Template, TemplateFormat, TemplateVariant};
533
534 let temp = tempfile::tempdir().unwrap();
535 let repo = Repository::init(temp.path(), None, false).unwrap();
536
537 let template =
538 Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
539 let adr = Adr::new(2, "Bare minimal regression");
540 let rendered = template
541 .render(&adr, &Config::default(), &std::collections::HashMap::new())
542 .unwrap();
543 let path = repo.adr_path().join("0002-bare-minimal-regression.md");
544 std::fs::write(&path, rendered).unwrap();
545
546 let report = check_all(&repo).unwrap();
547 let file_errors: Vec<_> = report
548 .issues
549 .iter()
550 .filter(|i| i.severity == IssueSeverity::Error)
551 .filter(|i| {
552 i.path
553 .as_ref()
554 .is_some_and(|p| p.to_string_lossy().contains("0002-bare-minimal-regression"))
555 })
556 .collect();
557 assert!(
558 file_errors.is_empty(),
559 "nygard bare-minimal output should have no doctor errors, got: {file_errors:?}"
560 );
561 }
562
563 #[test]
564 fn test_check_all_reports_parse_errors() {
565 use crate::Repository;
566
567 let temp = tempfile::tempdir().unwrap();
568 let repo = Repository::init(temp.path(), None, true).unwrap();
569
570 let bad_content =
572 "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad Date\n";
573 std::fs::write(repo.adr_path().join("0002-bad-date.md"), bad_content).unwrap();
574
575 let report = check_all(&repo).unwrap();
576
577 let parse_errors: Vec<_> = report
578 .issues
579 .iter()
580 .filter(|i| i.rule_id == "parse-error")
581 .collect();
582
583 assert_eq!(parse_errors.len(), 1, "should report 1 parse error");
584 assert_eq!(parse_errors[0].severity, IssueSeverity::Error);
585 assert!(
586 parse_errors[0]
587 .path
588 .as_ref()
589 .unwrap()
590 .to_string_lossy()
591 .contains("0002-bad-date.md")
592 );
593 }
594
595 #[test]
596 fn test_check_all_no_parse_errors_for_string_decision_makers() {
597 use crate::Repository;
598
599 let temp = tempfile::tempdir().unwrap();
600 let repo = Repository::init(temp.path(), None, true).unwrap();
601
602 let content = "---\nnumber: 2\nstatus: accepted\ndate: 2026-03-18\ndecision-makers: alice\n---\n\n# 2. Test\n\n## Context\n\nContext.\n\n## Decision\n\nDecision.\n\n## Consequences\n\nConsequences.\n";
604 std::fs::write(repo.adr_path().join("0002-test.md"), content).unwrap();
605
606 let report = check_all(&repo).unwrap();
607
608 let parse_errors: Vec<_> = report
609 .issues
610 .iter()
611 .filter(|i| i.rule_id == "parse-error")
612 .collect();
613
614 assert!(
615 parse_errors.is_empty(),
616 "string decision-makers should not cause parse error, got: {:?}",
617 parse_errors.iter().map(|i| &i.message).collect::<Vec<_>>()
618 );
619 }
620 fn make_nygard_adr(number: u32, title: &str, status: &str, links: &str) -> String {
623 format!(
624 "# {}. {}\n\nDate: 2024-01-01\n\n## Status\n\n{}{}\n## Context\n\nSome context.\n\n## Decision\n\nA decision.\n\n## Consequences\n\nSome consequences.\n",
625 number, title, status, links
626 )
627 }
628
629 #[test]
630 fn test_check_repository_broken_link_adr013() {
631 use crate::Repository;
632
633 let temp = tempfile::tempdir().unwrap();
634 let repo = Repository::init(temp.path(), None, false).unwrap();
636 let adr_dir = repo.adr_path();
637
638 std::fs::write(
640 adr_dir.join("0002-second.md"),
641 make_nygard_adr(
642 2,
643 "Second",
644 "Accepted",
645 "\n\nSupersedes [99. Unknown](0099-unknown.md)\n",
646 ),
647 )
648 .unwrap();
649
650 let report = check_repository(&repo).unwrap();
651
652 let has_adr013 = report.issues.iter().any(|i| i.rule_id == "ADR013");
654 assert!(
655 has_adr013,
656 "Expected ADR013 broken-link issue, got: {:?}",
657 report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
658 );
659 }
660
661 #[test]
662 fn test_check_repository_sequential_gap_adr011() {
663 use crate::Repository;
664
665 let temp = tempfile::tempdir().unwrap();
666 let repo = Repository::init(temp.path(), None, false).unwrap();
668 let adr_dir = repo.adr_path();
669
670 std::fs::write(
672 adr_dir.join("0002-second.md"),
673 make_nygard_adr(2, "Second", "Accepted", ""),
674 )
675 .unwrap();
676 std::fs::write(
677 adr_dir.join("0004-fourth.md"),
678 make_nygard_adr(4, "Fourth", "Accepted", ""),
679 )
680 .unwrap();
681
682 let report = check_repository(&repo).unwrap();
683
684 let has_adr011 = report.issues.iter().any(|i| i.rule_id == "ADR011");
686 assert!(
687 has_adr011,
688 "Expected ADR011 sequential-gap issue, got: {:?}",
689 report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
690 );
691 }
692
693 #[test]
694 fn test_check_repository_clean_repo_has_no_issues() {
695 use crate::Repository;
696
697 let temp = tempfile::tempdir().unwrap();
698 let repo = Repository::init(temp.path(), None, false).unwrap();
699 let adr_dir = repo.adr_path();
700
701 std::fs::write(
703 adr_dir.join("0002-second.md"),
704 make_nygard_adr(2, "Second", "Accepted", ""),
705 )
706 .unwrap();
707 std::fs::write(
708 adr_dir.join("0003-third.md"),
709 make_nygard_adr(3, "Third", "Proposed", ""),
710 )
711 .unwrap();
712
713 let report = check_repository(&repo).unwrap();
714
715 let collection_rule_ids = ["ADR010", "ADR011", "ADR012", "ADR013"];
716 let collection_issues: Vec<_> = report
717 .issues
718 .iter()
719 .filter(|i| collection_rule_ids.contains(&i.rule_id.as_str()))
720 .collect();
721
722 assert!(
723 collection_issues.is_empty(),
724 "Clean repo should have no collection-rule issues, got: {:?}",
725 collection_issues
726 .iter()
727 .map(|i| format!("{}: {}", i.rule_id, i.message))
728 .collect::<Vec<_>>()
729 );
730 }
731
732 #[test]
733 fn test_check_all_combines_lint_and_repository_checks() {
734 use crate::Repository;
735
736 let temp = tempfile::tempdir().unwrap();
737 let repo = Repository::init(temp.path(), None, false).unwrap();
738 let adr_dir = repo.adr_path();
739
740 std::fs::write(
742 adr_dir.join("0001-first.md"),
743 make_nygard_adr(1, "First", "Accepted", ""),
744 )
745 .unwrap();
746
747 let report = check_all(&repo).unwrap();
749
750 let adr011 = report
752 .issues
753 .iter()
754 .filter(|i| i.rule_id == "ADR011")
755 .count();
756 assert_eq!(
757 adr011, 0,
758 "Single valid ADR should have no sequential-gap issue"
759 );
760 }
761
762 #[test]
765 fn test_check_all_filtered_suppresses_ignored_rule() {
766 use crate::Repository;
767
768 let temp = tempfile::tempdir().unwrap();
769 let repo = Repository::init(temp.path(), None, false).unwrap();
773 let adr_dir = repo.adr_path();
774 std::fs::write(
775 adr_dir.join("0002-second.md"),
776 make_nygard_adr(2, "Second", "Accepted", ""),
777 )
778 .unwrap();
779 std::fs::write(
780 adr_dir.join("0004-fourth.md"),
781 make_nygard_adr(4, "Fourth", "Accepted", ""),
782 )
783 .unwrap();
784
785 let unfiltered = check_repository(&repo).unwrap();
787 let unfiltered_adr011 = unfiltered
788 .issues
789 .iter()
790 .filter(|i| i.rule_id == "ADR011")
791 .count();
792 assert!(
793 unfiltered_adr011 > 0,
794 "expected check_repository to report ADR011 before filtering"
795 );
796
797 std::fs::write(
801 temp.path().join("adrs.toml"),
802 "adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"adr011\"]\n",
803 )
804 .unwrap();
805 let repo = Repository::open(temp.path()).unwrap();
806 assert_eq!(repo.config().doctor.ignore, vec!["adr011".to_string()]);
807
808 let filtered = check_all(&repo).unwrap();
811 let filtered_adr011 = filtered
812 .issues
813 .iter()
814 .filter(|i| i.rule_id == "ADR011")
815 .count();
816 assert_eq!(
817 filtered_adr011, 0,
818 "check_all should suppress ADR011 issues per [doctor].ignore"
819 );
820
821 let still_unfiltered = check_repository(&repo).unwrap();
824 assert!(
825 still_unfiltered
826 .issues
827 .iter()
828 .any(|i| i.rule_id == "ADR011"),
829 "check_repository should remain unfiltered"
830 );
831 }
832
833 #[test]
834 fn test_check_all_filtered_returns_suppressed_count() {
835 use crate::Repository;
836
837 let temp = tempfile::tempdir().unwrap();
838 let repo = Repository::init(temp.path(), None, false).unwrap();
839 let adr_dir = repo.adr_path();
840 std::fs::write(
841 adr_dir.join("0002-second.md"),
842 make_nygard_adr(2, "Second", "Accepted", ""),
843 )
844 .unwrap();
845 std::fs::write(
846 adr_dir.join("0004-fourth.md"),
847 make_nygard_adr(4, "Fourth", "Accepted", ""),
848 )
849 .unwrap();
850
851 let unfiltered = check_all(&repo).unwrap();
852 let unfiltered_adr011 = unfiltered
853 .issues
854 .iter()
855 .filter(|i| i.rule_id == "ADR011")
856 .count();
857 assert!(unfiltered_adr011 > 0);
858
859 std::fs::write(
860 temp.path().join("adrs.toml"),
861 "adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"ADR011\"]\n",
862 )
863 .unwrap();
864 let repo = Repository::open(temp.path()).unwrap();
865
866 let (filtered, suppressed_count) = check_all_filtered(&repo, &[]).unwrap();
867 assert_eq!(suppressed_count, unfiltered_adr011);
868 assert!(
869 filtered.issues.iter().all(|i| i.rule_id != "ADR011"),
870 "filtered report should not contain ADR011"
871 );
872 }
873}