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