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 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 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 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 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 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 errors.extend(self.matching.validate());
229 errors.extend(self.filtering.validate());
230
231 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 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#[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}