1use crate::{DrivenConfig, DrivenError, Result};
13use std::collections::HashSet;
14use std::path::Path;
15
16#[derive(Debug, Default)]
18pub struct ConfigValidator {
19 errors: Vec<ValidationError>,
21 warnings: Vec<ValidationWarning>,
23}
24
25#[derive(Debug, Clone)]
27pub struct ValidationError {
28 pub field: String,
30 pub message: String,
32 pub suggestion: String,
34}
35
36#[derive(Debug, Clone)]
38pub struct ValidationWarning {
39 pub field: String,
41 pub message: String,
43 pub suggestion: String,
45}
46
47#[derive(Debug)]
49pub struct ValidationReport {
50 pub valid: bool,
52 pub errors: Vec<ValidationError>,
54 pub warnings: Vec<ValidationWarning>,
56}
57
58impl ValidationReport {
59 pub fn has_errors(&self) -> bool {
61 !self.errors.is_empty()
62 }
63
64 pub fn has_warnings(&self) -> bool {
66 !self.warnings.is_empty()
67 }
68
69 pub fn format_errors(&self) -> String {
71 self.errors
72 .iter()
73 .map(|e| {
74 format!(
75 " - {}: {}\n Suggestion: {}",
76 e.field, e.message, e.suggestion
77 )
78 })
79 .collect::<Vec<_>>()
80 .join("\n")
81 }
82
83 pub fn format_warnings(&self) -> String {
85 self.warnings
86 .iter()
87 .map(|w| {
88 format!(
89 " - {}: {}\n Suggestion: {}",
90 w.field, w.message, w.suggestion
91 )
92 })
93 .collect::<Vec<_>>()
94 .join("\n")
95 }
96}
97
98impl ConfigValidator {
99 pub fn new() -> Self {
101 Self::default()
102 }
103
104 pub fn validate(&mut self, config: &DrivenConfig) -> ValidationReport {
106 self.errors.clear();
107 self.warnings.clear();
108
109 self.validate_version(&config.version);
110 self.validate_editors(&config.editors);
111 self.validate_sync(&config.sync);
112 self.validate_templates(&config.templates);
113 self.validate_context(&config.context);
114
115 ValidationReport {
116 valid: self.errors.is_empty(),
117 errors: self.errors.clone(),
118 warnings: self.warnings.clone(),
119 }
120 }
121
122 fn validate_version(&mut self, version: &str) {
124 if version.is_empty() {
125 self.errors.push(ValidationError {
126 field: "version".to_string(),
127 message: "Version cannot be empty".to_string(),
128 suggestion: "Set version to '1.0'".to_string(),
129 });
130 } else if !version.chars().all(|c| c.is_ascii_digit() || c == '.') {
131 self.warnings.push(ValidationWarning {
132 field: "version".to_string(),
133 message: format!("Unusual version format: '{}'", version),
134 suggestion: "Use semantic versioning (e.g., '1.0', '2.1')".to_string(),
135 });
136 }
137 }
138
139 fn validate_editors(&mut self, editors: &crate::EditorConfig) {
141 let enabled_count = [
142 editors.cursor,
143 editors.copilot,
144 editors.windsurf,
145 editors.claude,
146 editors.aider,
147 editors.cline,
148 ]
149 .iter()
150 .filter(|&&e| e)
151 .count();
152
153 if enabled_count == 0 {
154 self.warnings.push(ValidationWarning {
155 field: "editors".to_string(),
156 message: "No editors are enabled".to_string(),
157 suggestion: "Enable at least one editor (e.g., editors.cursor = true)".to_string(),
158 });
159 }
160 }
161
162 fn validate_sync(&mut self, sync: &crate::SyncConfig) {
164 if sync.source_of_truth.is_empty() {
166 self.errors.push(ValidationError {
167 field: "sync.source_of_truth".to_string(),
168 message: "Source of truth path cannot be empty".to_string(),
169 suggestion: "Set to '.driven/rules.drv' or another valid path".to_string(),
170 });
171 } else if !sync.source_of_truth.ends_with(".drv") && !sync.source_of_truth.ends_with(".md")
172 {
173 self.warnings.push(ValidationWarning {
174 field: "sync.source_of_truth".to_string(),
175 message: format!("Unusual file extension: '{}'", sync.source_of_truth),
176 suggestion: "Use .drv for binary format or .md for markdown".to_string(),
177 });
178 }
179 }
180
181 fn validate_templates(&mut self, templates: &crate::TemplateConfig) {
183 let mut seen = HashSet::new();
185 for persona in &templates.personas {
186 if !seen.insert(persona) {
187 self.warnings.push(ValidationWarning {
188 field: "templates.personas".to_string(),
189 message: format!("Duplicate persona: '{}'", persona),
190 suggestion: "Remove duplicate entries".to_string(),
191 });
192 }
193 }
194
195 seen.clear();
197 for standard in &templates.standards {
198 if !seen.insert(standard) {
199 self.warnings.push(ValidationWarning {
200 field: "templates.standards".to_string(),
201 message: format!("Duplicate standard: '{}'", standard),
202 suggestion: "Remove duplicate entries".to_string(),
203 });
204 }
205 }
206 }
207
208 fn validate_context(&mut self, context: &crate::ContextConfig) {
210 if context.include.is_empty() {
212 self.warnings.push(ValidationWarning {
213 field: "context.include".to_string(),
214 message: "No include patterns specified".to_string(),
215 suggestion: "Add patterns like 'src/**' to include source files".to_string(),
216 });
217 }
218
219 for include in &context.include {
221 for exclude in &context.exclude {
222 if include == exclude {
223 self.errors.push(ValidationError {
224 field: "context".to_string(),
225 message: format!("Pattern '{}' is both included and excluded", include),
226 suggestion: "Remove the pattern from either include or exclude".to_string(),
227 });
228 }
229 }
230 }
231
232 if context.index_path.is_empty() {
234 self.errors.push(ValidationError {
235 field: "context.index_path".to_string(),
236 message: "Index path cannot be empty".to_string(),
237 suggestion: "Set to '.driven/index.drv'".to_string(),
238 });
239 }
240 }
241
242 pub fn validate_path_exists(&mut self, field: &str, path: &Path) {
244 if !path.exists() {
245 self.errors.push(ValidationError {
246 field: field.to_string(),
247 message: format!("Path does not exist: {}", path.display()),
248 suggestion: "Create the file or update the path".to_string(),
249 });
250 }
251 }
252
253 pub fn add_error(&mut self, field: &str, message: &str, suggestion: &str) {
255 self.errors.push(ValidationError {
256 field: field.to_string(),
257 message: message.to_string(),
258 suggestion: suggestion.to_string(),
259 });
260 }
261
262 pub fn add_warning(&mut self, field: &str, message: &str, suggestion: &str) {
264 self.warnings.push(ValidationWarning {
265 field: field.to_string(),
266 message: message.to_string(),
267 suggestion: suggestion.to_string(),
268 });
269 }
270}
271
272pub fn validate_config(config: &DrivenConfig) -> Result<ValidationReport> {
274 let mut validator = ConfigValidator::new();
275 let report = validator.validate(config);
276
277 if report.has_errors() {
278 Err(DrivenError::Validation(report.format_errors()))
279 } else {
280 Ok(report)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::EditorConfig;
288
289 #[test]
290 fn test_valid_config() {
291 let config = DrivenConfig::default();
292 let mut validator = ConfigValidator::new();
293 let report = validator.validate(&config);
294
295 assert!(report.valid);
296 assert!(!report.has_errors());
297 }
298
299 #[test]
300 fn test_empty_version() {
301 let mut config = DrivenConfig::default();
302 config.version = String::new();
303
304 let mut validator = ConfigValidator::new();
305 let report = validator.validate(&config);
306
307 assert!(!report.valid);
308 assert!(report.errors.iter().any(|e| e.field == "version"));
309 }
310
311 #[test]
312 fn test_no_editors_enabled() {
313 let mut config = DrivenConfig::default();
314 config.editors = EditorConfig {
315 cursor: false,
316 copilot: false,
317 windsurf: false,
318 claude: false,
319 aider: false,
320 cline: false,
321 };
322
323 let mut validator = ConfigValidator::new();
324 let report = validator.validate(&config);
325
326 assert!(report.has_warnings());
327 assert!(report.warnings.iter().any(|w| w.field == "editors"));
328 }
329
330 #[test]
331 fn test_empty_source_of_truth() {
332 let mut config = DrivenConfig::default();
333 config.sync.source_of_truth = String::new();
334
335 let mut validator = ConfigValidator::new();
336 let report = validator.validate(&config);
337
338 assert!(!report.valid);
339 assert!(
340 report
341 .errors
342 .iter()
343 .any(|e| e.field == "sync.source_of_truth")
344 );
345 }
346
347 #[test]
348 fn test_duplicate_personas() {
349 let mut config = DrivenConfig::default();
350 config.templates.personas = vec!["architect".to_string(), "architect".to_string()];
351
352 let mut validator = ConfigValidator::new();
353 let report = validator.validate(&config);
354
355 assert!(report.has_warnings());
356 assert!(
357 report
358 .warnings
359 .iter()
360 .any(|w| w.message.contains("Duplicate"))
361 );
362 }
363
364 #[test]
365 fn test_overlapping_patterns() {
366 let mut config = DrivenConfig::default();
367 config.context.include = vec!["src/**".to_string()];
368 config.context.exclude = vec!["src/**".to_string()];
369
370 let mut validator = ConfigValidator::new();
371 let report = validator.validate(&config);
372
373 assert!(!report.valid);
374 assert!(
375 report
376 .errors
377 .iter()
378 .any(|e| e.message.contains("both included and excluded"))
379 );
380 }
381
382 #[test]
383 fn test_validation_report_formatting() {
384 let report = ValidationReport {
385 valid: false,
386 errors: vec![ValidationError {
387 field: "test.field".to_string(),
388 message: "Test error".to_string(),
389 suggestion: "Fix it".to_string(),
390 }],
391 warnings: vec![ValidationWarning {
392 field: "test.warning".to_string(),
393 message: "Test warning".to_string(),
394 suggestion: "Consider fixing".to_string(),
395 }],
396 };
397
398 let errors = report.format_errors();
399 assert!(errors.contains("test.field"));
400 assert!(errors.contains("Test error"));
401
402 let warnings = report.format_warnings();
403 assert!(warnings.contains("test.warning"));
404 assert!(warnings.contains("Test warning"));
405 }
406}
407
408#[cfg(test)]
409mod property_tests {
410 use super::*;
411 use proptest::prelude::*;
412
413 proptest! {
418 #[test]
420 fn prop_default_config_valid(_seed in any::<u64>()) {
421 let config = DrivenConfig::default();
422 let mut validator = ConfigValidator::new();
423 let report = validator.validate(&config);
424
425 prop_assert!(report.valid, "Default config should be valid");
426 }
427
428 #[test]
430 fn prop_empty_version_invalid(_seed in any::<u64>()) {
431 let mut config = DrivenConfig::default();
432 config.version = String::new();
433
434 let mut validator = ConfigValidator::new();
435 let report = validator.validate(&config);
436
437 prop_assert!(!report.valid);
438 prop_assert!(report.errors.iter().any(|e| e.field == "version"));
439 }
440
441 #[test]
443 fn prop_empty_source_of_truth_invalid(_seed in any::<u64>()) {
444 let mut config = DrivenConfig::default();
445 config.sync.source_of_truth = String::new();
446
447 let mut validator = ConfigValidator::new();
448 let report = validator.validate(&config);
449
450 prop_assert!(!report.valid);
451 prop_assert!(report.errors.iter().any(|e| e.field.contains("source_of_truth")));
452 }
453
454 #[test]
456 fn prop_errors_have_suggestions(
457 version in ".*",
458 source in ".*",
459 ) {
460 let mut config = DrivenConfig::default();
461 config.version = version;
462 config.sync.source_of_truth = source;
463
464 let mut validator = ConfigValidator::new();
465 let report = validator.validate(&config);
466
467 for error in &report.errors {
468 prop_assert!(!error.suggestion.is_empty(),
469 "Error for '{}' should have a suggestion", error.field);
470 }
471
472 for warning in &report.warnings {
473 prop_assert!(!warning.suggestion.is_empty(),
474 "Warning for '{}' should have a suggestion", warning.field);
475 }
476 }
477
478 #[test]
480 fn prop_validation_deterministic(
481 version in "[0-9.]+",
482 ) {
483 let mut config = DrivenConfig::default();
484 config.version = version;
485
486 let mut validator1 = ConfigValidator::new();
487 let report1 = validator1.validate(&config);
488
489 let mut validator2 = ConfigValidator::new();
490 let report2 = validator2.validate(&config);
491
492 prop_assert_eq!(report1.valid, report2.valid);
493 prop_assert_eq!(report1.errors.len(), report2.errors.len());
494 prop_assert_eq!(report1.warnings.len(), report2.warnings.len());
495 }
496 }
497}