Skip to main content

sbom_tools/config/
validation.rs

1//! Configuration validation for sbom-tools.
2//!
3//! Provides validation traits and implementations for all configuration types.
4
5use super::types::{
6    AppConfig, BehaviorConfig, DiffConfig, EnrichmentConfig, FilterConfig, MatchingConfig,
7    MatrixConfig, MultiDiffConfig, OutputConfig, TimelineConfig, TuiConfig, ViewConfig,
8};
9
10// ============================================================================
11// Configuration Error
12// ============================================================================
13
14/// Error type for configuration validation.
15#[derive(Debug, Clone)]
16pub struct ConfigError {
17    /// The field that failed validation
18    pub field: String,
19    /// Description of the validation error
20    pub message: String,
21}
22
23impl std::fmt::Display for ConfigError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "{}: {}", self.field, self.message)
26    }
27}
28
29impl std::error::Error for ConfigError {}
30
31// ============================================================================
32// Validation Trait
33// ============================================================================
34
35/// Trait for validatable configuration types.
36pub trait Validatable {
37    /// Validate the configuration, returning any errors found.
38    fn validate(&self) -> Vec<ConfigError>;
39
40    /// Check if the configuration is valid.
41    fn is_valid(&self) -> bool {
42        self.validate().is_empty()
43    }
44}
45
46// ============================================================================
47// Validation Implementations
48// ============================================================================
49
50impl Validatable for AppConfig {
51    fn validate(&self) -> Vec<ConfigError> {
52        let mut errors = Vec::new();
53        errors.extend(self.matching.validate());
54        errors.extend(self.filtering.validate());
55        errors.extend(self.output.validate());
56        errors.extend(self.behavior.validate());
57        errors.extend(self.tui.validate());
58
59        if let Some(ref enrichment) = self.enrichment {
60            errors.extend(enrichment.validate());
61        }
62
63        errors
64    }
65}
66
67impl Validatable for MatchingConfig {
68    fn validate(&self) -> Vec<ConfigError> {
69        let mut errors = Vec::new();
70        let valid_presets = ["strict", "balanced", "permissive", "security-focused"];
71        if !valid_presets.contains(&self.fuzzy_preset.as_str()) {
72            errors.push(ConfigError {
73                field: "matching.fuzzy_preset".to_string(),
74                message: format!(
75                    "Invalid preset '{}'. Valid options: {}",
76                    self.fuzzy_preset,
77                    valid_presets.join(", ")
78                ),
79            });
80        }
81
82        if let Some(threshold) = self.threshold
83            && !(0.0..=1.0).contains(&threshold)
84        {
85            errors.push(ConfigError {
86                field: "matching.threshold".to_string(),
87                message: format!("Threshold must be between 0.0 and 1.0, got {threshold}"),
88            });
89        }
90
91        errors
92    }
93}
94
95impl Validatable for FilterConfig {
96    fn validate(&self) -> Vec<ConfigError> {
97        let mut errors = Vec::new();
98        if let Some(ref severity) = self.min_severity {
99            let valid_severities = ["critical", "high", "medium", "low", "info"];
100            if !valid_severities.contains(&severity.to_lowercase().as_str()) {
101                errors.push(ConfigError {
102                    field: "filtering.min_severity".to_string(),
103                    message: format!(
104                        "Invalid severity '{}'. Valid options: {}",
105                        severity,
106                        valid_severities.join(", ")
107                    ),
108                });
109            }
110        }
111        errors
112    }
113}
114
115impl Validatable for OutputConfig {
116    fn validate(&self) -> Vec<ConfigError> {
117        let mut errors = Vec::new();
118
119        // Validate output file path if specified
120        if let Some(ref file_path) = self.file
121            && let Some(parent) = file_path.parent()
122            && !parent.as_os_str().is_empty()
123            && !parent.exists()
124        {
125            errors.push(ConfigError {
126                field: "output.file".to_string(),
127                message: format!("Parent directory does not exist: {}", parent.display()),
128            });
129        }
130
131        // Warn about contradictory streaming configuration
132        if self.streaming.disabled && self.streaming.force {
133            errors.push(ConfigError {
134                field: "output.streaming".to_string(),
135                message: "Contradictory streaming config: both 'disabled' and 'force' are true. \
136                          'disabled' takes precedence."
137                    .to_string(),
138            });
139        }
140
141        errors
142    }
143}
144
145impl Validatable for BehaviorConfig {
146    fn validate(&self) -> Vec<ConfigError> {
147        // BehaviorConfig contains only boolean flags that don't need validation
148        Vec::new()
149    }
150}
151
152impl Validatable for TuiConfig {
153    fn validate(&self) -> Vec<ConfigError> {
154        let mut errors = Vec::new();
155
156        let valid_themes = ["dark", "light", "high-contrast"];
157        if !valid_themes.contains(&self.theme.as_str()) {
158            errors.push(ConfigError {
159                field: "tui.theme".to_string(),
160                message: format!(
161                    "Invalid theme '{}'. Valid options: {}",
162                    self.theme,
163                    valid_themes.join(", ")
164                ),
165            });
166        }
167
168        if !(0.0..=1.0).contains(&self.initial_threshold) {
169            errors.push(ConfigError {
170                field: "tui.initial_threshold".to_string(),
171                message: format!(
172                    "Initial threshold must be between 0.0 and 1.0, got {}",
173                    self.initial_threshold
174                ),
175            });
176        }
177
178        errors
179    }
180}
181
182impl Validatable for EnrichmentConfig {
183    fn validate(&self) -> Vec<ConfigError> {
184        let mut errors = Vec::new();
185
186        let valid_providers = ["osv", "nvd"];
187        if !valid_providers.contains(&self.provider.as_str()) {
188            errors.push(ConfigError {
189                field: "enrichment.provider".to_string(),
190                message: format!(
191                    "Invalid provider '{}'. Valid options: {}",
192                    self.provider,
193                    valid_providers.join(", ")
194                ),
195            });
196        }
197
198        if self.max_concurrent == 0 {
199            errors.push(ConfigError {
200                field: "enrichment.max_concurrent".to_string(),
201                message: "Max concurrent requests must be at least 1".to_string(),
202            });
203        }
204
205        errors
206    }
207}
208
209impl Validatable for DiffConfig {
210    fn validate(&self) -> Vec<ConfigError> {
211        let mut errors = Vec::new();
212
213        // Validate paths exist
214        if !self.paths.old.exists() {
215            errors.push(ConfigError {
216                field: "paths.old".to_string(),
217                message: format!("File not found: {}", self.paths.old.display()),
218            });
219        }
220        if !self.paths.new.exists() {
221            errors.push(ConfigError {
222                field: "paths.new".to_string(),
223                message: format!("File not found: {}", self.paths.new.display()),
224            });
225        }
226
227        // Validate nested configs
228        errors.extend(self.matching.validate());
229        errors.extend(self.filtering.validate());
230
231        // Validate rules file if specified
232        if let Some(ref rules_file) = self.rules.rules_file
233            && !rules_file.exists()
234        {
235            errors.push(ConfigError {
236                field: "rules.rules_file".to_string(),
237                message: format!("Rules file not found: {}", rules_file.display()),
238            });
239        }
240
241        // Validate ecosystem rules file if specified
242        if let Some(ref config_file) = self.ecosystem_rules.config_file
243            && !config_file.exists()
244        {
245            errors.push(ConfigError {
246                field: "ecosystem_rules.config_file".to_string(),
247                message: format!("Ecosystem rules file not found: {}", config_file.display()),
248            });
249        }
250
251        errors
252    }
253}
254
255impl Validatable for ViewConfig {
256    fn validate(&self) -> Vec<ConfigError> {
257        let mut errors = Vec::new();
258        if !self.sbom_path.exists() {
259            errors.push(ConfigError {
260                field: "sbom_path".to_string(),
261                message: format!("File not found: {}", self.sbom_path.display()),
262            });
263        }
264        errors
265    }
266}
267
268impl Validatable for MultiDiffConfig {
269    fn validate(&self) -> Vec<ConfigError> {
270        let mut errors = Vec::new();
271
272        if !self.baseline.exists() {
273            errors.push(ConfigError {
274                field: "baseline".to_string(),
275                message: format!("Baseline file not found: {}", self.baseline.display()),
276            });
277        }
278
279        for (i, target) in self.targets.iter().enumerate() {
280            if !target.exists() {
281                errors.push(ConfigError {
282                    field: format!("targets[{i}]"),
283                    message: format!("Target file not found: {}", target.display()),
284                });
285            }
286        }
287
288        if self.targets.is_empty() {
289            errors.push(ConfigError {
290                field: "targets".to_string(),
291                message: "At least one target SBOM is required".to_string(),
292            });
293        }
294
295        errors.extend(self.matching.validate());
296        errors
297    }
298}
299
300impl Validatable for TimelineConfig {
301    fn validate(&self) -> Vec<ConfigError> {
302        let mut errors = Vec::new();
303
304        for (i, path) in self.sbom_paths.iter().enumerate() {
305            if !path.exists() {
306                errors.push(ConfigError {
307                    field: format!("sbom_paths[{i}]"),
308                    message: format!("SBOM file not found: {}", path.display()),
309                });
310            }
311        }
312
313        if self.sbom_paths.len() < 2 {
314            errors.push(ConfigError {
315                field: "sbom_paths".to_string(),
316                message: "Timeline analysis requires at least 2 SBOMs".to_string(),
317            });
318        }
319
320        errors.extend(self.matching.validate());
321        errors
322    }
323}
324
325impl Validatable for MatrixConfig {
326    fn validate(&self) -> Vec<ConfigError> {
327        let mut errors = Vec::new();
328
329        for (i, path) in self.sbom_paths.iter().enumerate() {
330            if !path.exists() {
331                errors.push(ConfigError {
332                    field: format!("sbom_paths[{i}]"),
333                    message: format!("SBOM file not found: {}", path.display()),
334                });
335            }
336        }
337
338        if self.sbom_paths.len() < 2 {
339            errors.push(ConfigError {
340                field: "sbom_paths".to_string(),
341                message: "Matrix comparison requires at least 2 SBOMs".to_string(),
342            });
343        }
344
345        if !(0.0..=1.0).contains(&self.cluster_threshold) {
346            errors.push(ConfigError {
347                field: "cluster_threshold".to_string(),
348                message: format!(
349                    "Cluster threshold must be between 0.0 and 1.0, got {}",
350                    self.cluster_threshold
351                ),
352            });
353        }
354
355        errors.extend(self.matching.validate());
356        errors
357    }
358}
359
360// ============================================================================
361// Tests
362// ============================================================================
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn test_matching_config_validation() {
370        let config = MatchingConfig {
371            fuzzy_preset: "balanced".to_string(),
372            threshold: None,
373            include_unchanged: false,
374        };
375        assert!(config.is_valid());
376
377        let invalid = MatchingConfig {
378            fuzzy_preset: "invalid".to_string(),
379            threshold: None,
380            include_unchanged: false,
381        };
382        assert!(!invalid.is_valid());
383    }
384
385    #[test]
386    fn test_matching_config_threshold_validation() {
387        let valid = MatchingConfig {
388            fuzzy_preset: "balanced".to_string(),
389            threshold: Some(0.85),
390            include_unchanged: false,
391        };
392        assert!(valid.is_valid());
393
394        let invalid = MatchingConfig {
395            fuzzy_preset: "balanced".to_string(),
396            threshold: Some(1.5),
397            include_unchanged: false,
398        };
399        assert!(!invalid.is_valid());
400    }
401
402    #[test]
403    fn test_filter_config_validation() {
404        let config = FilterConfig {
405            only_changes: true,
406            min_severity: Some("high".to_string()),
407            exclude_vex_resolved: false,
408        };
409        assert!(config.is_valid());
410
411        let invalid = FilterConfig {
412            only_changes: true,
413            min_severity: Some("invalid".to_string()),
414            exclude_vex_resolved: false,
415        };
416        assert!(!invalid.is_valid());
417    }
418
419    #[test]
420    fn test_tui_config_validation() {
421        let valid = TuiConfig::default();
422        assert!(valid.is_valid());
423
424        let invalid = TuiConfig {
425            theme: "neon".to_string(),
426            ..TuiConfig::default()
427        };
428        assert!(!invalid.is_valid());
429    }
430
431    #[test]
432    fn test_enrichment_config_validation() {
433        let valid = EnrichmentConfig::default();
434        assert!(valid.is_valid());
435
436        let invalid = EnrichmentConfig {
437            max_concurrent: 0,
438            ..EnrichmentConfig::default()
439        };
440        assert!(!invalid.is_valid());
441    }
442
443    #[test]
444    fn test_config_error_display() {
445        let error = ConfigError {
446            field: "test_field".to_string(),
447            message: "test error message".to_string(),
448        };
449        assert_eq!(error.to_string(), "test_field: test error message");
450    }
451
452    #[test]
453    fn test_app_config_validation() {
454        let valid = AppConfig::default();
455        assert!(valid.is_valid());
456
457        let mut invalid = AppConfig::default();
458        invalid.matching.fuzzy_preset = "invalid".to_string();
459        assert!(!invalid.is_valid());
460    }
461}