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