Skip to main content

adrs_core/
lint.rs

1//! ADR linting using mdbook-lint rules.
2//!
3//! This module provides unified linting for ADRs, combining per-file validation
4//! (title format, required sections, date format) with repository-level checks
5//! (sequential numbering, duplicate detection, broken links).
6
7use 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/// Severity level for lint issues.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum IssueSeverity {
19    /// Informational message.
20    Info,
21    /// Warning that should be addressed.
22    Warning,
23    /// Error that needs to be fixed.
24    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/// A unified issue type for both per-file lint violations and repository-level diagnostics.
48#[derive(Debug, Clone)]
49pub struct Issue {
50    /// The rule that produced this issue (e.g., "ADR001", "adr-title-format").
51    pub rule_id: String,
52    /// Human-readable rule name.
53    pub rule_name: String,
54    /// The severity of this issue.
55    pub severity: IssueSeverity,
56    /// A human-readable message describing the issue.
57    pub message: String,
58    /// The path to the affected file, if applicable.
59    pub path: Option<PathBuf>,
60    /// Line number (1-based), if applicable.
61    pub line: Option<usize>,
62    /// Column number (1-based), if applicable.
63    pub column: Option<usize>,
64    /// The ADR number, if applicable.
65    pub adr_number: Option<u32>,
66    /// Related ADR numbers (for issues involving multiple ADRs).
67    pub related_adrs: Vec<u32>,
68}
69
70impl Issue {
71    /// Create a new issue from an mdbook-lint violation.
72    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/// Results from linting.
92#[derive(Debug, Default)]
93pub struct LintReport {
94    /// All issues found.
95    pub issues: Vec<Issue>,
96}
97
98impl LintReport {
99    /// Create a new empty report.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Add an issue to the report.
105    pub fn add(&mut self, issue: Issue) {
106        self.issues.push(issue);
107    }
108
109    /// Check if there are any errors.
110    pub fn has_errors(&self) -> bool {
111        self.issues
112            .iter()
113            .any(|i| i.severity == IssueSeverity::Error)
114    }
115
116    /// Check if there are any warnings.
117    pub fn has_warnings(&self) -> bool {
118        self.issues
119            .iter()
120            .any(|i| i.severity == IssueSeverity::Warning)
121    }
122
123    /// Check if the report is clean (no warnings or errors).
124    pub fn is_clean(&self) -> bool {
125        !self.has_errors() && !self.has_warnings()
126    }
127
128    /// Get the count of issues by severity.
129    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    /// Sort issues by severity (errors first), then by path, then by line.
137    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
147/// Lint a single ADR file.
148///
149/// Runs all per-file lint rules against the ADR content.
150pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
151    let mut report = LintReport::new();
152
153    // Get the file content
154    let Some(path) = &adr.path else {
155        return Ok(report); // No path, nothing to lint
156    };
157
158    let content = std::fs::read_to_string(path)?;
159
160    // Create mdbook-lint Document
161    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    // Run all single-document rules
180    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
226/// Lint all ADRs in a repository (per-file checks only).
227pub 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
240/// Run repository-level checks (collection rules).
241///
242/// These checks analyze the ADR set as a whole:
243/// - Sequential numbering (ADR011)
244/// - Duplicate numbers (ADR012)
245/// - Broken links (ADR013)
246/// - Superseded ADRs have replacements (ADR010)
247pub fn check_repository(repo: &Repository) -> Result<LintReport> {
248    let mut report = LintReport::new();
249    let adrs = repo.list()?;
250
251    // Build documents for collection rules
252    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    // Run collection rules
263    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                    // Collection rule violations may have path in the message
275                    // We need to parse it out or handle it differently
276                    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, // Collection rules may span multiple files
282                        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
317/// Run all checks and filter out issues matching ignored rule IDs/names.
318///
319/// Ignored rules are `repo.config().doctor.ignore` unioned with `extra_ignore`
320/// (e.g. CLI `--ignore` flags for a single invocation). Matching is
321/// case-insensitive against both `Issue.rule_id` and `Issue.rule_name`.
322///
323/// Returns the filtered report and the count of issues that were suppressed.
324pub fn check_all_filtered(
325    repo: &Repository,
326    extra_ignore: &[String],
327) -> Result<(LintReport, usize)> {
328    let mut report = LintReport::new();
329
330    // Use list_with_errors to capture parse failures
331    let (adrs, parse_errors) = repo.list_with_errors()?;
332
333    // Report parse errors as lint issues
334    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    // Run per-file lint on successfully parsed ADRs
349    for adr in &adrs {
350        let adr_report = lint_adr(adr)?;
351        report.issues.extend(adr_report.issues);
352    }
353
354    // Run repository-level checks (these still use repo.list() internally,
355    // which is fine — they only need successfully parsed ADRs)
356    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
384/// Run all checks: per-file lint + repository-level checks.
385///
386/// Also reports files that look like ADRs (digit-prefixed `.md` files in the
387/// ADR directory) but could not be parsed (e.g., invalid YAML frontmatter).
388pub 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        // Uses the actual ADR #0001 text produced by `adrs init`. The word "described"
434        // previously triggered an ADR014 false positive (fixed in mdbook-lint-rulesets 0.14.3).
435        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        // Print any issues for debugging
474        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        // Should have at least one issue (missing status)
516        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        // Regression for #330: a file produced by the Nygard bare-minimal
529        // template must not trip any doctor error (it previously emitted no
530        // `Date:` line and failed with ADR003). Empty-section ADR014 warnings
531        // are inherent to the variant and are not errors.
532        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        // Write an ADR with invalid YAML (bad date)
571        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        // Issue #216: decision-makers as string should not cause a parse error
603        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    // ========== check_repository collection rules (issue #239) ==========
621
622    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        // init creates ADR #1 automatically
635        let repo = Repository::init(temp.path(), None, false).unwrap();
636        let adr_dir = repo.adr_path();
637
638        // ADR 2 links to nonexistent ADR 99
639        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        // Should have an ADR013 (broken links) issue
653        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        // init creates ADR #1 automatically; write #2 and #4 to create a gap at #3
667        let repo = Repository::init(temp.path(), None, false).unwrap();
668        let adr_dir = repo.adr_path();
669
670        // ADRs 1, 2, 4 -- gap at 3
671        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        // Should have an ADR011 (sequential gap) issue
685        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        // Repository::init creates ADR #1 automatically -- use #2 and #3 to avoid duplicate
702        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        // Create a valid ADR so check_all has something to process
741        std::fs::write(
742            adr_dir.join("0001-first.md"),
743            make_nygard_adr(1, "First", "Accepted", ""),
744        )
745        .unwrap();
746
747        // check_all should succeed and return a report
748        let report = check_all(&repo).unwrap();
749
750        // With a valid sequential repo, no collection-rule violations
751        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    // ========== check_all_filtered / [doctor].ignore (issue #316) ==========
763
764    #[test]
765    fn test_check_all_filtered_suppresses_ignored_rule() {
766        use crate::Repository;
767
768        let temp = tempfile::tempdir().unwrap();
769        // init creates ADR #1 automatically; write #2 and #4 to create a gap at #3,
770        // which trips ADR011 (Warning severity, confirmed via
771        // test_check_repository_sequential_gap_adr011).
772        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        // Unfiltered: check_repository still reports ADR011.
786        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        // Write adrs.toml with a lowercase ignore entry, then re-open the repository
798        // so the config is loaded from disk (Repository::init keeps the in-memory
799        // config it built at creation time).
800        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        // check_all (and check_all_filtered) should no longer contain ADR011,
809        // proving case-insensitive matching against the real rule_id "ADR011".
810        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        // check_repository (unfiltered) should still report ADR011 -- filtering
822        // is check_all-level only.
823        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}