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 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub last_tab: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub last_view_tab: Option<String>,
173}
174
175impl Default for TuiPreferences {
176 fn default() -> Self {
177 Self {
178 theme: "dark".to_string(),
179 last_tab: None,
180 last_view_tab: None,
181 }
182 }
183}
184
185impl TuiPreferences {
186 #[must_use]
188 pub fn config_path() -> Option<PathBuf> {
189 dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
190 }
191
192 #[must_use]
194 pub fn load() -> Self {
195 Self::config_path()
196 .and_then(|p| std::fs::read_to_string(p).ok())
197 .and_then(|s| serde_json::from_str(&s).ok())
198 .unwrap_or_default()
199 }
200
201 pub fn save(&self) -> std::io::Result<()> {
203 if let Some(path) = Self::config_path() {
204 if let Some(parent) = path.parent() {
205 std::fs::create_dir_all(parent)?;
206 }
207 let json = serde_json::to_string_pretty(self)
208 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
209 std::fs::write(path, json)?;
210 }
211 Ok(())
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
221#[serde(default)]
222pub struct TuiConfig {
223 pub theme: String,
225 pub show_line_numbers: bool,
227 pub mouse_enabled: bool,
229 #[schemars(range(min = 0.0, max = 1.0))]
231 pub initial_threshold: f64,
232}
233
234impl Default for TuiConfig {
235 fn default() -> Self {
236 Self {
237 theme: "dark".to_string(),
238 show_line_numbers: true,
239 mouse_enabled: true,
240 initial_threshold: 0.8,
241 }
242 }
243}
244
245#[derive(Debug, Clone)]
251pub struct DiffConfig {
252 pub paths: DiffPaths,
254 pub output: OutputConfig,
256 pub matching: MatchingConfig,
258 pub filtering: FilterConfig,
260 pub behavior: BehaviorConfig,
262 pub graph_diff: GraphAwareDiffConfig,
264 pub rules: MatchingRulesPathConfig,
266 pub ecosystem_rules: EcosystemRulesConfig,
268 pub enrichment: EnrichmentConfig,
270}
271
272#[derive(Debug, Clone)]
274pub struct DiffPaths {
275 pub old: PathBuf,
277 pub new: PathBuf,
279}
280
281#[derive(Debug, Clone)]
283pub struct ViewConfig {
284 pub sbom_path: PathBuf,
286 pub output: OutputConfig,
288 pub validate_ntia: bool,
290 pub min_severity: Option<String>,
292 pub vulnerable_only: bool,
294 pub ecosystem_filter: Option<String>,
296 pub fail_on_vuln: bool,
298 pub enrichment: EnrichmentConfig,
300}
301
302#[derive(Debug, Clone)]
304pub struct MultiDiffConfig {
305 pub baseline: PathBuf,
307 pub targets: Vec<PathBuf>,
309 pub output: OutputConfig,
311 pub matching: MatchingConfig,
313 pub filtering: FilterConfig,
315 pub behavior: BehaviorConfig,
317 pub graph_diff: GraphAwareDiffConfig,
319 pub rules: MatchingRulesPathConfig,
321 pub ecosystem_rules: EcosystemRulesConfig,
323 pub enrichment: EnrichmentConfig,
325}
326
327#[derive(Debug, Clone)]
329pub struct TimelineConfig {
330 pub sbom_paths: Vec<PathBuf>,
332 pub output: OutputConfig,
334 pub matching: MatchingConfig,
336 pub filtering: FilterConfig,
338 pub behavior: BehaviorConfig,
340 pub graph_diff: GraphAwareDiffConfig,
342 pub rules: MatchingRulesPathConfig,
344 pub ecosystem_rules: EcosystemRulesConfig,
346 pub enrichment: EnrichmentConfig,
348}
349
350#[derive(Debug, Clone)]
352pub struct QueryConfig {
353 pub sbom_paths: Vec<PathBuf>,
355 pub output: OutputConfig,
357 pub enrichment: EnrichmentConfig,
359 pub limit: Option<usize>,
361 pub group_by_sbom: bool,
363}
364
365#[derive(Debug, Clone)]
367pub struct MatrixConfig {
368 pub sbom_paths: Vec<PathBuf>,
370 pub output: OutputConfig,
372 pub matching: MatchingConfig,
374 pub cluster_threshold: f64,
376 pub filtering: FilterConfig,
378 pub behavior: BehaviorConfig,
380 pub graph_diff: GraphAwareDiffConfig,
382 pub rules: MatchingRulesPathConfig,
384 pub ecosystem_rules: EcosystemRulesConfig,
386 pub enrichment: EnrichmentConfig,
388}
389
390#[derive(Debug, Clone)]
392pub struct VexConfig {
393 pub sbom_path: PathBuf,
395 pub vex_paths: Vec<PathBuf>,
397 pub output_format: ReportFormat,
399 pub output_file: Option<PathBuf>,
401 pub quiet: bool,
403 pub actionable_only: bool,
405 pub filter_state: Option<String>,
407 pub enrichment: EnrichmentConfig,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
417#[serde(default)]
418pub struct OutputConfig {
419 pub format: ReportFormat,
421 #[serde(skip_serializing_if = "Option::is_none")]
423 pub file: Option<PathBuf>,
424 pub report_types: ReportType,
426 pub no_color: bool,
428 pub streaming: StreamingConfig,
430 #[serde(skip_serializing_if = "Option::is_none")]
435 pub export_template: Option<String>,
436}
437
438impl Default for OutputConfig {
439 fn default() -> Self {
440 Self {
441 format: ReportFormat::Auto,
442 file: None,
443 report_types: ReportType::All,
444 no_color: false,
445 streaming: StreamingConfig::default(),
446 export_template: None,
447 }
448 }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
457#[serde(default)]
458pub struct StreamingConfig {
459 #[schemars(range(min = 0))]
462 pub threshold_bytes: u64,
463 pub force: bool,
466 pub disabled: bool,
468 pub stream_stdin: bool,
471}
472
473impl Default for StreamingConfig {
474 fn default() -> Self {
475 Self {
476 threshold_bytes: 10 * 1024 * 1024, force: false,
478 disabled: false,
479 stream_stdin: true,
480 }
481 }
482}
483
484impl StreamingConfig {
485 #[must_use]
487 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
488 if self.disabled {
489 return false;
490 }
491 if self.force {
492 return true;
493 }
494 if is_stdin && self.stream_stdin {
495 return true;
496 }
497 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
498 }
499
500 #[must_use]
502 pub fn always() -> Self {
503 Self {
504 force: true,
505 ..Default::default()
506 }
507 }
508
509 #[must_use]
511 pub fn never() -> Self {
512 Self {
513 disabled: true,
514 ..Default::default()
515 }
516 }
517
518 #[must_use]
520 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
521 self.threshold_bytes = mb * 1024 * 1024;
522 self
523 }
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
528#[serde(default)]
529pub struct MatchingConfig {
530 pub fuzzy_preset: String,
532 #[serde(skip_serializing_if = "Option::is_none")]
534 #[schemars(range(min = 0.0, max = 1.0))]
535 pub threshold: Option<f64>,
536 pub include_unchanged: bool,
538}
539
540impl Default for MatchingConfig {
541 fn default() -> Self {
542 Self {
543 fuzzy_preset: "balanced".to_string(),
544 threshold: None,
545 include_unchanged: false,
546 }
547 }
548}
549
550impl MatchingConfig {
551 #[must_use]
553 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
554 let mut config = FuzzyMatchConfig::from_preset(&self.fuzzy_preset).unwrap_or_else(|| {
555 tracing::warn!(
556 "Unknown fuzzy preset '{}', using 'balanced'. Valid: strict, balanced, permissive",
557 self.fuzzy_preset
558 );
559 FuzzyMatchConfig::balanced()
560 });
561
562 if let Some(threshold) = self.threshold {
564 config = config.with_threshold(threshold);
565 }
566
567 config
568 }
569}
570
571#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
573#[serde(default)]
574pub struct FilterConfig {
575 pub only_changes: bool,
577 #[serde(skip_serializing_if = "Option::is_none")]
579 pub min_severity: Option<String>,
580 #[serde(alias = "exclude_vex_not_affected")]
582 pub exclude_vex_resolved: bool,
583 pub fail_on_vex_gap: bool,
585}
586
587#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
589#[serde(default)]
590pub struct BehaviorConfig {
591 pub fail_on_vuln: bool,
593 pub fail_on_change: bool,
595 pub quiet: bool,
597 pub explain_matches: bool,
599 pub recommend_threshold: bool,
601}
602
603#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
605#[serde(default)]
606pub struct GraphAwareDiffConfig {
607 pub enabled: bool,
609 pub detect_reparenting: bool,
611 pub detect_depth_changes: bool,
613 pub max_depth: u32,
615 pub impact_threshold: Option<String>,
617 pub relation_filter: Vec<String>,
619}
620
621impl GraphAwareDiffConfig {
622 #[must_use]
624 pub const fn enabled() -> Self {
625 Self {
626 enabled: true,
627 detect_reparenting: true,
628 detect_depth_changes: true,
629 max_depth: 0,
630 impact_threshold: None,
631 relation_filter: Vec::new(),
632 }
633 }
634}
635
636#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
638#[serde(default)]
639pub struct MatchingRulesPathConfig {
640 #[serde(skip_serializing_if = "Option::is_none")]
642 pub rules_file: Option<PathBuf>,
643 pub dry_run: bool,
645}
646
647#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
649#[serde(default)]
650pub struct EcosystemRulesConfig {
651 #[serde(skip_serializing_if = "Option::is_none")]
653 pub config_file: Option<PathBuf>,
654 pub disabled: bool,
656 pub detect_typosquats: bool,
658}
659
660#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
665#[serde(default)]
666pub struct EnrichmentConfig {
667 pub enabled: bool,
669 pub provider: String,
671 #[schemars(range(min = 1))]
673 pub cache_ttl_hours: u64,
674 #[schemars(range(min = 1))]
676 pub max_concurrent: usize,
677 #[serde(skip_serializing_if = "Option::is_none")]
679 pub cache_dir: Option<std::path::PathBuf>,
680 pub bypass_cache: bool,
682 #[schemars(range(min = 1))]
684 pub timeout_secs: u64,
685 pub enable_eol: bool,
687 #[serde(default, skip_serializing_if = "Vec::is_empty")]
689 pub vex_paths: Vec<std::path::PathBuf>,
690}
691
692impl Default for EnrichmentConfig {
693 fn default() -> Self {
694 Self {
695 enabled: false,
696 provider: "osv".to_string(),
697 cache_ttl_hours: 24,
698 max_concurrent: 10,
699 cache_dir: None,
700 bypass_cache: false,
701 timeout_secs: 30,
702 enable_eol: false,
703 vex_paths: Vec::new(),
704 }
705 }
706}
707
708impl EnrichmentConfig {
709 #[must_use]
711 pub fn osv() -> Self {
712 Self {
713 enabled: true,
714 provider: "osv".to_string(),
715 ..Default::default()
716 }
717 }
718
719 #[must_use]
721 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
722 self.cache_dir = Some(dir);
723 self
724 }
725
726 #[must_use]
728 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
729 self.cache_ttl_hours = hours;
730 self
731 }
732
733 #[must_use]
735 pub const fn with_bypass_cache(mut self) -> Self {
736 self.bypass_cache = true;
737 self
738 }
739
740 #[must_use]
742 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
743 self.timeout_secs = secs;
744 self
745 }
746
747 #[must_use]
749 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
750 self.vex_paths = paths;
751 self
752 }
753}
754
755#[derive(Debug, Default)]
761pub struct DiffConfigBuilder {
762 old: Option<PathBuf>,
763 new: Option<PathBuf>,
764 output: OutputConfig,
765 matching: MatchingConfig,
766 filtering: FilterConfig,
767 behavior: BehaviorConfig,
768 graph_diff: GraphAwareDiffConfig,
769 rules: MatchingRulesPathConfig,
770 ecosystem_rules: EcosystemRulesConfig,
771 enrichment: EnrichmentConfig,
772}
773
774impl DiffConfigBuilder {
775 #[must_use]
776 pub fn new() -> Self {
777 Self::default()
778 }
779
780 #[must_use]
781 pub fn old_path(mut self, path: PathBuf) -> Self {
782 self.old = Some(path);
783 self
784 }
785
786 #[must_use]
787 pub fn new_path(mut self, path: PathBuf) -> Self {
788 self.new = Some(path);
789 self
790 }
791
792 #[must_use]
793 pub const fn output_format(mut self, format: ReportFormat) -> Self {
794 self.output.format = format;
795 self
796 }
797
798 #[must_use]
799 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
800 self.output.file = file;
801 self
802 }
803
804 #[must_use]
805 pub const fn report_types(mut self, types: ReportType) -> Self {
806 self.output.report_types = types;
807 self
808 }
809
810 #[must_use]
811 pub const fn no_color(mut self, no_color: bool) -> Self {
812 self.output.no_color = no_color;
813 self
814 }
815
816 #[must_use]
817 pub fn fuzzy_preset(mut self, preset: String) -> Self {
818 self.matching.fuzzy_preset = preset;
819 self
820 }
821
822 #[must_use]
823 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
824 self.matching.threshold = threshold;
825 self
826 }
827
828 #[must_use]
829 pub const fn include_unchanged(mut self, include: bool) -> Self {
830 self.matching.include_unchanged = include;
831 self
832 }
833
834 #[must_use]
835 pub const fn only_changes(mut self, only: bool) -> Self {
836 self.filtering.only_changes = only;
837 self
838 }
839
840 #[must_use]
841 pub fn min_severity(mut self, severity: Option<String>) -> Self {
842 self.filtering.min_severity = severity;
843 self
844 }
845
846 #[must_use]
847 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
848 self.behavior.fail_on_vuln = fail;
849 self
850 }
851
852 #[must_use]
853 pub const fn fail_on_change(mut self, fail: bool) -> Self {
854 self.behavior.fail_on_change = fail;
855 self
856 }
857
858 #[must_use]
859 pub const fn quiet(mut self, quiet: bool) -> Self {
860 self.behavior.quiet = quiet;
861 self
862 }
863
864 #[must_use]
865 pub const fn explain_matches(mut self, explain: bool) -> Self {
866 self.behavior.explain_matches = explain;
867 self
868 }
869
870 #[must_use]
871 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
872 self.behavior.recommend_threshold = recommend;
873 self
874 }
875
876 #[must_use]
877 pub fn graph_diff(mut self, enabled: bool) -> Self {
878 self.graph_diff = if enabled {
879 GraphAwareDiffConfig::enabled()
880 } else {
881 GraphAwareDiffConfig::default()
882 };
883 self
884 }
885
886 #[must_use]
887 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
888 self.rules.rules_file = file;
889 self
890 }
891
892 #[must_use]
893 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
894 self.rules.dry_run = dry_run;
895 self
896 }
897
898 #[must_use]
899 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
900 self.ecosystem_rules.config_file = file;
901 self
902 }
903
904 #[must_use]
905 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
906 self.ecosystem_rules.disabled = disabled;
907 self
908 }
909
910 #[must_use]
911 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
912 self.ecosystem_rules.detect_typosquats = detect;
913 self
914 }
915
916 #[must_use]
917 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
918 self.enrichment = config;
919 self
920 }
921
922 #[must_use]
923 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
924 self.enrichment.enabled = enabled;
925 self
926 }
927
928 pub fn build(self) -> anyhow::Result<DiffConfig> {
929 let old = self
930 .old
931 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
932 let new = self
933 .new
934 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
935
936 Ok(DiffConfig {
937 paths: DiffPaths { old, new },
938 output: self.output,
939 matching: self.matching,
940 filtering: self.filtering,
941 behavior: self.behavior,
942 graph_diff: self.graph_diff,
943 rules: self.rules,
944 ecosystem_rules: self.ecosystem_rules,
945 enrichment: self.enrichment,
946 })
947 }
948}