adrscope/domain/
validation.rs

1//! ADR validation system with extensible rules.
2//!
3//! This module provides a validation framework for checking ADRs against
4//! configurable rules, producing detailed reports.
5
6use std::path::PathBuf;
7
8use super::Adr;
9
10/// Severity level for validation issues.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
12pub enum Severity {
13    /// Non-blocking advisory.
14    Warning,
15    /// Blocking error.
16    Error,
17}
18
19impl Severity {
20    /// Returns the severity as a string.
21    #[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/// A single validation issue found in an ADR.
37#[derive(Debug, Clone)]
38pub struct ValidationIssue {
39    /// Severity of the issue.
40    pub severity: Severity,
41    /// Path to the ADR file.
42    pub path: PathBuf,
43    /// Human-readable description of the issue.
44    pub message: String,
45    /// Optional line number where the issue was found.
46    pub line: Option<usize>,
47    /// Name of the rule that produced this issue.
48    pub rule: String,
49}
50
51impl ValidationIssue {
52    /// Creates a new validation issue.
53    #[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    /// Creates an error issue.
70    #[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    /// Creates a warning issue.
76    #[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    /// Sets the line number.
82    #[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/// Aggregated result of validating a collection of ADRs.
105#[derive(Debug, Clone, Default)]
106pub struct ValidationReport {
107    /// All issues found during validation.
108    issues: Vec<ValidationIssue>,
109}
110
111impl ValidationReport {
112    /// Creates a new empty report.
113    #[must_use]
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Adds an issue to the report.
119    pub fn add_issue(&mut self, issue: ValidationIssue) {
120        self.issues.push(issue);
121    }
122
123    /// Adds multiple issues to the report.
124    pub fn add_issues(&mut self, issues: impl IntoIterator<Item = ValidationIssue>) {
125        self.issues.extend(issues);
126    }
127
128    /// Returns all issues.
129    #[must_use]
130    pub fn issues(&self) -> &[ValidationIssue] {
131        &self.issues
132    }
133
134    /// Returns issues filtered by severity.
135    #[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    /// Returns the count of error-level issues.
144    #[must_use]
145    pub fn error_count(&self) -> usize {
146        self.errors().len()
147    }
148
149    /// Returns the count of warning-level issues.
150    #[must_use]
151    pub fn warning_count(&self) -> usize {
152        self.warnings().len()
153    }
154
155    /// Returns error-level issues.
156    #[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    /// Returns warning-level issues.
165    #[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    /// Returns true if there are any error-level issues.
174    #[must_use]
175    pub fn has_errors(&self) -> bool {
176        self.error_count() > 0
177    }
178
179    /// Returns true if validation passed (no errors).
180    #[must_use]
181    pub fn is_valid(&self) -> bool {
182        !self.has_errors()
183    }
184
185    /// Returns true if the report is empty (no issues at all).
186    #[must_use]
187    pub fn is_empty(&self) -> bool {
188        self.issues.is_empty()
189    }
190
191    /// Returns the total number of issues.
192    #[must_use]
193    pub fn len(&self) -> usize {
194        self.issues.len()
195    }
196
197    /// Merges another report into this one.
198    pub fn merge(&mut self, other: Self) {
199        self.issues.extend(other.issues);
200    }
201}
202
203/// Trait for implementing validation rules.
204///
205/// Each rule should be focused and check for a specific condition.
206pub trait ValidationRule: Send + Sync {
207    /// Returns the human-readable name of this rule.
208    fn name(&self) -> &str;
209
210    /// Returns a description of what this rule checks.
211    fn description(&self) -> &str;
212
213    /// Validates a single ADR, appending any issues to the report.
214    fn validate(&self, adr: &Adr, report: &mut ValidationReport);
215}
216
217/// A validator that runs multiple rules against ADRs.
218#[derive(Default)]
219pub struct Validator {
220    rules: Vec<Box<dyn ValidationRule>>,
221}
222
223impl Validator {
224    /// Creates a new validator with the given rules.
225    #[must_use]
226    pub fn new(rules: Vec<Box<dyn ValidationRule>>) -> Self {
227        Self { rules }
228    }
229
230    /// Adds a rule to the validator.
231    pub fn add_rule(&mut self, rule: Box<dyn ValidationRule>) {
232        self.rules.push(rule);
233    }
234
235    /// Validates a single ADR using all configured rules.
236    #[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    /// Validates a collection of ADRs using all configured rules.
246    #[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    /// Returns the configured rules.
260    #[must_use]
261    pub fn rules(&self) -> &[Box<dyn ValidationRule>] {
262        &self.rules
263    }
264}
265
266// ============================================================================
267// Built-in validation rules
268// ============================================================================
269
270/// Rule that checks for required frontmatter fields.
271#[derive(Debug, Clone, Copy, Default)]
272pub struct RequiredFieldsRule;
273
274impl RequiredFieldsRule {
275    /// Creates a new required fields rule.
276    #[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/// Rule that warns about missing optional but recommended fields.
303#[derive(Debug, Clone, Copy, Default)]
304pub struct RecommendedFieldsRule;
305
306impl RecommendedFieldsRule {
307    /// Creates a new recommended fields rule.
308    #[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/// Returns the default set of validation rules.
351#[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()); // Warnings don't fail validation
415
416        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        // ADR with title should pass
435        let adr = create_test_adr("Test Title");
436        rule.validate(&adr, &mut report);
437        assert!(report.is_valid());
438
439        // ADR without title should fail
440        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        // Should have warnings for missing description, created, category
453        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        // ADR with no description, category, or created date
536        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        // Should have warnings for description, created, and category
550        assert_eq!(report.warning_count(), 3);
551    }
552}