Skip to main content

driven/
config_validation.rs

1//! Configuration Validation Module
2//!
3//! This module provides comprehensive validation for all configuration sections
4//! with actionable error messages and suggestions.
5//!
6//! ## Features
7//!
8//! - Validates all config sections ([driven], [generator], [dcp])
9//! - Provides suggestions for invalid configurations
10//! - Supports partial validation for incremental updates
11
12use crate::{DrivenConfig, DrivenError, Result};
13use std::collections::HashSet;
14use std::path::Path;
15
16/// Configuration validator
17#[derive(Debug, Default)]
18pub struct ConfigValidator {
19    /// Validation errors collected
20    errors: Vec<ValidationError>,
21    /// Validation warnings collected
22    warnings: Vec<ValidationWarning>,
23}
24
25/// A validation error
26#[derive(Debug, Clone)]
27pub struct ValidationError {
28    /// Field path (e.g., "sync.debounce_ms")
29    pub field: String,
30    /// Error message
31    pub message: String,
32    /// Suggestion for fixing
33    pub suggestion: String,
34}
35
36/// A validation warning
37#[derive(Debug, Clone)]
38pub struct ValidationWarning {
39    /// Field path
40    pub field: String,
41    /// Warning message
42    pub message: String,
43    /// Suggestion
44    pub suggestion: String,
45}
46
47/// Validation result
48#[derive(Debug)]
49pub struct ValidationReport {
50    /// Whether validation passed
51    pub valid: bool,
52    /// Errors found
53    pub errors: Vec<ValidationError>,
54    /// Warnings found
55    pub warnings: Vec<ValidationWarning>,
56}
57
58impl ValidationReport {
59    /// Check if there are any errors
60    pub fn has_errors(&self) -> bool {
61        !self.errors.is_empty()
62    }
63
64    /// Check if there are any warnings
65    pub fn has_warnings(&self) -> bool {
66        !self.warnings.is_empty()
67    }
68
69    /// Get a formatted error message
70    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    /// Get a formatted warning message
84    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    /// Create a new validator
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Validate a complete DrivenConfig
105    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    /// Validate version field
123    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    /// Validate editor configuration
140    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    /// Validate sync configuration
163    fn validate_sync(&mut self, sync: &crate::SyncConfig) {
164        // Validate source of truth path
165        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    /// Validate template configuration
182    fn validate_templates(&mut self, templates: &crate::TemplateConfig) {
183        // Check for duplicate personas
184        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        // Check for duplicate standards
196        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    /// Validate context configuration
209    fn validate_context(&mut self, context: &crate::ContextConfig) {
210        // Check for empty include patterns
211        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        // Check for overlapping include/exclude patterns
220        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        // Validate index path
233        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    /// Validate a file path exists
243    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    /// Add a custom error
254    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    /// Add a custom warning
263    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
272/// Validate a configuration and return a Result
273pub 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    // Property 12: Configuration Validation
414    // For any configuration input, validation SHALL correctly identify all
415    // invalid settings and provide actionable error messages.
416
417    proptest! {
418        /// Property: Valid default config always passes validation
419        #[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        /// Property: Empty version always fails validation
429        #[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        /// Property: Empty source_of_truth always fails validation
442        #[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        /// Property: All errors have suggestions
455        #[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        /// Property: Validation is deterministic
479        #[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}