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: FuzzyPreset) -> Self {
71 self.config.matching.fuzzy_preset = preset;
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, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
164#[serde(rename_all = "kebab-case")]
165pub enum ThemeName {
166 #[default]
168 Dark,
169 Light,
171 HighContrast,
173}
174
175impl ThemeName {
176 #[must_use]
178 pub fn as_str(&self) -> &'static str {
179 match self {
180 Self::Dark => "dark",
181 Self::Light => "light",
182 Self::HighContrast => "high-contrast",
183 }
184 }
185}
186
187impl std::fmt::Display for ThemeName {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.write_str(self.as_str())
190 }
191}
192
193impl std::str::FromStr for ThemeName {
194 type Err = String;
195
196 fn from_str(s: &str) -> Result<Self, Self::Err> {
197 match s.to_lowercase().as_str() {
198 "dark" => Ok(Self::Dark),
199 "light" => Ok(Self::Light),
200 "high-contrast" | "highcontrast" | "hc" => Ok(Self::HighContrast),
201 _ => Err(format!("unknown theme: {s}")),
202 }
203 }
204}
205
206#[derive(
208 Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, clap::ValueEnum,
209)]
210#[serde(rename_all = "kebab-case")]
211pub enum FuzzyPreset {
212 Strict,
214 #[default]
216 Balanced,
217 Permissive,
219 #[value(alias = "strict_multi")]
221 StrictMulti,
222 #[value(alias = "balanced_multi")]
224 BalancedMulti,
225 #[value(alias = "security_focused")]
227 SecurityFocused,
228}
229
230impl FuzzyPreset {
231 #[must_use]
233 pub fn as_str(&self) -> &'static str {
234 match self {
235 Self::Strict => "strict",
236 Self::Balanced => "balanced",
237 Self::Permissive => "permissive",
238 Self::StrictMulti => "strict-multi",
239 Self::BalancedMulti => "balanced-multi",
240 Self::SecurityFocused => "security-focused",
241 }
242 }
243}
244
245impl std::str::FromStr for FuzzyPreset {
246 type Err = String;
247
248 fn from_str(s: &str) -> Result<Self, Self::Err> {
249 match s.to_lowercase().replace('_', "-").as_str() {
250 "strict" => Ok(Self::Strict),
251 "balanced" => Ok(Self::Balanced),
252 "permissive" => Ok(Self::Permissive),
253 "strict-multi" => Ok(Self::StrictMulti),
254 "balanced-multi" => Ok(Self::BalancedMulti),
255 "security-focused" => Ok(Self::SecurityFocused),
256 _ => Err(format!("unknown fuzzy preset: {s}")),
257 }
258 }
259}
260
261impl std::fmt::Display for FuzzyPreset {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 f.write_str(self.as_str())
264 }
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
273pub struct TuiPreferences {
274 pub theme: ThemeName,
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub last_tab: Option<String>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub last_view_tab: Option<String>,
282}
283
284impl Default for TuiPreferences {
285 fn default() -> Self {
286 Self {
287 theme: ThemeName::Dark,
288 last_tab: None,
289 last_view_tab: None,
290 }
291 }
292}
293
294impl TuiPreferences {
295 #[must_use]
297 pub fn config_path() -> Option<PathBuf> {
298 dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
299 }
300
301 #[must_use]
303 pub fn load() -> Self {
304 Self::config_path()
305 .and_then(|p| std::fs::read_to_string(p).ok())
306 .and_then(|s| serde_json::from_str(&s).ok())
307 .unwrap_or_default()
308 }
309
310 pub fn save(&self) -> std::io::Result<()> {
312 if let Some(path) = Self::config_path() {
313 if let Some(parent) = path.parent() {
314 std::fs::create_dir_all(parent)?;
315 }
316 let json = serde_json::to_string_pretty(self)
317 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
318 std::fs::write(path, json)?;
319 }
320 Ok(())
321 }
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
330#[serde(default)]
331pub struct TuiConfig {
332 pub theme: ThemeName,
334 pub show_line_numbers: bool,
336 pub mouse_enabled: bool,
338 #[schemars(range(min = 0.0, max = 1.0))]
340 pub initial_threshold: f64,
341}
342
343impl Default for TuiConfig {
344 fn default() -> Self {
345 Self {
346 theme: ThemeName::Dark,
347 show_line_numbers: true,
348 mouse_enabled: true,
349 initial_threshold: 0.8,
350 }
351 }
352}
353
354#[derive(Debug, Clone)]
360pub struct DiffConfig {
361 pub paths: DiffPaths,
363 pub output: OutputConfig,
365 pub matching: MatchingConfig,
367 pub filtering: FilterConfig,
369 pub behavior: BehaviorConfig,
371 pub graph_diff: GraphAwareDiffConfig,
373 pub rules: MatchingRulesPathConfig,
375 pub ecosystem_rules: EcosystemRulesConfig,
377 pub enrichment: EnrichmentConfig,
379}
380
381#[derive(Debug, Clone)]
383pub struct DiffPaths {
384 pub old: PathBuf,
386 pub new: PathBuf,
388}
389
390#[derive(Debug, Clone)]
392pub struct ViewConfig {
393 pub sbom_path: PathBuf,
395 pub output: OutputConfig,
397 pub validate_ntia: bool,
399 pub min_severity: Option<String>,
401 pub vulnerable_only: bool,
403 pub ecosystem_filter: Option<String>,
405 pub fail_on_vuln: bool,
407 pub bom_profile: Option<crate::model::BomProfile>,
409 pub enrichment: EnrichmentConfig,
411 pub cra_sidecar_path: Option<PathBuf>,
415 pub cra_product_class: Option<String>,
419}
420
421#[derive(Debug, Clone)]
423pub struct MultiDiffConfig {
424 pub baseline: PathBuf,
426 pub targets: Vec<PathBuf>,
428 pub output: OutputConfig,
430 pub matching: MatchingConfig,
432 pub filtering: FilterConfig,
434 pub behavior: BehaviorConfig,
436 pub graph_diff: GraphAwareDiffConfig,
438 pub rules: MatchingRulesPathConfig,
440 pub ecosystem_rules: EcosystemRulesConfig,
442 pub enrichment: EnrichmentConfig,
444}
445
446#[derive(Debug, Clone)]
448pub struct TimelineConfig {
449 pub sbom_paths: Vec<PathBuf>,
451 pub output: OutputConfig,
453 pub matching: MatchingConfig,
455 pub filtering: FilterConfig,
457 pub behavior: BehaviorConfig,
459 pub graph_diff: GraphAwareDiffConfig,
461 pub rules: MatchingRulesPathConfig,
463 pub ecosystem_rules: EcosystemRulesConfig,
465 pub enrichment: EnrichmentConfig,
467}
468
469#[derive(Debug, Clone)]
471pub struct QueryConfig {
472 pub sbom_paths: Vec<PathBuf>,
474 pub output: OutputConfig,
476 pub enrichment: EnrichmentConfig,
478 pub limit: Option<usize>,
480 pub group_by_sbom: bool,
482}
483
484#[derive(Debug, Clone)]
486pub struct MatrixConfig {
487 pub sbom_paths: Vec<PathBuf>,
489 pub output: OutputConfig,
491 pub matching: MatchingConfig,
493 pub cluster_threshold: f64,
495 pub filtering: FilterConfig,
497 pub behavior: BehaviorConfig,
499 pub graph_diff: GraphAwareDiffConfig,
501 pub rules: MatchingRulesPathConfig,
503 pub ecosystem_rules: EcosystemRulesConfig,
505 pub enrichment: EnrichmentConfig,
507}
508
509#[derive(Debug, Clone)]
511pub struct VexConfig {
512 pub sbom_path: PathBuf,
514 pub vex_paths: Vec<PathBuf>,
516 pub output_format: ReportFormat,
518 pub output_file: Option<PathBuf>,
520 pub quiet: bool,
522 pub actionable_only: bool,
524 pub filter_state: Option<String>,
526 pub enrichment: EnrichmentConfig,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
536#[serde(default)]
537pub struct OutputConfig {
538 pub format: ReportFormat,
540 #[serde(skip_serializing_if = "Option::is_none")]
542 pub file: Option<PathBuf>,
543 pub report_types: ReportType,
545 pub no_color: bool,
547 pub streaming: StreamingConfig,
549 #[serde(skip_serializing_if = "Option::is_none")]
554 pub export_template: Option<String>,
555}
556
557impl Default for OutputConfig {
558 fn default() -> Self {
559 Self {
560 format: ReportFormat::Auto,
561 file: None,
562 report_types: ReportType::All,
563 no_color: false,
564 streaming: StreamingConfig::default(),
565 export_template: None,
566 }
567 }
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
576#[serde(default)]
577pub struct StreamingConfig {
578 #[schemars(range(min = 0))]
581 pub threshold_bytes: u64,
582 pub force: bool,
585 pub disabled: bool,
587 pub stream_stdin: bool,
590}
591
592impl Default for StreamingConfig {
593 fn default() -> Self {
594 Self {
595 threshold_bytes: 10 * 1024 * 1024, force: false,
597 disabled: false,
598 stream_stdin: true,
599 }
600 }
601}
602
603impl StreamingConfig {
604 #[must_use]
606 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
607 if self.disabled {
608 return false;
609 }
610 if self.force {
611 return true;
612 }
613 if is_stdin && self.stream_stdin {
614 return true;
615 }
616 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
617 }
618
619 #[must_use]
621 pub fn always() -> Self {
622 Self {
623 force: true,
624 ..Default::default()
625 }
626 }
627
628 #[must_use]
630 pub fn never() -> Self {
631 Self {
632 disabled: true,
633 ..Default::default()
634 }
635 }
636
637 #[must_use]
639 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
640 self.threshold_bytes = mb * 1024 * 1024;
641 self
642 }
643}
644
645#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
647#[serde(default)]
648pub struct MatchingConfig {
649 pub fuzzy_preset: FuzzyPreset,
651 #[serde(skip_serializing_if = "Option::is_none")]
653 #[schemars(range(min = 0.0, max = 1.0))]
654 pub threshold: Option<f64>,
655 pub include_unchanged: bool,
657}
658
659impl Default for MatchingConfig {
660 fn default() -> Self {
661 Self {
662 fuzzy_preset: FuzzyPreset::Balanced,
663 threshold: None,
664 include_unchanged: false,
665 }
666 }
667}
668
669impl MatchingConfig {
670 #[must_use]
672 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
673 let mut config =
674 FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
675 FuzzyMatchConfig::balanced()
677 });
678
679 if let Some(threshold) = self.threshold {
681 config = config.with_threshold(threshold);
682 }
683
684 config
685 }
686}
687
688#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
690#[serde(default)]
691pub struct FilterConfig {
692 pub only_changes: bool,
694 #[serde(skip_serializing_if = "Option::is_none")]
696 pub min_severity: Option<String>,
697 #[serde(alias = "exclude_vex_not_affected")]
699 pub exclude_vex_resolved: bool,
700 pub fail_on_vex_gap: bool,
702}
703
704#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
706#[serde(default)]
707pub struct BehaviorConfig {
708 pub fail_on_vuln: bool,
710 #[serde(default)]
712 pub fail_on_kev: bool,
713 pub fail_on_change: bool,
715 pub quiet: bool,
717 pub explain_matches: bool,
719 pub recommend_threshold: bool,
721}
722
723#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
725#[serde(default)]
726pub struct GraphAwareDiffConfig {
727 pub enabled: bool,
729 pub detect_reparenting: bool,
731 pub detect_depth_changes: bool,
733 pub max_depth: u32,
735 pub impact_threshold: Option<String>,
737 pub relation_filter: Vec<String>,
739}
740
741impl GraphAwareDiffConfig {
742 #[must_use]
744 pub const fn enabled() -> Self {
745 Self {
746 enabled: true,
747 detect_reparenting: true,
748 detect_depth_changes: true,
749 max_depth: 0,
750 impact_threshold: None,
751 relation_filter: Vec::new(),
752 }
753 }
754}
755
756#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
758#[serde(default)]
759pub struct MatchingRulesPathConfig {
760 #[serde(skip_serializing_if = "Option::is_none")]
762 pub rules_file: Option<PathBuf>,
763 pub dry_run: bool,
765}
766
767#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
769#[serde(default)]
770pub struct EcosystemRulesConfig {
771 #[serde(skip_serializing_if = "Option::is_none")]
773 pub config_file: Option<PathBuf>,
774 pub disabled: bool,
776 pub detect_typosquats: bool,
778}
779
780#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
785#[serde(default)]
786pub struct EnrichmentConfig {
787 pub enabled: bool,
789 pub provider: String,
791 #[schemars(range(min = 1))]
793 pub cache_ttl_hours: u64,
794 #[schemars(range(min = 1))]
796 pub max_concurrent: usize,
797 #[serde(skip_serializing_if = "Option::is_none")]
799 pub cache_dir: Option<std::path::PathBuf>,
800 pub bypass_cache: bool,
802 #[schemars(range(min = 1))]
804 pub timeout_secs: u64,
805 pub enable_eol: bool,
807 #[serde(default)]
809 pub enable_kev: bool,
810 #[serde(default)]
812 pub enable_epss: bool,
813 #[serde(default)]
815 pub enable_staleness: bool,
816 #[serde(default)]
819 pub enable_huggingface: bool,
820 #[serde(default, skip_serializing_if = "Vec::is_empty")]
822 pub vex_paths: Vec<std::path::PathBuf>,
823 #[serde(skip_serializing_if = "Option::is_none")]
825 pub api_base: Option<String>,
826 #[serde(skip_serializing_if = "Option::is_none")]
829 pub kev_url: Option<String>,
830 #[serde(skip_serializing_if = "Option::is_none")]
833 pub epss_url: Option<String>,
834 #[serde(skip_serializing_if = "Option::is_none")]
837 pub huggingface_url: Option<String>,
838 #[serde(default)]
841 pub offline: bool,
842}
843
844impl Default for EnrichmentConfig {
845 fn default() -> Self {
846 Self {
847 enabled: false,
848 provider: "osv".to_string(),
849 cache_ttl_hours: 24,
850 max_concurrent: 10,
851 cache_dir: None,
852 bypass_cache: false,
853 timeout_secs: 30,
854 enable_eol: false,
855 enable_kev: false,
856 enable_epss: false,
857 enable_staleness: false,
858 enable_huggingface: false,
859 vex_paths: Vec::new(),
860 api_base: None,
861 kev_url: None,
862 epss_url: None,
863 huggingface_url: None,
864 offline: false,
865 }
866 }
867}
868
869impl EnrichmentConfig {
870 #[must_use]
872 pub fn osv() -> Self {
873 Self {
874 enabled: true,
875 provider: "osv".to_string(),
876 ..Default::default()
877 }
878 }
879
880 #[must_use]
882 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
883 self.cache_dir = Some(dir);
884 self
885 }
886
887 #[must_use]
889 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
890 self.cache_ttl_hours = hours;
891 self
892 }
893
894 #[must_use]
896 pub const fn with_bypass_cache(mut self) -> Self {
897 self.bypass_cache = true;
898 self
899 }
900
901 #[must_use]
903 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
904 self.timeout_secs = secs;
905 self
906 }
907
908 #[must_use]
910 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
911 self.vex_paths = paths;
912 self
913 }
914
915 #[must_use]
917 pub fn with_api_base(mut self, api_base: impl Into<String>) -> Self {
918 self.api_base = Some(api_base.into());
919 self
920 }
921
922 #[must_use]
924 pub const fn with_kev(mut self) -> Self {
925 self.enable_kev = true;
926 self
927 }
928
929 #[must_use]
931 pub fn with_kev_url(mut self, kev_url: impl Into<String>) -> Self {
932 self.kev_url = Some(kev_url.into());
933 self
934 }
935
936 #[must_use]
938 pub const fn with_epss(mut self) -> Self {
939 self.enable_epss = true;
940 self
941 }
942
943 #[must_use]
945 pub fn with_epss_url(mut self, epss_url: impl Into<String>) -> Self {
946 self.epss_url = Some(epss_url.into());
947 self
948 }
949
950 #[must_use]
952 pub const fn with_staleness(mut self) -> Self {
953 self.enable_staleness = true;
954 self
955 }
956
957 #[must_use]
959 pub const fn with_huggingface(mut self) -> Self {
960 self.enable_huggingface = true;
961 self
962 }
963
964 #[must_use]
966 pub fn with_huggingface_url(mut self, url: impl Into<String>) -> Self {
967 self.huggingface_url = Some(url.into());
968 self
969 }
970
971 #[must_use]
973 pub const fn with_offline(mut self) -> Self {
974 self.offline = true;
975 self
976 }
977}
978
979#[derive(Debug, Default)]
985pub struct DiffConfigBuilder {
986 old: Option<PathBuf>,
987 new: Option<PathBuf>,
988 output: OutputConfig,
989 matching: MatchingConfig,
990 filtering: FilterConfig,
991 behavior: BehaviorConfig,
992 graph_diff: GraphAwareDiffConfig,
993 rules: MatchingRulesPathConfig,
994 ecosystem_rules: EcosystemRulesConfig,
995 enrichment: EnrichmentConfig,
996}
997
998impl DiffConfigBuilder {
999 #[must_use]
1000 pub fn new() -> Self {
1001 Self::default()
1002 }
1003
1004 #[must_use]
1005 pub fn old_path(mut self, path: PathBuf) -> Self {
1006 self.old = Some(path);
1007 self
1008 }
1009
1010 #[must_use]
1011 pub fn new_path(mut self, path: PathBuf) -> Self {
1012 self.new = Some(path);
1013 self
1014 }
1015
1016 #[must_use]
1017 pub const fn output_format(mut self, format: ReportFormat) -> Self {
1018 self.output.format = format;
1019 self
1020 }
1021
1022 #[must_use]
1023 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
1024 self.output.file = file;
1025 self
1026 }
1027
1028 #[must_use]
1029 pub const fn report_types(mut self, types: ReportType) -> Self {
1030 self.output.report_types = types;
1031 self
1032 }
1033
1034 #[must_use]
1035 pub const fn no_color(mut self, no_color: bool) -> Self {
1036 self.output.no_color = no_color;
1037 self
1038 }
1039
1040 #[must_use]
1041 pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
1042 self.matching.fuzzy_preset = preset;
1043 self
1044 }
1045
1046 #[must_use]
1047 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
1048 self.matching.threshold = threshold;
1049 self
1050 }
1051
1052 #[must_use]
1053 pub const fn include_unchanged(mut self, include: bool) -> Self {
1054 self.matching.include_unchanged = include;
1055 self
1056 }
1057
1058 #[must_use]
1059 pub const fn only_changes(mut self, only: bool) -> Self {
1060 self.filtering.only_changes = only;
1061 self
1062 }
1063
1064 #[must_use]
1065 pub fn min_severity(mut self, severity: Option<String>) -> Self {
1066 self.filtering.min_severity = severity;
1067 self
1068 }
1069
1070 #[must_use]
1071 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
1072 self.behavior.fail_on_vuln = fail;
1073 self
1074 }
1075
1076 #[must_use]
1077 pub const fn fail_on_kev(mut self, fail: bool) -> Self {
1078 self.behavior.fail_on_kev = fail;
1079 self
1080 }
1081
1082 #[must_use]
1083 pub const fn fail_on_change(mut self, fail: bool) -> Self {
1084 self.behavior.fail_on_change = fail;
1085 self
1086 }
1087
1088 #[must_use]
1089 pub const fn quiet(mut self, quiet: bool) -> Self {
1090 self.behavior.quiet = quiet;
1091 self
1092 }
1093
1094 #[must_use]
1095 pub const fn explain_matches(mut self, explain: bool) -> Self {
1096 self.behavior.explain_matches = explain;
1097 self
1098 }
1099
1100 #[must_use]
1101 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
1102 self.behavior.recommend_threshold = recommend;
1103 self
1104 }
1105
1106 #[must_use]
1107 pub fn graph_diff(mut self, enabled: bool) -> Self {
1108 self.graph_diff = if enabled {
1109 GraphAwareDiffConfig::enabled()
1110 } else {
1111 GraphAwareDiffConfig::default()
1112 };
1113 self
1114 }
1115
1116 #[must_use]
1117 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
1118 self.rules.rules_file = file;
1119 self
1120 }
1121
1122 #[must_use]
1123 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
1124 self.rules.dry_run = dry_run;
1125 self
1126 }
1127
1128 #[must_use]
1129 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1130 self.ecosystem_rules.config_file = file;
1131 self
1132 }
1133
1134 #[must_use]
1135 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1136 self.ecosystem_rules.disabled = disabled;
1137 self
1138 }
1139
1140 #[must_use]
1141 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1142 self.ecosystem_rules.detect_typosquats = detect;
1143 self
1144 }
1145
1146 #[must_use]
1147 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1148 self.enrichment = config;
1149 self
1150 }
1151
1152 #[must_use]
1153 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1154 self.enrichment.enabled = enabled;
1155 self
1156 }
1157
1158 pub fn build(self) -> anyhow::Result<DiffConfig> {
1159 let old = self
1160 .old
1161 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1162 let new = self
1163 .new
1164 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1165
1166 Ok(DiffConfig {
1167 paths: DiffPaths { old, new },
1168 output: self.output,
1169 matching: self.matching,
1170 filtering: self.filtering,
1171 behavior: self.behavior,
1172 graph_diff: self.graph_diff,
1173 rules: self.rules,
1174 ecosystem_rules: self.ecosystem_rules,
1175 enrichment: self.enrichment,
1176 })
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183
1184 #[test]
1185 fn theme_name_serde_roundtrip() {
1186 let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1188 assert_eq!(dark, ThemeName::Dark);
1189
1190 let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1191 assert_eq!(light, ThemeName::Light);
1192
1193 let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1194 assert_eq!(hc, ThemeName::HighContrast);
1195
1196 let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1198 assert_eq!(serialized, r#""high-contrast""#);
1199 }
1200
1201 #[test]
1202 fn theme_name_from_str() {
1203 assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1204 assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1205 assert_eq!(
1206 "high-contrast".parse::<ThemeName>().unwrap(),
1207 ThemeName::HighContrast
1208 );
1209 assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1210 assert!("neon".parse::<ThemeName>().is_err());
1211 }
1212
1213 #[test]
1214 fn fuzzy_preset_serde_roundtrip() {
1215 let presets = [
1216 ("strict", FuzzyPreset::Strict),
1217 ("balanced", FuzzyPreset::Balanced),
1218 ("permissive", FuzzyPreset::Permissive),
1219 ("strict-multi", FuzzyPreset::StrictMulti),
1220 ("balanced-multi", FuzzyPreset::BalancedMulti),
1221 ("security-focused", FuzzyPreset::SecurityFocused),
1222 ];
1223
1224 for (json_str, expected) in presets {
1225 let json = format!(r#""{json_str}""#);
1226 let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1227 assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1228
1229 let serialized = serde_json::to_string(&expected).unwrap();
1230 assert_eq!(serialized, json, "failed to serialize {expected:?}");
1231 }
1232 }
1233
1234 #[test]
1235 fn fuzzy_preset_from_str() {
1236 assert_eq!(
1237 "strict".parse::<FuzzyPreset>().unwrap(),
1238 FuzzyPreset::Strict
1239 );
1240 assert_eq!(
1241 "security-focused".parse::<FuzzyPreset>().unwrap(),
1242 FuzzyPreset::SecurityFocused
1243 );
1244 assert_eq!(
1246 "strict_multi".parse::<FuzzyPreset>().unwrap(),
1247 FuzzyPreset::StrictMulti
1248 );
1249 assert!("invalid".parse::<FuzzyPreset>().is_err());
1250 }
1251
1252 #[test]
1253 fn tui_preferences_json_backward_compat() {
1254 let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1256 let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1257 assert_eq!(prefs.theme, ThemeName::HighContrast);
1258 assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1259 }
1260}