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)]
29#[serde(default, deny_unknown_fields)]
30pub struct AppConfig {
31 pub matching: MatchingConfig,
33 pub output: OutputConfig,
35 pub filtering: FilterConfig,
37 pub behavior: BehaviorConfig,
39 pub graph_diff: GraphAwareDiffConfig,
41 pub rules: MatchingRulesPathConfig,
43 pub ecosystem_rules: EcosystemRulesConfig,
45 pub tui: TuiConfig,
47 pub compliance: ComplianceConfig,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub enrichment: Option<EnrichmentConfig>,
52}
53
54impl AppConfig {
55 #[must_use]
57 pub fn new() -> Self {
58 Self::default()
59 }
60
61 pub fn builder() -> AppConfigBuilder {
63 AppConfigBuilder::default()
64 }
65}
66
67#[derive(Debug, Default)]
73#[must_use]
74pub struct AppConfigBuilder {
75 config: AppConfig,
76}
77
78impl AppConfigBuilder {
79 pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
81 self.config.matching.fuzzy_preset = preset;
82 self
83 }
84
85 pub const fn matching_threshold(mut self, threshold: f64) -> Self {
87 self.config.matching.threshold = Some(threshold);
88 self
89 }
90
91 pub const fn output_format(mut self, format: ReportFormat) -> Self {
93 self.config.output.format = format;
94 self
95 }
96
97 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
99 self.config.output.file = file;
100 self
101 }
102
103 pub const fn no_color(mut self, no_color: bool) -> Self {
105 self.config.output.no_color = no_color;
106 self
107 }
108
109 pub const fn include_unchanged(mut self, include: bool) -> Self {
111 self.config.matching.include_unchanged = include;
112 self
113 }
114
115 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
117 self.config.behavior.fail_on_vuln = fail;
118 self
119 }
120
121 pub const fn fail_on_change(mut self, fail: bool) -> Self {
123 self.config.behavior.fail_on_change = fail;
124 self
125 }
126
127 pub const fn quiet(mut self, quiet: bool) -> Self {
129 self.config.behavior.quiet = quiet;
130 self
131 }
132
133 pub fn graph_diff(mut self, enabled: bool) -> Self {
135 self.config.graph_diff = if enabled {
136 GraphAwareDiffConfig::enabled()
137 } else {
138 GraphAwareDiffConfig::default()
139 };
140 self
141 }
142
143 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
145 self.config.rules.rules_file = file;
146 self
147 }
148
149 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
151 self.config.ecosystem_rules.config_file = file;
152 self
153 }
154
155 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
157 self.config.enrichment = Some(config);
158 self
159 }
160
161 #[must_use]
163 pub fn build(self) -> AppConfig {
164 self.config
165 }
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
174#[serde(rename_all = "kebab-case")]
175pub enum ThemeName {
176 #[default]
178 Dark,
179 Light,
181 HighContrast,
183 Monochrome,
185}
186
187impl ThemeName {
188 #[must_use]
190 pub fn as_str(&self) -> &'static str {
191 match self {
192 Self::Dark => "dark",
193 Self::Light => "light",
194 Self::HighContrast => "high-contrast",
195 Self::Monochrome => "monochrome",
196 }
197 }
198}
199
200impl std::fmt::Display for ThemeName {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 f.write_str(self.as_str())
203 }
204}
205
206impl std::str::FromStr for ThemeName {
207 type Err = String;
208
209 fn from_str(s: &str) -> Result<Self, Self::Err> {
210 match s.to_lowercase().as_str() {
211 "dark" => Ok(Self::Dark),
212 "light" => Ok(Self::Light),
213 "high-contrast" | "highcontrast" | "hc" => Ok(Self::HighContrast),
214 "monochrome" | "mono" => Ok(Self::Monochrome),
215 _ => Err(format!("unknown theme: {s}")),
216 }
217 }
218}
219
220#[derive(
222 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, clap::ValueEnum,
223)]
224#[serde(rename_all = "kebab-case")]
225pub enum FuzzyPreset {
226 Strict,
228 #[default]
230 Balanced,
231 Permissive,
233 #[value(alias = "strict_multi")]
235 StrictMulti,
236 #[value(alias = "balanced_multi")]
238 BalancedMulti,
239 #[value(alias = "security_focused")]
241 SecurityFocused,
242}
243
244impl FuzzyPreset {
245 #[must_use]
247 pub fn as_str(&self) -> &'static str {
248 match self {
249 Self::Strict => "strict",
250 Self::Balanced => "balanced",
251 Self::Permissive => "permissive",
252 Self::StrictMulti => "strict-multi",
253 Self::BalancedMulti => "balanced-multi",
254 Self::SecurityFocused => "security-focused",
255 }
256 }
257}
258
259impl std::str::FromStr for FuzzyPreset {
260 type Err = String;
261
262 fn from_str(s: &str) -> Result<Self, Self::Err> {
263 match s.to_lowercase().replace('_', "-").as_str() {
264 "strict" => Ok(Self::Strict),
265 "balanced" => Ok(Self::Balanced),
266 "permissive" => Ok(Self::Permissive),
267 "strict-multi" => Ok(Self::StrictMulti),
268 "balanced-multi" => Ok(Self::BalancedMulti),
269 "security-focused" => Ok(Self::SecurityFocused),
270 _ => Err(format!("unknown fuzzy preset: {s}")),
271 }
272 }
273}
274
275impl std::fmt::Display for FuzzyPreset {
276 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
277 f.write_str(self.as_str())
278 }
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
287pub struct TuiPreferences {
288 pub theme: ThemeName,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub last_tab: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub last_view_tab: Option<String>,
296}
297
298impl Default for TuiPreferences {
299 fn default() -> Self {
300 Self {
301 theme: ThemeName::Dark,
302 last_tab: None,
303 last_view_tab: None,
304 }
305 }
306}
307
308impl TuiPreferences {
309 #[must_use]
311 pub fn config_path() -> Option<PathBuf> {
312 dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
313 }
314
315 #[must_use]
317 pub fn load() -> Self {
318 Self::config_path()
319 .and_then(|p| std::fs::read_to_string(p).ok())
320 .and_then(|s| serde_json::from_str(&s).ok())
321 .unwrap_or_default()
322 }
323
324 pub fn save(&self) -> std::io::Result<()> {
326 if let Some(path) = Self::config_path() {
327 if let Some(parent) = path.parent() {
328 std::fs::create_dir_all(parent)?;
329 }
330 let json = serde_json::to_string_pretty(self)
331 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
332 std::fs::write(path, json)?;
333 }
334 Ok(())
335 }
336}
337
338#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
344#[serde(default, deny_unknown_fields)]
345pub struct TuiConfig {
346 pub theme: ThemeName,
348 pub show_line_numbers: bool,
350 pub mouse_enabled: bool,
352 #[schemars(range(min = 0.0, max = 1.0))]
354 pub initial_threshold: f64,
355}
356
357impl Default for TuiConfig {
358 fn default() -> Self {
359 Self {
360 theme: ThemeName::Dark,
361 show_line_numbers: true,
362 mouse_enabled: true,
363 initial_threshold: 0.8,
364 }
365 }
366}
367
368#[derive(Debug, Clone)]
374pub struct DiffConfig {
375 pub paths: DiffPaths,
377 pub output: OutputConfig,
379 pub matching: MatchingConfig,
381 pub filtering: FilterConfig,
383 pub behavior: BehaviorConfig,
385 pub graph_diff: GraphAwareDiffConfig,
387 pub rules: MatchingRulesPathConfig,
389 pub ecosystem_rules: EcosystemRulesConfig,
391 pub enrichment: EnrichmentConfig,
393}
394
395#[derive(Debug, Clone)]
397pub struct DiffPaths {
398 pub old: PathBuf,
400 pub new: PathBuf,
402}
403
404#[derive(Debug, Clone)]
406pub struct ViewConfig {
407 pub sbom_path: PathBuf,
409 pub output: OutputConfig,
411 pub validate_ntia: bool,
413 pub min_severity: Option<String>,
415 pub vulnerable_only: bool,
417 pub ecosystem_filter: Option<String>,
419 pub fail_on_vuln: bool,
421 pub bom_profile: Option<crate::model::BomProfile>,
423 pub enrichment: EnrichmentConfig,
425 pub cra_sidecar_path: Option<PathBuf>,
429 pub cra_product_class: Option<String>,
433}
434
435#[derive(Debug, Clone)]
437pub struct MultiDiffConfig {
438 pub baseline: PathBuf,
440 pub targets: Vec<PathBuf>,
442 pub output: OutputConfig,
444 pub matching: MatchingConfig,
446 pub filtering: FilterConfig,
448 pub behavior: BehaviorConfig,
450 pub graph_diff: GraphAwareDiffConfig,
452 pub rules: MatchingRulesPathConfig,
454 pub ecosystem_rules: EcosystemRulesConfig,
456 pub enrichment: EnrichmentConfig,
458}
459
460#[derive(Debug, Clone)]
462pub struct TimelineConfig {
463 pub sbom_paths: Vec<PathBuf>,
465 pub output: OutputConfig,
467 pub matching: MatchingConfig,
469 pub filtering: FilterConfig,
471 pub behavior: BehaviorConfig,
473 pub graph_diff: GraphAwareDiffConfig,
475 pub rules: MatchingRulesPathConfig,
477 pub ecosystem_rules: EcosystemRulesConfig,
479 pub enrichment: EnrichmentConfig,
481}
482
483#[derive(Debug, Clone)]
485pub struct QueryConfig {
486 pub sbom_paths: Vec<PathBuf>,
488 pub output: OutputConfig,
490 pub enrichment: EnrichmentConfig,
492 pub limit: Option<usize>,
494 pub group_by_sbom: bool,
496}
497
498#[derive(Debug, Clone)]
500pub struct MatrixConfig {
501 pub sbom_paths: Vec<PathBuf>,
503 pub output: OutputConfig,
505 pub matching: MatchingConfig,
507 pub cluster_threshold: f64,
509 pub filtering: FilterConfig,
511 pub behavior: BehaviorConfig,
513 pub graph_diff: GraphAwareDiffConfig,
515 pub rules: MatchingRulesPathConfig,
517 pub ecosystem_rules: EcosystemRulesConfig,
519 pub enrichment: EnrichmentConfig,
521}
522
523#[derive(Debug, Clone)]
525pub struct VexConfig {
526 pub sbom_path: PathBuf,
528 pub vex_paths: Vec<PathBuf>,
530 pub output_format: ReportFormat,
532 pub output_file: Option<PathBuf>,
534 pub quiet: bool,
536 pub actionable_only: bool,
538 pub filter_state: Option<String>,
540 pub enrichment: EnrichmentConfig,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
550#[serde(default, deny_unknown_fields)]
551pub struct OutputConfig {
552 pub format: ReportFormat,
554 #[serde(skip_serializing_if = "Option::is_none")]
556 pub file: Option<PathBuf>,
557 pub report_types: ReportType,
559 pub no_color: bool,
561 pub streaming: StreamingConfig,
563 #[serde(skip_serializing_if = "Option::is_none")]
568 pub export_template: Option<String>,
569}
570
571impl Default for OutputConfig {
572 fn default() -> Self {
573 Self {
574 format: ReportFormat::Auto,
575 file: None,
576 report_types: ReportType::All,
577 no_color: false,
578 streaming: StreamingConfig::default(),
579 export_template: None,
580 }
581 }
582}
583
584#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
590#[serde(default, deny_unknown_fields)]
591pub struct StreamingConfig {
592 #[schemars(range(min = 0))]
595 pub threshold_bytes: u64,
596 pub force: bool,
599 pub disabled: bool,
601 pub stream_stdin: bool,
604}
605
606impl Default for StreamingConfig {
607 fn default() -> Self {
608 Self {
609 threshold_bytes: 10 * 1024 * 1024, force: false,
611 disabled: false,
612 stream_stdin: true,
613 }
614 }
615}
616
617impl StreamingConfig {
618 #[must_use]
620 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
621 if self.disabled {
622 return false;
623 }
624 if self.force {
625 return true;
626 }
627 if is_stdin && self.stream_stdin {
628 return true;
629 }
630 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
631 }
632
633 #[must_use]
635 pub fn always() -> Self {
636 Self {
637 force: true,
638 ..Default::default()
639 }
640 }
641
642 #[must_use]
644 pub fn never() -> Self {
645 Self {
646 disabled: true,
647 ..Default::default()
648 }
649 }
650
651 #[must_use]
653 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
654 self.threshold_bytes = mb * 1024 * 1024;
655 self
656 }
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
661#[serde(default, deny_unknown_fields)]
662pub struct MatchingConfig {
663 pub fuzzy_preset: FuzzyPreset,
665 #[serde(skip_serializing_if = "Option::is_none")]
667 #[schemars(range(min = 0.0, max = 1.0))]
668 pub threshold: Option<f64>,
669 pub include_unchanged: bool,
671}
672
673impl Default for MatchingConfig {
674 fn default() -> Self {
675 Self {
676 fuzzy_preset: FuzzyPreset::Balanced,
677 threshold: None,
678 include_unchanged: false,
679 }
680 }
681}
682
683impl MatchingConfig {
684 #[must_use]
686 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
687 let mut config =
688 FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
689 FuzzyMatchConfig::balanced()
691 });
692
693 if let Some(threshold) = self.threshold {
695 config = config.with_threshold(threshold);
696 }
697
698 config
699 }
700}
701
702#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
704#[serde(default, deny_unknown_fields)]
705pub struct FilterConfig {
706 pub only_changes: bool,
708 #[serde(skip_serializing_if = "Option::is_none")]
710 pub min_severity: Option<String>,
711 #[serde(alias = "exclude_vex_not_affected")]
713 pub exclude_vex_resolved: bool,
714 pub fail_on_vex_gap: bool,
716 pub fail_on_ml_regression: bool,
718}
719
720#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
722#[serde(default, deny_unknown_fields)]
723pub struct BehaviorConfig {
724 pub fail_on_vuln: bool,
726 #[serde(default)]
728 pub fail_on_kev: bool,
729 pub fail_on_change: bool,
731 pub quiet: bool,
733 pub explain_matches: bool,
735 pub recommend_threshold: bool,
737}
738
739#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
741#[serde(default, deny_unknown_fields)]
742pub struct GraphAwareDiffConfig {
743 pub enabled: bool,
745 pub detect_reparenting: bool,
747 pub detect_depth_changes: bool,
749 pub max_depth: u32,
751 pub impact_threshold: Option<String>,
753 pub relation_filter: Vec<String>,
755}
756
757impl GraphAwareDiffConfig {
758 #[must_use]
760 pub const fn enabled() -> Self {
761 Self {
762 enabled: true,
763 detect_reparenting: true,
764 detect_depth_changes: true,
765 max_depth: 0,
766 impact_threshold: None,
767 relation_filter: Vec::new(),
768 }
769 }
770}
771
772#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
774#[serde(default, deny_unknown_fields)]
775pub struct MatchingRulesPathConfig {
776 #[serde(skip_serializing_if = "Option::is_none")]
778 pub rules_file: Option<PathBuf>,
779 pub dry_run: bool,
781}
782
783#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
785#[serde(default, deny_unknown_fields)]
786pub struct EcosystemRulesConfig {
787 #[serde(skip_serializing_if = "Option::is_none")]
789 pub config_file: Option<PathBuf>,
790 pub disabled: bool,
792 pub detect_typosquats: bool,
794}
795
796#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
808#[serde(default, deny_unknown_fields)]
809pub struct ComplianceConfig {
810 #[serde(skip_serializing_if = "Vec::is_empty")]
813 pub standards: Vec<String>,
814 #[serde(skip_serializing_if = "Option::is_none")]
817 pub profile: Option<String>,
818 #[serde(skip_serializing_if = "Option::is_none")]
820 #[schemars(range(min = 0.0, max = 100.0))]
821 pub min_score: Option<f32>,
822 pub fail_on_warning: bool,
824 pub fail_on_noncompliant: bool,
826 #[serde(skip_serializing_if = "Option::is_none")]
829 pub cra_sidecar: Option<PathBuf>,
830 #[serde(skip_serializing_if = "Option::is_none")]
833 pub cra_product_class: Option<String>,
834}
835
836#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
841#[serde(default, deny_unknown_fields)]
842pub struct EnrichmentConfig {
843 pub enabled: bool,
845 pub provider: String,
847 #[schemars(range(min = 1))]
849 pub cache_ttl_hours: u64,
850 #[schemars(range(min = 1))]
852 pub max_concurrent: usize,
853 #[serde(skip_serializing_if = "Option::is_none")]
855 pub cache_dir: Option<std::path::PathBuf>,
856 pub bypass_cache: bool,
858 #[schemars(range(min = 1))]
860 pub timeout_secs: u64,
861 pub enable_eol: bool,
863 #[serde(default)]
865 pub enable_kev: bool,
866 #[serde(default)]
868 pub enable_epss: bool,
869 #[serde(default)]
871 pub enable_staleness: bool,
872 #[serde(default)]
875 pub enable_huggingface: bool,
876 #[serde(default, skip_serializing_if = "Vec::is_empty")]
878 pub vex_paths: Vec<std::path::PathBuf>,
879 #[serde(skip_serializing_if = "Option::is_none")]
881 pub api_base: Option<String>,
882 #[serde(skip_serializing_if = "Option::is_none")]
885 pub kev_url: Option<String>,
886 #[serde(skip_serializing_if = "Option::is_none")]
889 pub epss_url: Option<String>,
890 #[serde(skip_serializing_if = "Option::is_none")]
893 pub huggingface_url: Option<String>,
894 #[serde(default)]
897 pub offline: bool,
898}
899
900impl Default for EnrichmentConfig {
901 fn default() -> Self {
902 Self {
903 enabled: false,
904 provider: "osv".to_string(),
905 cache_ttl_hours: 24,
906 max_concurrent: 10,
907 cache_dir: None,
908 bypass_cache: false,
909 timeout_secs: 30,
910 enable_eol: false,
911 enable_kev: false,
912 enable_epss: false,
913 enable_staleness: false,
914 enable_huggingface: false,
915 vex_paths: Vec::new(),
916 api_base: None,
917 kev_url: None,
918 epss_url: None,
919 huggingface_url: None,
920 offline: false,
921 }
922 }
923}
924
925impl EnrichmentConfig {
926 #[must_use]
928 pub fn osv() -> Self {
929 Self {
930 enabled: true,
931 provider: "osv".to_string(),
932 ..Default::default()
933 }
934 }
935
936 #[must_use]
938 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
939 self.cache_dir = Some(dir);
940 self
941 }
942
943 #[must_use]
945 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
946 self.cache_ttl_hours = hours;
947 self
948 }
949
950 #[must_use]
952 pub const fn with_bypass_cache(mut self) -> Self {
953 self.bypass_cache = true;
954 self
955 }
956
957 #[must_use]
959 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
960 self.timeout_secs = secs;
961 self
962 }
963
964 #[must_use]
966 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
967 self.vex_paths = paths;
968 self
969 }
970
971 #[must_use]
973 pub fn with_api_base(mut self, api_base: impl Into<String>) -> Self {
974 self.api_base = Some(api_base.into());
975 self
976 }
977
978 #[must_use]
980 pub const fn with_kev(mut self) -> Self {
981 self.enable_kev = true;
982 self
983 }
984
985 #[must_use]
987 pub fn with_kev_url(mut self, kev_url: impl Into<String>) -> Self {
988 self.kev_url = Some(kev_url.into());
989 self
990 }
991
992 #[must_use]
994 pub const fn with_epss(mut self) -> Self {
995 self.enable_epss = true;
996 self
997 }
998
999 #[must_use]
1001 pub fn with_epss_url(mut self, epss_url: impl Into<String>) -> Self {
1002 self.epss_url = Some(epss_url.into());
1003 self
1004 }
1005
1006 #[must_use]
1008 pub const fn with_staleness(mut self) -> Self {
1009 self.enable_staleness = true;
1010 self
1011 }
1012
1013 #[must_use]
1015 pub const fn with_huggingface(mut self) -> Self {
1016 self.enable_huggingface = true;
1017 self
1018 }
1019
1020 #[must_use]
1022 pub fn with_huggingface_url(mut self, url: impl Into<String>) -> Self {
1023 self.huggingface_url = Some(url.into());
1024 self
1025 }
1026
1027 #[must_use]
1029 pub const fn with_offline(mut self) -> Self {
1030 self.offline = true;
1031 self
1032 }
1033}
1034
1035#[derive(Debug, Default)]
1041pub struct DiffConfigBuilder {
1042 old: Option<PathBuf>,
1043 new: Option<PathBuf>,
1044 output: OutputConfig,
1045 matching: MatchingConfig,
1046 filtering: FilterConfig,
1047 behavior: BehaviorConfig,
1048 graph_diff: GraphAwareDiffConfig,
1049 rules: MatchingRulesPathConfig,
1050 ecosystem_rules: EcosystemRulesConfig,
1051 enrichment: EnrichmentConfig,
1052}
1053
1054impl DiffConfigBuilder {
1055 #[must_use]
1056 pub fn new() -> Self {
1057 Self::default()
1058 }
1059
1060 #[must_use]
1061 pub fn old_path(mut self, path: PathBuf) -> Self {
1062 self.old = Some(path);
1063 self
1064 }
1065
1066 #[must_use]
1067 pub fn new_path(mut self, path: PathBuf) -> Self {
1068 self.new = Some(path);
1069 self
1070 }
1071
1072 #[must_use]
1073 pub const fn output_format(mut self, format: ReportFormat) -> Self {
1074 self.output.format = format;
1075 self
1076 }
1077
1078 #[must_use]
1079 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
1080 self.output.file = file;
1081 self
1082 }
1083
1084 #[must_use]
1085 pub const fn report_types(mut self, types: ReportType) -> Self {
1086 self.output.report_types = types;
1087 self
1088 }
1089
1090 #[must_use]
1091 pub const fn no_color(mut self, no_color: bool) -> Self {
1092 self.output.no_color = no_color;
1093 self
1094 }
1095
1096 #[must_use]
1097 pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
1098 self.matching.fuzzy_preset = preset;
1099 self
1100 }
1101
1102 #[must_use]
1103 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
1104 self.matching.threshold = threshold;
1105 self
1106 }
1107
1108 #[must_use]
1109 pub const fn include_unchanged(mut self, include: bool) -> Self {
1110 self.matching.include_unchanged = include;
1111 self
1112 }
1113
1114 #[must_use]
1115 pub const fn only_changes(mut self, only: bool) -> Self {
1116 self.filtering.only_changes = only;
1117 self
1118 }
1119
1120 #[must_use]
1121 pub const fn fail_on_ml_regression(mut self, fail: bool) -> Self {
1122 self.filtering.fail_on_ml_regression = fail;
1123 self
1124 }
1125
1126 #[must_use]
1127 pub fn min_severity(mut self, severity: Option<String>) -> Self {
1128 self.filtering.min_severity = severity;
1129 self
1130 }
1131
1132 #[must_use]
1133 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
1134 self.behavior.fail_on_vuln = fail;
1135 self
1136 }
1137
1138 #[must_use]
1139 pub const fn fail_on_kev(mut self, fail: bool) -> Self {
1140 self.behavior.fail_on_kev = fail;
1141 self
1142 }
1143
1144 #[must_use]
1145 pub const fn fail_on_change(mut self, fail: bool) -> Self {
1146 self.behavior.fail_on_change = fail;
1147 self
1148 }
1149
1150 #[must_use]
1151 pub const fn quiet(mut self, quiet: bool) -> Self {
1152 self.behavior.quiet = quiet;
1153 self
1154 }
1155
1156 #[must_use]
1157 pub const fn explain_matches(mut self, explain: bool) -> Self {
1158 self.behavior.explain_matches = explain;
1159 self
1160 }
1161
1162 #[must_use]
1163 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
1164 self.behavior.recommend_threshold = recommend;
1165 self
1166 }
1167
1168 #[must_use]
1169 pub fn graph_diff(mut self, enabled: bool) -> Self {
1170 self.graph_diff = if enabled {
1171 GraphAwareDiffConfig::enabled()
1172 } else {
1173 GraphAwareDiffConfig::default()
1174 };
1175 self
1176 }
1177
1178 #[must_use]
1179 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
1180 self.rules.rules_file = file;
1181 self
1182 }
1183
1184 #[must_use]
1185 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
1186 self.rules.dry_run = dry_run;
1187 self
1188 }
1189
1190 #[must_use]
1191 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1192 self.ecosystem_rules.config_file = file;
1193 self
1194 }
1195
1196 #[must_use]
1197 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1198 self.ecosystem_rules.disabled = disabled;
1199 self
1200 }
1201
1202 #[must_use]
1203 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1204 self.ecosystem_rules.detect_typosquats = detect;
1205 self
1206 }
1207
1208 #[must_use]
1209 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1210 self.enrichment = config;
1211 self
1212 }
1213
1214 #[must_use]
1215 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1216 self.enrichment.enabled = enabled;
1217 self
1218 }
1219
1220 pub fn build(self) -> anyhow::Result<DiffConfig> {
1221 let old = self
1222 .old
1223 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1224 let new = self
1225 .new
1226 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1227
1228 Ok(DiffConfig {
1229 paths: DiffPaths { old, new },
1230 output: self.output,
1231 matching: self.matching,
1232 filtering: self.filtering,
1233 behavior: self.behavior,
1234 graph_diff: self.graph_diff,
1235 rules: self.rules,
1236 ecosystem_rules: self.ecosystem_rules,
1237 enrichment: self.enrichment,
1238 })
1239 }
1240}
1241
1242#[cfg(test)]
1243mod tests {
1244 use super::*;
1245
1246 #[test]
1247 fn theme_name_serde_roundtrip() {
1248 let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1250 assert_eq!(dark, ThemeName::Dark);
1251
1252 let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1253 assert_eq!(light, ThemeName::Light);
1254
1255 let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1256 assert_eq!(hc, ThemeName::HighContrast);
1257
1258 let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1260 assert_eq!(serialized, r#""high-contrast""#);
1261 }
1262
1263 #[test]
1264 fn theme_name_from_str() {
1265 assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1266 assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1267 assert_eq!(
1268 "high-contrast".parse::<ThemeName>().unwrap(),
1269 ThemeName::HighContrast
1270 );
1271 assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1272 assert!("neon".parse::<ThemeName>().is_err());
1273 }
1274
1275 #[test]
1276 fn fuzzy_preset_serde_roundtrip() {
1277 let presets = [
1278 ("strict", FuzzyPreset::Strict),
1279 ("balanced", FuzzyPreset::Balanced),
1280 ("permissive", FuzzyPreset::Permissive),
1281 ("strict-multi", FuzzyPreset::StrictMulti),
1282 ("balanced-multi", FuzzyPreset::BalancedMulti),
1283 ("security-focused", FuzzyPreset::SecurityFocused),
1284 ];
1285
1286 for (json_str, expected) in presets {
1287 let json = format!(r#""{json_str}""#);
1288 let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1289 assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1290
1291 let serialized = serde_json::to_string(&expected).unwrap();
1292 assert_eq!(serialized, json, "failed to serialize {expected:?}");
1293 }
1294 }
1295
1296 #[test]
1297 fn fuzzy_preset_from_str() {
1298 assert_eq!(
1299 "strict".parse::<FuzzyPreset>().unwrap(),
1300 FuzzyPreset::Strict
1301 );
1302 assert_eq!(
1303 "security-focused".parse::<FuzzyPreset>().unwrap(),
1304 FuzzyPreset::SecurityFocused
1305 );
1306 assert_eq!(
1308 "strict_multi".parse::<FuzzyPreset>().unwrap(),
1309 FuzzyPreset::StrictMulti
1310 );
1311 assert!("invalid".parse::<FuzzyPreset>().is_err());
1312 }
1313
1314 #[test]
1315 fn tui_preferences_json_backward_compat() {
1316 let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1318 let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1319 assert_eq!(prefs.theme, ThemeName::HighContrast);
1320 assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1321 }
1322}