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 pub max_depth: u32,
556 pub impact_threshold: Option<String>,
558 pub relation_filter: Vec<String>,
560}
561
562impl GraphAwareDiffConfig {
563 #[must_use]
565 pub const fn enabled() -> Self {
566 Self {
567 enabled: true,
568 detect_reparenting: true,
569 detect_depth_changes: true,
570 max_depth: 0,
571 impact_threshold: None,
572 relation_filter: Vec::new(),
573 }
574 }
575}
576
577#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
579#[serde(default)]
580pub struct MatchingRulesPathConfig {
581 #[serde(skip_serializing_if = "Option::is_none")]
583 pub rules_file: Option<PathBuf>,
584 pub dry_run: bool,
586}
587
588#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
590#[serde(default)]
591pub struct EcosystemRulesConfig {
592 #[serde(skip_serializing_if = "Option::is_none")]
594 pub config_file: Option<PathBuf>,
595 pub disabled: bool,
597 pub detect_typosquats: bool,
599}
600
601#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
606#[serde(default)]
607pub struct EnrichmentConfig {
608 pub enabled: bool,
610 pub provider: String,
612 #[schemars(range(min = 1))]
614 pub cache_ttl_hours: u64,
615 #[schemars(range(min = 1))]
617 pub max_concurrent: usize,
618 #[serde(skip_serializing_if = "Option::is_none")]
620 pub cache_dir: Option<std::path::PathBuf>,
621 pub bypass_cache: bool,
623 #[schemars(range(min = 1))]
625 pub timeout_secs: u64,
626 pub enable_eol: bool,
628 #[serde(default, skip_serializing_if = "Vec::is_empty")]
630 pub vex_paths: Vec<std::path::PathBuf>,
631}
632
633impl Default for EnrichmentConfig {
634 fn default() -> Self {
635 Self {
636 enabled: false,
637 provider: "osv".to_string(),
638 cache_ttl_hours: 24,
639 max_concurrent: 10,
640 cache_dir: None,
641 bypass_cache: false,
642 timeout_secs: 30,
643 enable_eol: false,
644 vex_paths: Vec::new(),
645 }
646 }
647}
648
649impl EnrichmentConfig {
650 #[must_use]
652 pub fn osv() -> Self {
653 Self {
654 enabled: true,
655 provider: "osv".to_string(),
656 ..Default::default()
657 }
658 }
659
660 #[must_use]
662 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
663 self.cache_dir = Some(dir);
664 self
665 }
666
667 #[must_use]
669 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
670 self.cache_ttl_hours = hours;
671 self
672 }
673
674 #[must_use]
676 pub const fn with_bypass_cache(mut self) -> Self {
677 self.bypass_cache = true;
678 self
679 }
680
681 #[must_use]
683 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
684 self.timeout_secs = secs;
685 self
686 }
687
688 #[must_use]
690 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
691 self.vex_paths = paths;
692 self
693 }
694}
695
696#[derive(Debug, Default)]
702pub struct DiffConfigBuilder {
703 old: Option<PathBuf>,
704 new: Option<PathBuf>,
705 output: OutputConfig,
706 matching: MatchingConfig,
707 filtering: FilterConfig,
708 behavior: BehaviorConfig,
709 graph_diff: GraphAwareDiffConfig,
710 rules: MatchingRulesPathConfig,
711 ecosystem_rules: EcosystemRulesConfig,
712 enrichment: EnrichmentConfig,
713}
714
715impl DiffConfigBuilder {
716 #[must_use]
717 pub fn new() -> Self {
718 Self::default()
719 }
720
721 #[must_use]
722 pub fn old_path(mut self, path: PathBuf) -> Self {
723 self.old = Some(path);
724 self
725 }
726
727 #[must_use]
728 pub fn new_path(mut self, path: PathBuf) -> Self {
729 self.new = Some(path);
730 self
731 }
732
733 #[must_use]
734 pub const fn output_format(mut self, format: ReportFormat) -> Self {
735 self.output.format = format;
736 self
737 }
738
739 #[must_use]
740 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
741 self.output.file = file;
742 self
743 }
744
745 #[must_use]
746 pub const fn report_types(mut self, types: ReportType) -> Self {
747 self.output.report_types = types;
748 self
749 }
750
751 #[must_use]
752 pub const fn no_color(mut self, no_color: bool) -> Self {
753 self.output.no_color = no_color;
754 self
755 }
756
757 #[must_use]
758 pub fn fuzzy_preset(mut self, preset: String) -> Self {
759 self.matching.fuzzy_preset = preset;
760 self
761 }
762
763 #[must_use]
764 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
765 self.matching.threshold = threshold;
766 self
767 }
768
769 #[must_use]
770 pub const fn include_unchanged(mut self, include: bool) -> Self {
771 self.matching.include_unchanged = include;
772 self
773 }
774
775 #[must_use]
776 pub const fn only_changes(mut self, only: bool) -> Self {
777 self.filtering.only_changes = only;
778 self
779 }
780
781 #[must_use]
782 pub fn min_severity(mut self, severity: Option<String>) -> Self {
783 self.filtering.min_severity = severity;
784 self
785 }
786
787 #[must_use]
788 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
789 self.behavior.fail_on_vuln = fail;
790 self
791 }
792
793 #[must_use]
794 pub const fn fail_on_change(mut self, fail: bool) -> Self {
795 self.behavior.fail_on_change = fail;
796 self
797 }
798
799 #[must_use]
800 pub const fn quiet(mut self, quiet: bool) -> Self {
801 self.behavior.quiet = quiet;
802 self
803 }
804
805 #[must_use]
806 pub const fn explain_matches(mut self, explain: bool) -> Self {
807 self.behavior.explain_matches = explain;
808 self
809 }
810
811 #[must_use]
812 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
813 self.behavior.recommend_threshold = recommend;
814 self
815 }
816
817 #[must_use]
818 pub fn graph_diff(mut self, enabled: bool) -> Self {
819 self.graph_diff = if enabled {
820 GraphAwareDiffConfig::enabled()
821 } else {
822 GraphAwareDiffConfig::default()
823 };
824 self
825 }
826
827 #[must_use]
828 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
829 self.rules.rules_file = file;
830 self
831 }
832
833 #[must_use]
834 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
835 self.rules.dry_run = dry_run;
836 self
837 }
838
839 #[must_use]
840 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
841 self.ecosystem_rules.config_file = file;
842 self
843 }
844
845 #[must_use]
846 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
847 self.ecosystem_rules.disabled = disabled;
848 self
849 }
850
851 #[must_use]
852 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
853 self.ecosystem_rules.detect_typosquats = detect;
854 self
855 }
856
857 #[must_use]
858 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
859 self.enrichment = config;
860 self
861 }
862
863 #[must_use]
864 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
865 self.enrichment.enabled = enabled;
866 self
867 }
868
869 pub fn build(self) -> anyhow::Result<DiffConfig> {
870 let old = self
871 .old
872 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
873 let new = self
874 .new
875 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
876
877 Ok(DiffConfig {
878 paths: DiffPaths { old, new },
879 output: self.output,
880 matching: self.matching,
881 filtering: self.filtering,
882 behavior: self.behavior,
883 graph_diff: self.graph_diff,
884 rules: self.rules,
885 ecosystem_rules: self.ecosystem_rules,
886 enrichment: self.enrichment,
887 })
888 }
889}