use crate::{Adr, Repository, Result};
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,
};
use std::path::PathBuf;
#[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))
});
}
}
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 rules: Vec<Box<dyn Rule>> = vec![
Box::new(Adr001::default()),
Box::new(Adr002::default()),
Box::new(Adr003::default()),
Box::new(Adr004::default()),
Box::new(Adr005::default()),
Box::new(Adr006::default()),
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::default()),
];
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 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 {
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)
}
pub fn check_all(repo: &Repository) -> Result<LintReport> {
let mut report = LintReport::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();
Ok(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 = r#"# 1. Record architecture decisions
Date: 2024-03-04
## Status
Accepted
## Context
We need to record the architectural decisions made on this project.
## Decision
We will use Architecture Decision Records, as described by Michael Nygard in his article "Documenting Architecture Decisions".
## Consequences
See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.
"#;
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_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<_>>()
);
}
#[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"
);
}
}