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> {
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 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 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 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}