use crate::{Adr, Repository, Result};
use globset::Glob;
use mdbook_lint_core::Document;
use mdbook_lint_core::rule::{CollectionRule, Rule};
use mdbook_lint_rulesets::adr::{
Adr001, Adr002, Adr003, Adr004, Adr005, Adr006, Adr007, Adr008, Adr009, Adr010, Adr011, Adr012,
Adr013, Adr014, Adr015, Adr016, Adr017, AdrFormat,
};
use std::collections::HashSet;
use std::path::PathBuf;
const ALWAYS_PATHLESS_RULES: &[&str] = &[
"ADR010",
"adr-superseded-has-replacement",
"ADR011",
"adr-sequential-numbering",
"ADR012",
"adr-no-duplicate-numbers",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum IssueSeverity {
Info,
Warning,
Error,
}
impl std::fmt::Display for IssueSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IssueSeverity::Info => write!(f, "info"),
IssueSeverity::Warning => write!(f, "warning"),
IssueSeverity::Error => write!(f, "error"),
}
}
}
impl From<mdbook_lint_core::Severity> for IssueSeverity {
fn from(severity: mdbook_lint_core::Severity) -> Self {
match severity {
mdbook_lint_core::Severity::Error => IssueSeverity::Error,
mdbook_lint_core::Severity::Warning => IssueSeverity::Warning,
mdbook_lint_core::Severity::Info => IssueSeverity::Info,
}
}
}
#[derive(Debug, Clone)]
pub struct Issue {
pub rule_id: String,
pub rule_name: String,
pub severity: IssueSeverity,
pub message: String,
pub path: Option<PathBuf>,
pub line: Option<usize>,
pub column: Option<usize>,
pub adr_number: Option<u32>,
pub related_adrs: Vec<u32>,
}
impl Issue {
fn from_violation(
violation: mdbook_lint_core::Violation,
path: Option<PathBuf>,
adr_number: Option<u32>,
) -> Self {
Self {
rule_id: violation.rule_id,
rule_name: violation.rule_name,
severity: violation.severity.into(),
message: violation.message,
path,
line: Some(violation.line),
column: Some(violation.column),
adr_number,
related_adrs: Vec::new(),
}
}
}
#[derive(Debug, Default)]
pub struct LintReport {
pub issues: Vec<Issue>,
}
impl LintReport {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, issue: Issue) {
self.issues.push(issue);
}
pub fn has_errors(&self) -> bool {
self.issues
.iter()
.any(|i| i.severity == IssueSeverity::Error)
}
pub fn has_warnings(&self) -> bool {
self.issues
.iter()
.any(|i| i.severity == IssueSeverity::Warning)
}
pub fn is_clean(&self) -> bool {
!self.has_errors() && !self.has_warnings()
}
pub fn count_by_severity(&self, severity: IssueSeverity) -> usize {
self.issues
.iter()
.filter(|i| i.severity == severity)
.count()
}
pub fn sort(&mut self) {
self.issues.sort_by(|a, b| {
b.severity
.cmp(&a.severity)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.line.cmp(&b.line))
});
}
}
fn detect_adr_format(content: &str) -> AdrFormat {
let mut has_nygard = false;
for line in content.lines() {
let Some(heading) = line.strip_prefix("## ") else {
continue;
};
let heading = heading.trim();
if heading.eq_ignore_ascii_case("Context and Problem Statement")
|| heading.eq_ignore_ascii_case("Decision Outcome")
|| heading.eq_ignore_ascii_case("Considered Options")
{
return AdrFormat::Madr4;
}
if heading.eq_ignore_ascii_case("Context") || heading.eq_ignore_ascii_case("Decision") {
has_nygard = true;
}
}
if has_nygard {
AdrFormat::Nygard
} else {
AdrFormat::Auto
}
}
pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
let mut report = LintReport::new();
let Some(path) = &adr.path else {
return Ok(report); };
let content = std::fs::read_to_string(path)?;
let doc = match Document::new(content, path.clone()) {
Ok(d) => d,
Err(e) => {
report.add(Issue {
rule_id: "parse-error".to_string(),
rule_name: "parse-error".to_string(),
severity: IssueSeverity::Error,
message: format!("Failed to parse document: {e}"),
path: Some(path.clone()),
line: None,
column: None,
adr_number: Some(adr.number),
related_adrs: Vec::new(),
});
return Ok(report);
}
};
let format = detect_adr_format(&doc.content);
let rules: Vec<Box<dyn Rule>> = vec![
Box::new(Adr001::default()),
Box::new(Adr002::default()),
Box::new(Adr003::default()),
Box::new(Adr004::with_format(format)),
Box::new(Adr005::with_format(format)),
Box::new(Adr006::with_format(format)),
Box::new(Adr007::default()),
Box::new(Adr008::default()),
Box::new(Adr009::default()),
Box::new(Adr014::default()),
Box::new(Adr015::default()),
Box::new(Adr016::default()),
Box::new(Adr017::with_format(format)),
];
for rule in rules {
match rule.check(&doc) {
Ok(violations) => {
for violation in violations {
report.add(Issue::from_violation(
violation,
Some(path.clone()),
Some(adr.number),
));
}
}
Err(e) => {
report.add(Issue {
rule_id: rule.id().to_string(),
rule_name: rule.name().to_string(),
severity: IssueSeverity::Error,
message: format!("Rule failed: {e}"),
path: Some(path.clone()),
line: None,
column: None,
adr_number: Some(adr.number),
related_adrs: Vec::new(),
});
}
}
}
Ok(report)
}
pub fn lint_all(repo: &Repository) -> Result<LintReport> {
let mut report = LintReport::new();
let adrs = repo.list()?;
for adr in &adrs {
let adr_report = lint_adr(adr)?;
report.issues.extend(adr_report.issues);
}
report.sort();
Ok(report)
}
pub fn check_repository(repo: &Repository) -> Result<LintReport> {
let mut report = LintReport::new();
let adrs = repo.list()?;
let mut documents = Vec::new();
for adr in &adrs {
if let Some(path) = &adr.path {
let content = std::fs::read_to_string(path)?;
if let Ok(doc) = Document::new(content, path.clone()) {
documents.push(doc);
}
}
}
let mut broken_link_fragments: Vec<String> = Vec::new();
if repo.config().is_next_gen() {
let existing_numbers: std::collections::HashSet<u32> =
adrs.iter().map(|a| a.number).collect();
for adr in &adrs {
for link in &adr.links {
if existing_numbers.contains(&link.target) {
continue;
}
let path = adr.path.clone().unwrap_or_default();
broken_link_fragments.push(format!(
"{}: Link to '{:04}",
path.display(),
link.target
));
report.add(Issue {
rule_id: "ADR013".to_string(),
rule_name: "adr-valid-adr-links".to_string(),
severity: IssueSeverity::Error,
message: format!(
"ADR {} '{}' links to non-existent ADR {}",
adr.number, adr.title, link.target
),
path: adr.path.clone(),
line: None,
column: None,
adr_number: Some(adr.number),
related_adrs: Vec::new(),
});
}
}
}
let adrs_by_number: std::collections::HashMap<u32, &Adr> =
adrs.iter().map(|a| (a.number, a)).collect();
for adr in &adrs {
for link in &adr.links {
let Some(target_adr) = adrs_by_number.get(&link.target) else {
continue;
};
let has_back_link = target_adr
.links
.iter()
.any(|back| back.target == adr.number);
if !has_back_link {
report.add(Issue {
rule_id: "asymmetric-link".to_string(),
rule_name: "adr-asymmetric-link".to_string(),
severity: IssueSeverity::Warning,
message: format!(
"ADR {} '{}' links to ADR {} as '{}' but ADR {} has no link back to ADR {}",
adr.number, adr.title, link.target, link.kind, link.target, adr.number
),
path: adr.path.clone(),
line: None,
column: None,
adr_number: Some(adr.number),
related_adrs: Vec::new(),
});
}
}
}
let collection_rules: Vec<Box<dyn CollectionRule>> = vec![
Box::new(Adr010),
Box::new(Adr011),
Box::new(Adr012),
Box::new(Adr013),
];
for rule in collection_rules {
match rule.check_collection(&documents) {
Ok(violations) => {
for violation in violations {
if violation.rule_id == "ADR013"
&& broken_link_fragments
.iter()
.any(|fragment| violation.message.contains(fragment.as_str()))
{
continue;
}
report.add(Issue {
rule_id: rule.id().to_string(),
rule_name: rule.name().to_string(),
severity: violation.severity.into(),
message: violation.message,
path: None, line: if violation.line > 0 {
Some(violation.line)
} else {
None
},
column: if violation.column > 0 {
Some(violation.column)
} else {
None
},
adr_number: None,
related_adrs: Vec::new(),
});
}
}
Err(e) => {
report.add(Issue {
rule_id: rule.id().to_string(),
rule_name: rule.name().to_string(),
severity: IssueSeverity::Error,
message: format!("Rule failed: {e}"),
path: None,
line: None,
column: None,
adr_number: None,
related_adrs: Vec::new(),
});
}
}
}
report.sort();
Ok(report)
}
struct CompiledIgnorePath {
matcher: globset::GlobMatcher,
rules: HashSet<String>,
}
pub fn check_all_filtered(
repo: &Repository,
extra_ignore: &[String],
) -> Result<(LintReport, usize, Vec<String>)> {
let mut report = LintReport::new();
let mut warnings = Vec::new();
let (adrs, parse_errors) = repo.list_with_errors()?;
for (path, error) in &parse_errors {
report.add(Issue {
rule_id: "parse-error".to_string(),
rule_name: "adr-parse-error".to_string(),
severity: IssueSeverity::Error,
message: format!("Failed to parse ADR: {error}"),
path: Some(path.clone()),
line: None,
column: None,
adr_number: None,
related_adrs: Vec::new(),
});
}
for adr in &adrs {
let adr_report = lint_adr(adr)?;
report.issues.extend(adr_report.issues);
}
let repo_report = check_repository(repo)?;
report.issues.extend(repo_report.issues);
report.sort();
let ignore_set: HashSet<String> = repo
.config()
.doctor
.ignore
.iter()
.chain(extra_ignore.iter())
.map(|s| s.to_lowercase())
.collect();
let mut compiled_ignore_paths: Vec<CompiledIgnorePath> = Vec::new();
for entry in &repo.config().doctor.ignore_path {
match Glob::new(&entry.glob) {
Ok(glob) => compiled_ignore_paths.push(CompiledIgnorePath {
matcher: glob.compile_matcher(),
rules: entry.rules.iter().map(|s| s.to_lowercase()).collect(),
}),
Err(e) => warnings.push(format!(
"[[doctor.ignore_path]] glob '{}' is invalid and will not be applied: {e}",
entry.glob
)),
}
for rule in &entry.rules {
if ALWAYS_PATHLESS_RULES
.iter()
.any(|pathless| pathless.eq_ignore_ascii_case(rule))
{
warnings.push(format!(
"[[doctor.ignore_path]] entry for glob '{}' names rule '{}', which only ever produces diagnostics without a path; this exemption can never suppress it",
entry.glob, rule
));
}
}
}
if ignore_set.is_empty() && compiled_ignore_paths.is_empty() {
return Ok((report, 0, warnings));
}
let root = repo.root();
let before = report.issues.len();
report.issues.retain(|issue| {
if ignore_set.contains(&issue.rule_id.to_lowercase())
|| ignore_set.contains(&issue.rule_name.to_lowercase())
{
return false;
}
let Some(path) = &issue.path else {
return true;
};
let relative = path.strip_prefix(root).unwrap_or(path);
let relative_str = relative.to_string_lossy().replace('\\', "/");
let rule_id = issue.rule_id.to_lowercase();
let rule_name = issue.rule_name.to_lowercase();
!compiled_ignore_paths.iter().any(|entry| {
entry.matcher.is_match(&relative_str)
&& (entry.rules.contains(&rule_id) || entry.rules.contains(&rule_name))
})
});
let suppressed = before - report.issues.len();
Ok((report, suppressed, warnings))
}
pub fn check_all(repo: &Repository) -> Result<LintReport> {
check_all_filtered(repo, &[]).map(|(report, _, _)| report)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Adr;
#[test]
fn test_issue_severity_ordering() {
assert!(IssueSeverity::Error > IssueSeverity::Warning);
assert!(IssueSeverity::Warning > IssueSeverity::Info);
}
#[test]
fn test_lint_report_empty() {
let report = LintReport::new();
assert!(report.is_clean());
assert!(!report.has_errors());
assert!(!report.has_warnings());
}
#[test]
fn test_lint_report_with_issues() {
let mut report = LintReport::new();
report.add(Issue {
rule_id: "ADR001".to_string(),
rule_name: "adr-title-format".to_string(),
severity: IssueSeverity::Error,
message: "Title format invalid".to_string(),
path: Some(PathBuf::from("0001-test.md")),
line: Some(1),
column: Some(1),
adr_number: Some(1),
related_adrs: Vec::new(),
});
assert!(report.has_errors());
assert!(!report.is_clean());
assert_eq!(report.count_by_severity(IssueSeverity::Error), 1);
}
#[test]
fn test_lint_valid_nygard_adr() {
let content = format!(
r#"# 1. Record architecture decisions
Date: 2024-03-04
## Status
Accepted
## Context
{}
## Decision
{}
## Consequences
{}
"#,
crate::init_adr::CONTEXT,
crate::init_adr::DECISION,
crate::init_adr::CONSEQUENCES,
);
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir
.path()
.join("adr")
.join("0001-record-architecture-decisions.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, content).unwrap();
let mut adr = Adr::new(1, "Record architecture decisions");
adr.path = Some(path);
let report = lint_adr(&adr).unwrap();
for issue in &report.issues {
println!(
"{}: {} ({}:{})",
issue.rule_id,
issue.message,
issue.line.unwrap_or(0),
issue.column.unwrap_or(0)
);
}
assert!(report.is_clean(), "Expected no issues for valid Nygard ADR");
}
#[test]
fn test_lint_invalid_adr_missing_status() {
let content = r#"# 1. Test decision
Date: 2024-03-04
## Context
Some context.
## Decision
Some decision.
## Consequences
Some consequences.
"#;
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("adr").join("0001-test-decision.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, content).unwrap();
let mut adr = Adr::new(1, "Test decision");
adr.path = Some(path);
let report = lint_adr(&adr).unwrap();
assert!(
!report.is_clean(),
"Expected issues for ADR missing status section"
);
assert!(
report.issues.iter().any(|i| i.rule_id == "ADR002"),
"Expected ADR002 (missing status) violation"
);
}
#[test]
fn test_nygard_bare_minimal_template_passes_doctor() {
use crate::{Adr, Config, Repository, Template, TemplateFormat, TemplateVariant};
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let template =
Template::builtin_with_variant(TemplateFormat::Nygard, TemplateVariant::BareMinimal);
let adr = Adr::new(2, "Bare minimal regression");
let rendered = template
.render(&adr, &Config::default(), &std::collections::HashMap::new())
.unwrap();
let path = repo.adr_path().join("0002-bare-minimal-regression.md");
std::fs::write(&path, rendered).unwrap();
let report = check_all(&repo).unwrap();
let file_errors: Vec<_> = report
.issues
.iter()
.filter(|i| i.severity == IssueSeverity::Error)
.filter(|i| {
i.path
.as_ref()
.is_some_and(|p| p.to_string_lossy().contains("0002-bare-minimal-regression"))
})
.collect();
assert!(
file_errors.is_empty(),
"nygard bare-minimal output should have no doctor errors, got: {file_errors:?}"
);
}
#[test]
fn test_detect_adr_format_from_headings() {
assert_eq!(
detect_adr_format("---\nstatus: accepted\n---\n\n## Context\n\n## Decision\n"),
AdrFormat::Nygard
);
assert_eq!(
detect_adr_format(
"---\nstatus: accepted\n---\n\n## Context and Problem Statement\n\n## Decision Outcome\n"
),
AdrFormat::Madr4
);
assert_eq!(
detect_adr_format("# 1. Title\n\nDate: 2024-03-04\n\n## Context\n"),
AdrFormat::Nygard
);
assert_eq!(
detect_adr_format("---\nstatus: accepted\n---\n\n# Title only\n"),
AdrFormat::Auto
);
}
#[test]
fn test_ng_init_repo_passes_doctor() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let report = check_all(&repo).unwrap();
let errors: Vec<_> = report
.issues
.iter()
.filter(|i| i.severity == IssueSeverity::Error)
.collect();
assert!(
errors.is_empty(),
"freshly `--ng init`ed repo should pass doctor, got: {errors:?}"
);
}
#[test]
fn test_frontmatter_nygard_adr_not_flagged_for_madr_sections() {
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";
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir
.path()
.join("adr")
.join("0001-record-architecture-decisions.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, content).unwrap();
let mut adr = Adr::new(1, "Record architecture decisions");
adr.path = Some(path);
let report = lint_adr(&adr).unwrap();
assert!(
!report
.issues
.iter()
.any(|i| i.rule_id == "ADR004" || i.rule_id == "ADR005"),
"frontmatter+Nygard ADR must not trip MADR section rules, got: {:?}",
report.issues
);
}
#[test]
fn test_genuine_madr_missing_decision_outcome_still_flagged() {
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";
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("adr").join("0001-use-postgres.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, content).unwrap();
let mut adr = Adr::new(1, "Use Postgres");
adr.path = Some(path);
let report = lint_adr(&adr).unwrap();
assert!(
report.issues.iter().any(|i| i.rule_id == "ADR005"),
"MADR ADR missing '## Decision Outcome' must still trip ADR005, got: {:?}",
report.issues
);
}
#[test]
fn test_check_all_reports_parse_errors() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let bad_content =
"---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad Date\n";
std::fs::write(repo.adr_path().join("0002-bad-date.md"), bad_content).unwrap();
let report = check_all(&repo).unwrap();
let parse_errors: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "parse-error")
.collect();
assert_eq!(parse_errors.len(), 1, "should report 1 parse error");
assert_eq!(parse_errors[0].severity, IssueSeverity::Error);
assert!(
parse_errors[0]
.path
.as_ref()
.unwrap()
.to_string_lossy()
.contains("0002-bad-date.md")
);
}
#[test]
fn test_check_all_no_parse_errors_for_string_decision_makers() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
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";
std::fs::write(repo.adr_path().join("0002-test.md"), content).unwrap();
let report = check_all(&repo).unwrap();
let parse_errors: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "parse-error")
.collect();
assert!(
parse_errors.is_empty(),
"string decision-makers should not cause parse error, got: {:?}",
parse_errors.iter().map(|i| &i.message).collect::<Vec<_>>()
);
}
fn make_nygard_adr(number: u32, title: &str, status: &str, links: &str) -> String {
format!(
"# {}. {}\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",
number, title, status, links
)
}
#[test]
fn test_check_repository_broken_link_adr013() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(
2,
"Second",
"Accepted",
"\n\nSupersedes [99. Unknown](0099-unknown.md)\n",
),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let has_adr013 = report.issues.iter().any(|i| i.rule_id == "ADR013");
assert!(
has_adr013,
"Expected ADR013 broken-link issue, got: {:?}",
report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
);
}
fn make_frontmatter_adr(number: u32, title: &str, status: &str, links_yaml: &str) -> String {
format!(
"---\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",
number, title, status, links_yaml
)
}
#[test]
fn test_check_repository_frontmatter_broken_link_adr013_error() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_frontmatter_adr(
2,
"Second",
"proposed",
"links:\n - target: 99\n kind: relatesto\n",
),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let broken = report
.issues
.iter()
.find(|i| i.rule_id == "ADR013" && i.severity == IssueSeverity::Error);
assert!(
broken.is_some(),
"Expected ADR013 error for frontmatter link to non-existent ADR 99, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, i.severity, &i.message))
.collect::<Vec<_>>()
);
let issue = broken.unwrap();
assert_eq!(issue.adr_number, Some(2));
assert!(
issue.path.is_some(),
"expected a file location on the issue"
);
assert!(
issue.message.contains("links to non-existent ADR 99"),
"unexpected message: {}",
issue.message
);
assert!(
report.has_errors(),
"a broken frontmatter link must make the report (and doctor's exit code) nonzero"
);
}
#[test]
fn test_check_repository_frontmatter_link_to_existing_adr_no_issue() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_frontmatter_adr(
2,
"Second",
"proposed",
"links:\n - target: 1\n kind: relatesto\n",
),
)
.unwrap();
let report = check_repository(&repo).unwrap();
assert!(
!report.issues.iter().any(|i| i.rule_id == "ADR013"),
"link to an existing ADR should not produce ADR013, got: {:?}",
report.issues.iter().map(|i| &i.message).collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_dedups_frontmatter_and_body_broken_link() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
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";
std::fs::write(adr_dir.join("0002-second.md"), content).unwrap();
let report = check_repository(&repo).unwrap();
let adr013_issues: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "ADR013")
.collect();
assert_eq!(
adr013_issues.len(),
1,
"a broken link present in both frontmatter and body should report once, got: {:?}",
adr013_issues
.iter()
.map(|i| (&i.severity, &i.message))
.collect::<Vec<_>>()
);
assert_eq!(adr013_issues[0].severity, IssueSeverity::Error);
}
#[test]
fn test_check_repository_dedups_frontmatter_and_body_link_with_real_filename() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
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";
std::fs::write(adr_dir.join("0002-second.md"), content).unwrap();
let report = check_repository(&repo).unwrap();
let adr013_issues: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "ADR013")
.collect();
assert_eq!(
adr013_issues.len(),
1,
"a broken link whose body filename resolved should report once, got: {:?}",
adr013_issues
.iter()
.map(|i| (&i.severity, &i.message))
.collect::<Vec<_>>()
);
assert_eq!(adr013_issues[0].severity, IssueSeverity::Error);
}
#[test]
fn test_check_repository_asymmetric_link_half_deleted_one_warning() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(
2,
"Second",
"Accepted",
"\n\nSupersedes [1. Record architecture decisions](0001-record-architecture-decisions.md)\n",
),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let warnings: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "asymmetric-link")
.collect();
assert_eq!(
warnings.len(),
1,
"expected exactly one asymmetric-link warning, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
assert_eq!(warnings[0].rule_name, "adr-asymmetric-link");
assert_eq!(warnings[0].severity, IssueSeverity::Warning);
assert_eq!(warnings[0].adr_number, Some(2));
assert!(
warnings[0].message.contains("ADR 2")
&& warnings[0].message.contains("ADR 1")
&& warnings[0].message.contains("no link back to ADR 2"),
"unexpected message: {}",
warnings[0].message
);
}
#[test]
fn test_check_repository_asymmetric_link_mis_targeted_two_warnings() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0001-record-architecture-decisions.md"),
make_frontmatter_adr(
1,
"Record architecture decisions",
"superseded",
"links:\n - target: 3\n kind: supersededby\n",
),
)
.unwrap();
std::fs::write(
adr_dir.join("0002-second.md"),
make_frontmatter_adr(
2,
"Second",
"proposed",
"links:\n - target: 1\n kind: supersedes\n",
),
)
.unwrap();
std::fs::write(
adr_dir.join("0003-third.md"),
make_frontmatter_adr(3, "Third", "proposed", ""),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let warnings: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "asymmetric-link")
.collect();
assert_eq!(
warnings.len(),
2,
"expected two asymmetric-link warnings, one per independently broken half, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
let adr_numbers: Vec<_> = warnings.iter().filter_map(|i| i.adr_number).collect();
assert!(
adr_numbers.contains(&1) && adr_numbers.contains(&2),
"expected warnings naming both ADR 1 and ADR 2, got: {adr_numbers:?}"
);
assert!(
warnings
.iter()
.all(|i| i.severity == IssueSeverity::Warning)
);
}
#[test]
fn test_check_repository_symmetric_link_via_repository_link_no_warning() {
use crate::{LinkKind, Repository};
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_frontmatter_adr(2, "Second", "proposed", ""),
)
.unwrap();
repo.link(2, 1, LinkKind::Supersedes, LinkKind::SupersededBy)
.unwrap();
let report = check_repository(&repo).unwrap();
assert!(
!report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
"a pair built through Repository::link must not be flagged, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_non_derived_reverse_kind_no_warning() {
use crate::{LinkKind, Repository};
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, true).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_frontmatter_adr(2, "Second", "proposed", ""),
)
.unwrap();
repo.link(2, 1, LinkKind::Supersedes, LinkKind::RelatesTo)
.unwrap();
let report = check_repository(&repo).unwrap();
assert!(
!report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
"a non-derived but present reverse link must not be flagged, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_broken_link_no_asymmetric_link_warning() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(
2,
"Second",
"Accepted",
"\n\nSupersedes [99. Unknown](0099-unknown.md)\n",
),
)
.unwrap();
let report = check_repository(&repo).unwrap();
assert!(
report.issues.iter().any(|i| i.rule_id == "ADR013"),
"expected the broken-link diagnostic to still fire"
);
assert!(
!report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
"a link to a nonexistent ADR must not also be flagged as asymmetric, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_symmetric_relates_to_no_warning() {
use crate::{LinkKind, Repository};
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(2, "Second", "Accepted", ""),
)
.unwrap();
repo.link(2, 1, LinkKind::RelatesTo, LinkKind::RelatesTo)
.unwrap();
let report = check_repository(&repo).unwrap();
assert!(
!report.issues.iter().any(|i| i.rule_id == "asymmetric-link"),
"a symmetric RelatesTo pair must not be flagged, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_one_way_relates_to_one_warning() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(
2,
"Second",
"Accepted",
"\n\nRelates to [1. Record architecture decisions](0001-record-architecture-decisions.md)\n",
),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let warnings: Vec<_> = report
.issues
.iter()
.filter(|i| i.rule_id == "asymmetric-link")
.collect();
assert_eq!(
warnings.len(),
1,
"expected exactly one asymmetric-link warning for a one-way RelatesTo, got: {:?}",
report
.issues
.iter()
.map(|i| (&i.rule_id, &i.message))
.collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_sequential_gap_adr011() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(2, "Second", "Accepted", ""),
)
.unwrap();
std::fs::write(
adr_dir.join("0004-fourth.md"),
make_nygard_adr(4, "Fourth", "Accepted", ""),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let has_adr011 = report.issues.iter().any(|i| i.rule_id == "ADR011");
assert!(
has_adr011,
"Expected ADR011 sequential-gap issue, got: {:?}",
report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
);
}
#[test]
fn test_check_repository_clean_repo_has_no_issues() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(2, "Second", "Accepted", ""),
)
.unwrap();
std::fs::write(
adr_dir.join("0003-third.md"),
make_nygard_adr(3, "Third", "Proposed", ""),
)
.unwrap();
let report = check_repository(&repo).unwrap();
let collection_rule_ids = ["ADR010", "ADR011", "ADR012", "ADR013"];
let collection_issues: Vec<_> = report
.issues
.iter()
.filter(|i| collection_rule_ids.contains(&i.rule_id.as_str()))
.collect();
assert!(
collection_issues.is_empty(),
"Clean repo should have no collection-rule issues, got: {:?}",
collection_issues
.iter()
.map(|i| format!("{}: {}", i.rule_id, i.message))
.collect::<Vec<_>>()
);
}
#[test]
fn test_check_all_combines_lint_and_repository_checks() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0001-first.md"),
make_nygard_adr(1, "First", "Accepted", ""),
)
.unwrap();
let report = check_all(&repo).unwrap();
let adr011 = report
.issues
.iter()
.filter(|i| i.rule_id == "ADR011")
.count();
assert_eq!(
adr011, 0,
"Single valid ADR should have no sequential-gap issue"
);
}
#[test]
fn test_check_all_filtered_suppresses_ignored_rule() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(2, "Second", "Accepted", ""),
)
.unwrap();
std::fs::write(
adr_dir.join("0004-fourth.md"),
make_nygard_adr(4, "Fourth", "Accepted", ""),
)
.unwrap();
let unfiltered = check_repository(&repo).unwrap();
let unfiltered_adr011 = unfiltered
.issues
.iter()
.filter(|i| i.rule_id == "ADR011")
.count();
assert!(
unfiltered_adr011 > 0,
"expected check_repository to report ADR011 before filtering"
);
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"adr011\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
assert_eq!(repo.config().doctor.ignore, vec!["adr011".to_string()]);
let filtered = check_all(&repo).unwrap();
let filtered_adr011 = filtered
.issues
.iter()
.filter(|i| i.rule_id == "ADR011")
.count();
assert_eq!(
filtered_adr011, 0,
"check_all should suppress ADR011 issues per [doctor].ignore"
);
let still_unfiltered = check_repository(&repo).unwrap();
assert!(
still_unfiltered
.issues
.iter()
.any(|i| i.rule_id == "ADR011"),
"check_repository should remain unfiltered"
);
}
#[test]
fn test_check_all_filtered_returns_suppressed_count() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(2, "Second", "Accepted", ""),
)
.unwrap();
std::fs::write(
adr_dir.join("0004-fourth.md"),
make_nygard_adr(4, "Fourth", "Accepted", ""),
)
.unwrap();
let unfiltered = check_all(&repo).unwrap();
let unfiltered_adr011 = unfiltered
.issues
.iter()
.filter(|i| i.rule_id == "ADR011")
.count();
assert!(unfiltered_adr011 > 0);
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"ADR011\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert_eq!(suppressed_count, unfiltered_adr011);
assert!(
filtered.issues.iter().all(|i| i.rule_id != "ADR011"),
"filtered report should not contain ADR011"
);
assert!(
warnings.is_empty(),
"no [[doctor.ignore_path]] entries configured, expected no warnings"
);
}
fn make_nygard_adr_with_consequences(
number: u32,
title: &str,
status: &str,
consequences: &str,
) -> String {
format!(
"# {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"
)
}
#[test]
fn test_check_all_filtered_scoped_ignore_suppresses_on_matching_record_only() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
)
.unwrap();
std::fs::write(
adr_dir.join("0003-third.md"),
make_nygard_adr_with_consequences(3, "Third", "Accepted", "TBD"),
)
.unwrap();
let (unfiltered, _, _) = check_all_filtered(&repo, &[]).unwrap();
let unfiltered_adr014_paths: Vec<_> = unfiltered
.issues
.iter()
.filter(|i| i.rule_id == "ADR014")
.filter_map(|i| i.path.clone())
.collect();
assert!(
unfiltered_adr014_paths
.iter()
.any(|p| p.ends_with("0002-second.md")),
"expected ADR014 on 0002-second.md before scoping, got: {unfiltered_adr014_paths:?}"
);
assert!(
unfiltered_adr014_paths
.iter()
.any(|p| p.ends_with("0003-third.md")),
"expected ADR014 on 0003-third.md before scoping, got: {unfiltered_adr014_paths:?}"
);
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"ADR014\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
assert!(suppressed_count > 0);
let filtered_adr014_paths: Vec<_> = filtered
.issues
.iter()
.filter(|i| i.rule_id == "ADR014")
.filter_map(|i| i.path.clone())
.collect();
assert!(
!filtered_adr014_paths
.iter()
.any(|p| p.ends_with("0002-second.md")),
"0002-second.md's ADR014 should be suppressed, got: {filtered_adr014_paths:?}"
);
assert!(
filtered_adr014_paths
.iter()
.any(|p| p.ends_with("0003-third.md")),
"0003-third.md's ADR014 should still fire, got: {filtered_adr014_paths:?}"
);
}
#[test]
fn test_check_all_filtered_scoped_ignore_double_star_matches_subdirectory() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(
temp.path(),
Some(PathBuf::from("docs/architecture/decisions")),
false,
)
.unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
)
.unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"docs/architecture/decisions\"\n\n[[doctor.ignore_path]]\nglob = \"**/0002-*.md\"\nrules = [\"ADR014\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
assert!(
suppressed_count > 0,
"expected the ** glob to match the nested record"
);
assert!(
!filtered.issues.iter().any(|i| i.rule_id == "ADR014"
&& i.path
.as_ref()
.is_some_and(|p| p.ends_with("0002-second.md"))),
"nested record's ADR014 should be suppressed by the ** glob"
);
}
#[test]
fn test_check_all_filtered_scoped_ignore_matching_nothing_suppresses_nothing() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
)
.unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/9999-*.md\"\nrules = [\"ADR014\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
assert_eq!(
suppressed_count, 0,
"a glob matching nothing should suppress nothing"
);
assert!(
filtered.issues.iter().any(|i| i.rule_id == "ADR014"),
"ADR014 should still fire since the glob did not match"
);
}
#[test]
fn test_check_all_filtered_scoped_and_repo_wide_ignores_compose() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
)
.unwrap();
std::fs::write(
adr_dir.join("0003-third.md"),
make_nygard_adr(3, "Third", "Accepted", ""),
)
.unwrap();
std::fs::write(
adr_dir.join("0005-fifth.md"),
make_nygard_adr(5, "Fifth", "Accepted", ""),
)
.unwrap();
let (unfiltered, _, _) =
check_all_filtered(&Repository::open(temp.path()).unwrap(), &[]).unwrap();
let unfiltered_adr014 = unfiltered
.issues
.iter()
.filter(|i| i.rule_id == "ADR014")
.count();
let unfiltered_adr011 = unfiltered
.issues
.iter()
.filter(|i| i.rule_id == "ADR011")
.count();
assert!(unfiltered_adr014 > 0);
assert!(unfiltered_adr011 > 0);
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[doctor]\nignore = [\"ADR011\"]\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"ADR014\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
assert_eq!(
suppressed_count,
unfiltered_adr014 + unfiltered_adr011,
"both the scoped and repository-wide ignores should count, with no double counting"
);
assert!(!filtered.issues.iter().any(|i| i.rule_id == "ADR014"));
assert!(!filtered.issues.iter().any(|i| i.rule_id == "ADR011"));
}
#[test]
fn test_check_all_filtered_scoped_ignore_matches_by_rule_name() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
)
.unwrap();
let (unfiltered, _, _) =
check_all_filtered(&Repository::open(temp.path()).unwrap(), &[]).unwrap();
let rule_name = unfiltered
.issues
.iter()
.find(|i| i.rule_id == "ADR014")
.map(|i| i.rule_name.clone())
.expect("expected an ADR014 issue");
std::fs::write(
temp.path().join("adrs.toml"),
format!(
"adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"{rule_name}\"]\n"
),
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert!(
warnings.is_empty(),
"expected no warnings, got {warnings:?}"
);
assert!(suppressed_count > 0);
assert!(!filtered.issues.iter().any(|i| i.rule_id == "ADR014"));
}
#[test]
fn test_check_all_filtered_invalid_glob_warns_and_config_still_loads() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr_with_consequences(2, "Second", "Accepted", "TBD"),
)
.unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/[0002-*.md\"\nrules = [\"ADR014\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (filtered, suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert_eq!(
suppressed_count, 0,
"an invalid glob must not suppress anything"
);
assert!(
filtered.issues.iter().any(|i| i.rule_id == "ADR014"),
"ADR014 should still fire since the invalid glob was not applied"
);
assert!(
warnings.iter().any(|w| w.contains("doc/adr/[0002-*.md")),
"expected a warning naming the invalid glob, got: {warnings:?}"
);
}
#[test]
fn test_check_all_filtered_ignore_path_naming_collection_rule_warns() {
use crate::Repository;
let temp = tempfile::tempdir().unwrap();
let repo = Repository::init(temp.path(), None, false).unwrap();
let adr_dir = repo.adr_path();
std::fs::write(
adr_dir.join("0002-second.md"),
make_nygard_adr(2, "Second", "Accepted", ""),
)
.unwrap();
std::fs::write(
temp.path().join("adrs.toml"),
"adr_dir = \"doc/adr\"\n\n[[doctor.ignore_path]]\nglob = \"doc/adr/0002-*.md\"\nrules = [\"ADR011\"]\n",
)
.unwrap();
let repo = Repository::open(temp.path()).unwrap();
let (_filtered, _suppressed_count, warnings) = check_all_filtered(&repo, &[]).unwrap();
assert!(
warnings.iter().any(|w| w.contains("ADR011")),
"expected a warning naming the rule that can never fire, got: {warnings:?}"
);
}
}