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)]
356pub struct VexConfig {
357 pub sbom_path: PathBuf,
359 pub vex_paths: Vec<PathBuf>,
361 pub output_format: ReportFormat,
363 pub output_file: Option<PathBuf>,
365 pub quiet: bool,
367 pub actionable_only: bool,
369 pub filter_state: Option<String>,
371 pub enrichment: EnrichmentConfig,
373}
374
375#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
381#[serde(default)]
382pub struct OutputConfig {
383 pub format: ReportFormat,
385 #[serde(skip_serializing_if = "Option::is_none")]
387 pub file: Option<PathBuf>,
388 pub report_types: ReportType,
390 pub no_color: bool,
392 pub streaming: StreamingConfig,
394 #[serde(skip_serializing_if = "Option::is_none")]
399 pub export_template: Option<String>,
400}
401
402impl Default for OutputConfig {
403 fn default() -> Self {
404 Self {
405 format: ReportFormat::Auto,
406 file: None,
407 report_types: ReportType::All,
408 no_color: false,
409 streaming: StreamingConfig::default(),
410 export_template: None,
411 }
412 }
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
421#[serde(default)]
422pub struct StreamingConfig {
423 #[schemars(range(min = 0))]
426 pub threshold_bytes: u64,
427 pub force: bool,
430 pub disabled: bool,
432 pub stream_stdin: bool,
435}
436
437impl Default for StreamingConfig {
438 fn default() -> Self {
439 Self {
440 threshold_bytes: 10 * 1024 * 1024, force: false,
442 disabled: false,
443 stream_stdin: true,
444 }
445 }
446}
447
448impl StreamingConfig {
449 #[must_use]
451 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
452 if self.disabled {
453 return false;
454 }
455 if self.force {
456 return true;
457 }
458 if is_stdin && self.stream_stdin {
459 return true;
460 }
461 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
462 }
463
464 #[must_use]
466 pub fn always() -> Self {
467 Self {
468 force: true,
469 ..Default::default()
470 }
471 }
472
473 #[must_use]
475 pub fn never() -> Self {
476 Self {
477 disabled: true,
478 ..Default::default()
479 }
480 }
481
482 #[must_use]
484 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
485 self.threshold_bytes = mb * 1024 * 1024;
486 self
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
492#[serde(default)]
493pub struct MatchingConfig {
494 pub fuzzy_preset: String,
496 #[serde(skip_serializing_if = "Option::is_none")]
498 #[schemars(range(min = 0.0, max = 1.0))]
499 pub threshold: Option<f64>,
500 pub include_unchanged: bool,
502}
503
504impl Default for MatchingConfig {
505 fn default() -> Self {
506 Self {
507 fuzzy_preset: "balanced".to_string(),
508 threshold: None,
509 include_unchanged: false,
510 }
511 }
512}
513
514impl MatchingConfig {
515 #[must_use]
517 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
518 let mut config = FuzzyMatchConfig::from_preset(&self.fuzzy_preset).unwrap_or_else(|| {
519 tracing::warn!(
520 "Unknown fuzzy preset '{}', using 'balanced'. Valid: strict, balanced, permissive",
521 self.fuzzy_preset
522 );
523 FuzzyMatchConfig::balanced()
524 });
525
526 if let Some(threshold) = self.threshold {
528 config = config.with_threshold(threshold);
529 }
530
531 config
532 }
533}
534
535#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
537#[serde(default)]
538pub struct FilterConfig {
539 pub only_changes: bool,
541 #[serde(skip_serializing_if = "Option::is_none")]
543 pub min_severity: Option<String>,
544 #[serde(alias = "exclude_vex_not_affected")]
546 pub exclude_vex_resolved: bool,
547 pub fail_on_vex_gap: bool,
549}
550
551#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
553#[serde(default)]
554pub struct BehaviorConfig {
555 pub fail_on_vuln: bool,
557 pub fail_on_change: bool,
559 pub quiet: bool,
561 pub explain_matches: bool,
563 pub recommend_threshold: bool,
565}
566
567#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
569#[serde(default)]
570pub struct GraphAwareDiffConfig {
571 pub enabled: bool,
573 pub detect_reparenting: bool,
575 pub detect_depth_changes: bool,
577 pub max_depth: u32,
579 pub impact_threshold: Option<String>,
581 pub relation_filter: Vec<String>,
583}
584
585impl GraphAwareDiffConfig {
586 #[must_use]
588 pub const fn enabled() -> Self {
589 Self {
590 enabled: true,
591 detect_reparenting: true,
592 detect_depth_changes: true,
593 max_depth: 0,
594 impact_threshold: None,
595 relation_filter: Vec::new(),
596 }
597 }
598}
599
600#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
602#[serde(default)]
603pub struct MatchingRulesPathConfig {
604 #[serde(skip_serializing_if = "Option::is_none")]
606 pub rules_file: Option<PathBuf>,
607 pub dry_run: bool,
609}
610
611#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
613#[serde(default)]
614pub struct EcosystemRulesConfig {
615 #[serde(skip_serializing_if = "Option::is_none")]
617 pub config_file: Option<PathBuf>,
618 pub disabled: bool,
620 pub detect_typosquats: bool,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
629#[serde(default)]
630pub struct EnrichmentConfig {
631 pub enabled: bool,
633 pub provider: String,
635 #[schemars(range(min = 1))]
637 pub cache_ttl_hours: u64,
638 #[schemars(range(min = 1))]
640 pub max_concurrent: usize,
641 #[serde(skip_serializing_if = "Option::is_none")]
643 pub cache_dir: Option<std::path::PathBuf>,
644 pub bypass_cache: bool,
646 #[schemars(range(min = 1))]
648 pub timeout_secs: u64,
649 pub enable_eol: bool,
651 #[serde(default, skip_serializing_if = "Vec::is_empty")]
653 pub vex_paths: Vec<std::path::PathBuf>,
654}
655
656impl Default for EnrichmentConfig {
657 fn default() -> Self {
658 Self {
659 enabled: false,
660 provider: "osv".to_string(),
661 cache_ttl_hours: 24,
662 max_concurrent: 10,
663 cache_dir: None,
664 bypass_cache: false,
665 timeout_secs: 30,
666 enable_eol: false,
667 vex_paths: Vec::new(),
668 }
669 }
670}
671
672impl EnrichmentConfig {
673 #[must_use]
675 pub fn osv() -> Self {
676 Self {
677 enabled: true,
678 provider: "osv".to_string(),
679 ..Default::default()
680 }
681 }
682
683 #[must_use]
685 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
686 self.cache_dir = Some(dir);
687 self
688 }
689
690 #[must_use]
692 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
693 self.cache_ttl_hours = hours;
694 self
695 }
696
697 #[must_use]
699 pub const fn with_bypass_cache(mut self) -> Self {
700 self.bypass_cache = true;
701 self
702 }
703
704 #[must_use]
706 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
707 self.timeout_secs = secs;
708 self
709 }
710
711 #[must_use]
713 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
714 self.vex_paths = paths;
715 self
716 }
717}
718
719#[derive(Debug, Default)]
725pub struct DiffConfigBuilder {
726 old: Option<PathBuf>,
727 new: Option<PathBuf>,
728 output: OutputConfig,
729 matching: MatchingConfig,
730 filtering: FilterConfig,
731 behavior: BehaviorConfig,
732 graph_diff: GraphAwareDiffConfig,
733 rules: MatchingRulesPathConfig,
734 ecosystem_rules: EcosystemRulesConfig,
735 enrichment: EnrichmentConfig,
736}
737
738impl DiffConfigBuilder {
739 #[must_use]
740 pub fn new() -> Self {
741 Self::default()
742 }
743
744 #[must_use]
745 pub fn old_path(mut self, path: PathBuf) -> Self {
746 self.old = Some(path);
747 self
748 }
749
750 #[must_use]
751 pub fn new_path(mut self, path: PathBuf) -> Self {
752 self.new = Some(path);
753 self
754 }
755
756 #[must_use]
757 pub const fn output_format(mut self, format: ReportFormat) -> Self {
758 self.output.format = format;
759 self
760 }
761
762 #[must_use]
763 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
764 self.output.file = file;
765 self
766 }
767
768 #[must_use]
769 pub const fn report_types(mut self, types: ReportType) -> Self {
770 self.output.report_types = types;
771 self
772 }
773
774 #[must_use]
775 pub const fn no_color(mut self, no_color: bool) -> Self {
776 self.output.no_color = no_color;
777 self
778 }
779
780 #[must_use]
781 pub fn fuzzy_preset(mut self, preset: String) -> Self {
782 self.matching.fuzzy_preset = preset;
783 self
784 }
785
786 #[must_use]
787 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
788 self.matching.threshold = threshold;
789 self
790 }
791
792 #[must_use]
793 pub const fn include_unchanged(mut self, include: bool) -> Self {
794 self.matching.include_unchanged = include;
795 self
796 }
797
798 #[must_use]
799 pub const fn only_changes(mut self, only: bool) -> Self {
800 self.filtering.only_changes = only;
801 self
802 }
803
804 #[must_use]
805 pub fn min_severity(mut self, severity: Option<String>) -> Self {
806 self.filtering.min_severity = severity;
807 self
808 }
809
810 #[must_use]
811 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
812 self.behavior.fail_on_vuln = fail;
813 self
814 }
815
816 #[must_use]
817 pub const fn fail_on_change(mut self, fail: bool) -> Self {
818 self.behavior.fail_on_change = fail;
819 self
820 }
821
822 #[must_use]
823 pub const fn quiet(mut self, quiet: bool) -> Self {
824 self.behavior.quiet = quiet;
825 self
826 }
827
828 #[must_use]
829 pub const fn explain_matches(mut self, explain: bool) -> Self {
830 self.behavior.explain_matches = explain;
831 self
832 }
833
834 #[must_use]
835 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
836 self.behavior.recommend_threshold = recommend;
837 self
838 }
839
840 #[must_use]
841 pub fn graph_diff(mut self, enabled: bool) -> Self {
842 self.graph_diff = if enabled {
843 GraphAwareDiffConfig::enabled()
844 } else {
845 GraphAwareDiffConfig::default()
846 };
847 self
848 }
849
850 #[must_use]
851 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
852 self.rules.rules_file = file;
853 self
854 }
855
856 #[must_use]
857 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
858 self.rules.dry_run = dry_run;
859 self
860 }
861
862 #[must_use]
863 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
864 self.ecosystem_rules.config_file = file;
865 self
866 }
867
868 #[must_use]
869 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
870 self.ecosystem_rules.disabled = disabled;
871 self
872 }
873
874 #[must_use]
875 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
876 self.ecosystem_rules.detect_typosquats = detect;
877 self
878 }
879
880 #[must_use]
881 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
882 self.enrichment = config;
883 self
884 }
885
886 #[must_use]
887 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
888 self.enrichment.enabled = enabled;
889 self
890 }
891
892 pub fn build(self) -> anyhow::Result<DiffConfig> {
893 let old = self
894 .old
895 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
896 let new = self
897 .new
898 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
899
900 Ok(DiffConfig {
901 paths: DiffPaths { old, new },
902 output: self.output,
903 matching: self.matching,
904 filtering: self.filtering,
905 behavior: self.behavior,
906 graph_diff: self.graph_diff,
907 rules: self.rules,
908 ecosystem_rules: self.ecosystem_rules,
909 enrichment: self.enrichment,
910 })
911 }
912}