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 pub enrichment: EnrichmentConfig,
290}
291
292#[derive(Debug, Clone)]
294pub struct MultiDiffConfig {
295 pub baseline: PathBuf,
297 pub targets: Vec<PathBuf>,
299 pub output: OutputConfig,
301 pub matching: MatchingConfig,
303}
304
305#[derive(Debug, Clone)]
307pub struct TimelineConfig {
308 pub sbom_paths: Vec<PathBuf>,
310 pub output: OutputConfig,
312 pub matching: MatchingConfig,
314}
315
316#[derive(Debug, Clone)]
318pub struct QueryConfig {
319 pub sbom_paths: Vec<PathBuf>,
321 pub output: OutputConfig,
323 pub enrichment: EnrichmentConfig,
325 pub limit: Option<usize>,
327 pub group_by_sbom: bool,
329}
330
331#[derive(Debug, Clone)]
333pub struct MatrixConfig {
334 pub sbom_paths: Vec<PathBuf>,
336 pub output: OutputConfig,
338 pub matching: MatchingConfig,
340 pub cluster_threshold: f64,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
350#[serde(default)]
351pub struct OutputConfig {
352 pub format: ReportFormat,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub file: Option<PathBuf>,
357 pub report_types: ReportType,
359 pub no_color: bool,
361 pub streaming: StreamingConfig,
363}
364
365impl Default for OutputConfig {
366 fn default() -> Self {
367 Self {
368 format: ReportFormat::Auto,
369 file: None,
370 report_types: ReportType::All,
371 no_color: false,
372 streaming: StreamingConfig::default(),
373 }
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
383#[serde(default)]
384pub struct StreamingConfig {
385 #[schemars(range(min = 0))]
388 pub threshold_bytes: u64,
389 pub force: bool,
392 pub disabled: bool,
394 pub stream_stdin: bool,
397}
398
399impl Default for StreamingConfig {
400 fn default() -> Self {
401 Self {
402 threshold_bytes: 10 * 1024 * 1024, force: false,
404 disabled: false,
405 stream_stdin: true,
406 }
407 }
408}
409
410impl StreamingConfig {
411 #[must_use]
413 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
414 if self.disabled {
415 return false;
416 }
417 if self.force {
418 return true;
419 }
420 if is_stdin && self.stream_stdin {
421 return true;
422 }
423 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
424 }
425
426 #[must_use]
428 pub fn always() -> Self {
429 Self {
430 force: true,
431 ..Default::default()
432 }
433 }
434
435 #[must_use]
437 pub fn never() -> Self {
438 Self {
439 disabled: true,
440 ..Default::default()
441 }
442 }
443
444 #[must_use]
446 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
447 self.threshold_bytes = mb * 1024 * 1024;
448 self
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
454#[serde(default)]
455pub struct MatchingConfig {
456 pub fuzzy_preset: String,
458 #[serde(skip_serializing_if = "Option::is_none")]
460 #[schemars(range(min = 0.0, max = 1.0))]
461 pub threshold: Option<f64>,
462 pub include_unchanged: bool,
464}
465
466impl Default for MatchingConfig {
467 fn default() -> Self {
468 Self {
469 fuzzy_preset: "balanced".to_string(),
470 threshold: None,
471 include_unchanged: false,
472 }
473 }
474}
475
476impl MatchingConfig {
477 #[must_use]
479 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
480 let mut config = FuzzyMatchConfig::from_preset(&self.fuzzy_preset).unwrap_or_else(|| {
481 tracing::warn!(
482 "Unknown fuzzy preset '{}', using 'balanced'. Valid: strict, balanced, permissive",
483 self.fuzzy_preset
484 );
485 FuzzyMatchConfig::balanced()
486 });
487
488 if let Some(threshold) = self.threshold {
490 config = config.with_threshold(threshold);
491 }
492
493 config
494 }
495}
496
497#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
499#[serde(default)]
500pub struct FilterConfig {
501 pub only_changes: bool,
503 #[serde(skip_serializing_if = "Option::is_none")]
505 pub min_severity: Option<String>,
506 #[serde(alias = "exclude_vex_not_affected")]
508 pub exclude_vex_resolved: bool,
509}
510
511#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
513#[serde(default)]
514pub struct BehaviorConfig {
515 pub fail_on_vuln: bool,
517 pub fail_on_change: bool,
519 pub quiet: bool,
521 pub explain_matches: bool,
523 pub recommend_threshold: bool,
525}
526
527#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
529#[serde(default)]
530pub struct GraphAwareDiffConfig {
531 pub enabled: bool,
533 pub detect_reparenting: bool,
535 pub detect_depth_changes: bool,
537}
538
539impl GraphAwareDiffConfig {
540 #[must_use]
542 pub const fn enabled() -> Self {
543 Self {
544 enabled: true,
545 detect_reparenting: true,
546 detect_depth_changes: true,
547 }
548 }
549}
550
551#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
553#[serde(default)]
554pub struct MatchingRulesPathConfig {
555 #[serde(skip_serializing_if = "Option::is_none")]
557 pub rules_file: Option<PathBuf>,
558 pub dry_run: bool,
560}
561
562#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
564#[serde(default)]
565pub struct EcosystemRulesConfig {
566 #[serde(skip_serializing_if = "Option::is_none")]
568 pub config_file: Option<PathBuf>,
569 pub disabled: bool,
571 pub detect_typosquats: bool,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
580#[serde(default)]
581pub struct EnrichmentConfig {
582 pub enabled: bool,
584 pub provider: String,
586 #[schemars(range(min = 1))]
588 pub cache_ttl_hours: u64,
589 #[schemars(range(min = 1))]
591 pub max_concurrent: usize,
592 #[serde(skip_serializing_if = "Option::is_none")]
594 pub cache_dir: Option<std::path::PathBuf>,
595 pub bypass_cache: bool,
597 #[schemars(range(min = 1))]
599 pub timeout_secs: u64,
600 pub enable_eol: bool,
602 #[serde(default, skip_serializing_if = "Vec::is_empty")]
604 pub vex_paths: Vec<std::path::PathBuf>,
605}
606
607impl Default for EnrichmentConfig {
608 fn default() -> Self {
609 Self {
610 enabled: false,
611 provider: "osv".to_string(),
612 cache_ttl_hours: 24,
613 max_concurrent: 10,
614 cache_dir: None,
615 bypass_cache: false,
616 timeout_secs: 30,
617 enable_eol: false,
618 vex_paths: Vec::new(),
619 }
620 }
621}
622
623impl EnrichmentConfig {
624 #[must_use]
626 pub fn osv() -> Self {
627 Self {
628 enabled: true,
629 provider: "osv".to_string(),
630 ..Default::default()
631 }
632 }
633
634 #[must_use]
636 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
637 self.cache_dir = Some(dir);
638 self
639 }
640
641 #[must_use]
643 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
644 self.cache_ttl_hours = hours;
645 self
646 }
647
648 #[must_use]
650 pub const fn with_bypass_cache(mut self) -> Self {
651 self.bypass_cache = true;
652 self
653 }
654
655 #[must_use]
657 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
658 self.timeout_secs = secs;
659 self
660 }
661
662 #[must_use]
664 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
665 self.vex_paths = paths;
666 self
667 }
668}
669
670#[derive(Debug, Default)]
676pub struct DiffConfigBuilder {
677 old: Option<PathBuf>,
678 new: Option<PathBuf>,
679 output: OutputConfig,
680 matching: MatchingConfig,
681 filtering: FilterConfig,
682 behavior: BehaviorConfig,
683 graph_diff: GraphAwareDiffConfig,
684 rules: MatchingRulesPathConfig,
685 ecosystem_rules: EcosystemRulesConfig,
686 enrichment: EnrichmentConfig,
687}
688
689impl DiffConfigBuilder {
690 #[must_use]
691 pub fn new() -> Self {
692 Self::default()
693 }
694
695 #[must_use]
696 pub fn old_path(mut self, path: PathBuf) -> Self {
697 self.old = Some(path);
698 self
699 }
700
701 #[must_use]
702 pub fn new_path(mut self, path: PathBuf) -> Self {
703 self.new = Some(path);
704 self
705 }
706
707 #[must_use]
708 pub const fn output_format(mut self, format: ReportFormat) -> Self {
709 self.output.format = format;
710 self
711 }
712
713 #[must_use]
714 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
715 self.output.file = file;
716 self
717 }
718
719 #[must_use]
720 pub const fn report_types(mut self, types: ReportType) -> Self {
721 self.output.report_types = types;
722 self
723 }
724
725 #[must_use]
726 pub const fn no_color(mut self, no_color: bool) -> Self {
727 self.output.no_color = no_color;
728 self
729 }
730
731 #[must_use]
732 pub fn fuzzy_preset(mut self, preset: String) -> Self {
733 self.matching.fuzzy_preset = preset;
734 self
735 }
736
737 #[must_use]
738 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
739 self.matching.threshold = threshold;
740 self
741 }
742
743 #[must_use]
744 pub const fn include_unchanged(mut self, include: bool) -> Self {
745 self.matching.include_unchanged = include;
746 self
747 }
748
749 #[must_use]
750 pub const fn only_changes(mut self, only: bool) -> Self {
751 self.filtering.only_changes = only;
752 self
753 }
754
755 #[must_use]
756 pub fn min_severity(mut self, severity: Option<String>) -> Self {
757 self.filtering.min_severity = severity;
758 self
759 }
760
761 #[must_use]
762 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
763 self.behavior.fail_on_vuln = fail;
764 self
765 }
766
767 #[must_use]
768 pub const fn fail_on_change(mut self, fail: bool) -> Self {
769 self.behavior.fail_on_change = fail;
770 self
771 }
772
773 #[must_use]
774 pub const fn quiet(mut self, quiet: bool) -> Self {
775 self.behavior.quiet = quiet;
776 self
777 }
778
779 #[must_use]
780 pub const fn explain_matches(mut self, explain: bool) -> Self {
781 self.behavior.explain_matches = explain;
782 self
783 }
784
785 #[must_use]
786 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
787 self.behavior.recommend_threshold = recommend;
788 self
789 }
790
791 #[must_use]
792 pub fn graph_diff(mut self, enabled: bool) -> Self {
793 self.graph_diff = if enabled {
794 GraphAwareDiffConfig::enabled()
795 } else {
796 GraphAwareDiffConfig::default()
797 };
798 self
799 }
800
801 #[must_use]
802 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
803 self.rules.rules_file = file;
804 self
805 }
806
807 #[must_use]
808 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
809 self.rules.dry_run = dry_run;
810 self
811 }
812
813 #[must_use]
814 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
815 self.ecosystem_rules.config_file = file;
816 self
817 }
818
819 #[must_use]
820 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
821 self.ecosystem_rules.disabled = disabled;
822 self
823 }
824
825 #[must_use]
826 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
827 self.ecosystem_rules.detect_typosquats = detect;
828 self
829 }
830
831 #[must_use]
832 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
833 self.enrichment = config;
834 self
835 }
836
837 #[must_use]
838 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
839 self.enrichment.enabled = enabled;
840 self
841 }
842
843 pub fn build(self) -> anyhow::Result<DiffConfig> {
844 let old = self.old.ok_or_else(|| anyhow::anyhow!("old path is required"))?;
845 let new = self.new.ok_or_else(|| anyhow::anyhow!("new path is required"))?;
846
847 Ok(DiffConfig {
848 paths: DiffPaths { old, new },
849 output: self.output,
850 matching: self.matching,
851 filtering: self.filtering,
852 behavior: self.behavior,
853 graph_diff: self.graph_diff,
854 rules: self.rules,
855 ecosystem_rules: self.ecosystem_rules,
856 enrichment: self.enrichment,
857 })
858 }
859}