1use crate::{Adr, Repository, Result};
8use globset::Glob;
9use mdbook_lint_core::Document;
10use mdbook_lint_core::rule::{CollectionRule, Rule};
11use mdbook_lint_rulesets::adr::{
12 Adr001, Adr002, Adr003, Adr004, Adr005, Adr006, Adr007, Adr008, Adr009, Adr010, Adr011, Adr012,
13 Adr013, Adr014, Adr015, Adr016, Adr017, AdrFormat,
14};
15use std::collections::HashSet;
16use std::path::PathBuf;
17
18const ALWAYS_PATHLESS_RULES: &[&str] = &[
31 "ADR010",
32 "adr-superseded-has-replacement",
33 "ADR011",
34 "adr-sequential-numbering",
35 "ADR012",
36 "adr-no-duplicate-numbers",
37];
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum IssueSeverity {
42 Info,
44 Warning,
46 Error,
48}
49
50impl std::fmt::Display for IssueSeverity {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 match self {
53 IssueSeverity::Info => write!(f, "info"),
54 IssueSeverity::Warning => write!(f, "warning"),
55 IssueSeverity::Error => write!(f, "error"),
56 }
57 }
58}
59
60impl From<mdbook_lint_core::Severity> for IssueSeverity {
61 fn from(severity: mdbook_lint_core::Severity) -> Self {
62 match severity {
63 mdbook_lint_core::Severity::Error => IssueSeverity::Error,
64 mdbook_lint_core::Severity::Warning => IssueSeverity::Warning,
65 mdbook_lint_core::Severity::Info => IssueSeverity::Info,
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct Issue {
73 pub rule_id: String,
75 pub rule_name: String,
77 pub severity: IssueSeverity,
79 pub message: String,
81 pub path: Option<PathBuf>,
83 pub line: Option<usize>,
85 pub column: Option<usize>,
87 pub adr_number: Option<u32>,
89 pub related_adrs: Vec<u32>,
91}
92
93impl Issue {
94 fn from_violation(
96 violation: mdbook_lint_core::Violation,
97 path: Option<PathBuf>,
98 adr_number: Option<u32>,
99 ) -> Self {
100 Self {
101 rule_id: violation.rule_id,
102 rule_name: violation.rule_name,
103 severity: violation.severity.into(),
104 message: violation.message,
105 path,
106 line: Some(violation.line),
107 column: Some(violation.column),
108 adr_number,
109 related_adrs: Vec::new(),
110 }
111 }
112}
113
114#[derive(Debug, Default)]
116pub struct LintReport {
117 pub issues: Vec<Issue>,
119}
120
121impl LintReport {
122 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn add(&mut self, issue: Issue) {
129 self.issues.push(issue);
130 }
131
132 pub fn has_errors(&self) -> bool {
134 self.issues
135 .iter()
136 .any(|i| i.severity == IssueSeverity::Error)
137 }
138
139 pub fn has_warnings(&self) -> bool {
141 self.issues
142 .iter()
143 .any(|i| i.severity == IssueSeverity::Warning)
144 }
145
146 pub fn is_clean(&self) -> bool {
148 !self.has_errors() && !self.has_warnings()
149 }
150
151 pub fn count_by_severity(&self, severity: IssueSeverity) -> usize {
153 self.issues
154 .iter()
155 .filter(|i| i.severity == severity)
156 .count()
157 }
158
159 pub fn sort(&mut self) {
161 self.issues.sort_by(|a, b| {
162 b.severity
163 .cmp(&a.severity)
164 .then_with(|| a.path.cmp(&b.path))
165 .then_with(|| a.line.cmp(&b.line))
166 });
167 }
168}
169
170fn detect_adr_format(content: &str) -> AdrFormat {
185 let mut has_nygard = false;
186
187 for line in content.lines() {
188 let Some(heading) = line.strip_prefix("## ") else {
189 continue;
190 };
191 let heading = heading.trim();
192
193 if heading.eq_ignore_ascii_case("Context and Problem Statement")
194 || heading.eq_ignore_ascii_case("Decision Outcome")
195 || heading.eq_ignore_ascii_case("Considered Options")
196 {
197 return AdrFormat::Madr4;
198 }
199
200 if heading.eq_ignore_ascii_case("Context") || heading.eq_ignore_ascii_case("Decision") {
201 has_nygard = true;
202 }
203 }
204
205 if has_nygard {
206 AdrFormat::Nygard
207 } else {
208 AdrFormat::Auto
209 }
210}
211
212pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
216 let mut report = LintReport::new();
217
218 let Some(path) = &adr.path else {
220 return Ok(report); };
222
223 let content = std::fs::read_to_string(path)?;
224
225 let doc = match Document::new(content, path.clone()) {
227 Ok(d) => d,
228 Err(e) => {
229 report.add(Issue {
230 rule_id: "parse-error".to_string(),
231 rule_name: "parse-error".to_string(),
232 severity: IssueSeverity::Error,
233 message: format!("Failed to parse document: {e}"),
234 path: Some(path.clone()),
235 line: None,
236 column: None,
237 adr_number: Some(adr.number),
238 related_adrs: Vec::new(),
239 });
240 return Ok(report);
241 }
242 };
243
244 let format = detect_adr_format(&doc.content);
253
254 let rules: Vec<Box<dyn Rule>> = vec![
256 Box::new(Adr001::default()),
257 Box::new(Adr002::default()),
258 Box::new(Adr003::default()),
259 Box::new(Adr004::with_format(format)),
260 Box::new(Adr005::with_format(format)),
261 Box::new(Adr006::with_format(format)),
262 Box::new(Adr007::default()),
263 Box::new(Adr008::default()),
264 Box::new(Adr009::default()),
265 Box::new(Adr014::default()),
266 Box::new(Adr015::default()),
267 Box::new(Adr016::default()),
268 Box::new(Adr017::with_format(format)),
269 ];
270
271 for rule in rules {
272 match rule.check(&doc) {
273 Ok(violations) => {
274 for violation in violations {
275 report.add(Issue::from_violation(
276 violation,
277 Some(path.clone()),
278 Some(adr.number),
279 ));
280 }
281 }
282 Err(e) => {
283 report.add(Issue {
284 rule_id: rule.id().to_string(),
285 rule_name: rule.name().to_string(),
286 severity: IssueSeverity::Error,
287 message: format!("Rule failed: {e}"),
288 path: Some(path.clone()),
289 line: None,
290 column: None,
291 adr_number: Some(adr.number),
292 related_adrs: Vec::new(),
293 });
294 }
295 }
296 }
297
298 Ok(report)
299}
300
301pub fn lint_all(repo: &Repository) -> Result<LintReport> {
303 let mut report = LintReport::new();
304 let adrs = repo.list()?;
305
306 for adr in &adrs {
307 let adr_report = lint_adr(adr)?;
308 report.issues.extend(adr_report.issues);
309 }
310
311 report.sort();
312 Ok(report)
313}
314
315pub fn check_repository(repo: &Repository) -> Result<LintReport> {
324 let mut report = LintReport::new();
325 let adrs = repo.list()?;
326
327 let mut documents = Vec::new();
329 for adr in &adrs {
330 if let Some(path) = &adr.path {
331 let content = std::fs::read_to_string(path)?;
332 if let Ok(doc) = Document::new(content, path.clone()) {
333 documents.push(doc);
334 }
335 }
336 }
337
338 let mut broken_link_fragments: Vec<String> = Vec::new();
369
370 if repo.config().is_next_gen() {
371 let existing_numbers: std::collections::HashSet<u32> =
372 adrs.iter().map(|a| a.number).collect();
373
374 for adr in &adrs {
375 for link in &adr.links {
376 if existing_numbers.contains(&link.target) {
377 continue;
378 }
379
380 let path = adr.path.clone().unwrap_or_default();
381 broken_link_fragments.push(format!(
382 "{}: Link to '{:04}",
383 path.display(),
384 link.target
385 ));
386
387 report.add(Issue {
388 rule_id: "ADR013".to_string(),
389 rule_name: "adr-valid-adr-links".to_string(),
390 severity: IssueSeverity::Error,
391 message: format!(
392 "ADR {} '{}' links to non-existent ADR {}",
393 adr.number, adr.title, link.target
394 ),
395 path: adr.path.clone(),
396 line: None,
397 column: None,
398 adr_number: Some(adr.number),
399 related_adrs: Vec::new(),
400 });
401 }
402 }
403 }
404
405 let adrs_by_number: std::collections::HashMap<u32, &Adr> =
427 adrs.iter().map(|a| (a.number, a)).collect();
428
429 for adr in &adrs {
430 for link in &adr.links {
431 let Some(target_adr) = adrs_by_number.get(&link.target) else {
432 continue;
433 };
434
435 let has_back_link = target_adr
436 .links
437 .iter()
438 .any(|back| back.target == adr.number);
439
440 if !has_back_link {
441 report.add(Issue {
442 rule_id: "asymmetric-link".to_string(),
443 rule_name: "adr-asymmetric-link".to_string(),
444 severity: IssueSeverity::Warning,
445 message: format!(
446 "ADR {} '{}' links to ADR {} as '{}' but ADR {} has no link back to ADR {}",
447 adr.number, adr.title, link.target, link.kind, link.target, adr.number
448 ),
449 path: adr.path.clone(),
450 line: None,
451 column: None,
452 adr_number: Some(adr.number),
453 related_adrs: Vec::new(),
454 });
455 }
456 }
457 }
458
459 let collection_rules: Vec<Box<dyn CollectionRule>> = vec![
461 Box::new(Adr010),
462 Box::new(Adr011),
463 Box::new(Adr012),
464 Box::new(Adr013),
465 ];
466
467 for rule in collection_rules {
468 match rule.check_collection(&documents) {
469 Ok(violations) => {
470 for violation in violations {
471 if violation.rule_id == "ADR013"
475 && broken_link_fragments
476 .iter()
477 .any(|fragment| violation.message.contains(fragment.as_str()))
478 {
479 continue;
480 }
481
482 report.add(Issue {
485 rule_id: rule.id().to_string(),
486 rule_name: rule.name().to_string(),
487 severity: violation.severity.into(),
488 message: violation.message,
489 path: None, line: if violation.line > 0 {
491 Some(violation.line)
492 } else {
493 None
494 },
495 column: if violation.column > 0 {
496 Some(violation.column)
497 } else {
498 None
499 },
500 adr_number: None,
501 related_adrs: Vec::new(),
502 });
503 }
504 }
505 Err(e) => {
506 report.add(Issue {
507 rule_id: rule.id().to_string(),
508 rule_name: rule.name().to_string(),
509 severity: IssueSeverity::Error,
510 message: format!("Rule failed: {e}"),
511 path: None,
512 line: None,
513 column: None,
514 adr_number: None,
515 related_adrs: Vec::new(),
516 });
517 }
518 }
519 }
520
521 report.sort();
522 Ok(report)
523}
524
525struct CompiledIgnorePath {
527 matcher: globset::GlobMatcher,
528 rules: HashSet<String>,
529}
530
531pub fn check_all_filtered(
549 repo: &Repository,
550 extra_ignore: &[String],
551) -> Result<(LintReport, usize, Vec<String>)> {
552 let mut report = LintReport::new();
553 let mut warnings = Vec::new();
554
555 let (adrs, parse_errors) = repo.list_with_errors()?;
557
558 for (path, error) in &parse_errors {
560 report.add(Issue {
561 rule_id: "parse-error".to_string(),
562 rule_name: "adr-parse-error".to_string(),
563 severity: IssueSeverity::Error,
564 message: format!("Failed to parse ADR: {error}"),
565 path: Some(path.clone()),
566 line: None,
567 column: None,
568 adr_number: None,
569 related_adrs: Vec::new(),
570 });
571 }
572
573 for adr in &adrs {
575 let adr_report = lint_adr(adr)?;
576 report.issues.extend(adr_report.issues);
577 }
578
579 let repo_report = check_repository(repo)?;
582 report.issues.extend(repo_report.issues);
583
584 report.sort();
585
586 let ignore_set: HashSet<String> = repo
587 .config()
588 .doctor
589 .ignore
590 .iter()
591 .chain(extra_ignore.iter())
592 .map(|s| s.to_lowercase())
593 .collect();
594
595 let mut compiled_ignore_paths: Vec<CompiledIgnorePath> = Vec::new();
602 for entry in &repo.config().doctor.ignore_path {
603 match Glob::new(&entry.glob) {
604 Ok(glob) => compiled_ignore_paths.push(CompiledIgnorePath {
605 matcher: glob.compile_matcher(),
606 rules: entry.rules.iter().map(|s| s.to_lowercase()).collect(),
607 }),
608 Err(e) => warnings.push(format!(
609 "[[doctor.ignore_path]] glob '{}' is invalid and will not be applied: {e}",
610 entry.glob
611 )),
612 }
613
614 for rule in &entry.rules {
615 if ALWAYS_PATHLESS_RULES
616 .iter()
617 .any(|pathless| pathless.eq_ignore_ascii_case(rule))
618 {
619 warnings.push(format!(
620 "[[doctor.ignore_path]] entry for glob '{}' names rule '{}', which only ever produces diagnostics without a path; this exemption can never suppress it",
621 entry.glob, rule
622 ));
623 }
624 }
625 }
626
627 if ignore_set.is_empty() && compiled_ignore_paths.is_empty() {
628 return Ok((report, 0, warnings));
629 }
630
631 let root = repo.root();
632 let before = report.issues.len();
633 report.issues.retain(|issue| {
634 if ignore_set.contains(&issue.rule_id.to_lowercase())
635 || ignore_set.contains(&issue.rule_name.to_lowercase())
636 {
637 return false;
638 }
639
640 let Some(path) = &issue.path else {
641 return true;
642 };
643
644 let relative = path.strip_prefix(root).unwrap_or(path);
645 let relative_str = relative.to_string_lossy().replace('\\', "/");
646
647 let rule_id = issue.rule_id.to_lowercase();
648 let rule_name = issue.rule_name.to_lowercase();
649
650 !compiled_ignore_paths.iter().any(|entry| {
651 entry.matcher.is_match(&relative_str)
652 && (entry.rules.contains(&rule_id) || entry.rules.contains(&rule_name))
653 })
654 });
655 let suppressed = before - report.issues.len();
656
657 Ok((report, suppressed, warnings))
658}
659
660pub fn check_all(repo: &Repository) -> Result<LintReport> {
665 check_all_filtered(repo, &[]).map(|(report, _, _)| report)
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use crate::Adr;
672
673 #[test]
674 fn test_issue_severity_ordering() {
675 assert!(IssueSeverity::Error > IssueSeverity::Warning);
676 assert!(IssueSeverity::Warning > IssueSeverity::Info);
677 }
678
679 #[test]
680 fn test_lint_report_empty() {
681 let report = LintReport::new();
682 assert!(report.is_clean());
683 assert!(!report.has_errors());
684 assert!(!report.has_warnings());
685 }
686
687 #[test]
688 fn test_lint_report_with_issues() {
689 let mut report = LintReport::new();
690 report.add(Issue {
691 rule_id: "ADR001".to_string(),
692 rule_name: "adr-title-format".to_string(),
693 severity: IssueSeverity::Error,
694 message: "Title format invalid".to_string(),
695 path: Some(PathBuf::from("0001-test.md")),
696 line: Some(1),
697 column: Some(1),
698 adr_number: Some(1),
699 related_adrs: Vec::new(),
700 });
701
702 assert!(report.has_errors());
703 assert!(!report.is_clean());
704 assert_eq!(report.count_by_severity(IssueSeverity::Error), 1);
705 }
706
707 #[test]
708 fn test_lint_valid_nygard_adr() {
709 let content = format!(
712 r#"# 1. Record architecture decisions
713
714Date: 2024-03-04
715
716## Status
717
718Accepted
719
720## Context
721
722{}
723
724## Decision
725
726{}
727
728## Consequences
729
730{}
731"#,
732 crate::init_adr::CONTEXT,
733 crate::init_adr::DECISION,
734 crate::init_adr::CONSEQUENCES,
735 );
736 let temp_dir = tempfile::tempdir().unwrap();
737 let path = temp_dir
738 .path()
739 .join("adr")
740 .join("0001-record-architecture-decisions.md");
741 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
742 std::fs::write(&path, content).unwrap();
743
744 let mut adr = Adr::new(1, "Record architecture decisions");
745 adr.path = Some(path);
746
747 let report = lint_adr(&adr).unwrap();
748
749 for issue in &report.issues {
751 println!(
752 "{}: {} ({}:{})",
753 issue.rule_id,
754 issue.message,
755 issue.line.unwrap_or(0),
756 issue.column.unwrap_or(0)
757 );
758 }
759
760 assert!(report.is_clean(), "Expected no issues for valid Nygard ADR");
761 }
762
763 #[test]
764 fn test_lint_invalid_adr_missing_status() {
765 let content = r#"# 1. Test decision
766
767Date: 2024-03-04
768
769## Context
770
771Some context.
772
773## Decision
774
775Some decision.
776
777## Consequences
778
779Some consequences.
780"#;
781 let temp_dir = tempfile::tempdir().unwrap();
782 let path = temp_dir.path().join("adr").join("0001-test-decision.md");
783 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
784 std::fs::write(&path, content).unwrap();
785
786 let mut adr = Adr::new(1, "Test decision");
787 adr.path = Some(path);
788
789 let report = lint_adr(&adr).unwrap();
790
791 assert!(
793 !report.is_clean(),
794 "Expected issues for ADR missing status section"
795 );
796 assert!(
797 report.issues.iter().any(|i| i.rule_id == "ADR002"),
798 "Expected ADR002 (missing status) violation"
799 );
800 }
801
802 #[test]
803 fn test_nygard_bare_minimal_template_passes_doctor() {
804 use crate::{Adr, Config, Repository, Template, TemplateFormat, TemplateVariant};
809
810 let temp = tempfile::tempdir().unwrap();
811 let repo = Repository::init(temp.path(), None, false).unwrap();
812
813 let template =
814 Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
815 let adr = Adr::new(2, "Bare minimal regression");
816 let rendered = template
817 .render(&adr, &Config::default(), &std::collections::HashMap::new())
818 .unwrap();
819 let path = repo.adr_path().join("0002-bare-minimal-regression.md");
820 std::fs::write(&path, rendered).unwrap();
821
822 let report = check_all(&repo).unwrap();
823 let file_errors: Vec<_> = report
824 .issues
825 .iter()
826 .filter(|i| i.severity == IssueSeverity::Error)
827 .filter(|i| {
828 i.path
829 .as_ref()
830 .is_some_and(|p| p.to_string_lossy().contains("0002-bare-minimal-regression"))
831 })
832 .collect();
833 assert!(
834 file_errors.is_empty(),
835 "nygard bare-minimal output should have no doctor errors, got: {file_errors:?}"
836 );
837 }
838
839 #[test]
840 fn test_detect_adr_format_from_headings() {
841 assert_eq!(
843 detect_adr_format("---\nstatus: accepted\n---\n\n## Context\n\n## Decision\n"),
844 AdrFormat::Nygard
845 );
846 assert_eq!(
848 detect_adr_format(
849 "---\nstatus: accepted\n---\n\n## Context and Problem Statement\n\n## Decision Outcome\n"
850 ),
851 AdrFormat::Madr4
852 );
853 assert_eq!(
855 detect_adr_format("# 1. Title\n\nDate: 2024-03-04\n\n## Context\n"),
856 AdrFormat::Nygard
857 );
858 assert_eq!(
860 detect_adr_format("---\nstatus: accepted\n---\n\n# Title only\n"),
861 AdrFormat::Auto
862 );
863 }
864
865 #[test]
866 fn test_ng_init_repo_passes_doctor() {
867 use crate::Repository;
873
874 let temp = tempfile::tempdir().unwrap();
875 let repo = Repository::init(temp.path(), None, true).unwrap();
876
877 let report = check_all(&repo).unwrap();
878 let errors: Vec<_> = report
879 .issues
880 .iter()
881 .filter(|i| i.severity == IssueSeverity::Error)
882 .collect();
883 assert!(
884 errors.is_empty(),
885 "freshly `--ng init`ed repo should pass doctor, got: {errors:?}"
886 );
887 }
888
889 #[test]
890 fn test_frontmatter_nygard_adr_not_flagged_for_madr_sections() {
891 let content = "---\nnumber: 1\ntitle: Record architecture decisions\ndate: 2024-03-04\nstatus: accepted\n---\n\n## Context\n\nSome context.\n\n## Decision\n\nSome decision.\n\n## Consequences\n\nSome consequences.\n";
894 let temp_dir = tempfile::tempdir().unwrap();
895 let path = temp_dir
896 .path()
897 .join("adr")
898 .join("0001-record-architecture-decisions.md");
899 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
900 std::fs::write(&path, content).unwrap();
901
902 let mut adr = Adr::new(1, "Record architecture decisions");
903 adr.path = Some(path);
904
905 let report = lint_adr(&adr).unwrap();
906 assert!(
907 !report
908 .issues
909 .iter()
910 .any(|i| i.rule_id == "ADR004" || i.rule_id == "ADR005"),
911 "frontmatter+Nygard ADR must not trip MADR section rules, got: {:?}",
912 report.issues
913 );
914 }
915
916 #[test]
917 fn test_genuine_madr_missing_decision_outcome_still_flagged() {
918 let content = "---\nnumber: 1\ntitle: Use Postgres\ndate: 2024-03-04\nstatus: accepted\n---\n\n## Context and Problem Statement\n\nWhich database?\n\n## Considered Options\n\n* Postgres\n* MySQL\n";
921 let temp_dir = tempfile::tempdir().unwrap();
922 let path = temp_dir.path().join("adr").join("0001-use-postgres.md");
923 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
924 std::fs::write(&path, content).unwrap();
925
926 let mut adr = Adr::new(1, "Use Postgres");
927 adr.path = Some(path);
928
929 let report = lint_adr(&adr).unwrap();
930 assert!(
931 report.issues.iter().any(|i| i.rule_id == "ADR005"),
932 "MADR ADR missing '## Decision Outcome' must still trip ADR005, got: {:?}",
933 report.issues
934 );
935 }
936
937 #[test]
938 fn test_check_all_reports_parse_errors() {
939 use crate::Repository;
940
941 let temp = tempfile::tempdir().unwrap();
942 let repo = Repository::init(temp.path(), None, true).unwrap();
943
944 let bad_content =
946 "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad Date\n";
947 std::fs::write(repo.adr_path().join("0002-bad-date.md"), bad_content).unwrap();
948
949 let report = check_all(&repo).unwrap();
950
951 let parse_errors: Vec<_> = report
952 .issues
953 .iter()
954 .filter(|i| i.rule_id == "parse-error")
955 .collect();
956
957 assert_eq!(parse_errors.len(), 1, "should report 1 parse error");
958 assert_eq!(parse_errors[0].severity, IssueSeverity::Error);
959 assert!(
960 parse_errors[0]
961 .path
962 .as_ref()
963 .unwrap()
964 .to_string_lossy()
965 .contains("0002-bad-date.md")
966 );
967 }
968
969 #[test]
970 fn test_check_all_no_parse_errors_for_string_decision_makers() {
971 use crate::Repository;
972
973 let temp = tempfile::tempdir().unwrap();
974 let repo = Repository::init(temp.path(), None, true).unwrap();
975
976 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";
978 std::fs::write(repo.adr_path().join("0002-test.md"), content).unwrap();
979
980 let report = check_all(&repo).unwrap();
981
982 let parse_errors: Vec<_> = report
983 .issues
984 .iter()
985 .filter(|i| i.rule_id == "parse-error")
986 .collect();
987
988 assert!(
989 parse_errors.is_empty(),
990 "string decision-makers should not cause parse error, got: {:?}",
991 parse_errors.iter().map(|i| &i.message).collect::<Vec<_>>()
992 );
993 }
994 fn make_nygard_adr(number: u32, title: &str, status: &str, links: &str) -> String {
997 format!(
998 "# {}. {}\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",
999 number, title, status, links
1000 )
1001 }
1002
1003 #[test]
1004 fn test_check_repository_broken_link_adr013() {
1005 use crate::Repository;
1006
1007 let temp = tempfile::tempdir().unwrap();
1008 let repo = Repository::init(temp.path(), None, false).unwrap();
1010 let adr_dir = repo.adr_path();
1011
1012 std::fs::write(
1014 adr_dir.join("0002-second.md"),
1015 make_nygard_adr(
1016 2,
1017 "Second",
1018 "Accepted",
1019 "\n\nSupersedes [99. Unknown](0099-unknown.md)\n",
1020 ),
1021 )
1022 .unwrap();
1023
1024 let report = check_repository(&repo).unwrap();
1025
1026 let has_adr013 = report.issues.iter().any(|i| i.rule_id == "ADR013");
1028 assert!(
1029 has_adr013,
1030 "Expected ADR013 broken-link issue, got: {:?}",
1031 report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
1032 );
1033 }
1034
1035 fn make_frontmatter_adr(number: u32, title: &str, status: &str, links_yaml: &str) -> String {
1036 format!(
1037 "---\nnumber: {}\ntitle: {}\ndate: 2024-01-01\nstatus: {}\n{}---\n\n## Context\n\nSome context.\n\n## Decision\n\nA decision.\n\n## Consequences\n\nSome consequences.\n",
1038 number, title, status, links_yaml
1039 )
1040 }
1041
1042 #[test]
1043 fn test_check_repository_frontmatter_broken_link_adr013_error() {
1044 use crate::Repository;
1045
1046 let temp = tempfile::tempdir().unwrap();
1047 let repo = Repository::init(temp.path(), None, true).unwrap();
1049 let adr_dir = repo.adr_path();
1050
1051 std::fs::write(
1053 adr_dir.join("0002-second.md"),
1054 make_frontmatter_adr(
1055 2,
1056 "Second",
1057 "proposed",
1058 "links:\n - target: 99\n kind: relatesto\n",
1059 ),
1060 )
1061 .unwrap();
1062
1063 let report = check_repository(&repo).unwrap();
1064
1065 let broken = report
1066 .issues
1067 .iter()
1068 .find(|i| i.rule_id == "ADR013" && i.severity == IssueSeverity::Error);
1069 assert!(
1070 broken.is_some(),
1071 "Expected ADR013 error for frontmatter link to non-existent ADR 99, got: {:?}",
1072 report
1073 .issues
1074 .iter()
1075 .map(|i| (&i.rule_id, i.severity, &i.message))
1076 .collect::<Vec<_>>()
1077 );
1078 let issue = broken.unwrap();
1079 assert_eq!(issue.adr_number, Some(2));
1080 assert!(
1081 issue.path.is_some(),
1082 "expected a file location on the issue"
1083 );
1084 assert!(
1085 issue.message.contains("links to non-existent ADR 99"),
1086 "unexpected message: {}",
1087 issue.message
1088 );
1089 assert!(
1090 report.has_errors(),
1091 "a broken frontmatter link must make the report (and doctor's exit code) nonzero"
1092 );
1093 }
1094
1095 #[test]
1096 fn test_check_repository_frontmatter_link_to_existing_adr_no_issue() {
1097 use crate::Repository;
1098
1099 let temp = tempfile::tempdir().unwrap();
1100 let repo = Repository::init(temp.path(), None, true).unwrap();
1101 let adr_dir = repo.adr_path();
1102
1103 std::fs::write(
1105 adr_dir.join("0002-second.md"),
1106 make_frontmatter_adr(
1107 2,
1108 "Second",
1109 "proposed",
1110 "links:\n - target: 1\n kind: relatesto\n",
1111 ),
1112 )
1113 .unwrap();
1114
1115 let report = check_repository(&repo).unwrap();
1116
1117 assert!(
1118 !report.issues.iter().any(|i| i.rule_id == "ADR013"),
1119 "link to an existing ADR should not produce ADR013, got: {:?}",
1120 report.issues.iter().map(|i| &i.message).collect::<Vec<_>>()
1121 );
1122 }
1123
1124 #[test]
1125 fn test_check_repository_dedups_frontmatter_and_body_broken_link() {
1126 use crate::Repository;
1127
1128 let temp = tempfile::tempdir().unwrap();
1129 let repo = Repository::init(temp.path(), None, true).unwrap();
1130 let adr_dir = repo.adr_path();
1131
1132 let content = "---\nnumber: 2\ntitle: Second\ndate: 2024-01-01\nstatus: proposed\nlinks:\n - target: 99\n kind: relatesto\n---\n\n# 2. Second\n\nDate: 2024-01-01\n\n## Status\n\nProposed\n\nRelates to [99. ...](0099-....md)\n\n## Context\n\nSome context.\n\n## Decision\n\nA decision.\n\n## Consequences\n\nSome consequences.\n";
1138 std::fs::write(adr_dir.join("0002-second.md"), content).unwrap();
1139
1140 let report = check_repository(&repo).unwrap();
1141
1142 let adr013_issues: Vec<_> = report
1143 .issues
1144 .iter()
1145 .filter(|i| i.rule_id == "ADR013")
1146 .collect();
1147 assert_eq!(
1148 adr013_issues.len(),
1149 1,
1150 "a broken link present in both frontmatter and body should report once, got: {:?}",
1151 adr013_issues
1152 .iter()
1153 .map(|i| (&i.severity, &i.message))
1154 .collect::<Vec<_>>()
1155 );
1156 assert_eq!(adr013_issues[0].severity, IssueSeverity::Error);
1157 }
1158
1159 #[test]
1160 fn test_check_repository_dedups_frontmatter_and_body_link_with_real_filename() {
1161 use crate::Repository;
1162
1163 let temp = tempfile::tempdir().unwrap();
1164 let repo = Repository::init(temp.path(), None, true).unwrap();
1165 let adr_dir = repo.adr_path();
1166
1167 let content = "---\nnumber: 2\ntitle: Second\ndate: 2024-01-01\nstatus: proposed\nlinks:\n - target: 99\n kind: relatesto\n---\n\n# 2. Second\n\nDate: 2024-01-01\n\n## Status\n\nProposed\n\nRelates to [99. Old Title](0099-old-title.md)\n\n## Context\n\nSome context.\n\n## Decision\n\nA decision.\n\n## Consequences\n\nSome consequences.\n";
1172 std::fs::write(adr_dir.join("0002-second.md"), content).unwrap();
1173
1174 let report = check_repository(&repo).unwrap();
1175
1176 let adr013_issues: Vec<_> = report
1177 .issues
1178 .iter()
1179 .filter(|i| i.rule_id == "ADR013")
1180 .collect();
1181 assert_eq!(
1182 adr013_issues.len(),
1183 1,
1184 "a broken link whose body filename resolved should report once, got: {:?}",
1185 adr013_issues
1186 .iter()
1187 .map(|i| (&i.severity, &i.message))
1188 .collect::<Vec<_>>()
1189 );
1190 assert_eq!(adr013_issues[0].severity, IssueSeverity::Error);
1191 }
1192
1193 #[test]
1196 fn test_check_repository_asymmetric_link_half_deleted_one_warning() {
1197 use crate::Repository;
1198
1199 let temp = tempfile::tempdir().unwrap();
1200 let repo = Repository::init(temp.path(), None, false).unwrap();
1205 let adr_dir = repo.adr_path();
1206
1207 std::fs::write(
1208 adr_dir.join("0002-second.md"),
1209 make_nygard_adr(
1210 2,
1211 "Second",
1212 "Accepted",
1213 "\n\nSupersedes [1. Record architecture decisions](0001-record-architecture-decisions.md)\n",
1214 ),
1215 )
1216 .unwrap();
1217
1218 let report = check_repository(&repo).unwrap();
1219
1220 let warnings: Vec<_> = report
1221 .issues
1222 .iter()
1223 .filter(|i| i.rule_id == "asymmetric-link")
1224 .collect();
1225 assert_eq!(
1226 warnings.len(),
1227 1,
1228 "expected exactly one asymmetric-link warning, got: {:?}",
1229 report
1230 .issues
1231 .iter()
1232 .map(|i| (&i.rule_id, &i.message))
1233 .collect::<Vec<_>>()
1234 );
1235 assert_eq!(warnings[0].rule_name, "adr-asymmetric-link");
1236 assert_eq!(warnings[0].severity, IssueSeverity::Warning);
1237 assert_eq!(warnings[0].adr_number, Some(2));
1238 assert!(
1239 warnings[0].message.contains("ADR 2")
1240 && warnings[0].message.contains("ADR 1")
1241 && warnings[0].message.contains("no link back to ADR 2"),
1242 "unexpected message: {}",
1243 warnings[0].message
1244 );
1245 }
1246
1247 #[test]
1248 fn test_check_repository_asymmetric_link_mis_targeted_two_warnings() {
1249 use crate::Repository;
1250
1251 let temp = tempfile::tempdir().unwrap();
1252 let repo = Repository::init(temp.path(), None, true).unwrap();
1254 let adr_dir = repo.adr_path();
1255
1256 std::fs::write(
1261 adr_dir.join("0001-record-architecture-decisions.md"),
1262 make_frontmatter_adr(
1263 1,
1264 "Record architecture decisions",
1265 "superseded",
1266 "links:\n - target: 3\n kind: supersededby\n",
1267 ),
1268 )
1269 .unwrap();
1270 std::fs::write(
1271 adr_dir.join("0002-second.md"),
1272 make_frontmatter_adr(
1273 2,
1274 "Second",
1275 "proposed",
1276 "links:\n - target: 1\n kind: supersedes\n",
1277 ),
1278 )
1279 .unwrap();
1280 std::fs::write(
1281 adr_dir.join("0003-third.md"),
1282 make_frontmatter_adr(3, "Third", "proposed", ""),
1283 )
1284 .unwrap();
1285
1286 let report = check_repository(&repo).unwrap();
1287
1288 let warnings: Vec<_> = report
1289 .issues
1290 .iter()
1291 .filter(|i| i.rule_id == "asymmetric-link")
1292 .collect();
1293 assert_eq!(
1294 warnings.len(),
1295 2,
1296 "expected two asymmetric-link warnings, one per independently broken half, got: {:?}",
1297 report
1298 .issues
1299 .iter()
1300 .map(|i| (&i.rule_id, &i.message))
1301 .collect::<Vec<_>>()
1302 );
1303
1304 let adr_numbers: Vec<_> = warnings.iter().filter_map(|i| i.adr_number).collect();
1305 assert!(
1306 adr_numbers.contains(&1) && adr_numbers.contains(&2),
1307 "expected warnings naming both ADR 1 and ADR 2, got: {adr_numbers:?}"
1308 );
1309 assert!(
1310 warnings
1311 .iter()
1312 .all(|i| i.severity == IssueSeverity::Warning)
1313 );
1314 }
1315
1316 #[test]
1317 fn test_check_repository_symmetric_link_via_repository_link_no_warning() {
1318 use crate::{LinkKind, Repository};
1319
1320 let temp = tempfile::tempdir().unwrap();
1321 let repo = Repository::init(temp.path(), None, true).unwrap();
1322 let adr_dir = repo.adr_path();
1323
1324 std::fs::write(
1326 adr_dir.join("0002-second.md"),
1327 make_frontmatter_adr(2, "Second", "proposed", ""),
1328 )
1329 .unwrap();
1330
1331 repo.link(2, 1, LinkKind::Supersedes, LinkKind::SupersededBy)
1335 .unwrap();
1336
1337 let report = check_repository(&repo).unwrap();
1338
1339 assert!(
1340 !report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
1341 "a pair built through Repository::link must not be flagged, got: {:?}",
1342 report
1343 .issues
1344 .iter()
1345 .map(|i| (&i.rule_id, &i.message))
1346 .collect::<Vec<_>>()
1347 );
1348 }
1349
1350 #[test]
1351 fn test_check_repository_non_derived_reverse_kind_no_warning() {
1352 use crate::{LinkKind, Repository};
1353
1354 let temp = tempfile::tempdir().unwrap();
1355 let repo = Repository::init(temp.path(), None, true).unwrap();
1356 let adr_dir = repo.adr_path();
1357
1358 std::fs::write(
1359 adr_dir.join("0002-second.md"),
1360 make_frontmatter_adr(2, "Second", "proposed", ""),
1361 )
1362 .unwrap();
1363
1364 repo.link(2, 1, LinkKind::Supersedes, LinkKind::RelatesTo)
1370 .unwrap();
1371
1372 let report = check_repository(&repo).unwrap();
1373
1374 assert!(
1375 !report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
1376 "a non-derived but present reverse link must not be flagged, got: {:?}",
1377 report
1378 .issues
1379 .iter()
1380 .map(|i| (&i.rule_id, &i.message))
1381 .collect::<Vec<_>>()
1382 );
1383 }
1384
1385 #[test]
1386 fn test_check_repository_broken_link_no_asymmetric_link_warning() {
1387 use crate::Repository;
1388
1389 let temp = tempfile::tempdir().unwrap();
1390 let repo = Repository::init(temp.path(), None, false).unwrap();
1392 let adr_dir = repo.adr_path();
1393
1394 std::fs::write(
1398 adr_dir.join("0002-second.md"),
1399 make_nygard_adr(
1400 2,
1401 "Second",
1402 "Accepted",
1403 "\n\nSupersedes [99. Unknown](0099-unknown.md)\n",
1404 ),
1405 )
1406 .unwrap();
1407
1408 let report = check_repository(&repo).unwrap();
1409
1410 assert!(
1411 report.issues.iter().any(|i| i.rule_id == "ADR013"),
1412 "expected the broken-link diagnostic to still fire"
1413 );
1414 assert!(
1415 !report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
1416 "a link to a nonexistent ADR must not also be flagged as asymmetric, got: {:?}",
1417 report
1418 .issues
1419 .iter()
1420 .map(|i| (&i.rule_id, &i.message))
1421 .collect::<Vec<_>>()
1422 );
1423 }
1424
1425 #[test]
1426 fn test_check_repository_symmetric_relates_to_no_warning() {
1427 use crate::{LinkKind, Repository};
1428
1429 let temp = tempfile::tempdir().unwrap();
1430 let repo = Repository::init(temp.path(), None, false).unwrap();
1431 let adr_dir = repo.adr_path();
1432
1433 std::fs::write(
1434 adr_dir.join("0002-second.md"),
1435 make_nygard_adr(2, "Second", "Accepted", ""),
1436 )
1437 .unwrap();
1438
1439 repo.link(2, 1, LinkKind::RelatesTo, LinkKind::RelatesTo)
1442 .unwrap();
1443
1444 let report = check_repository(&repo).unwrap();
1445
1446 assert!(
1447 !report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
1448 "a symmetric RelatesTo pair must not be flagged, got: {:?}",
1449 report
1450 .issues
1451 .iter()
1452 .map(|i| (&i.rule_id, &i.message))
1453 .collect::<Vec<_>>()
1454 );
1455 }
1456
1457 #[test]
1458 fn test_check_repository_one_way_relates_to_one_warning() {
1459 use crate::Repository;
1460
1461 let temp = tempfile::tempdir().unwrap();
1462 let repo = Repository::init(temp.path(), None, false).unwrap();
1464 let adr_dir = repo.adr_path();
1465
1466 std::fs::write(
1470 adr_dir.join("0002-second.md"),
1471 make_nygard_adr(
1472 2,
1473 "Second",
1474 "Accepted",
1475 "\n\nRelates to [1. Record architecture decisions](0001-record-architecture-decisions.md)\n",
1476 ),
1477 )
1478 .unwrap();
1479
1480 let report = check_repository(&repo).unwrap();
1481
1482 let warnings: Vec<_> = report
1483 .issues
1484 .iter()
1485 .filter(|i| i.rule_id == "asymmetric-link")
1486 .collect();
1487 assert_eq!(
1488 warnings.len(),
1489 1,
1490 "expected exactly one asymmetric-link warning for a one-way RelatesTo, got: {:?}",
1491 report
1492 .issues
1493 .iter()
1494 .map(|i| (&i.rule_id, &i.message))
1495 .collect::<Vec<_>>()
1496 );
1497 }
1498
1499 #[test]
1500 fn test_check_repository_sequential_gap_adr011() {
1501 use crate::Repository;
1502
1503 let temp = tempfile::tempdir().unwrap();
1504 let repo = Repository::init(temp.path(), None, false).unwrap();
1506 let adr_dir = repo.adr_path();
1507
1508 std::fs::write(
1510 adr_dir.join("0002-second.md"),
1511 make_nygard_adr(2, "Second", "Accepted", ""),
1512 )
1513 .unwrap();
1514 std::fs::write(
1515 adr_dir.join("0004-fourth.md"),
1516 make_nygard_adr(4, "Fourth", "Accepted", ""),
1517 )
1518 .unwrap();
1519
1520 let report = check_repository(&repo).unwrap();
1521
1522 let has_adr011 = report.issues.iter().any(|i| i.rule_id == "ADR011");
1524 assert!(
1525 has_adr011,
1526 "Expected ADR011 sequential-gap issue, got: {:?}",
1527 report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
1528 );
1529 }
1530
1531 #[test]
1532 fn test_check_repository_clean_repo_has_no_issues() {
1533 use crate::Repository;
1534
1535 let temp = tempfile::tempdir().unwrap();
1536 let repo = Repository::init(temp.path(), None, false).unwrap();
1537 let adr_dir = repo.adr_path();
1538
1539 std::fs::write(
1541 adr_dir.join("0002-second.md"),
1542 make_nygard_adr(2, "Second", "Accepted", ""),
1543 )
1544 .unwrap();
1545 std::fs::write(
1546 adr_dir.join("0003-third.md"),
1547 make_nygard_adr(3, "Third", "Proposed", ""),
1548 )
1549 .unwrap();
1550
1551 let report = check_repository(&repo).unwrap();
1552
1553 let collection_rule_ids = ["ADR010", "ADR011", "ADR012", "ADR013"];
1554 let collection_issues: Vec<_> = report
1555 .issues
1556 .iter()
1557 .filter(|i| collection_rule_ids.contains(&i.rule_id.as_str()))
1558 .collect();
1559
1560 assert!(
1561 collection_issues.is_empty(),
1562 "Clean repo should have no collection-rule issues, got: {:?}",
1563 collection_issues
1564 .iter()
1565 .map(|i| format!("{}: {}", i.rule_id, i.message))
1566 .collect::<Vec<_>>()
1567 );
1568 }
1569
1570 #[test]
1571 fn test_check_all_combines_lint_and_repository_checks() {
1572 use crate::Repository;
1573
1574 let temp = tempfile::tempdir().unwrap();
1575 let repo = Repository::init(temp.path(), None, false).unwrap();
1576 let adr_dir = repo.adr_path();
1577
1578 std::fs::write(
1580 adr_dir.join("0001-first.md"),
1581 make_nygard_adr(1, "First", "Accepted", ""),
1582 )
1583 .unwrap();
1584
1585 let report = check_all(&repo).unwrap();
1587
1588 let adr011 = report
1590 .issues
1591 .iter()
1592 .filter(|i| i.rule_id == "ADR011")
1593 .count();
1594 assert_eq!(
1595 adr011, 0,
1596 "Single valid ADR should have no sequential-gap issue"
1597 );
1598 }
1599
1600 #[test]
1603 fn test_check_all_filtered_suppresses_ignored_rule() {
1604 use crate::Repository;
1605
1606 let temp = tempfile::tempdir().unwrap();
1607 let repo = Repository::init(temp.path(), None, false).unwrap();
1611 let adr_dir = repo.adr_path();
1612 std::fs::write(
1613 adr_dir.join("0002-second.md"),
1614 make_nygard_adr(2, "Second", "Accepted", ""),
1615 )
1616 .unwrap();
1617 std::fs::write(
1618 adr_dir.join("0004-fourth.md"),
1619 make_nygard_adr(4, "Fourth", "Accepted", ""),
1620 )
1621 .unwrap();
1622
1623 let unfiltered = check_repository(&repo).unwrap();
1625 let unfiltered_adr011 = unfiltered
1626 .issues
1627 .iter()
1628 .filter(|i| i.rule_id == "ADR011")
1629 .count();
1630 assert!(
1631 unfiltered_adr011 > 0,
1632 "expected check_repository to report ADR011 before filtering"
1633 );
1634
1635 std::fs::write(
1639 temp.path().join("adrs.toml"),
1640 "adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"adr011\"]\n",
1641 )
1642 .unwrap();
1643 let repo = Repository::open(temp.path()).unwrap();
1644 assert_eq!(repo.config().doctor.ignore, vec!["adr011".to_string()]);
1645
1646 let filtered = check_all(&repo).unwrap();
1649 let filtered_adr011 = filtered
1650 .issues
1651 .iter()
1652 .filter(|i| i.rule_id == "ADR011")
1653 .count();
1654 assert_eq!(
1655 filtered_adr011, 0,
1656 "check_all should suppress ADR011 issues per [doctor].ignore"
1657 );
1658
1659 let still_unfiltered = check_repository(&repo).unwrap();
1662 assert!(
1663 still_unfiltered
1664 .issues
1665 .iter()
1666 .any(|i| i.rule_id == "ADR011"),
1667 "check_repository should remain unfiltered"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_check_all_filtered_returns_suppressed_count() {
1673 use crate::Repository;
1674
1675 let temp = tempfile::tempdir().unwrap();
1676 let repo = Repository::init(temp.path(), None, false).unwrap();
1677 let adr_dir = repo.adr_path();
1678 std::fs::write(
1679 adr_dir.join("0002-second.md"),
1680 make_nygard_adr(2, "Second", "Accepted", ""),
1681 )
1682 .unwrap();
1683 std::fs::write(
1684 adr_dir.join("0004-fourth.md"),
1685 make_nygard_adr(4, "Fourth", "Accepted", ""),
1686 )
1687 .unwrap();
1688
1689 let unfiltered = check_all(&repo).unwrap();
1690 let unfiltered_adr011 = unfiltered
1691 .issues
1692 .iter()
1693 .filter(|i| i.rule_id == "ADR011")
1694 .count();
1695 assert!(unfiltered_adr011 > 0);
1696
1697 std::fs::write(
1698 temp.path().join("adrs.toml"),
1699 "adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"ADR011\"]\n",
1700 )
1701 .unwrap();
1702 let repo = Repository::open(temp.path()).unwrap();
1703
1704 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
1705 assert_eq!(suppressed_count, unfiltered_adr011);
1706 assert!(
1707 filtered.issues.iter().all(|i| i.rule_id != "ADR011"),
1708 "filtered report should not contain ADR011"
1709 );
1710 assert!(
1711 warnings.is_empty(),
1712 "no [[doctor.ignore_path]] entries configured, expected no warnings"
1713 );
1714 }
1715
1716 fn make_nygard_adr_with_consequences(
1721 number: u32,
1722 title: &str,
1723 status: &str,
1724 consequences: &str,
1725 ) -> String {
1726 format!(
1727 "# {number}. {title}\n\nDate: 2024-01-01\n\n## Status\n\n{status}\n\n## Context\n\nSome context.\n\n## Decision\n\nA decision.\n\n## Consequences\n\n{consequences}\n"
1728 )
1729 }
1730
1731 #[test]
1732 fn test_check_all_filtered_scoped_ignore_suppresses_on_matching_record_only() {
1733 use crate::Repository;
1737
1738 let temp = tempfile::tempdir().unwrap();
1739 let repo = Repository::init(temp.path(), None, false).unwrap();
1740 let adr_dir = repo.adr_path();
1741
1742 std::fs::write(
1745 adr_dir.join("0002-second.md"),
1746 make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
1747 )
1748 .unwrap();
1749 std::fs::write(
1750 adr_dir.join("0003-third.md"),
1751 make_nygard_adr_with_consequences(3, "Third", "Accepted", "TBD"),
1752 )
1753 .unwrap();
1754
1755 let (unfiltered, _, _) = check_all_filtered(&repo, &[]).unwrap();
1757 let unfiltered_adr014_paths: Vec<_> = unfiltered
1758 .issues
1759 .iter()
1760 .filter(|i| i.rule_id == "ADR014")
1761 .filter_map(|i| i.path.clone())
1762 .collect();
1763 assert!(
1764 unfiltered_adr014_paths
1765 .iter()
1766 .any(|p| p.ends_with("0002-second.md")),
1767 "expected ADR014 on 0002-second.md before scoping, got: {unfiltered_adr014_paths:?}"
1768 );
1769 assert!(
1770 unfiltered_adr014_paths
1771 .iter()
1772 .any(|p| p.ends_with("0003-third.md")),
1773 "expected ADR014 on 0003-third.md before scoping, got: {unfiltered_adr014_paths:?}"
1774 );
1775
1776 std::fs::write(
1778 temp.path().join("adrs.toml"),
1779 "adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"ADR014\"]\n",
1780 )
1781 .unwrap();
1782 let repo = Repository::open(temp.path()).unwrap();
1783
1784 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
1785 assert!(
1786 warnings.is_empty(),
1787 "expected no warnings, got {warnings:?}"
1788 );
1789 assert!(suppressed_count > 0);
1790
1791 let filtered_adr014_paths: Vec<_> = filtered
1792 .issues
1793 .iter()
1794 .filter(|i| i.rule_id == "ADR014")
1795 .filter_map(|i| i.path.clone())
1796 .collect();
1797 assert!(
1798 !filtered_adr014_paths
1799 .iter()
1800 .any(|p| p.ends_with("0002-second.md")),
1801 "0002-second.md's ADR014 should be suppressed, got: {filtered_adr014_paths:?}"
1802 );
1803 assert!(
1804 filtered_adr014_paths
1805 .iter()
1806 .any(|p| p.ends_with("0003-third.md")),
1807 "0003-third.md's ADR014 should still fire, got: {filtered_adr014_paths:?}"
1808 );
1809 }
1810
1811 #[test]
1812 fn test_check_all_filtered_scoped_ignore_double_star_matches_subdirectory() {
1813 use crate::Repository;
1821
1822 let temp = tempfile::tempdir().unwrap();
1823 let repo = Repository::init(
1824 temp.path(),
1825 Some(PathBuf::from("docs/architecture/decisions")),
1826 false,
1827 )
1828 .unwrap();
1829 let adr_dir = repo.adr_path();
1830 std::fs::write(
1831 adr_dir.join("0002-second.md"),
1832 make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
1833 )
1834 .unwrap();
1835
1836 std::fs::write(
1837 temp.path().join("adrs.toml"),
1838 "adr_dir = \"docs/architecture/decisions\"\n\n[[doctor.ignore_path]]\nglob = \"**/0002-*.md\"\nrules = [\"ADR014\"]\n",
1839 )
1840 .unwrap();
1841 let repo = Repository::open(temp.path()).unwrap();
1842
1843 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
1844 assert!(
1845 warnings.is_empty(),
1846 "expected no warnings, got {warnings:?}"
1847 );
1848 assert!(
1849 suppressed_count > 0,
1850 "expected the ** glob to match the nested record"
1851 );
1852 assert!(
1853 !filtered.issues.iter().any(|i| i.rule_id == "ADR014"
1854 && i.path
1855 .as_ref()
1856 .is_some_and(|p| p.ends_with("0002-second.md"))),
1857 "nested record's ADR014 should be suppressed by the ** glob"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_check_all_filtered_scoped_ignore_matching_nothing_suppresses_nothing() {
1863 use crate::Repository;
1864
1865 let temp = tempfile::tempdir().unwrap();
1866 let repo = Repository::init(temp.path(), None, false).unwrap();
1867 let adr_dir = repo.adr_path();
1868 std::fs::write(
1869 adr_dir.join("0002-second.md"),
1870 make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
1871 )
1872 .unwrap();
1873
1874 std::fs::write(
1875 temp.path().join("adrs.toml"),
1876 "adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/9999-*.md\"\nrules = [\"ADR014\"]\n",
1877 )
1878 .unwrap();
1879 let repo = Repository::open(temp.path()).unwrap();
1880
1881 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
1882 assert!(
1883 warnings.is_empty(),
1884 "expected no warnings, got {warnings:?}"
1885 );
1886 assert_eq!(
1887 suppressed_count, 0,
1888 "a glob matching nothing should suppress nothing"
1889 );
1890 assert!(
1891 filtered.issues.iter().any(|i| i.rule_id == "ADR014"),
1892 "ADR014 should still fire since the glob did not match"
1893 );
1894 }
1895
1896 #[test]
1897 fn test_check_all_filtered_scoped_and_repo_wide_ignores_compose() {
1898 use crate::Repository;
1899
1900 let temp = tempfile::tempdir().unwrap();
1901 let repo = Repository::init(temp.path(), None, false).unwrap();
1902 let adr_dir = repo.adr_path();
1903 std::fs::write(
1906 adr_dir.join("0002-second.md"),
1907 make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
1908 )
1909 .unwrap();
1910 std::fs::write(
1911 adr_dir.join("0003-third.md"),
1912 make_nygard_adr(3, "Third", "Accepted", ""),
1913 )
1914 .unwrap();
1915 std::fs::write(
1916 adr_dir.join("0005-fifth.md"),
1917 make_nygard_adr(5, "Fifth", "Accepted", ""),
1918 )
1919 .unwrap();
1920
1921 let (unfiltered, _, _) =
1922 check_all_filtered(&Repository::open(temp.path()).unwrap(), &[]).unwrap();
1923 let unfiltered_adr014 = unfiltered
1924 .issues
1925 .iter()
1926 .filter(|i| i.rule_id == "ADR014")
1927 .count();
1928 let unfiltered_adr011 = unfiltered
1929 .issues
1930 .iter()
1931 .filter(|i| i.rule_id == "ADR011")
1932 .count();
1933 assert!(unfiltered_adr014 > 0);
1934 assert!(unfiltered_adr011 > 0);
1935
1936 std::fs::write(
1937 temp.path().join("adrs.toml"),
1938 "adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"ADR011\"]\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"ADR014\"]\n",
1939 )
1940 .unwrap();
1941 let repo = Repository::open(temp.path()).unwrap();
1942
1943 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
1944 assert!(
1945 warnings.is_empty(),
1946 "expected no warnings, got {warnings:?}"
1947 );
1948 assert_eq!(
1949 suppressed_count,
1950 unfiltered_adr014 + unfiltered_adr011,
1951 "both the scoped and repository-wide ignores should count, with no double counting"
1952 );
1953 assert!(!filtered.issues.iter().any(|i| i.rule_id == "ADR014"));
1954 assert!(!filtered.issues.iter().any(|i| i.rule_id == "ADR011"));
1955 }
1956
1957 #[test]
1958 fn test_check_all_filtered_scoped_ignore_matches_by_rule_name() {
1959 use crate::Repository;
1960
1961 let temp = tempfile::tempdir().unwrap();
1962 let repo = Repository::init(temp.path(), None, false).unwrap();
1963 let adr_dir = repo.adr_path();
1964 std::fs::write(
1965 adr_dir.join("0002-second.md"),
1966 make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
1967 )
1968 .unwrap();
1969
1970 let (unfiltered, _, _) =
1972 check_all_filtered(&Repository::open(temp.path()).unwrap(), &[]).unwrap();
1973 let rule_name = unfiltered
1974 .issues
1975 .iter()
1976 .find(|i| i.rule_id == "ADR014")
1977 .map(|i| i.rule_name.clone())
1978 .expect("expected an ADR014 issue");
1979
1980 std::fs::write(
1981 temp.path().join("adrs.toml"),
1982 format!(
1983 "adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"{rule_name}\"]\n"
1984 ),
1985 )
1986 .unwrap();
1987 let repo = Repository::open(temp.path()).unwrap();
1988
1989 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
1990 assert!(
1991 warnings.is_empty(),
1992 "expected no warnings, got {warnings:?}"
1993 );
1994 assert!(suppressed_count > 0);
1995 assert!(!filtered.issues.iter().any(|i| i.rule_id == "ADR014"));
1996 }
1997
1998 #[test]
1999 fn test_check_all_filtered_invalid_glob_warns_and_config_still_loads() {
2000 use crate::Repository;
2001
2002 let temp = tempfile::tempdir().unwrap();
2003 let repo = Repository::init(temp.path(), None, false).unwrap();
2004 let adr_dir = repo.adr_path();
2005 std::fs::write(
2006 adr_dir.join("0002-second.md"),
2007 make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
2008 )
2009 .unwrap();
2010
2011 std::fs::write(
2013 temp.path().join("adrs.toml"),
2014 "adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/[0002-*.md\"\nrules = [\"ADR014\"]\n",
2015 )
2016 .unwrap();
2017 let repo = Repository::open(temp.path()).unwrap();
2018
2019 let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
2020 assert_eq!(
2021 suppressed_count, 0,
2022 "an invalid glob must not suppress anything"
2023 );
2024 assert!(
2025 filtered.issues.iter().any(|i| i.rule_id == "ADR014"),
2026 "ADR014 should still fire since the invalid glob was not applied"
2027 );
2028 assert!(
2029 warnings.iter().any(|w| w.contains("doc/adr/[0002-*.md")),
2030 "expected a warning naming the invalid glob, got: {warnings:?}"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_check_all_filtered_ignore_path_naming_collection_rule_warns() {
2036 use crate::Repository;
2037
2038 let temp = tempfile::tempdir().unwrap();
2039 let repo = Repository::init(temp.path(), None, false).unwrap();
2040 let adr_dir = repo.adr_path();
2041 std::fs::write(
2042 adr_dir.join("0002-second.md"),
2043 make_nygard_adr(2, "Second", "Accepted", ""),
2044 )
2045 .unwrap();
2046
2047 std::fs::write(
2050 temp.path().join("adrs.toml"),
2051 "adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"ADR011\"]\n",
2052 )
2053 .unwrap();
2054 let repo = Repository::open(temp.path()).unwrap();
2055
2056 let (_filtered, _suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
2057 assert!(
2058 warnings.iter().any(|w| w.contains("ADR011")),
2059 "expected a warning naming the rule that can never fire, got: {warnings:?}"
2060 );
2061 }
2062}