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