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}
314
315#[derive(Debug, Clone)]
317pub struct TimelineConfig {
318 pub sbom_paths: Vec<PathBuf>,
320 pub output: OutputConfig,
322 pub matching: MatchingConfig,
324}
325
326#[derive(Debug, Clone)]
328pub struct QueryConfig {
329 pub sbom_paths: Vec<PathBuf>,
331 pub output: OutputConfig,
333 pub enrichment: EnrichmentConfig,
335 pub limit: Option<usize>,
337 pub group_by_sbom: bool,
339}
340
341#[derive(Debug, Clone)]
343pub struct MatrixConfig {
344 pub sbom_paths: Vec<PathBuf>,
346 pub output: OutputConfig,
348 pub matching: MatchingConfig,
350 pub cluster_threshold: f64,
352}
353
354#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
360#[serde(default)]
361pub struct OutputConfig {
362 pub format: ReportFormat,
364 #[serde(skip_serializing_if = "Option::is_none")]
366 pub file: Option<PathBuf>,
367 pub report_types: ReportType,
369 pub no_color: bool,
371 pub streaming: StreamingConfig,
373 #[serde(skip_serializing_if = "Option::is_none")]
378 pub export_template: Option<String>,
379}
380
381impl Default for OutputConfig {
382 fn default() -> Self {
383 Self {
384 format: ReportFormat::Auto,
385 file: None,
386 report_types: ReportType::All,
387 no_color: false,
388 streaming: StreamingConfig::default(),
389 export_template: None,
390 }
391 }
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
400#[serde(default)]
401pub struct StreamingConfig {
402 #[schemars(range(min = 0))]
405 pub threshold_bytes: u64,
406 pub force: bool,
409 pub disabled: bool,
411 pub stream_stdin: bool,
414}
415
416impl Default for StreamingConfig {
417 fn default() -> Self {
418 Self {
419 threshold_bytes: 10 * 1024 * 1024, force: false,
421 disabled: false,
422 stream_stdin: true,
423 }
424 }
425}
426
427impl StreamingConfig {
428 #[must_use]
430 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
431 if self.disabled {
432 return false;
433 }
434 if self.force {
435 return true;
436 }
437 if is_stdin && self.stream_stdin {
438 return true;
439 }
440 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
441 }
442
443 #[must_use]
445 pub fn always() -> Self {
446 Self {
447 force: true,
448 ..Default::default()
449 }
450 }
451
452 #[must_use]
454 pub fn never() -> Self {
455 Self {
456 disabled: true,
457 ..Default::default()
458 }
459 }
460
461 #[must_use]
463 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
464 self.threshold_bytes = mb * 1024 * 1024;
465 self
466 }
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
471#[serde(default)]
472pub struct MatchingConfig {
473 pub fuzzy_preset: String,
475 #[serde(skip_serializing_if = "Option::is_none")]
477 #[schemars(range(min = 0.0, max = 1.0))]
478 pub threshold: Option<f64>,
479 pub include_unchanged: bool,
481}
482
483impl Default for MatchingConfig {
484 fn default() -> Self {
485 Self {
486 fuzzy_preset: "balanced".to_string(),
487 threshold: None,
488 include_unchanged: false,
489 }
490 }
491}
492
493impl MatchingConfig {
494 #[must_use]
496 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
497 let mut config = FuzzyMatchConfig::from_preset(&self.fuzzy_preset).unwrap_or_else(|| {
498 tracing::warn!(
499 "Unknown fuzzy preset '{}', using 'balanced'. Valid: strict, balanced, permissive",
500 self.fuzzy_preset
501 );
502 FuzzyMatchConfig::balanced()
503 });
504
505 if let Some(threshold) = self.threshold {
507 config = config.with_threshold(threshold);
508 }
509
510 config
511 }
512}
513
514#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
516#[serde(default)]
517pub struct FilterConfig {
518 pub only_changes: bool,
520 #[serde(skip_serializing_if = "Option::is_none")]
522 pub min_severity: Option<String>,
523 #[serde(alias = "exclude_vex_not_affected")]
525 pub exclude_vex_resolved: bool,
526}
527
528#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
530#[serde(default)]
531pub struct BehaviorConfig {
532 pub fail_on_vuln: bool,
534 pub fail_on_change: bool,
536 pub quiet: bool,
538 pub explain_matches: bool,
540 pub recommend_threshold: bool,
542}
543
544#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
546#[serde(default)]
547pub struct GraphAwareDiffConfig {
548 pub enabled: bool,
550 pub detect_reparenting: bool,
552 pub detect_depth_changes: bool,
554}
555
556impl GraphAwareDiffConfig {
557 #[must_use]
559 pub const fn enabled() -> Self {
560 Self {
561 enabled: true,
562 detect_reparenting: true,
563 detect_depth_changes: true,
564 }
565 }
566}
567
568#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
570#[serde(default)]
571pub struct MatchingRulesPathConfig {
572 #[serde(skip_serializing_if = "Option::is_none")]
574 pub rules_file: Option<PathBuf>,
575 pub dry_run: bool,
577}
578
579#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
581#[serde(default)]
582pub struct EcosystemRulesConfig {
583 #[serde(skip_serializing_if = "Option::is_none")]
585 pub config_file: Option<PathBuf>,
586 pub disabled: bool,
588 pub detect_typosquats: bool,
590}
591
592#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
597#[serde(default)]
598pub struct EnrichmentConfig {
599 pub enabled: bool,
601 pub provider: String,
603 #[schemars(range(min = 1))]
605 pub cache_ttl_hours: u64,
606 #[schemars(range(min = 1))]
608 pub max_concurrent: usize,
609 #[serde(skip_serializing_if = "Option::is_none")]
611 pub cache_dir: Option<std::path::PathBuf>,
612 pub bypass_cache: bool,
614 #[schemars(range(min = 1))]
616 pub timeout_secs: u64,
617 pub enable_eol: bool,
619 #[serde(default, skip_serializing_if = "Vec::is_empty")]
621 pub vex_paths: Vec<std::path::PathBuf>,
622}
623
624impl Default for EnrichmentConfig {
625 fn default() -> Self {
626 Self {
627 enabled: false,
628 provider: "osv".to_string(),
629 cache_ttl_hours: 24,
630 max_concurrent: 10,
631 cache_dir: None,
632 bypass_cache: false,
633 timeout_secs: 30,
634 enable_eol: false,
635 vex_paths: Vec::new(),
636 }
637 }
638}
639
640impl EnrichmentConfig {
641 #[must_use]
643 pub fn osv() -> Self {
644 Self {
645 enabled: true,
646 provider: "osv".to_string(),
647 ..Default::default()
648 }
649 }
650
651 #[must_use]
653 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
654 self.cache_dir = Some(dir);
655 self
656 }
657
658 #[must_use]
660 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
661 self.cache_ttl_hours = hours;
662 self
663 }
664
665 #[must_use]
667 pub const fn with_bypass_cache(mut self) -> Self {
668 self.bypass_cache = true;
669 self
670 }
671
672 #[must_use]
674 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
675 self.timeout_secs = secs;
676 self
677 }
678
679 #[must_use]
681 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
682 self.vex_paths = paths;
683 self
684 }
685}
686
687#[derive(Debug, Default)]
693pub struct DiffConfigBuilder {
694 old: Option<PathBuf>,
695 new: Option<PathBuf>,
696 output: OutputConfig,
697 matching: MatchingConfig,
698 filtering: FilterConfig,
699 behavior: BehaviorConfig,
700 graph_diff: GraphAwareDiffConfig,
701 rules: MatchingRulesPathConfig,
702 ecosystem_rules: EcosystemRulesConfig,
703 enrichment: EnrichmentConfig,
704}
705
706impl DiffConfigBuilder {
707 #[must_use]
708 pub fn new() -> Self {
709 Self::default()
710 }
711
712 #[must_use]
713 pub fn old_path(mut self, path: PathBuf) -> Self {
714 self.old = Some(path);
715 self
716 }
717
718 #[must_use]
719 pub fn new_path(mut self, path: PathBuf) -> Self {
720 self.new = Some(path);
721 self
722 }
723
724 #[must_use]
725 pub const fn output_format(mut self, format: ReportFormat) -> Self {
726 self.output.format = format;
727 self
728 }
729
730 #[must_use]
731 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
732 self.output.file = file;
733 self
734 }
735
736 #[must_use]
737 pub const fn report_types(mut self, types: ReportType) -> Self {
738 self.output.report_types = types;
739 self
740 }
741
742 #[must_use]
743 pub const fn no_color(mut self, no_color: bool) -> Self {
744 self.output.no_color = no_color;
745 self
746 }
747
748 #[must_use]
749 pub fn fuzzy_preset(mut self, preset: String) -> Self {
750 self.matching.fuzzy_preset = preset;
751 self
752 }
753
754 #[must_use]
755 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
756 self.matching.threshold = threshold;
757 self
758 }
759
760 #[must_use]
761 pub const fn include_unchanged(mut self, include: bool) -> Self {
762 self.matching.include_unchanged = include;
763 self
764 }
765
766 #[must_use]
767 pub const fn only_changes(mut self, only: bool) -> Self {
768 self.filtering.only_changes = only;
769 self
770 }
771
772 #[must_use]
773 pub fn min_severity(mut self, severity: Option<String>) -> Self {
774 self.filtering.min_severity = severity;
775 self
776 }
777
778 #[must_use]
779 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
780 self.behavior.fail_on_vuln = fail;
781 self
782 }
783
784 #[must_use]
785 pub const fn fail_on_change(mut self, fail: bool) -> Self {
786 self.behavior.fail_on_change = fail;
787 self
788 }
789
790 #[must_use]
791 pub const fn quiet(mut self, quiet: bool) -> Self {
792 self.behavior.quiet = quiet;
793 self
794 }
795
796 #[must_use]
797 pub const fn explain_matches(mut self, explain: bool) -> Self {
798 self.behavior.explain_matches = explain;
799 self
800 }
801
802 #[must_use]
803 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
804 self.behavior.recommend_threshold = recommend;
805 self
806 }
807
808 #[must_use]
809 pub fn graph_diff(mut self, enabled: bool) -> Self {
810 self.graph_diff = if enabled {
811 GraphAwareDiffConfig::enabled()
812 } else {
813 GraphAwareDiffConfig::default()
814 };
815 self
816 }
817
818 #[must_use]
819 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
820 self.rules.rules_file = file;
821 self
822 }
823
824 #[must_use]
825 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
826 self.rules.dry_run = dry_run;
827 self
828 }
829
830 #[must_use]
831 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
832 self.ecosystem_rules.config_file = file;
833 self
834 }
835
836 #[must_use]
837 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
838 self.ecosystem_rules.disabled = disabled;
839 self
840 }
841
842 #[must_use]
843 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
844 self.ecosystem_rules.detect_typosquats = detect;
845 self
846 }
847
848 #[must_use]
849 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
850 self.enrichment = config;
851 self
852 }
853
854 #[must_use]
855 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
856 self.enrichment.enabled = enabled;
857 self
858 }
859
860 pub fn build(self) -> anyhow::Result<DiffConfig> {
861 let old = self.old.ok_or_else(|| anyhow::anyhow!("old path is required"))?;
862 let new = self.new.ok_or_else(|| anyhow::anyhow!("new path is required"))?;
863
864 Ok(DiffConfig {
865 paths: DiffPaths { old, new },
866 output: self.output,
867 matching: self.matching,
868 filtering: self.filtering,
869 behavior: self.behavior,
870 graph_diff: self.graph_diff,
871 rules: self.rules,
872 ecosystem_rules: self.ecosystem_rules,
873 enrichment: self.enrichment,
874 })
875 }
876}