1use super::types::{AppConfig, MatchingConfig, FilterConfig, OutputConfig, BehaviorConfig, TuiConfig, EnrichmentConfig, DiffConfig, ViewConfig, MultiDiffConfig, TimelineConfig, MatrixConfig};
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 && !(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 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 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 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 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 errors.extend(self.matching.validate());
223 errors.extend(self.filtering.validate());
224
225 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 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#[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}