Skip to main content

adrs_core/
lint.rs

1//! ADR linting using mdbook-lint rules.
2//!
3//! This module provides unified linting for ADRs, combining per-file validation
4//! (title format, required sections, date format) with repository-level checks
5//! (sequential numbering, duplicate detection, broken links).
6
7use crate::{Adr, Repository, Result};
8use mdbook_lint_core::Document;
9use mdbook_lint_core::rule::{CollectionRule, Rule};
10use mdbook_lint_rulesets::adr::{
11    Adr001, Adr002, Adr003, Adr004, Adr005, Adr006, Adr007, Adr008, Adr009, Adr010, Adr011, Adr012,
12    Adr013, Adr014, Adr015, Adr016, Adr017,
13};
14use std::path::PathBuf;
15
16/// Severity level for lint issues.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum IssueSeverity {
19    /// Informational message.
20    Info,
21    /// Warning that should be addressed.
22    Warning,
23    /// Error that needs to be fixed.
24    Error,
25}
26
27impl std::fmt::Display for IssueSeverity {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            IssueSeverity::Info => write!(f, "info"),
31            IssueSeverity::Warning => write!(f, "warning"),
32            IssueSeverity::Error => write!(f, "error"),
33        }
34    }
35}
36
37impl From<mdbook_lint_core::Severity> for IssueSeverity {
38    fn from(severity: mdbook_lint_core::Severity) -> Self {
39        match severity {
40            mdbook_lint_core::Severity::Error => IssueSeverity::Error,
41            mdbook_lint_core::Severity::Warning => IssueSeverity::Warning,
42            mdbook_lint_core::Severity::Info => IssueSeverity::Info,
43        }
44    }
45}
46
47/// A unified issue type for both per-file lint violations and repository-level diagnostics.
48#[derive(Debug, Clone)]
49pub struct Issue {
50    /// The rule that produced this issue (e.g., "ADR001", "adr-title-format").
51    pub rule_id: String,
52    /// Human-readable rule name.
53    pub rule_name: String,
54    /// The severity of this issue.
55    pub severity: IssueSeverity,
56    /// A human-readable message describing the issue.
57    pub message: String,
58    /// The path to the affected file, if applicable.
59    pub path: Option<PathBuf>,
60    /// Line number (1-based), if applicable.
61    pub line: Option<usize>,
62    /// Column number (1-based), if applicable.
63    pub column: Option<usize>,
64    /// The ADR number, if applicable.
65    pub adr_number: Option<u32>,
66    /// Related ADR numbers (for issues involving multiple ADRs).
67    pub related_adrs: Vec<u32>,
68}
69
70impl Issue {
71    /// Create a new issue from an mdbook-lint violation.
72    fn from_violation(
73        violation: mdbook_lint_core::Violation,
74        path: Option<PathBuf>,
75        adr_number: Option<u32>,
76    ) -> Self {
77        Self {
78            rule_id: violation.rule_id,
79            rule_name: violation.rule_name,
80            severity: violation.severity.into(),
81            message: violation.message,
82            path,
83            line: Some(violation.line),
84            column: Some(violation.column),
85            adr_number,
86            related_adrs: Vec::new(),
87        }
88    }
89}
90
91/// Results from linting.
92#[derive(Debug, Default)]
93pub struct LintReport {
94    /// All issues found.
95    pub issues: Vec<Issue>,
96}
97
98impl LintReport {
99    /// Create a new empty report.
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Add an issue to the report.
105    pub fn add(&mut self, issue: Issue) {
106        self.issues.push(issue);
107    }
108
109    /// Check if there are any errors.
110    pub fn has_errors(&self) -> bool {
111        self.issues
112            .iter()
113            .any(|i| i.severity == IssueSeverity::Error)
114    }
115
116    /// Check if there are any warnings.
117    pub fn has_warnings(&self) -> bool {
118        self.issues
119            .iter()
120            .any(|i| i.severity == IssueSeverity::Warning)
121    }
122
123    /// Check if the report is clean (no warnings or errors).
124    pub fn is_clean(&self) -> bool {
125        !self.has_errors() && !self.has_warnings()
126    }
127
128    /// Get the count of issues by severity.
129    pub fn count_by_severity(&self, severity: IssueSeverity) -> usize {
130        self.issues
131            .iter()
132            .filter(|i| i.severity == severity)
133            .count()
134    }
135
136    /// Sort issues by severity (errors first), then by path, then by line.
137    pub fn sort(&mut self) {
138        self.issues.sort_by(|a, b| {
139            b.severity
140                .cmp(&a.severity)
141                .then_with(|| a.path.cmp(&b.path))
142                .then_with(|| a.line.cmp(&b.line))
143        });
144    }
145}
146
147/// Lint a single ADR file.
148///
149/// Runs all per-file lint rules against the ADR content.
150pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
151    let mut report = LintReport::new();
152
153    // Get the file content
154    let Some(path) = &adr.path else {
155        return Ok(report); // No path, nothing to lint
156    };
157
158    let content = std::fs::read_to_string(path)?;
159
160    // Create mdbook-lint Document
161    let doc = match Document::new(content, path.clone()) {
162        Ok(d) => d,
163        Err(e) => {
164            report.add(Issue {
165                rule_id: "parse-error".to_string(),
166                rule_name: "parse-error".to_string(),
167                severity: IssueSeverity::Error,
168                message: format!("Failed to parse document: {e}"),
169                path: Some(path.clone()),
170                line: None,
171                column: None,
172                adr_number: Some(adr.number),
173                related_adrs: Vec::new(),
174            });
175            return Ok(report);
176        }
177    };
178
179    // Run all single-document rules
180    let rules: Vec<Box<dyn Rule>> = vec![
181        Box::new(Adr001::default()),
182        Box::new(Adr002::default()),
183        Box::new(Adr003::default()),
184        Box::new(Adr004::default()),
185        Box::new(Adr005::default()),
186        Box::new(Adr006::default()),
187        Box::new(Adr007::default()),
188        Box::new(Adr008::default()),
189        Box::new(Adr009::default()),
190        Box::new(Adr014::default()),
191        Box::new(Adr015::default()),
192        Box::new(Adr016::default()),
193        Box::new(Adr017::default()),
194    ];
195
196    for rule in rules {
197        match rule.check(&doc) {
198            Ok(violations) => {
199                for violation in violations {
200                    report.add(Issue::from_violation(
201                        violation,
202                        Some(path.clone()),
203                        Some(adr.number),
204                    ));
205                }
206            }
207            Err(e) => {
208                report.add(Issue {
209                    rule_id: rule.id().to_string(),
210                    rule_name: rule.name().to_string(),
211                    severity: IssueSeverity::Error,
212                    message: format!("Rule failed: {e}"),
213                    path: Some(path.clone()),
214                    line: None,
215                    column: None,
216                    adr_number: Some(adr.number),
217                    related_adrs: Vec::new(),
218                });
219            }
220        }
221    }
222
223    Ok(report)
224}
225
226/// Lint all ADRs in a repository (per-file checks only).
227pub fn lint_all(repo: &Repository) -> Result<LintReport> {
228    let mut report = LintReport::new();
229    let adrs = repo.list()?;
230
231    for adr in &adrs {
232        let adr_report = lint_adr(adr)?;
233        report.issues.extend(adr_report.issues);
234    }
235
236    report.sort();
237    Ok(report)
238}
239
240/// Run repository-level checks (collection rules).
241///
242/// These checks analyze the ADR set as a whole:
243/// - Sequential numbering (ADR011)
244/// - Duplicate numbers (ADR012)
245/// - Broken links (ADR013)
246/// - Superseded ADRs have replacements (ADR010)
247pub fn check_repository(repo: &Repository) -> Result<LintReport> {
248    let mut report = LintReport::new();
249    let adrs = repo.list()?;
250
251    // Build documents for collection rules
252    let mut documents = Vec::new();
253    for adr in &adrs {
254        if let Some(path) = &adr.path {
255            let content = std::fs::read_to_string(path)?;
256            if let Ok(doc) = Document::new(content, path.clone()) {
257                documents.push(doc);
258            }
259        }
260    }
261
262    // Run collection rules
263    let collection_rules: Vec<Box<dyn CollectionRule>> = vec![
264        Box::new(Adr010),
265        Box::new(Adr011),
266        Box::new(Adr012),
267        Box::new(Adr013),
268    ];
269
270    for rule in collection_rules {
271        match rule.check_collection(&documents) {
272            Ok(violations) => {
273                for violation in violations {
274                    // Collection rule violations may have path in the message
275                    // We need to parse it out or handle it differently
276                    report.add(Issue {
277                        rule_id: rule.id().to_string(),
278                        rule_name: rule.name().to_string(),
279                        severity: violation.severity.into(),
280                        message: violation.message,
281                        path: None, // Collection rules may span multiple files
282                        line: if violation.line > 0 {
283                            Some(violation.line)
284                        } else {
285                            None
286                        },
287                        column: if violation.column > 0 {
288                            Some(violation.column)
289                        } else {
290                            None
291                        },
292                        adr_number: None,
293                        related_adrs: Vec::new(),
294                    });
295                }
296            }
297            Err(e) => {
298                report.add(Issue {
299                    rule_id: rule.id().to_string(),
300                    rule_name: rule.name().to_string(),
301                    severity: IssueSeverity::Error,
302                    message: format!("Rule failed: {e}"),
303                    path: None,
304                    line: None,
305                    column: None,
306                    adr_number: None,
307                    related_adrs: Vec::new(),
308                });
309            }
310        }
311    }
312
313    report.sort();
314    Ok(report)
315}
316
317/// Run all checks: per-file lint + repository-level checks.
318pub fn check_all(repo: &Repository) -> Result<LintReport> {
319    let mut report = lint_all(repo)?;
320    let repo_report = check_repository(repo)?;
321    report.issues.extend(repo_report.issues);
322    report.sort();
323    Ok(report)
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::Adr;
330
331    #[test]
332    fn test_issue_severity_ordering() {
333        assert!(IssueSeverity::Error > IssueSeverity::Warning);
334        assert!(IssueSeverity::Warning > IssueSeverity::Info);
335    }
336
337    #[test]
338    fn test_lint_report_empty() {
339        let report = LintReport::new();
340        assert!(report.is_clean());
341        assert!(!report.has_errors());
342        assert!(!report.has_warnings());
343    }
344
345    #[test]
346    fn test_lint_report_with_issues() {
347        let mut report = LintReport::new();
348        report.add(Issue {
349            rule_id: "ADR001".to_string(),
350            rule_name: "adr-title-format".to_string(),
351            severity: IssueSeverity::Error,
352            message: "Title format invalid".to_string(),
353            path: Some(PathBuf::from("0001-test.md")),
354            line: Some(1),
355            column: Some(1),
356            adr_number: Some(1),
357            related_adrs: Vec::new(),
358        });
359
360        assert!(report.has_errors());
361        assert!(!report.is_clean());
362        assert_eq!(report.count_by_severity(IssueSeverity::Error), 1);
363    }
364
365    #[test]
366    fn test_lint_valid_nygard_adr() {
367        // Create a temporary file with valid Nygard format
368        let content = r#"# 1. Record architecture decisions
369
370Date: 2024-03-04
371
372## Status
373
374Accepted
375
376## Context
377
378We need to record the architectural decisions made on this project.
379
380## Decision
381
382We will use Architecture Decision Records.
383
384## Consequences
385
386See Michael Nygard's article for details.
387"#;
388        let temp_dir = tempfile::tempdir().unwrap();
389        let path = temp_dir
390            .path()
391            .join("adr")
392            .join("0001-record-architecture-decisions.md");
393        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
394        std::fs::write(&path, content).unwrap();
395
396        let mut adr = Adr::new(1, "Record architecture decisions");
397        adr.path = Some(path);
398
399        let report = lint_adr(&adr).unwrap();
400
401        // Print any issues for debugging
402        for issue in &report.issues {
403            println!(
404                "{}: {} ({}:{})",
405                issue.rule_id,
406                issue.message,
407                issue.line.unwrap_or(0),
408                issue.column.unwrap_or(0)
409            );
410        }
411
412        assert!(report.is_clean(), "Expected no issues for valid Nygard ADR");
413    }
414
415    #[test]
416    fn test_lint_invalid_adr_missing_status() {
417        let content = r#"# 1. Test decision
418
419Date: 2024-03-04
420
421## Context
422
423Some context.
424
425## Decision
426
427Some decision.
428
429## Consequences
430
431Some consequences.
432"#;
433        let temp_dir = tempfile::tempdir().unwrap();
434        let path = temp_dir.path().join("adr").join("0001-test-decision.md");
435        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
436        std::fs::write(&path, content).unwrap();
437
438        let mut adr = Adr::new(1, "Test decision");
439        adr.path = Some(path);
440
441        let report = lint_adr(&adr).unwrap();
442
443        // Should have at least one issue (missing status)
444        assert!(
445            !report.is_clean(),
446            "Expected issues for ADR missing status section"
447        );
448        assert!(
449            report.issues.iter().any(|i| i.rule_id == "ADR002"),
450            "Expected ADR002 (missing status) violation"
451        );
452    }
453}