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 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
18/// Rule IDs and rule names that are *always* produced without a path.
19///
20/// These are the upstream collection rules whose only source is
21/// `check_repository`'s `CollectionRule::check_collection` loop (`path: None`
22/// there -- see the comment on that loop). `ADR013` / `adr-valid-adr-links` is
23/// deliberately excluded: that rule id is also used by the frontmatter
24/// broken-link check above the collection-rule loop, which *does* set a path,
25/// so a `[[doctor.ignore_path]]` entry naming it can fire for that source even
26/// though it can never match the collection-rule source.
27///
28/// Used to warn when a `[[doctor.ignore_path]]` entry names a rule that can
29/// never be suppressed by path (issue #365).
30const 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/// Severity level for lint issues.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
41pub enum IssueSeverity {
42    /// Informational message.
43    Info,
44    /// Warning that should be addressed.
45    Warning,
46    /// Error that needs to be fixed.
47    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/// A unified issue type for both per-file lint violations and repository-level diagnostics.
71#[derive(Debug, Clone)]
72pub struct Issue {
73    /// The rule that produced this issue (e.g., "ADR001", "adr-title-format").
74    pub rule_id: String,
75    /// Human-readable rule name.
76    pub rule_name: String,
77    /// The severity of this issue.
78    pub severity: IssueSeverity,
79    /// A human-readable message describing the issue.
80    pub message: String,
81    /// The path to the affected file, if applicable.
82    pub path: Option<PathBuf>,
83    /// Line number (1-based), if applicable.
84    pub line: Option<usize>,
85    /// Column number (1-based), if applicable.
86    pub column: Option<usize>,
87    /// The ADR number, if applicable.
88    pub adr_number: Option<u32>,
89    /// Related ADR numbers (for issues involving multiple ADRs).
90    pub related_adrs: Vec<u32>,
91}
92
93impl Issue {
94    /// Create a new issue from an mdbook-lint violation.
95    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/// Results from linting.
115#[derive(Debug, Default)]
116pub struct LintReport {
117    /// All issues found.
118    pub issues: Vec<Issue>,
119}
120
121impl LintReport {
122    /// Create a new empty report.
123    pub fn new() -> Self {
124        Self::default()
125    }
126
127    /// Add an issue to the report.
128    pub fn add(&mut self, issue: Issue) {
129        self.issues.push(issue);
130    }
131
132    /// Check if there are any errors.
133    pub fn has_errors(&self) -> bool {
134        self.issues
135            .iter()
136            .any(|i| i.severity == IssueSeverity::Error)
137    }
138
139    /// Check if there are any warnings.
140    pub fn has_warnings(&self) -> bool {
141        self.issues
142            .iter()
143            .any(|i| i.severity == IssueSeverity::Warning)
144    }
145
146    /// Check if the report is clean (no warnings or errors).
147    pub fn is_clean(&self) -> bool {
148        !self.has_errors() && !self.has_warnings()
149    }
150
151    /// Get the count of issues by severity.
152    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    /// Sort issues by severity (errors first), then by path, then by line.
160    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
170/// Detect an ADR document's format from its section headings.
171///
172/// The mdbook-lint ADR rules auto-detect format from YAML frontmatter alone,
173/// treating every frontmatter document as MADR. That misclassifies a
174/// frontmatter-backed Nygard ADR (as produced by `adrs --ng init`), which then
175/// fails the MADR section rules (#348). This detects the format the way a reader
176/// would: from the headings actually present.
177///
178/// - A MADR-specific H2 (`## Context and Problem Statement`, `## Decision
179///   Outcome`, or `## Considered Options`) means [`AdrFormat::Madr4`].
180/// - Otherwise a Nygard H2 (`## Context` or `## Decision`) means
181///   [`AdrFormat::Nygard`].
182/// - With neither present, fall back to [`AdrFormat::Auto`] so the rules apply
183///   their own frontmatter-based heuristic unchanged.
184fn 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
212/// Lint a single ADR file.
213///
214/// Runs all per-file lint rules against the ADR content.
215pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
216    let mut report = LintReport::new();
217
218    // Get the file content
219    let Some(path) = &adr.path else {
220        return Ok(report); // No path, nothing to lint
221    };
222
223    let content = std::fs::read_to_string(path)?;
224
225    // Create mdbook-lint Document
226    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    // Detect the document's actual format from its section headings, not from
245    // the mere presence of YAML frontmatter. The mdbook-lint ADR rules default
246    // to `AdrFormat::Auto`, which classifies any frontmatter document as MADR
247    // (see #348: `adrs --ng init` writes frontmatter + Nygard headings, which
248    // Auto then flags as missing the MADR `## Context and Problem Statement` /
249    // `## Decision Outcome` sections). Pinning the format-sensitive rules to the
250    // format we detect from the headings keeps a frontmatter-backed Nygard ADR
251    // valid while still validating genuine MADR documents.
252    let format = detect_adr_format(&doc.content);
253
254    // Run all single-document rules
255    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
301/// Lint all ADRs in a repository (per-file checks only).
302pub 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
315/// Run repository-level checks (collection rules).
316///
317/// These checks analyze the ADR set as a whole:
318/// - Sequential numbering (ADR011)
319/// - Duplicate numbers (ADR012)
320/// - Broken links (ADR013)
321/// - Superseded ADRs have replacements (ADR010)
322/// - Asymmetric links (`asymmetric-link`)
323pub fn check_repository(repo: &Repository) -> Result<LintReport> {
324    let mut report = LintReport::new();
325    let adrs = repo.list()?;
326
327    // Build documents for collection rules
328    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    // Resolve frontmatter `links[].target` against the ADR numbers actually
339    // present in the repository. This is distinct from the upstream ADR013
340    // rule below, which only checks markdown link *filenames* in the
341    // rendered body and has no notion of frontmatter (#355). A frontmatter
342    // link is a structured reference to an ADR number, so an unresolvable
343    // target is unambiguously broken and reported at `Error` rather than the
344    // upstream rule's `Warning`.
345    //
346    // Gated to nextgen mode: in compatible mode, `adr.links` is populated by
347    // parsing legacy body syntax like `Supersedes [1. Title](0001-title.md)`
348    // (see parse.rs), not frontmatter, and that path is already covered by
349    // the upstream markdown-filename check below. Checking it again here
350    // would change compatible-mode severity/exit-code behavior, which is out
351    // of scope for this fix.
352    //
353    // Track the prefix of the message the upstream ADR013 rule would emit for
354    // each broken link so its warning can be suppressed once our error covers
355    // the same broken link. Nygard-family templates render a link both in
356    // frontmatter and as a body markdown link, so without this a single
357    // broken link would otherwise produce two ADR013 issues for the same
358    // record.
359    //
360    // The prefix stops at the target's zero-padded number rather than
361    // spelling out a whole filename, because the body filename varies: an
362    // unresolvable target renders as the `{:04}-....md` fallback (see
363    // template.rs's `resolve_link_titles`), but a link rendered while its
364    // target still existed keeps that target's real filename, which is the
365    // case #355 was found through. Anchoring on the record's path and the
366    // padded number matches both without matching a different record or a
367    // different target.
368    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    // Check that every link is reciprocated (#357). `adrs link` maintains
406    // both halves of a relationship by construction, so an asymmetric pair
407    // is evidence that something outside the tool edited the record -- a
408    // hand edit, a renumber, a badly resolved merge. For each link A -> B,
409    // require that B carries some link back to A.
410    //
411    // The back-link is matched by target only, not by `LinkKind::reverse()`'s
412    // exact kind. `adrs link` accepts an explicit `reverse_kind` override
413    // (see commands/link.rs), so requiring the derived kind exactly would
414    // flag the tool's own supported output as corruption; asking only "does
415    // B link back to A" agrees with the tool by construction.
416    //
417    // Links whose target does not exist are skipped: that is a broken link,
418    // already reported as ADR013 above (frontmatter) or below (markdown
419    // body). Reporting it again as asymmetry would give two diagnostics for
420    // one problem.
421    //
422    // Unlike the frontmatter check above, this is not gated to nextgen mode:
423    // `adr.links` is populated in compatible mode by parsing legacy body
424    // syntax (see parse.rs) and in nextgen mode from frontmatter, and there
425    // is no upstream rule here to conflict with.
426    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    // Run collection rules
460    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                    // Suppress the upstream markdown-filename ADR013 warning
472                    // when the frontmatter check above already reported the
473                    // same broken link as an error (see comment above).
474                    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                    // Collection rule violations may have path in the message
483                    // We need to parse it out or handle it differently
484                    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, // Collection rules may span multiple files
490                        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
525/// A compiled `[[doctor.ignore_path]]` entry, ready to match against issue paths.
526struct CompiledIgnorePath {
527    matcher: globset::GlobMatcher,
528    rules: HashSet<String>,
529}
530
531/// Run all checks and filter out issues matching ignored rule IDs/names,
532/// repository-wide or path-scoped.
533///
534/// Repository-wide ignores are `repo.config().doctor.ignore` unioned with
535/// `extra_ignore` (e.g. CLI `--ignore` flags for a single invocation).
536/// Path-scoped ignores are `repo.config().doctor.ignore_path`: each entry
537/// suppresses its `rules` only for issues whose path, relative to the
538/// repository root with separators normalized to `/`, matches its `glob`.
539/// Both forms match rules case-insensitively against `Issue.rule_id` and
540/// `Issue.rule_name` (issue #365).
541///
542/// Returns the filtered report, the count of issues that were suppressed
543/// (repository-wide and path-scoped combined, each issue counted once even
544/// if it matched both), and any config warnings: an invalid glob that could
545/// not be compiled, or a `[[doctor.ignore_path]]` entry naming a rule that
546/// only ever produces path-less diagnostics and so can never be suppressed
547/// this way.
548pub 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    // Use list_with_errors to capture parse failures
556    let (adrs, parse_errors) = repo.list_with_errors()?;
557
558    // Report parse errors as lint issues
559    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    // Run per-file lint on successfully parsed ADRs
574    for adr in &adrs {
575        let adr_report = lint_adr(adr)?;
576        report.issues.extend(adr_report.issues);
577    }
578
579    // Run repository-level checks (these still use repo.list() internally,
580    // which is fine — they only need successfully parsed ADRs)
581    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    // Compile each `[[doctor.ignore_path]]` entry. An invalid glob is a
596    // config error in the same family as #363 -- the user believes an
597    // exemption is active when it is not -- so it is reported as a warning
598    // rather than silently dropped, and the rest of the config still loads
599    // and applies (see `warn_unknown_config_keys` in the CLI for the same
600    // non-fatal-warning convention).
601    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
660/// Run all checks: per-file lint + repository-level checks.
661///
662/// Also reports files that look like ADRs (digit-prefixed `.md` files in the
663/// ADR directory) but could not be parsed (e.g., invalid YAML frontmatter).
664pub 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        // Uses the actual ADR #0001 text produced by `adrs init`. The word "described"
710        // previously triggered an ADR014 false positive (fixed in mdbook-lint-rulesets 0.14.3).
711        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        // Print any issues for debugging
750        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        // Should have at least one issue (missing status)
792        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        // Regression for #330: a file produced by the Nygard bare-minimal
805        // template must not trip any doctor error (it previously emitted no
806        // `Date:` line and failed with ADR003). Empty-section ADR014 warnings
807        // are inherent to the variant and are not errors.
808        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        // Frontmatter + Nygard headings is Nygard, not MADR (#348).
842        assert_eq!(
843            detect_adr_format("---\nstatus: accepted\n---\n\n## Context\n\n## Decision\n"),
844            AdrFormat::Nygard
845        );
846        // MADR-specific headings win regardless of frontmatter.
847        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        // Plain Nygard (no frontmatter) is Nygard.
854        assert_eq!(
855            detect_adr_format("# 1. Title\n\nDate: 2024-03-04\n\n## Context\n"),
856            AdrFormat::Nygard
857        );
858        // Neither heading set present: defer to the rules' own heuristic.
859        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        // Regression for #348: `adrs --ng init` writes ADR #0001 with YAML
868        // frontmatter and Nygard headings. The mdbook-lint ADR rules auto-detect
869        // format from frontmatter alone and flagged it as MADR, demanding
870        // `## Context and Problem Statement` / `## Decision Outcome` (ADR004/005).
871        // A freshly initialized next-gen repository must pass doctor unchanged.
872        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        // #348: frontmatter presence alone must not trigger the MADR section
892        // rules on a document that uses Nygard headings.
893        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        // Detection must not weaken validation of real MADR documents: a MADR
919        // ADR (MADR headings) that omits `## Decision Outcome` still gets ADR005.
920        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        // Write an ADR with invalid YAML (bad date)
945        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        // Issue #216: decision-makers as string should not cause a parse error
977        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    // ========== check_repository collection rules (issue #239) ==========
995
996    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        // init creates ADR #1 automatically
1009        let repo = Repository::init(temp.path(), None, false).unwrap();
1010        let adr_dir = repo.adr_path();
1011
1012        // ADR 2 links to nonexistent ADR 99
1013        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        // Should have an ADR013 (broken links) issue
1027        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        // nextgen mode: links live in YAML frontmatter (issue #355 repro)
1048        let repo = Repository::init(temp.path(), None, true).unwrap();
1049        let adr_dir = repo.adr_path();
1050
1051        // ADR 2 has a frontmatter link to nonexistent ADR 99
1052        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        // init() creates ADR #1; link ADR 2 to it.
1104        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        // A Nygard-format nextgen render puts a broken link's target in both
1133        // the frontmatter `links:` block and a body markdown link, with the
1134        // same unresolved fallback filename (see template.rs's
1135        // `resolve_link_titles`, which falls back to `{:04}-....md` for a
1136        // target it can't find). One broken link should report once.
1137        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        // A link that was rendered while its target still existed carries the
1168        // target's real filename, not the `{:04}-....md` unresolved fallback.
1169        // This is the case #355 was found through: renumbering left a stale
1170        // target behind. It is still one broken link and must report once.
1171        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    // ========== check_repository asymmetric-link rule (issue #357) ==========
1194
1195    #[test]
1196    fn test_check_repository_asymmetric_link_half_deleted_one_warning() {
1197        use crate::Repository;
1198
1199        let temp = tempfile::tempdir().unwrap();
1200        // Compatible mode, matching the issue's own reproduction. `init`
1201        // creates ADR #1 with no links -- the "half-deleted" state: its
1202        // `Superseded by` line was removed by hand, leaving ADR #2's
1203        // `Supersedes` link with nothing pointing back (issue #357 case 1).
1204        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        // Nextgen mode (frontmatter links) -- covers the both-modes requirement.
1253        let repo = Repository::init(temp.path(), None, true).unwrap();
1254        let adr_dir = repo.adr_path();
1255
1256        // ADR 1's reverse link is repointed at ADR 3, which exists but has no
1257        // relationship to either. ADR 2 still claims to supersede ADR 1
1258        // (issue #357 case 2). Both halves are independently broken, so this
1259        // must produce two warnings, not one.
1260        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        // ADR 2, so `repo.link` has two existing ADRs to connect.
1325        std::fs::write(
1326            adr_dir.join("0002-second.md"),
1327            make_frontmatter_adr(2, "Second", "proposed", ""),
1328        )
1329        .unwrap();
1330
1331        // Built through the tool's own API, not by hand, so this test breaks
1332        // if the rule and `adrs link` ever disagree about what counts as
1333        // symmetric.
1334        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        // `adrs link` accepts an explicit `reverse_kind` override
1365        // (commands/link.rs), so a pair whose reverse kind is not
1366        // `LinkKind::Supersedes.reverse()` (`SupersededBy`) is still valid
1367        // tool output, not corruption. Here ADR 1's link back to ADR 2 is
1368        // `RelatesTo` rather than the derived `SupersededBy`.
1369        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        // init creates ADR #1 automatically.
1391        let repo = Repository::init(temp.path(), None, false).unwrap();
1392        let adr_dir = repo.adr_path();
1393
1394        // ADR 2 links to nonexistent ADR 99: a broken link, not an
1395        // asymmetric one. It must be reported once, as ADR013, and not
1396        // again as asymmetric-link.
1397        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        // `LinkKind::RelatesTo.reverse()` is `RelatesTo` itself, so a
1440        // symmetric `RelatesTo` pair is the default `repo.link` output.
1441        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        // init creates ADR #1 with no links.
1463        let repo = Repository::init(temp.path(), None, false).unwrap();
1464        let adr_dir = repo.adr_path();
1465
1466        // ADR 2 relates to ADR 1, but ADR 1 has no reciprocal link -- a
1467        // one-way RelatesTo is a plausible hand-written relationship, and
1468        // should be a single warning, not silence.
1469        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        // init creates ADR #1 automatically; write #2 and #4 to create a gap at #3
1505        let repo = Repository::init(temp.path(), None, false).unwrap();
1506        let adr_dir = repo.adr_path();
1507
1508        // ADRs 1, 2, 4 -- gap at 3
1509        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        // Should have an ADR011 (sequential gap) issue
1523        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        // Repository::init creates ADR #1 automatically -- use #2 and #3 to avoid duplicate
1540        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        // Create a valid ADR so check_all has something to process
1579        std::fs::write(
1580            adr_dir.join("0001-first.md"),
1581            make_nygard_adr(1, "First", "Accepted", ""),
1582        )
1583        .unwrap();
1584
1585        // check_all should succeed and return a report
1586        let report = check_all(&repo).unwrap();
1587
1588        // With a valid sequential repo, no collection-rule violations
1589        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    // ========== check_all_filtered / [doctor].ignore (issue #316) ==========
1601
1602    #[test]
1603    fn test_check_all_filtered_suppresses_ignored_rule() {
1604        use crate::Repository;
1605
1606        let temp = tempfile::tempdir().unwrap();
1607        // init creates ADR #1 automatically; write #2 and #4 to create a gap at #3,
1608        // which trips ADR011 (Warning severity, confirmed via
1609        // test_check_repository_sequential_gap_adr011).
1610        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        // Unfiltered: check_repository still reports ADR011.
1624        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        // Write adrs.toml with a lowercase ignore entry, then re-open the repository
1636        // so the config is loaded from disk (Repository::init keeps the in-memory
1637        // config it built at creation time).
1638        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        // check_all (and check_all_filtered) should no longer contain ADR011,
1647        // proving case-insensitive matching against the real rule_id "ADR011".
1648        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        // check_repository (unfiltered) should still report ADR011 -- filtering
1660        // is check_all-level only.
1661        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    // ========== check_all_filtered / [[doctor.ignore_path]] (issue #365) ==========
1717
1718    /// An ADR with an explicit Consequences body, so a test can trigger
1719    /// ADR014's placeholder-text check on demand.
1720    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        // The headline test (#365): a scoped ignore suppresses ADR014 on the
1734        // record it names and leaves ADR014 firing on a different record that
1735        // trips the same placeholder-text check.
1736        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        // ADR 1 (created by init) already has real content; add two more ADRs
1743        // that both trip ADR014 via placeholder text in Consequences.
1744        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        // Unfiltered: both records trip ADR014.
1756        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        // Scope the exemption to 0002-second.md only.
1777        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        // `**` must span the intermediate directory components of a nested
1814        // `adr_dir` (e.g. "docs/architecture/decisions", the shape used in
1815        // #363's own reproduction), not just match within a single directory.
1816        // `Repository::list` only reads ADR files directly inside `adr_dir`
1817        // (`max_depth(1)`), so the subdirectory being spanned here is
1818        // `adr_dir` itself relative to the repository root, not a
1819        // subdirectory of `adr_dir`.
1820        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        // 0002 trips ADR014 (scoped away below); 0003/0005 create a numbering
1904        // gap that trips ADR011 (suppressed repository-wide below).
1905        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        // Confirm ADR014's rule name before relying on it below.
1971        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        // '[' with no closing ']' is an invalid glob pattern.
2012        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        // ADR011 (sequential numbering) is an upstream collection rule and
2048        // never carries a path, so this exemption can never fire.
2049        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}