1use super::types::*;
6
7#[derive(Debug, Clone)]
13pub struct ConfigError {
14 pub field: String,
16 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
28pub trait Validatable {
34 fn validate(&self) -> Vec<ConfigError>;
36
37 fn is_valid(&self) -> bool {
39 self.validate().is_empty()
40 }
41}
42
43impl 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 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 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 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 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 errors.extend(self.matching.validate());
226 errors.extend(self.filtering.validate());
227
228 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 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#[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}