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.
318///
319/// Also reports files that look like ADRs (digit-prefixed `.md` files in the
320/// ADR directory) but could not be parsed (e.g., invalid YAML frontmatter).
321pub fn check_all(repo: &Repository) -> Result<LintReport> {
322    let mut report = LintReport::new();
323
324    // Use list_with_errors to capture parse failures
325    let (adrs, parse_errors) = repo.list_with_errors()?;
326
327    // Report parse errors as lint issues
328    for (path, error) in &parse_errors {
329        report.add(Issue {
330            rule_id: "parse-error".to_string(),
331            rule_name: "adr-parse-error".to_string(),
332            severity: IssueSeverity::Error,
333            message: format!("Failed to parse ADR: {error}"),
334            path: Some(path.clone()),
335            line: None,
336            column: None,
337            adr_number: None,
338            related_adrs: Vec::new(),
339        });
340    }
341
342    // Run per-file lint on successfully parsed ADRs
343    for adr in &adrs {
344        let adr_report = lint_adr(adr)?;
345        report.issues.extend(adr_report.issues);
346    }
347
348    // Run repository-level checks (these still use repo.list() internally,
349    // which is fine — they only need successfully parsed ADRs)
350    let repo_report = check_repository(repo)?;
351    report.issues.extend(repo_report.issues);
352
353    report.sort();
354    Ok(report)
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use crate::Adr;
361
362    #[test]
363    fn test_issue_severity_ordering() {
364        assert!(IssueSeverity::Error > IssueSeverity::Warning);
365        assert!(IssueSeverity::Warning > IssueSeverity::Info);
366    }
367
368    #[test]
369    fn test_lint_report_empty() {
370        let report = LintReport::new();
371        assert!(report.is_clean());
372        assert!(!report.has_errors());
373        assert!(!report.has_warnings());
374    }
375
376    #[test]
377    fn test_lint_report_with_issues() {
378        let mut report = LintReport::new();
379        report.add(Issue {
380            rule_id: "ADR001".to_string(),
381            rule_name: "adr-title-format".to_string(),
382            severity: IssueSeverity::Error,
383            message: "Title format invalid".to_string(),
384            path: Some(PathBuf::from("0001-test.md")),
385            line: Some(1),
386            column: Some(1),
387            adr_number: Some(1),
388            related_adrs: Vec::new(),
389        });
390
391        assert!(report.has_errors());
392        assert!(!report.is_clean());
393        assert_eq!(report.count_by_severity(IssueSeverity::Error), 1);
394    }
395
396    #[test]
397    fn test_lint_valid_nygard_adr() {
398        // Uses the actual ADR #0001 text produced by `adrs init`. The word "described"
399        // previously triggered an ADR014 false positive (fixed in mdbook-lint-rulesets 0.14.3).
400        let content = r#"# 1. Record architecture decisions
401
402Date: 2024-03-04
403
404## Status
405
406Accepted
407
408## Context
409
410We need to record the architectural decisions made on this project.
411
412## Decision
413
414We will use Architecture Decision Records, as described by Michael Nygard in his article "Documenting Architecture Decisions".
415
416## Consequences
417
418See Michael Nygard's article, linked above. For a lightweight ADR toolset, see Nat Pryce's adr-tools.
419"#;
420        let temp_dir = tempfile::tempdir().unwrap();
421        let path = temp_dir
422            .path()
423            .join("adr")
424            .join("0001-record-architecture-decisions.md");
425        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
426        std::fs::write(&path, content).unwrap();
427
428        let mut adr = Adr::new(1, "Record architecture decisions");
429        adr.path = Some(path);
430
431        let report = lint_adr(&adr).unwrap();
432
433        // Print any issues for debugging
434        for issue in &report.issues {
435            println!(
436                "{}: {} ({}:{})",
437                issue.rule_id,
438                issue.message,
439                issue.line.unwrap_or(0),
440                issue.column.unwrap_or(0)
441            );
442        }
443
444        assert!(report.is_clean(), "Expected no issues for valid Nygard ADR");
445    }
446
447    #[test]
448    fn test_lint_invalid_adr_missing_status() {
449        let content = r#"# 1. Test decision
450
451Date: 2024-03-04
452
453## Context
454
455Some context.
456
457## Decision
458
459Some decision.
460
461## Consequences
462
463Some consequences.
464"#;
465        let temp_dir = tempfile::tempdir().unwrap();
466        let path = temp_dir.path().join("adr").join("0001-test-decision.md");
467        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
468        std::fs::write(&path, content).unwrap();
469
470        let mut adr = Adr::new(1, "Test decision");
471        adr.path = Some(path);
472
473        let report = lint_adr(&adr).unwrap();
474
475        // Should have at least one issue (missing status)
476        assert!(
477            !report.is_clean(),
478            "Expected issues for ADR missing status section"
479        );
480        assert!(
481            report.issues.iter().any(|i| i.rule_id == "ADR002"),
482            "Expected ADR002 (missing status) violation"
483        );
484    }
485
486    #[test]
487    fn test_check_all_reports_parse_errors() {
488        use crate::Repository;
489
490        let temp = tempfile::tempdir().unwrap();
491        let repo = Repository::init(temp.path(), None, true).unwrap();
492
493        // Write an ADR with invalid YAML (bad date)
494        let bad_content =
495            "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad Date\n";
496        std::fs::write(repo.adr_path().join("0002-bad-date.md"), bad_content).unwrap();
497
498        let report = check_all(&repo).unwrap();
499
500        let parse_errors: Vec<_> = report
501            .issues
502            .iter()
503            .filter(|i| i.rule_id == "parse-error")
504            .collect();
505
506        assert_eq!(parse_errors.len(), 1, "should report 1 parse error");
507        assert_eq!(parse_errors[0].severity, IssueSeverity::Error);
508        assert!(
509            parse_errors[0]
510                .path
511                .as_ref()
512                .unwrap()
513                .to_string_lossy()
514                .contains("0002-bad-date.md")
515        );
516    }
517
518    #[test]
519    fn test_check_all_no_parse_errors_for_string_decision_makers() {
520        use crate::Repository;
521
522        let temp = tempfile::tempdir().unwrap();
523        let repo = Repository::init(temp.path(), None, true).unwrap();
524
525        // Issue #216: decision-makers as string should not cause a parse error
526        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";
527        std::fs::write(repo.adr_path().join("0002-test.md"), content).unwrap();
528
529        let report = check_all(&repo).unwrap();
530
531        let parse_errors: Vec<_> = report
532            .issues
533            .iter()
534            .filter(|i| i.rule_id == "parse-error")
535            .collect();
536
537        assert!(
538            parse_errors.is_empty(),
539            "string decision-makers should not cause parse error, got: {:?}",
540            parse_errors.iter().map(|i| &i.message).collect::<Vec<_>>()
541        );
542    }
543    // ========== check_repository collection rules (issue #239) ==========
544
545    fn make_nygard_adr(number: u32, title: &str, status: &str, links: &str) -> String {
546        format!(
547            "# {}. {}\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",
548            number, title, status, links
549        )
550    }
551
552    #[test]
553    fn test_check_repository_broken_link_adr013() {
554        use crate::Repository;
555
556        let temp = tempfile::tempdir().unwrap();
557        // init creates ADR #1 automatically
558        let repo = Repository::init(temp.path(), None, false).unwrap();
559        let adr_dir = repo.adr_path();
560
561        // ADR 2 links to nonexistent ADR 99
562        std::fs::write(
563            adr_dir.join("0002-second.md"),
564            make_nygard_adr(
565                2,
566                "Second",
567                "Accepted",
568                "\n\nSupersedes [99. Unknown](0099-unknown.md)\n",
569            ),
570        )
571        .unwrap();
572
573        let report = check_repository(&repo).unwrap();
574
575        // Should have an ADR013 (broken links) issue
576        let has_adr013 = report.issues.iter().any(|i| i.rule_id == "ADR013");
577        assert!(
578            has_adr013,
579            "Expected ADR013 broken-link issue, got: {:?}",
580            report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
581        );
582    }
583
584    #[test]
585    fn test_check_repository_sequential_gap_adr011() {
586        use crate::Repository;
587
588        let temp = tempfile::tempdir().unwrap();
589        // init creates ADR #1 automatically; write #2 and #4 to create a gap at #3
590        let repo = Repository::init(temp.path(), None, false).unwrap();
591        let adr_dir = repo.adr_path();
592
593        // ADRs 1, 2, 4 -- gap at 3
594        std::fs::write(
595            adr_dir.join("0002-second.md"),
596            make_nygard_adr(2, "Second", "Accepted", ""),
597        )
598        .unwrap();
599        std::fs::write(
600            adr_dir.join("0004-fourth.md"),
601            make_nygard_adr(4, "Fourth", "Accepted", ""),
602        )
603        .unwrap();
604
605        let report = check_repository(&repo).unwrap();
606
607        // Should have an ADR011 (sequential gap) issue
608        let has_adr011 = report.issues.iter().any(|i| i.rule_id == "ADR011");
609        assert!(
610            has_adr011,
611            "Expected ADR011 sequential-gap issue, got: {:?}",
612            report.issues.iter().map(|i| &i.rule_id).collect::<Vec<_>>()
613        );
614    }
615
616    #[test]
617    fn test_check_repository_clean_repo_has_no_issues() {
618        use crate::Repository;
619
620        let temp = tempfile::tempdir().unwrap();
621        let repo = Repository::init(temp.path(), None, false).unwrap();
622        let adr_dir = repo.adr_path();
623
624        // Repository::init creates ADR #1 automatically -- use #2 and #3 to avoid duplicate
625        std::fs::write(
626            adr_dir.join("0002-second.md"),
627            make_nygard_adr(2, "Second", "Accepted", ""),
628        )
629        .unwrap();
630        std::fs::write(
631            adr_dir.join("0003-third.md"),
632            make_nygard_adr(3, "Third", "Proposed", ""),
633        )
634        .unwrap();
635
636        let report = check_repository(&repo).unwrap();
637
638        let collection_rule_ids = ["ADR010", "ADR011", "ADR012", "ADR013"];
639        let collection_issues: Vec<_> = report
640            .issues
641            .iter()
642            .filter(|i| collection_rule_ids.contains(&i.rule_id.as_str()))
643            .collect();
644
645        assert!(
646            collection_issues.is_empty(),
647            "Clean repo should have no collection-rule issues, got: {:?}",
648            collection_issues
649                .iter()
650                .map(|i| format!("{}: {}", i.rule_id, i.message))
651                .collect::<Vec<_>>()
652        );
653    }
654
655    #[test]
656    fn test_check_all_combines_lint_and_repository_checks() {
657        use crate::Repository;
658
659        let temp = tempfile::tempdir().unwrap();
660        let repo = Repository::init(temp.path(), None, false).unwrap();
661        let adr_dir = repo.adr_path();
662
663        // Create a valid ADR so check_all has something to process
664        std::fs::write(
665            adr_dir.join("0001-first.md"),
666            make_nygard_adr(1, "First", "Accepted", ""),
667        )
668        .unwrap();
669
670        // check_all should succeed and return a report
671        let report = check_all(&repo).unwrap();
672
673        // With a valid sequential repo, no collection-rule violations
674        let adr011 = report
675            .issues
676            .iter()
677            .filter(|i| i.rule_id == "ADR011")
678            .count();
679        assert_eq!(
680            adr011, 0,
681            "Single valid ADR should have no sequential-gap issue"
682        );
683    }
684}