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 #[must_use]
47 pub fn new() -> Self {
48 Self::default()
49 }
50
51 pub fn builder() -> AppConfigBuilder {
53 AppConfigBuilder::default()
54 }
55}
56
57#[derive(Debug, Default)]
63#[must_use]
64pub struct AppConfigBuilder {
65 config: AppConfig,
66}
67
68impl AppConfigBuilder {
69 pub fn fuzzy_preset(mut self, preset: impl Into<String>) -> Self {
71 self.config.matching.fuzzy_preset = preset.into();
72 self
73 }
74
75 pub const fn matching_threshold(mut self, threshold: f64) -> Self {
77 self.config.matching.threshold = Some(threshold);
78 self
79 }
80
81 pub const fn output_format(mut self, format: ReportFormat) -> Self {
83 self.config.output.format = format;
84 self
85 }
86
87 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
89 self.config.output.file = file;
90 self
91 }
92
93 pub const fn no_color(mut self, no_color: bool) -> Self {
95 self.config.output.no_color = no_color;
96 self
97 }
98
99 pub const fn include_unchanged(mut self, include: bool) -> Self {
101 self.config.matching.include_unchanged = include;
102 self
103 }
104
105 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
107 self.config.behavior.fail_on_vuln = fail;
108 self
109 }
110
111 pub const fn fail_on_change(mut self, fail: bool) -> Self {
113 self.config.behavior.fail_on_change = fail;
114 self
115 }
116
117 pub const fn quiet(mut self, quiet: bool) -> Self {
119 self.config.behavior.quiet = quiet;
120 self
121 }
122
123 pub fn graph_diff(mut self, enabled: bool) -> Self {
125 self.config.graph_diff = if enabled {
126 GraphAwareDiffConfig::enabled()
127 } else {
128 GraphAwareDiffConfig::default()
129 };
130 self
131 }
132
133 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
135 self.config.rules.rules_file = file;
136 self
137 }
138
139 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
141 self.config.ecosystem_rules.config_file = file;
142 self
143 }
144
145 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
147 self.config.enrichment = Some(config);
148 self
149 }
150
151 #[must_use]
153 pub fn build(self) -> AppConfig {
154 self.config
155 }
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
164pub struct TuiPreferences {
165 pub theme: String,
167}
168
169impl Default for TuiPreferences {
170 fn default() -> Self {
171 Self {
172 theme: "dark".to_string(),
173 }
174 }
175}
176
177impl TuiPreferences {
178 #[must_use]
180 pub fn config_path() -> Option<PathBuf> {
181 dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
182 }
183
184 #[must_use]
186 pub fn load() -> Self {
187 Self::config_path()
188 .and_then(|p| std::fs::read_to_string(p).ok())
189 .and_then(|s| serde_json::from_str(&s).ok())
190 .unwrap_or_default()
191 }
192
193 pub fn save(&self) -> std::io::Result<()> {
195 if let Some(path) = Self::config_path() {
196 if let Some(parent) = path.parent() {
197 std::fs::create_dir_all(parent)?;
198 }
199 let json = serde_json::to_string_pretty(self)
200 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
201 std::fs::write(path, json)?;
202 }
203 Ok(())
204 }
205}
206
207#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
213#[serde(default)]
214pub struct TuiConfig {
215 pub theme: String,
217 pub show_line_numbers: bool,
219 pub mouse_enabled: bool,
221 #[schemars(range(min = 0.0, max = 1.0))]
223 pub initial_threshold: f64,
224}
225
226impl Default for TuiConfig {
227 fn default() -> Self {
228 Self {
229 theme: "dark".to_string(),
230 show_line_numbers: true,
231 mouse_enabled: true,
232 initial_threshold: 0.8,
233 }
234 }
235}
236
237#[derive(Debug, Clone)]
243pub struct DiffConfig {
244 pub paths: DiffPaths,
246 pub output: OutputConfig,
248 pub matching: MatchingConfig,
250 pub filtering: FilterConfig,
252 pub behavior: BehaviorConfig,
254 pub graph_diff: GraphAwareDiffConfig,
256 pub rules: MatchingRulesPathConfig,
258 pub ecosystem_rules: EcosystemRulesConfig,
260 pub enrichment: EnrichmentConfig,
262}
263
264#[derive(Debug, Clone)]
266pub struct DiffPaths {
267 pub old: PathBuf,
269 pub new: PathBuf,
271}
272
273#[derive(Debug, Clone)]
275pub struct ViewConfig {
276 pub sbom_path: PathBuf,
278 pub output: OutputConfig,
280 pub validate_ntia: bool,
282 pub min_severity: Option<String>,
284 pub vulnerable_only: bool,
286 pub ecosystem_filter: Option<String>,
288}
289
290#[derive(Debug, Clone)]
292pub struct MultiDiffConfig {
293 pub baseline: PathBuf,
295 pub targets: Vec<PathBuf>,
297 pub output: OutputConfig,
299 pub matching: MatchingConfig,
301}
302
303#[derive(Debug, Clone)]
305pub struct TimelineConfig {
306 pub sbom_paths: Vec<PathBuf>,
308 pub output: OutputConfig,
310 pub matching: MatchingConfig,
312}
313
314#[derive(Debug, Clone)]
316pub struct MatrixConfig {
317 pub sbom_paths: Vec<PathBuf>,
319 pub output: OutputConfig,
321 pub matching: MatchingConfig,
323 pub cluster_threshold: f64,
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
333#[serde(default)]
334pub struct OutputConfig {
335 pub format: ReportFormat,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub file: Option<PathBuf>,
340 pub report_types: ReportType,
342 pub no_color: bool,
344 pub streaming: StreamingConfig,
346}
347
348impl Default for OutputConfig {
349 fn default() -> Self {
350 Self {
351 format: ReportFormat::Auto,
352 file: None,
353 report_types: ReportType::All,
354 no_color: false,
355 streaming: StreamingConfig::default(),
356 }
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
366#[serde(default)]
367pub struct StreamingConfig {
368 #[schemars(range(min = 0))]
371 pub threshold_bytes: u64,
372 pub force: bool,
375 pub disabled: bool,
377 pub stream_stdin: bool,
380}
381
382impl Default for StreamingConfig {
383 fn default() -> Self {
384 Self {
385 threshold_bytes: 10 * 1024 * 1024, force: false,
387 disabled: false,
388 stream_stdin: true,
389 }
390 }
391}
392
393impl StreamingConfig {
394 #[must_use]
396 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
397 if self.disabled {
398 return false;
399 }
400 if self.force {
401 return true;
402 }
403 if is_stdin && self.stream_stdin {
404 return true;
405 }
406 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
407 }
408
409 #[must_use]
411 pub fn always() -> Self {
412 Self {
413 force: true,
414 ..Default::default()
415 }
416 }
417
418 #[must_use]
420 pub fn never() -> Self {
421 Self {
422 disabled: true,
423 ..Default::default()
424 }
425 }
426
427 #[must_use]
429 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
430 self.threshold_bytes = mb * 1024 * 1024;
431 self
432 }
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
437#[serde(default)]
438pub struct MatchingConfig {
439 pub fuzzy_preset: String,
441 #[serde(skip_serializing_if = "Option::is_none")]
443 #[schemars(range(min = 0.0, max = 1.0))]
444 pub threshold: Option<f64>,
445 pub include_unchanged: bool,
447}
448
449impl Default for MatchingConfig {
450 fn default() -> Self {
451 Self {
452 fuzzy_preset: "balanced".to_string(),
453 threshold: None,
454 include_unchanged: false,
455 }
456 }
457}
458
459impl MatchingConfig {
460 #[must_use]
462 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
463 let mut config = FuzzyMatchConfig::from_preset(&self.fuzzy_preset).unwrap_or_else(|| {
464 tracing::warn!(
465 "Unknown fuzzy preset '{}', using 'balanced'. Valid: strict, balanced, permissive",
466 self.fuzzy_preset
467 );
468 FuzzyMatchConfig::balanced()
469 });
470
471 if let Some(threshold) = self.threshold {
473 config = config.with_threshold(threshold);
474 }
475
476 config
477 }
478}
479
480#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
482#[serde(default)]
483pub struct FilterConfig {
484 pub only_changes: bool,
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub min_severity: Option<String>,
489 #[serde(alias = "exclude_vex_not_affected")]
491 pub exclude_vex_resolved: bool,
492}
493
494#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
496#[serde(default)]
497pub struct BehaviorConfig {
498 pub fail_on_vuln: bool,
500 pub fail_on_change: bool,
502 pub quiet: bool,
504 pub explain_matches: bool,
506 pub recommend_threshold: bool,
508}
509
510#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
512#[serde(default)]
513pub struct GraphAwareDiffConfig {
514 pub enabled: bool,
516 pub detect_reparenting: bool,
518 pub detect_depth_changes: bool,
520}
521
522impl GraphAwareDiffConfig {
523 #[must_use]
525 pub const fn enabled() -> Self {
526 Self {
527 enabled: true,
528 detect_reparenting: true,
529 detect_depth_changes: true,
530 }
531 }
532}
533
534#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
536#[serde(default)]
537pub struct MatchingRulesPathConfig {
538 #[serde(skip_serializing_if = "Option::is_none")]
540 pub rules_file: Option<PathBuf>,
541 pub dry_run: bool,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
547#[serde(default)]
548pub struct EcosystemRulesConfig {
549 #[serde(skip_serializing_if = "Option::is_none")]
551 pub config_file: Option<PathBuf>,
552 pub disabled: bool,
554 pub detect_typosquats: bool,
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
563#[serde(default)]
564pub struct EnrichmentConfig {
565 pub enabled: bool,
567 pub provider: String,
569 #[schemars(range(min = 1))]
571 pub cache_ttl_hours: u64,
572 #[schemars(range(min = 1))]
574 pub max_concurrent: usize,
575 #[serde(skip_serializing_if = "Option::is_none")]
577 pub cache_dir: Option<std::path::PathBuf>,
578 pub bypass_cache: bool,
580 #[schemars(range(min = 1))]
582 pub timeout_secs: u64,
583}
584
585impl Default for EnrichmentConfig {
586 fn default() -> Self {
587 Self {
588 enabled: false,
589 provider: "osv".to_string(),
590 cache_ttl_hours: 24,
591 max_concurrent: 10,
592 cache_dir: None,
593 bypass_cache: false,
594 timeout_secs: 30,
595 }
596 }
597}
598
599impl EnrichmentConfig {
600 #[must_use]
602 pub fn osv() -> Self {
603 Self {
604 enabled: true,
605 provider: "osv".to_string(),
606 ..Default::default()
607 }
608 }
609
610 #[must_use]
612 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
613 self.cache_dir = Some(dir);
614 self
615 }
616
617 #[must_use]
619 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
620 self.cache_ttl_hours = hours;
621 self
622 }
623
624 #[must_use]
626 pub const fn with_bypass_cache(mut self) -> Self {
627 self.bypass_cache = true;
628 self
629 }
630
631 #[must_use]
633 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
634 self.timeout_secs = secs;
635 self
636 }
637}
638
639#[derive(Debug, Default)]
645pub struct DiffConfigBuilder {
646 old: Option<PathBuf>,
647 new: Option<PathBuf>,
648 output: OutputConfig,
649 matching: MatchingConfig,
650 filtering: FilterConfig,
651 behavior: BehaviorConfig,
652 graph_diff: GraphAwareDiffConfig,
653 rules: MatchingRulesPathConfig,
654 ecosystem_rules: EcosystemRulesConfig,
655 enrichment: EnrichmentConfig,
656}
657
658impl DiffConfigBuilder {
659 #[must_use]
660 pub fn new() -> Self {
661 Self::default()
662 }
663
664 #[must_use]
665 pub fn old_path(mut self, path: PathBuf) -> Self {
666 self.old = Some(path);
667 self
668 }
669
670 #[must_use]
671 pub fn new_path(mut self, path: PathBuf) -> Self {
672 self.new = Some(path);
673 self
674 }
675
676 #[must_use]
677 pub const fn output_format(mut self, format: ReportFormat) -> Self {
678 self.output.format = format;
679 self
680 }
681
682 #[must_use]
683 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
684 self.output.file = file;
685 self
686 }
687
688 #[must_use]
689 pub const fn report_types(mut self, types: ReportType) -> Self {
690 self.output.report_types = types;
691 self
692 }
693
694 #[must_use]
695 pub const fn no_color(mut self, no_color: bool) -> Self {
696 self.output.no_color = no_color;
697 self
698 }
699
700 #[must_use]
701 pub fn fuzzy_preset(mut self, preset: String) -> Self {
702 self.matching.fuzzy_preset = preset;
703 self
704 }
705
706 #[must_use]
707 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
708 self.matching.threshold = threshold;
709 self
710 }
711
712 #[must_use]
713 pub const fn include_unchanged(mut self, include: bool) -> Self {
714 self.matching.include_unchanged = include;
715 self
716 }
717
718 #[must_use]
719 pub const fn only_changes(mut self, only: bool) -> Self {
720 self.filtering.only_changes = only;
721 self
722 }
723
724 #[must_use]
725 pub fn min_severity(mut self, severity: Option<String>) -> Self {
726 self.filtering.min_severity = severity;
727 self
728 }
729
730 #[must_use]
731 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
732 self.behavior.fail_on_vuln = fail;
733 self
734 }
735
736 #[must_use]
737 pub const fn fail_on_change(mut self, fail: bool) -> Self {
738 self.behavior.fail_on_change = fail;
739 self
740 }
741
742 #[must_use]
743 pub const fn quiet(mut self, quiet: bool) -> Self {
744 self.behavior.quiet = quiet;
745 self
746 }
747
748 #[must_use]
749 pub const fn explain_matches(mut self, explain: bool) -> Self {
750 self.behavior.explain_matches = explain;
751 self
752 }
753
754 #[must_use]
755 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
756 self.behavior.recommend_threshold = recommend;
757 self
758 }
759
760 #[must_use]
761 pub fn graph_diff(mut self, enabled: bool) -> Self {
762 self.graph_diff = if enabled {
763 GraphAwareDiffConfig::enabled()
764 } else {
765 GraphAwareDiffConfig::default()
766 };
767 self
768 }
769
770 #[must_use]
771 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
772 self.rules.rules_file = file;
773 self
774 }
775
776 #[must_use]
777 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
778 self.rules.dry_run = dry_run;
779 self
780 }
781
782 #[must_use]
783 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
784 self.ecosystem_rules.config_file = file;
785 self
786 }
787
788 #[must_use]
789 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
790 self.ecosystem_rules.disabled = disabled;
791 self
792 }
793
794 #[must_use]
795 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
796 self.ecosystem_rules.detect_typosquats = detect;
797 self
798 }
799
800 #[must_use]
801 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
802 self.enrichment = config;
803 self
804 }
805
806 #[must_use]
807 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
808 self.enrichment.enabled = enabled;
809 self
810 }
811
812 pub fn build(self) -> anyhow::Result<DiffConfig> {
813 let old = self.old.ok_or_else(|| anyhow::anyhow!("old path is required"))?;
814 let new = self.new.ok_or_else(|| anyhow::anyhow!("new path is required"))?;
815
816 Ok(DiffConfig {
817 paths: DiffPaths { old, new },
818 output: self.output,
819 matching: self.matching,
820 filtering: self.filtering,
821 behavior: self.behavior,
822 graph_diff: self.graph_diff,
823 rules: self.rules,
824 ecosystem_rules: self.ecosystem_rules,
825 enrichment: self.enrichment,
826 })
827 }
828}