adrscope/application/
validate.rs

1//! Validate ADRs use case.
2//!
3//! Orchestrates ADR discovery, parsing, and validation.
4
5use std::path::Path;
6
7use crate::domain::{Severity, ValidationReport, Validator, default_rules};
8use crate::error::Result;
9use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
10
11/// Options for the validate command.
12#[derive(Debug, Clone)]
13pub struct ValidateOptions {
14    /// Input directory containing ADR files.
15    pub input_dir: String,
16    /// Glob pattern for matching ADR files.
17    pub pattern: String,
18    /// Whether to fail on warnings.
19    pub strict: bool,
20}
21
22impl Default for ValidateOptions {
23    fn default() -> Self {
24        Self {
25            input_dir: "docs/decisions".to_string(),
26            pattern: "**/*.md".to_string(),
27            strict: false,
28        }
29    }
30}
31
32impl ValidateOptions {
33    /// Creates new options with the given input directory.
34    #[must_use]
35    pub fn new(input_dir: impl Into<String>) -> Self {
36        Self {
37            input_dir: input_dir.into(),
38            ..Default::default()
39        }
40    }
41
42    /// Sets the glob pattern for matching files.
43    #[must_use]
44    pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
45        self.pattern = pattern.into();
46        self
47    }
48
49    /// Sets strict mode (fail on warnings).
50    #[must_use]
51    pub const fn with_strict(mut self, strict: bool) -> Self {
52        self.strict = strict;
53        self
54    }
55}
56
57/// Use case for validating ADRs.
58#[derive(Debug)]
59pub struct ValidateUseCase<F: FileSystem> {
60    fs: F,
61    parser: DefaultAdrParser,
62}
63
64impl<F: FileSystem> ValidateUseCase<F> {
65    /// Creates a new validate use case.
66    #[must_use]
67    pub fn new(fs: F) -> Self {
68        Self {
69            fs,
70            parser: DefaultAdrParser::new(),
71        }
72    }
73
74    /// Executes the validation use case.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if:
79    /// - No ADR files are found
80    /// - File reading fails
81    pub fn execute(&self, options: &ValidateOptions) -> Result<ValidateResult> {
82        // Discover ADR files
83        let base = Path::new(&options.input_dir);
84        let files = self.fs.glob(base, &options.pattern)?;
85
86        if files.is_empty() {
87            return Err(crate::error::Error::NoAdrsFound {
88                path: base.to_path_buf(),
89            });
90        }
91
92        // Build validator with default rules
93        let validator = Validator::new(default_rules());
94
95        // Validate each file
96        let mut reports = Vec::with_capacity(files.len());
97        let mut parse_errors = Vec::new();
98
99        for file_path in &files {
100            match self.validate_file(file_path, &validator) {
101                Ok(report) => reports.push((file_path.clone(), report)),
102                Err(e) => parse_errors.push((file_path.clone(), e)),
103            }
104        }
105
106        // Aggregate results
107        let mut total_errors = 0;
108        let mut total_warnings = 0;
109
110        for (_, report) in &reports {
111            total_errors += report.errors().len();
112            total_warnings += report.warnings().len();
113        }
114
115        // Determine if validation passed
116        let passed = if options.strict {
117            total_errors == 0 && total_warnings == 0 && parse_errors.is_empty()
118        } else {
119            total_errors == 0 && parse_errors.is_empty()
120        };
121
122        Ok(ValidateResult {
123            reports,
124            parse_errors,
125            total_errors,
126            total_warnings,
127            passed,
128        })
129    }
130
131    fn validate_file(&self, path: &Path, validator: &Validator) -> Result<ValidationReport> {
132        let content = self.fs.read_to_string(path)?;
133        let adr = self.parser.parse(path, &content)?;
134        Ok(validator.validate(&adr))
135    }
136}
137
138/// Result of the validation use case.
139#[derive(Debug)]
140pub struct ValidateResult {
141    /// Validation reports for each successfully parsed file.
142    pub reports: Vec<(std::path::PathBuf, ValidationReport)>,
143    /// Files that failed to parse.
144    pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
145    /// Total number of validation errors.
146    pub total_errors: usize,
147    /// Total number of validation warnings.
148    pub total_warnings: usize,
149    /// Whether validation passed.
150    pub passed: bool,
151}
152
153impl ValidateResult {
154    /// Returns all issues (both errors and warnings).
155    #[must_use]
156    pub fn all_issues(
157        &self,
158    ) -> impl Iterator<Item = (&std::path::PathBuf, &crate::domain::ValidationIssue)> {
159        self.reports
160            .iter()
161            .flat_map(|(path, report)| report.issues().iter().map(move |issue| (path, issue)))
162    }
163
164    /// Returns only error-level issues.
165    #[must_use]
166    pub fn error_issues(
167        &self,
168    ) -> impl Iterator<Item = (&std::path::PathBuf, &crate::domain::ValidationIssue)> {
169        self.all_issues()
170            .filter(|(_, issue)| issue.severity == Severity::Error)
171    }
172
173    /// Returns only warning-level issues.
174    #[must_use]
175    pub fn warning_issues(
176        &self,
177    ) -> impl Iterator<Item = (&std::path::PathBuf, &crate::domain::ValidationIssue)> {
178        self.all_issues()
179            .filter(|(_, issue)| issue.severity == Severity::Warning)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::infrastructure::fs::test_support::InMemoryFileSystem;
187
188    fn valid_adr_content() -> &'static str {
189        r"---
190title: Use PostgreSQL for persistence
191status: accepted
192category: database
193created: 2025-01-15
194description: We decided to use PostgreSQL as our primary database.
195author: Jane Doe
196---
197
198# Use PostgreSQL for persistence
199
200## Context
201
202We need a database.
203"
204    }
205
206    fn minimal_adr_content() -> &'static str {
207        r"---
208title: Minimal ADR
209status: proposed
210---
211
212# Minimal ADR
213
214Some content.
215"
216    }
217
218    fn invalid_adr_content() -> &'static str {
219        r"---
220description: Missing title
221---
222
223# No Title
224
225Some content.
226"
227    }
228
229    #[test]
230    fn test_validate_valid_adr() {
231        let fs = InMemoryFileSystem::new();
232        fs.add_file("docs/decisions/adr-0001.md", valid_adr_content());
233
234        let use_case = ValidateUseCase::new(fs);
235        let options = ValidateOptions::new("docs/decisions");
236
237        let result = use_case.execute(&options);
238        assert!(result.is_ok());
239
240        let result = result.unwrap();
241        assert!(result.passed);
242        assert_eq!(result.total_errors, 0);
243    }
244
245    #[test]
246    fn test_validate_minimal_adr_has_warnings() {
247        let fs = InMemoryFileSystem::new();
248        fs.add_file("docs/decisions/adr-0001.md", minimal_adr_content());
249
250        let use_case = ValidateUseCase::new(fs);
251        let options = ValidateOptions::new("docs/decisions");
252
253        let result = use_case.execute(&options);
254        assert!(result.is_ok());
255
256        let result = result.unwrap();
257        // Passes without strict mode
258        assert!(result.passed);
259        assert_eq!(result.total_errors, 0);
260        // Should have warnings for missing recommended fields
261        assert!(result.total_warnings > 0);
262    }
263
264    #[test]
265    fn test_validate_strict_mode() {
266        let fs = InMemoryFileSystem::new();
267        fs.add_file("docs/decisions/adr-0001.md", minimal_adr_content());
268
269        let use_case = ValidateUseCase::new(fs);
270        let options = ValidateOptions::new("docs/decisions").with_strict(true);
271
272        let result = use_case.execute(&options);
273        assert!(result.is_ok());
274
275        let result = result.unwrap();
276        // Fails in strict mode due to warnings
277        assert!(!result.passed);
278    }
279
280    #[test]
281    fn test_validate_invalid_adr() {
282        let fs = InMemoryFileSystem::new();
283        fs.add_file("docs/decisions/adr-0001.md", invalid_adr_content());
284
285        let use_case = ValidateUseCase::new(fs);
286        let options = ValidateOptions::new("docs/decisions");
287
288        let result = use_case.execute(&options);
289        // Should fail to parse due to missing title
290        assert!(
291            result.is_err()
292                || result
293                    .as_ref()
294                    .map(|r| !r.parse_errors.is_empty())
295                    .unwrap_or(false)
296        );
297    }
298
299    #[test]
300    fn test_validate_no_adrs() {
301        let fs = InMemoryFileSystem::new();
302        let use_case = ValidateUseCase::new(fs);
303        let options = ValidateOptions::new("empty/dir");
304
305        let result = use_case.execute(&options);
306        assert!(result.is_err());
307    }
308
309    #[test]
310    fn test_validate_options_builder() {
311        let options = ValidateOptions::new("input")
312            .with_pattern("*.md")
313            .with_strict(true);
314
315        assert_eq!(options.input_dir, "input");
316        assert_eq!(options.pattern, "*.md");
317        assert!(options.strict);
318    }
319}