1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum IssueSeverity {
19 Info,
21 Warning,
23 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#[derive(Debug, Clone)]
49pub struct Issue {
50 pub rule_id: String,
52 pub rule_name: String,
54 pub severity: IssueSeverity,
56 pub message: String,
58 pub path: Option<PathBuf>,
60 pub line: Option<usize>,
62 pub column: Option<usize>,
64 pub adr_number: Option<u32>,
66 pub related_adrs: Vec<u32>,
68}
69
70impl Issue {
71 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#[derive(Debug, Default)]
93pub struct LintReport {
94 pub issues: Vec<Issue>,
96}
97
98impl LintReport {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn add(&mut self, issue: Issue) {
106 self.issues.push(issue);
107 }
108
109 pub fn has_errors(&self) -> bool {
111 self.issues
112 .iter()
113 .any(|i| i.severity == IssueSeverity::Error)
114 }
115
116 pub fn has_warnings(&self) -> bool {
118 self.issues
119 .iter()
120 .any(|i| i.severity == IssueSeverity::Warning)
121 }
122
123 pub fn is_clean(&self) -> bool {
125 !self.has_errors() && !self.has_warnings()
126 }
127
128 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 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
147pub fn lint_adr(adr: &Adr) -> Result<LintReport> {
151 let mut report = LintReport::new();
152
153 let Some(path) = &adr.path else {
155 return Ok(report); };
157
158 let content = std::fs::read_to_string(path)?;
159
160 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 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
226pub 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
240pub fn check_repository(repo: &Repository) -> Result<LintReport> {
248 let mut report = LintReport::new();
249 let adrs = repo.list()?;
250
251 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 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 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, 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
317pub fn check_all(repo: &Repository) -> Result<LintReport> {
322 let mut report = LintReport::new();
323
324 let (adrs, parse_errors) = repo.list_with_errors()?;
326
327 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 for adr in &adrs {
344 let adr_report = lint_adr(adr)?;
345 report.issues.extend(adr_report.issues);
346 }
347
348 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 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 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 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 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 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 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 let repo = Repository::init(temp.path(), None, false).unwrap();
559 let adr_dir = repo.adr_path();
560
561 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 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 let repo = Repository::init(temp.path(), None, false).unwrap();
591 let adr_dir = repo.adr_path();
592
593 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 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 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 std::fs::write(
665 adr_dir.join("0001-first.md"),
666 make_nygard_adr(1, "First", "Accepted", ""),
667 )
668 .unwrap();
669
670 let report = check_all(&repo).unwrap();
672
673 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}