1use crate::matching::FuzzyMatchConfig;
6use crate::reports::{ReportFormat, ReportType};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
21#[serde(default)]
22pub struct AppConfig {
23 pub matching: MatchingConfig,
25 pub output: OutputConfig,
27 pub filtering: FilterConfig,
29 pub behavior: BehaviorConfig,
31 pub graph_diff: GraphAwareDiffConfig,
33 pub rules: MatchingRulesPathConfig,
35 pub ecosystem_rules: EcosystemRulesConfig,
37 pub tui: TuiConfig,
39 #[serde(skip_serializing_if = "Option::is_none")]
41 pub enrichment: Option<EnrichmentConfig>,
42}
43
44impl AppConfig {
45 pub fn new() -> Self {
47 Self::default()
48 }
49
50 pub fn builder() -> AppConfigBuilder {
52 AppConfigBuilder::default()
53 }
54}
55
56#[derive(Debug, Default)]
62pub struct AppConfigBuilder {
63 config: AppConfig,
64}
65
66impl AppConfigBuilder {
67 pub fn fuzzy_preset(mut self, preset: impl Into<String>) -> Self {
69 self.config.matching.fuzzy_preset = preset.into();
70 self
71 }
72
73 pub fn matching_threshold(mut self, threshold: f64) -> Self {
75 self.config.matching.threshold = Some(threshold);
76 self
77 }
78
79 pub fn output_format(mut self, format: ReportFormat) -> Self {
81 self.config.output.format = format;
82 self
83 }
84
85 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
87 self.config.output.file = file;
88 self
89 }
90
91 pub fn no_color(mut self, no_color: bool) -> Self {
93 self.config.output.no_color = no_color;
94 self
95 }
96
97 pub fn include_unchanged(mut self, include: bool) -> Self {
99 self.config.matching.include_unchanged = include;
100 self
101 }
102
103 pub fn fail_on_vuln(mut self, fail: bool) -> Self {
105 self.config.behavior.fail_on_vuln = fail;
106 self
107 }
108
109 pub fn fail_on_change(mut self, fail: bool) -> Self {
111 self.config.behavior.fail_on_change = fail;
112 self
113 }
114
115 pub fn quiet(mut self, quiet: bool) -> Self {
117 self.config.behavior.quiet = quiet;
118 self
119 }
120
121 pub fn graph_diff(mut self, enabled: bool) -> Self {
123 self.config.graph_diff = if enabled {
124 GraphAwareDiffConfig::enabled()
125 } else {
126 GraphAwareDiffConfig::default()
127 };
128 self
129 }
130
131 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
133 self.config.rules.rules_file = file;
134 self
135 }
136
137 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
139 self.config.ecosystem_rules.config_file = file;
140 self
141 }
142
143 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
145 self.config.enrichment = Some(config);
146 self
147 }
148
149 pub fn build(self) -> AppConfig {
151 self.config
152 }
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
161pub struct TuiPreferences {
162 pub theme: String,
164}
165
166impl Default for TuiPreferences {
167 fn default() -> Self {
168 Self {
169 theme: "dark".to_string(),
170 }
171 }
172}
173
174impl TuiPreferences {
175 pub fn config_path() -> Option<PathBuf> {
177 dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
178 }
179
180 pub fn load() -> Self {
182 Self::config_path()
183 .and_then(|p| std::fs::read_to_string(p).ok())
184 .and_then(|s| serde_json::from_str(&s).ok())
185 .unwrap_or_default()
186 }
187
188 pub fn save(&self) -> std::io::Result<()> {
190 if let Some(path) = Self::config_path() {
191 if let Some(parent) = path.parent() {
192 std::fs::create_dir_all(parent)?;
193 }
194 let json = serde_json::to_string_pretty(self)
195 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
196 std::fs::write(path, json)?;
197 }
198 Ok(())
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
208#[serde(default)]
209pub struct TuiConfig {
210 pub theme: String,
212 pub show_line_numbers: bool,
214 pub mouse_enabled: bool,
216 #[schemars(range(min = 0.0, max = 1.0))]
218 pub initial_threshold: f64,
219}
220
221impl Default for TuiConfig {
222 fn default() -> Self {
223 Self {
224 theme: "dark".to_string(),
225 show_line_numbers: true,
226 mouse_enabled: true,
227 initial_threshold: 0.8,
228 }
229 }
230}
231
232#[derive(Debug, Clone)]
238pub struct DiffConfig {
239 pub paths: DiffPaths,
241 pub output: OutputConfig,
243 pub matching: MatchingConfig,
245 pub filtering: FilterConfig,
247 pub behavior: BehaviorConfig,
249 pub graph_diff: GraphAwareDiffConfig,
251 pub rules: MatchingRulesPathConfig,
253 pub ecosystem_rules: EcosystemRulesConfig,
255 pub enrichment: EnrichmentConfig,
257}
258
259#[derive(Debug, Clone)]
261pub struct DiffPaths {
262 pub old: PathBuf,
264 pub new: PathBuf,
266}
267
268#[derive(Debug, Clone)]
270pub struct ViewConfig {
271 pub sbom_path: PathBuf,
273 pub output: OutputConfig,
275 pub validate_ntia: bool,
277 pub min_severity: Option<String>,
279 pub vulnerable_only: bool,
281 pub ecosystem_filter: Option<String>,
283}
284
285#[derive(Debug, Clone)]
287pub struct MultiDiffConfig {
288 pub baseline: PathBuf,
290 pub targets: Vec<PathBuf>,
292 pub output: OutputConfig,
294 pub matching: MatchingConfig,
296}
297
298#[derive(Debug, Clone)]
300pub struct TimelineConfig {
301 pub sbom_paths: Vec<PathBuf>,
303 pub output: OutputConfig,
305 pub matching: MatchingConfig,
307}
308
309#[derive(Debug, Clone)]
311pub struct MatrixConfig {
312 pub sbom_paths: Vec<PathBuf>,
314 pub output: OutputConfig,
316 pub matching: MatchingConfig,
318 pub cluster_threshold: f64,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
328#[serde(default)]
329pub struct OutputConfig {
330 pub format: ReportFormat,
332 #[serde(skip_serializing_if = "Option::is_none")]
334 pub file: Option<PathBuf>,
335 pub report_types: ReportType,
337 pub no_color: bool,
339 pub streaming: StreamingConfig,
341}
342
343impl Default for OutputConfig {
344 fn default() -> Self {
345 Self {
346 format: ReportFormat::Auto,
347 file: None,
348 report_types: ReportType::All,
349 no_color: false,
350 streaming: StreamingConfig::default(),
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
361#[serde(default)]
362pub struct StreamingConfig {
363 #[schemars(range(min = 0))]
366 pub threshold_bytes: u64,
367 pub force: bool,
370 pub disabled: bool,
372 pub stream_stdin: bool,
375}
376
377impl Default for StreamingConfig {
378 fn default() -> Self {
379 Self {
380 threshold_bytes: 10 * 1024 * 1024, force: false,
382 disabled: false,
383 stream_stdin: true,
384 }
385 }
386}
387
388impl StreamingConfig {
389 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
391 if self.disabled {
392 return false;
393 }
394 if self.force {
395 return true;
396 }
397 if is_stdin && self.stream_stdin {
398 return true;
399 }
400 match file_size {
401 Some(size) => size >= self.threshold_bytes,
402 None => self.stream_stdin, }
404 }
405
406 pub fn always() -> Self {
408 Self {
409 force: true,
410 ..Default::default()
411 }
412 }
413
414 pub fn never() -> Self {
416 Self {
417 disabled: true,
418 ..Default::default()
419 }
420 }
421
422 pub fn with_threshold_mb(mut self, mb: u64) -> Self {
424 self.threshold_bytes = mb * 1024 * 1024;
425 self
426 }
427}
428
429#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
431#[serde(default)]
432pub struct MatchingConfig {
433 pub fuzzy_preset: String,
435 #[serde(skip_serializing_if = "Option::is_none")]
437 #[schemars(range(min = 0.0, max = 1.0))]
438 pub threshold: Option<f64>,
439 pub include_unchanged: bool,
441}
442
443impl Default for MatchingConfig {
444 fn default() -> Self {
445 Self {
446 fuzzy_preset: "balanced".to_string(),
447 threshold: None,
448 include_unchanged: false,
449 }
450 }
451}
452
453impl MatchingConfig {
454 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
456 let mut config = FuzzyMatchConfig::from_preset(&self.fuzzy_preset).unwrap_or_else(|| {
457 tracing::warn!(
458 "Unknown fuzzy preset '{}', using 'balanced'. Valid: strict, balanced, permissive",
459 self.fuzzy_preset
460 );
461 FuzzyMatchConfig::balanced()
462 });
463
464 if let Some(threshold) = self.threshold {
466 config = config.with_threshold(threshold);
467 }
468
469 config
470 }
471}
472
473#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
475#[serde(default)]
476pub struct FilterConfig {
477 pub only_changes: bool,
479 #[serde(skip_serializing_if = "Option::is_none")]
481 pub min_severity: Option<String>,
482 #[serde(alias = "exclude_vex_not_affected")]
484 pub exclude_vex_resolved: bool,
485}
486
487#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
489#[serde(default)]
490pub struct BehaviorConfig {
491 pub fail_on_vuln: bool,
493 pub fail_on_change: bool,
495 pub quiet: bool,
497 pub explain_matches: bool,
499 pub recommend_threshold: bool,
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
505#[serde(default)]
506pub struct GraphAwareDiffConfig {
507 pub enabled: bool,
509 pub detect_reparenting: bool,
511 pub detect_depth_changes: bool,
513}
514
515impl GraphAwareDiffConfig {
516 pub fn enabled() -> Self {
518 Self {
519 enabled: true,
520 detect_reparenting: true,
521 detect_depth_changes: true,
522 }
523 }
524}
525
526#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
528#[serde(default)]
529pub struct MatchingRulesPathConfig {
530 #[serde(skip_serializing_if = "Option::is_none")]
532 pub rules_file: Option<PathBuf>,
533 pub dry_run: bool,
535}
536
537#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
539#[serde(default)]
540pub struct EcosystemRulesConfig {
541 #[serde(skip_serializing_if = "Option::is_none")]
543 pub config_file: Option<PathBuf>,
544 pub disabled: bool,
546 pub detect_typosquats: bool,
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
555#[serde(default)]
556pub struct EnrichmentConfig {
557 pub enabled: bool,
559 pub provider: String,
561 #[schemars(range(min = 1))]
563 pub cache_ttl_hours: u64,
564 #[schemars(range(min = 1))]
566 pub max_concurrent: usize,
567 #[serde(skip_serializing_if = "Option::is_none")]
569 pub cache_dir: Option<std::path::PathBuf>,
570 pub bypass_cache: bool,
572 #[schemars(range(min = 1))]
574 pub timeout_secs: u64,
575}
576
577impl Default for EnrichmentConfig {
578 fn default() -> Self {
579 Self {
580 enabled: false,
581 provider: "osv".to_string(),
582 cache_ttl_hours: 24,
583 max_concurrent: 10,
584 cache_dir: None,
585 bypass_cache: false,
586 timeout_secs: 30,
587 }
588 }
589}
590
591impl EnrichmentConfig {
592 pub fn osv() -> Self {
594 Self {
595 enabled: true,
596 provider: "osv".to_string(),
597 ..Default::default()
598 }
599 }
600
601 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
603 self.cache_dir = Some(dir);
604 self
605 }
606
607 pub fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
609 self.cache_ttl_hours = hours;
610 self
611 }
612
613 pub fn with_bypass_cache(mut self) -> Self {
615 self.bypass_cache = true;
616 self
617 }
618
619 pub fn with_timeout_secs(mut self, secs: u64) -> Self {
621 self.timeout_secs = secs;
622 self
623 }
624}
625
626#[derive(Debug, Default)]
632pub struct DiffConfigBuilder {
633 old: Option<PathBuf>,
634 new: Option<PathBuf>,
635 output: OutputConfig,
636 matching: MatchingConfig,
637 filtering: FilterConfig,
638 behavior: BehaviorConfig,
639 graph_diff: GraphAwareDiffConfig,
640 rules: MatchingRulesPathConfig,
641 ecosystem_rules: EcosystemRulesConfig,
642 enrichment: EnrichmentConfig,
643}
644
645impl DiffConfigBuilder {
646 pub fn new() -> Self {
647 Self::default()
648 }
649
650 pub fn old_path(mut self, path: PathBuf) -> Self {
651 self.old = Some(path);
652 self
653 }
654
655 pub fn new_path(mut self, path: PathBuf) -> Self {
656 self.new = Some(path);
657 self
658 }
659
660 pub fn output_format(mut self, format: ReportFormat) -> Self {
661 self.output.format = format;
662 self
663 }
664
665 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
666 self.output.file = file;
667 self
668 }
669
670 pub fn report_types(mut self, types: ReportType) -> Self {
671 self.output.report_types = types;
672 self
673 }
674
675 pub fn no_color(mut self, no_color: bool) -> Self {
676 self.output.no_color = no_color;
677 self
678 }
679
680 pub fn fuzzy_preset(mut self, preset: String) -> Self {
681 self.matching.fuzzy_preset = preset;
682 self
683 }
684
685 pub fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
686 self.matching.threshold = threshold;
687 self
688 }
689
690 pub fn include_unchanged(mut self, include: bool) -> Self {
691 self.matching.include_unchanged = include;
692 self
693 }
694
695 pub fn only_changes(mut self, only: bool) -> Self {
696 self.filtering.only_changes = only;
697 self
698 }
699
700 pub fn min_severity(mut self, severity: Option<String>) -> Self {
701 self.filtering.min_severity = severity;
702 self
703 }
704
705 pub fn fail_on_vuln(mut self, fail: bool) -> Self {
706 self.behavior.fail_on_vuln = fail;
707 self
708 }
709
710 pub fn fail_on_change(mut self, fail: bool) -> Self {
711 self.behavior.fail_on_change = fail;
712 self
713 }
714
715 pub fn quiet(mut self, quiet: bool) -> Self {
716 self.behavior.quiet = quiet;
717 self
718 }
719
720 pub fn explain_matches(mut self, explain: bool) -> Self {
721 self.behavior.explain_matches = explain;
722 self
723 }
724
725 pub fn recommend_threshold(mut self, recommend: bool) -> Self {
726 self.behavior.recommend_threshold = recommend;
727 self
728 }
729
730 pub fn graph_diff(mut self, enabled: bool) -> Self {
731 self.graph_diff = if enabled {
732 GraphAwareDiffConfig::enabled()
733 } else {
734 GraphAwareDiffConfig::default()
735 };
736 self
737 }
738
739 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
740 self.rules.rules_file = file;
741 self
742 }
743
744 pub fn dry_run_rules(mut self, dry_run: bool) -> Self {
745 self.rules.dry_run = dry_run;
746 self
747 }
748
749 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
750 self.ecosystem_rules.config_file = file;
751 self
752 }
753
754 pub fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
755 self.ecosystem_rules.disabled = disabled;
756 self
757 }
758
759 pub fn detect_typosquats(mut self, detect: bool) -> Self {
760 self.ecosystem_rules.detect_typosquats = detect;
761 self
762 }
763
764 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
765 self.enrichment = config;
766 self
767 }
768
769 pub fn enable_enrichment(mut self, enabled: bool) -> Self {
770 self.enrichment.enabled = enabled;
771 self
772 }
773
774 pub fn build(self) -> anyhow::Result<DiffConfig> {
775 let old = self.old.ok_or_else(|| anyhow::anyhow!("old path is required"))?;
776 let new = self.new.ok_or_else(|| anyhow::anyhow!("new path is required"))?;
777
778 Ok(DiffConfig {
779 paths: DiffPaths { old, new },
780 output: self.output,
781 matching: self.matching,
782 filtering: self.filtering,
783 behavior: self.behavior,
784 graph_diff: self.graph_diff,
785 rules: self.rules,
786 ecosystem_rules: self.ecosystem_rules,
787 enrichment: self.enrichment,
788 })
789 }
790}