1use super::types::{
6 AppConfig, BehaviorConfig, DiffConfig, EnrichmentConfig, FilterConfig, MatchingConfig,
7 MatrixConfig, MultiDiffConfig, OutputConfig, TimelineConfig, TuiConfig, ViewConfig,
8};
9
10#[derive(Debug, Clone)]
16pub struct ConfigError {
17 pub field: String,
19 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
31pub trait Validatable {
37 fn validate(&self) -> Vec<ConfigError>;
39
40 fn is_valid(&self) -> bool {
42 self.validate().is_empty()
43 }
44}
45
46impl 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 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 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 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 Vec::new()
139 }
140}
141
142impl Validatable for TuiConfig {
143 fn validate(&self) -> Vec<ConfigError> {
144 let mut errors = Vec::new();
145
146 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 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 errors.extend(self.matching.validate());
209 errors.extend(self.filtering.validate());
210
211 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 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#[cfg(test)]
345mod tests {
346 use super::*;
347
348 #[test]
349 fn test_matching_config_validation() {
350 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 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 let mut invalid = AppConfig::default();
439 invalid.matching.threshold = Some(5.0);
440 assert!(!invalid.is_valid());
441 }
442}