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 = lint_all(repo)?;
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"
);
}
}