adrscope/application/
validate.rs1use std::path::Path;
6
7use crate::domain::{Severity, ValidationReport, Validator, default_rules};
8use crate::error::Result;
9use crate::infrastructure::{AdrParser, DefaultAdrParser, FileSystem};
10
11#[derive(Debug, Clone)]
13pub struct ValidateOptions {
14 pub input_dir: String,
16 pub pattern: String,
18 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 #[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 #[must_use]
44 pub fn with_pattern(mut self, pattern: impl Into<String>) -> Self {
45 self.pattern = pattern.into();
46 self
47 }
48
49 #[must_use]
51 pub const fn with_strict(mut self, strict: bool) -> Self {
52 self.strict = strict;
53 self
54 }
55}
56
57#[derive(Debug)]
59pub struct ValidateUseCase<F: FileSystem> {
60 fs: F,
61 parser: DefaultAdrParser,
62}
63
64impl<F: FileSystem> ValidateUseCase<F> {
65 #[must_use]
67 pub fn new(fs: F) -> Self {
68 Self {
69 fs,
70 parser: DefaultAdrParser::new(),
71 }
72 }
73
74 pub fn execute(&self, options: &ValidateOptions) -> Result<ValidateResult> {
82 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 let validator = Validator::new(default_rules());
94
95 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 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 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#[derive(Debug)]
140pub struct ValidateResult {
141 pub reports: Vec<(std::path::PathBuf, ValidationReport)>,
143 pub parse_errors: Vec<(std::path::PathBuf, crate::error::Error)>,
145 pub total_errors: usize,
147 pub total_warnings: usize,
149 pub passed: bool,
151}
152
153impl ValidateResult {
154 #[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 #[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 #[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 assert!(result.passed);
259 assert_eq!(result.total_errors, 0);
260 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 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 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}