1use std::path::PathBuf;
7
8use super::Adr;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Severity {
13 Warning,
15 Error,
17}
18
19impl Severity {
20 #[must_use]
22 pub const fn as_str(&self) -> &'static str {
23 match self {
24 Self::Warning => "warning",
25 Self::Error => "error",
26 }
27 }
28}
29
30impl std::fmt::Display for Severity {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 write!(f, "{}", self.as_str())
33 }
34}
35
36#[derive(Debug, Clone)]
38pub struct ValidationIssue {
39 pub severity: Severity,
41 pub path: PathBuf,
43 pub message: String,
45 pub line: Option<usize>,
47 pub rule: String,
49}
50
51impl ValidationIssue {
52 #[must_use]
54 pub fn new(
55 severity: Severity,
56 path: PathBuf,
57 message: impl Into<String>,
58 rule: impl Into<String>,
59 ) -> Self {
60 Self {
61 severity,
62 path,
63 message: message.into(),
64 line: None,
65 rule: rule.into(),
66 }
67 }
68
69 #[must_use]
71 pub fn error(path: PathBuf, message: impl Into<String>, rule: impl Into<String>) -> Self {
72 Self::new(Severity::Error, path, message, rule)
73 }
74
75 #[must_use]
77 pub fn warning(path: PathBuf, message: impl Into<String>, rule: impl Into<String>) -> Self {
78 Self::new(Severity::Warning, path, message, rule)
79 }
80
81 #[must_use]
83 pub const fn with_line(mut self, line: usize) -> Self {
84 self.line = Some(line);
85 self
86 }
87}
88
89impl std::fmt::Display for ValidationIssue {
90 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91 let location = self.line.map_or_else(String::new, |l| format!(":{l}"));
92 write!(
93 f,
94 "{}: {}{}: {} [{}]",
95 self.severity,
96 self.path.display(),
97 location,
98 self.message,
99 self.rule
100 )
101 }
102}
103
104#[derive(Debug, Clone, Default)]
106pub struct ValidationReport {
107 issues: Vec<ValidationIssue>,
109}
110
111impl ValidationReport {
112 #[must_use]
114 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn add_issue(&mut self, issue: ValidationIssue) {
120 self.issues.push(issue);
121 }
122
123 pub fn add_issues(&mut self, issues: impl IntoIterator<Item = ValidationIssue>) {
125 self.issues.extend(issues);
126 }
127
128 #[must_use]
130 pub fn issues(&self) -> &[ValidationIssue] {
131 &self.issues
132 }
133
134 #[must_use]
136 pub fn issues_by_severity(&self, severity: Severity) -> Vec<&ValidationIssue> {
137 self.issues
138 .iter()
139 .filter(|i| i.severity == severity)
140 .collect()
141 }
142
143 #[must_use]
145 pub fn error_count(&self) -> usize {
146 self.errors().len()
147 }
148
149 #[must_use]
151 pub fn warning_count(&self) -> usize {
152 self.warnings().len()
153 }
154
155 #[must_use]
157 pub fn errors(&self) -> Vec<&ValidationIssue> {
158 self.issues
159 .iter()
160 .filter(|i| i.severity == Severity::Error)
161 .collect()
162 }
163
164 #[must_use]
166 pub fn warnings(&self) -> Vec<&ValidationIssue> {
167 self.issues
168 .iter()
169 .filter(|i| i.severity == Severity::Warning)
170 .collect()
171 }
172
173 #[must_use]
175 pub fn has_errors(&self) -> bool {
176 self.error_count() > 0
177 }
178
179 #[must_use]
181 pub fn is_valid(&self) -> bool {
182 !self.has_errors()
183 }
184
185 #[must_use]
187 pub fn is_empty(&self) -> bool {
188 self.issues.is_empty()
189 }
190
191 #[must_use]
193 pub fn len(&self) -> usize {
194 self.issues.len()
195 }
196
197 pub fn merge(&mut self, other: Self) {
199 self.issues.extend(other.issues);
200 }
201}
202
203pub trait ValidationRule: Send + Sync {
207 fn name(&self) -> &str;
209
210 fn description(&self) -> &str;
212
213 fn validate(&self, adr: &Adr, report: &mut ValidationReport);
215}
216
217#[derive(Default)]
219pub struct Validator {
220 rules: Vec<Box<dyn ValidationRule>>,
221}
222
223impl Validator {
224 #[must_use]
226 pub fn new(rules: Vec<Box<dyn ValidationRule>>) -> Self {
227 Self { rules }
228 }
229
230 pub fn add_rule(&mut self, rule: Box<dyn ValidationRule>) {
232 self.rules.push(rule);
233 }
234
235 #[must_use]
237 pub fn validate(&self, adr: &Adr) -> ValidationReport {
238 let mut report = ValidationReport::new();
239 for rule in &self.rules {
240 rule.validate(adr, &mut report);
241 }
242 report
243 }
244
245 #[must_use]
247 pub fn validate_all(&self, adrs: &[Adr]) -> ValidationReport {
248 let mut report = ValidationReport::new();
249
250 for adr in adrs {
251 for rule in &self.rules {
252 rule.validate(adr, &mut report);
253 }
254 }
255
256 report
257 }
258
259 #[must_use]
261 pub fn rules(&self) -> &[Box<dyn ValidationRule>] {
262 &self.rules
263 }
264}
265
266#[derive(Debug, Clone, Copy, Default)]
272pub struct RequiredFieldsRule;
273
274impl RequiredFieldsRule {
275 #[must_use]
277 pub const fn new() -> Self {
278 Self
279 }
280}
281
282impl ValidationRule for RequiredFieldsRule {
283 fn name(&self) -> &str {
284 "required-fields"
285 }
286
287 fn description(&self) -> &str {
288 "Checks that required frontmatter fields are present"
289 }
290
291 fn validate(&self, adr: &Adr, report: &mut ValidationReport) {
292 if adr.title().is_empty() {
293 report.add_issue(ValidationIssue::error(
294 adr.source_path().clone(),
295 "missing required field 'title'",
296 self.name(),
297 ));
298 }
299 }
300}
301
302#[derive(Debug, Clone, Copy, Default)]
304pub struct RecommendedFieldsRule;
305
306impl RecommendedFieldsRule {
307 #[must_use]
309 pub const fn new() -> Self {
310 Self
311 }
312}
313
314impl ValidationRule for RecommendedFieldsRule {
315 fn name(&self) -> &str {
316 "recommended-fields"
317 }
318
319 fn description(&self) -> &str {
320 "Warns about missing recommended fields"
321 }
322
323 fn validate(&self, adr: &Adr, report: &mut ValidationReport) {
324 if adr.description().is_empty() {
325 report.add_issue(ValidationIssue::warning(
326 adr.source_path().clone(),
327 "missing recommended field 'description'",
328 self.name(),
329 ));
330 }
331
332 if adr.created().is_none() {
333 report.add_issue(ValidationIssue::warning(
334 adr.source_path().clone(),
335 "missing recommended field 'created'",
336 self.name(),
337 ));
338 }
339
340 if adr.category().is_empty() {
341 report.add_issue(ValidationIssue::warning(
342 adr.source_path().clone(),
343 "missing recommended field 'category'",
344 self.name(),
345 ));
346 }
347 }
348}
349
350#[must_use]
352pub fn default_rules() -> Vec<Box<dyn ValidationRule>> {
353 vec![
354 Box::new(RequiredFieldsRule),
355 Box::new(RecommendedFieldsRule),
356 ]
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::domain::{AdrId, Frontmatter};
363 use std::path::PathBuf;
364
365 fn create_test_adr(title: &str) -> Adr {
366 let frontmatter = Frontmatter::new(title);
367 Adr::new(
368 AdrId::new("test"),
369 "test.md".to_string(),
370 PathBuf::from("test.md"),
371 frontmatter,
372 String::new(),
373 String::new(),
374 String::new(),
375 )
376 }
377
378 #[test]
379 fn test_validation_issue_display() {
380 let issue =
381 ValidationIssue::error(PathBuf::from("test.md"), "missing title", "required-fields");
382 let display = issue.to_string();
383 assert!(display.contains("error:"));
384 assert!(display.contains("test.md"));
385 assert!(display.contains("missing title"));
386 assert!(display.contains("[required-fields]"));
387 }
388
389 #[test]
390 fn test_validation_issue_with_line() {
391 let issue = ValidationIssue::warning(
392 PathBuf::from("test.md"),
393 "missing description",
394 "recommended",
395 )
396 .with_line(5);
397
398 let display = issue.to_string();
399 assert!(display.contains(":5:"));
400 }
401
402 #[test]
403 fn test_validation_report() {
404 let mut report = ValidationReport::new();
405 assert!(report.is_empty());
406 assert!(report.is_valid());
407
408 report.add_issue(ValidationIssue::warning(
409 PathBuf::from("a.md"),
410 "warning 1",
411 "test",
412 ));
413 assert!(!report.is_empty());
414 assert!(report.is_valid()); report.add_issue(ValidationIssue::error(
417 PathBuf::from("b.md"),
418 "error 1",
419 "test",
420 ));
421 assert!(!report.is_valid());
422 assert!(report.has_errors());
423
424 assert_eq!(report.warning_count(), 1);
425 assert_eq!(report.error_count(), 1);
426 assert_eq!(report.len(), 2);
427 }
428
429 #[test]
430 fn test_required_fields_rule() {
431 let rule = RequiredFieldsRule;
432 let mut report = ValidationReport::new();
433
434 let adr = create_test_adr("Test Title");
436 rule.validate(&adr, &mut report);
437 assert!(report.is_valid());
438
439 let mut report = ValidationReport::new();
441 let adr = create_test_adr("");
442 rule.validate(&adr, &mut report);
443 assert!(report.has_errors());
444 }
445
446 #[test]
447 fn test_validator_with_multiple_rules() {
448 let validator = Validator::new(default_rules());
449 let adr = create_test_adr("Test");
450 let report = validator.validate_all(&[adr]);
451
452 assert!(report.warning_count() > 0);
454 }
455
456 #[test]
457 fn test_validation_report_add_issues() {
458 let mut report = ValidationReport::new();
459
460 let issues = vec![
461 ValidationIssue::error(PathBuf::from("a.md"), "error 1", "test"),
462 ValidationIssue::warning(PathBuf::from("b.md"), "warning 1", "test"),
463 ValidationIssue::error(PathBuf::from("c.md"), "error 2", "test"),
464 ];
465
466 report.add_issues(issues);
467
468 assert_eq!(report.len(), 3);
469 assert_eq!(report.error_count(), 2);
470 assert_eq!(report.warning_count(), 1);
471 }
472
473 #[test]
474 fn test_validation_report_issues_accessor() {
475 let mut report = ValidationReport::new();
476
477 report.add_issue(ValidationIssue::error(
478 PathBuf::from("test.md"),
479 "error message",
480 "test-rule",
481 ));
482
483 let issues = report.issues();
484 assert_eq!(issues.len(), 1);
485 assert_eq!(issues[0].message, "error message");
486 assert_eq!(issues[0].rule, "test-rule");
487 }
488
489 #[test]
490 fn test_validation_report_issues_by_severity() {
491 let mut report = ValidationReport::new();
492
493 report.add_issue(ValidationIssue::error(
494 PathBuf::from("a.md"),
495 "error 1",
496 "test",
497 ));
498 report.add_issue(ValidationIssue::warning(
499 PathBuf::from("b.md"),
500 "warning 1",
501 "test",
502 ));
503 report.add_issue(ValidationIssue::error(
504 PathBuf::from("c.md"),
505 "error 2",
506 "test",
507 ));
508
509 let errors = report.issues_by_severity(Severity::Error);
510 assert_eq!(errors.len(), 2);
511
512 let warnings = report.issues_by_severity(Severity::Warning);
513 assert_eq!(warnings.len(), 1);
514 }
515
516 #[test]
517 fn test_required_fields_rule_metadata() {
518 let rule = RequiredFieldsRule::new();
519 assert_eq!(rule.name(), "required-fields");
520 assert!(!rule.description().is_empty());
521 }
522
523 #[test]
524 fn test_recommended_fields_rule_metadata() {
525 let rule = RecommendedFieldsRule::new();
526 assert_eq!(rule.name(), "recommended-fields");
527 assert!(!rule.description().is_empty());
528 }
529
530 #[test]
531 fn test_recommended_fields_rule_validation() {
532 let rule = RecommendedFieldsRule::new();
533 let mut report = ValidationReport::new();
534
535 let frontmatter = Frontmatter::new("Test ADR");
537 let adr = Adr::new(
538 AdrId::new("test"),
539 "test.md".to_string(),
540 PathBuf::from("test.md"),
541 frontmatter,
542 String::new(),
543 String::new(),
544 String::new(),
545 );
546
547 rule.validate(&adr, &mut report);
548
549 assert_eq!(report.warning_count(), 3);
551 }
552}