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(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
208#[serde(rename_all = "kebab-case")]
209pub enum FuzzyPreset {
210 Strict,
212 #[default]
214 Balanced,
215 Permissive,
217 StrictMulti,
219 BalancedMulti,
221 SecurityFocused,
223}
224
225impl FuzzyPreset {
226 #[must_use]
228 pub fn as_str(&self) -> &'static str {
229 match self {
230 Self::Strict => "strict",
231 Self::Balanced => "balanced",
232 Self::Permissive => "permissive",
233 Self::StrictMulti => "strict-multi",
234 Self::BalancedMulti => "balanced-multi",
235 Self::SecurityFocused => "security-focused",
236 }
237 }
238}
239
240impl std::str::FromStr for FuzzyPreset {
241 type Err = String;
242
243 fn from_str(s: &str) -> Result<Self, Self::Err> {
244 match s.to_lowercase().replace('_', "-").as_str() {
245 "strict" => Ok(Self::Strict),
246 "balanced" => Ok(Self::Balanced),
247 "permissive" => Ok(Self::Permissive),
248 "strict-multi" => Ok(Self::StrictMulti),
249 "balanced-multi" => Ok(Self::BalancedMulti),
250 "security-focused" => Ok(Self::SecurityFocused),
251 _ => Err(format!("unknown fuzzy preset: {s}")),
252 }
253 }
254}
255
256impl std::fmt::Display for FuzzyPreset {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.write_str(self.as_str())
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
268pub struct TuiPreferences {
269 pub theme: ThemeName,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub last_tab: Option<String>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub last_view_tab: Option<String>,
277}
278
279impl Default for TuiPreferences {
280 fn default() -> Self {
281 Self {
282 theme: ThemeName::Dark,
283 last_tab: None,
284 last_view_tab: None,
285 }
286 }
287}
288
289impl TuiPreferences {
290 #[must_use]
292 pub fn config_path() -> Option<PathBuf> {
293 dirs::config_dir().map(|p| p.join("sbom-tools").join("preferences.json"))
294 }
295
296 #[must_use]
298 pub fn load() -> Self {
299 Self::config_path()
300 .and_then(|p| std::fs::read_to_string(p).ok())
301 .and_then(|s| serde_json::from_str(&s).ok())
302 .unwrap_or_default()
303 }
304
305 pub fn save(&self) -> std::io::Result<()> {
307 if let Some(path) = Self::config_path() {
308 if let Some(parent) = path.parent() {
309 std::fs::create_dir_all(parent)?;
310 }
311 let json = serde_json::to_string_pretty(self)
312 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
313 std::fs::write(path, json)?;
314 }
315 Ok(())
316 }
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
325#[serde(default)]
326pub struct TuiConfig {
327 pub theme: ThemeName,
329 pub show_line_numbers: bool,
331 pub mouse_enabled: bool,
333 #[schemars(range(min = 0.0, max = 1.0))]
335 pub initial_threshold: f64,
336}
337
338impl Default for TuiConfig {
339 fn default() -> Self {
340 Self {
341 theme: ThemeName::Dark,
342 show_line_numbers: true,
343 mouse_enabled: true,
344 initial_threshold: 0.8,
345 }
346 }
347}
348
349#[derive(Debug, Clone)]
355pub struct DiffConfig {
356 pub paths: DiffPaths,
358 pub output: OutputConfig,
360 pub matching: MatchingConfig,
362 pub filtering: FilterConfig,
364 pub behavior: BehaviorConfig,
366 pub graph_diff: GraphAwareDiffConfig,
368 pub rules: MatchingRulesPathConfig,
370 pub ecosystem_rules: EcosystemRulesConfig,
372 pub enrichment: EnrichmentConfig,
374}
375
376#[derive(Debug, Clone)]
378pub struct DiffPaths {
379 pub old: PathBuf,
381 pub new: PathBuf,
383}
384
385#[derive(Debug, Clone)]
387pub struct ViewConfig {
388 pub sbom_path: PathBuf,
390 pub output: OutputConfig,
392 pub validate_ntia: bool,
394 pub min_severity: Option<String>,
396 pub vulnerable_only: bool,
398 pub ecosystem_filter: Option<String>,
400 pub fail_on_vuln: bool,
402 pub bom_profile: Option<crate::model::BomProfile>,
404 pub enrichment: EnrichmentConfig,
406}
407
408#[derive(Debug, Clone)]
410pub struct MultiDiffConfig {
411 pub baseline: PathBuf,
413 pub targets: Vec<PathBuf>,
415 pub output: OutputConfig,
417 pub matching: MatchingConfig,
419 pub filtering: FilterConfig,
421 pub behavior: BehaviorConfig,
423 pub graph_diff: GraphAwareDiffConfig,
425 pub rules: MatchingRulesPathConfig,
427 pub ecosystem_rules: EcosystemRulesConfig,
429 pub enrichment: EnrichmentConfig,
431}
432
433#[derive(Debug, Clone)]
435pub struct TimelineConfig {
436 pub sbom_paths: Vec<PathBuf>,
438 pub output: OutputConfig,
440 pub matching: MatchingConfig,
442 pub filtering: FilterConfig,
444 pub behavior: BehaviorConfig,
446 pub graph_diff: GraphAwareDiffConfig,
448 pub rules: MatchingRulesPathConfig,
450 pub ecosystem_rules: EcosystemRulesConfig,
452 pub enrichment: EnrichmentConfig,
454}
455
456#[derive(Debug, Clone)]
458pub struct QueryConfig {
459 pub sbom_paths: Vec<PathBuf>,
461 pub output: OutputConfig,
463 pub enrichment: EnrichmentConfig,
465 pub limit: Option<usize>,
467 pub group_by_sbom: bool,
469}
470
471#[derive(Debug, Clone)]
473pub struct MatrixConfig {
474 pub sbom_paths: Vec<PathBuf>,
476 pub output: OutputConfig,
478 pub matching: MatchingConfig,
480 pub cluster_threshold: f64,
482 pub filtering: FilterConfig,
484 pub behavior: BehaviorConfig,
486 pub graph_diff: GraphAwareDiffConfig,
488 pub rules: MatchingRulesPathConfig,
490 pub ecosystem_rules: EcosystemRulesConfig,
492 pub enrichment: EnrichmentConfig,
494}
495
496#[derive(Debug, Clone)]
498pub struct VexConfig {
499 pub sbom_path: PathBuf,
501 pub vex_paths: Vec<PathBuf>,
503 pub output_format: ReportFormat,
505 pub output_file: Option<PathBuf>,
507 pub quiet: bool,
509 pub actionable_only: bool,
511 pub filter_state: Option<String>,
513 pub enrichment: EnrichmentConfig,
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
523#[serde(default)]
524pub struct OutputConfig {
525 pub format: ReportFormat,
527 #[serde(skip_serializing_if = "Option::is_none")]
529 pub file: Option<PathBuf>,
530 pub report_types: ReportType,
532 pub no_color: bool,
534 pub streaming: StreamingConfig,
536 #[serde(skip_serializing_if = "Option::is_none")]
541 pub export_template: Option<String>,
542}
543
544impl Default for OutputConfig {
545 fn default() -> Self {
546 Self {
547 format: ReportFormat::Auto,
548 file: None,
549 report_types: ReportType::All,
550 no_color: false,
551 streaming: StreamingConfig::default(),
552 export_template: None,
553 }
554 }
555}
556
557#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
563#[serde(default)]
564pub struct StreamingConfig {
565 #[schemars(range(min = 0))]
568 pub threshold_bytes: u64,
569 pub force: bool,
572 pub disabled: bool,
574 pub stream_stdin: bool,
577}
578
579impl Default for StreamingConfig {
580 fn default() -> Self {
581 Self {
582 threshold_bytes: 10 * 1024 * 1024, force: false,
584 disabled: false,
585 stream_stdin: true,
586 }
587 }
588}
589
590impl StreamingConfig {
591 #[must_use]
593 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
594 if self.disabled {
595 return false;
596 }
597 if self.force {
598 return true;
599 }
600 if is_stdin && self.stream_stdin {
601 return true;
602 }
603 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
604 }
605
606 #[must_use]
608 pub fn always() -> Self {
609 Self {
610 force: true,
611 ..Default::default()
612 }
613 }
614
615 #[must_use]
617 pub fn never() -> Self {
618 Self {
619 disabled: true,
620 ..Default::default()
621 }
622 }
623
624 #[must_use]
626 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
627 self.threshold_bytes = mb * 1024 * 1024;
628 self
629 }
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
634#[serde(default)]
635pub struct MatchingConfig {
636 pub fuzzy_preset: FuzzyPreset,
638 #[serde(skip_serializing_if = "Option::is_none")]
640 #[schemars(range(min = 0.0, max = 1.0))]
641 pub threshold: Option<f64>,
642 pub include_unchanged: bool,
644}
645
646impl Default for MatchingConfig {
647 fn default() -> Self {
648 Self {
649 fuzzy_preset: FuzzyPreset::Balanced,
650 threshold: None,
651 include_unchanged: false,
652 }
653 }
654}
655
656impl MatchingConfig {
657 #[must_use]
659 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
660 let mut config =
661 FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
662 FuzzyMatchConfig::balanced()
664 });
665
666 if let Some(threshold) = self.threshold {
668 config = config.with_threshold(threshold);
669 }
670
671 config
672 }
673}
674
675#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
677#[serde(default)]
678pub struct FilterConfig {
679 pub only_changes: bool,
681 #[serde(skip_serializing_if = "Option::is_none")]
683 pub min_severity: Option<String>,
684 #[serde(alias = "exclude_vex_not_affected")]
686 pub exclude_vex_resolved: bool,
687 pub fail_on_vex_gap: bool,
689}
690
691#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
693#[serde(default)]
694pub struct BehaviorConfig {
695 pub fail_on_vuln: bool,
697 pub fail_on_change: bool,
699 pub quiet: bool,
701 pub explain_matches: bool,
703 pub recommend_threshold: bool,
705}
706
707#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
709#[serde(default)]
710pub struct GraphAwareDiffConfig {
711 pub enabled: bool,
713 pub detect_reparenting: bool,
715 pub detect_depth_changes: bool,
717 pub max_depth: u32,
719 pub impact_threshold: Option<String>,
721 pub relation_filter: Vec<String>,
723}
724
725impl GraphAwareDiffConfig {
726 #[must_use]
728 pub const fn enabled() -> Self {
729 Self {
730 enabled: true,
731 detect_reparenting: true,
732 detect_depth_changes: true,
733 max_depth: 0,
734 impact_threshold: None,
735 relation_filter: Vec::new(),
736 }
737 }
738}
739
740#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
742#[serde(default)]
743pub struct MatchingRulesPathConfig {
744 #[serde(skip_serializing_if = "Option::is_none")]
746 pub rules_file: Option<PathBuf>,
747 pub dry_run: bool,
749}
750
751#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
753#[serde(default)]
754pub struct EcosystemRulesConfig {
755 #[serde(skip_serializing_if = "Option::is_none")]
757 pub config_file: Option<PathBuf>,
758 pub disabled: bool,
760 pub detect_typosquats: bool,
762}
763
764#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
769#[serde(default)]
770pub struct EnrichmentConfig {
771 pub enabled: bool,
773 pub provider: String,
775 #[schemars(range(min = 1))]
777 pub cache_ttl_hours: u64,
778 #[schemars(range(min = 1))]
780 pub max_concurrent: usize,
781 #[serde(skip_serializing_if = "Option::is_none")]
783 pub cache_dir: Option<std::path::PathBuf>,
784 pub bypass_cache: bool,
786 #[schemars(range(min = 1))]
788 pub timeout_secs: u64,
789 pub enable_eol: bool,
791 #[serde(default, skip_serializing_if = "Vec::is_empty")]
793 pub vex_paths: Vec<std::path::PathBuf>,
794}
795
796impl Default for EnrichmentConfig {
797 fn default() -> Self {
798 Self {
799 enabled: false,
800 provider: "osv".to_string(),
801 cache_ttl_hours: 24,
802 max_concurrent: 10,
803 cache_dir: None,
804 bypass_cache: false,
805 timeout_secs: 30,
806 enable_eol: false,
807 vex_paths: Vec::new(),
808 }
809 }
810}
811
812impl EnrichmentConfig {
813 #[must_use]
815 pub fn osv() -> Self {
816 Self {
817 enabled: true,
818 provider: "osv".to_string(),
819 ..Default::default()
820 }
821 }
822
823 #[must_use]
825 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
826 self.cache_dir = Some(dir);
827 self
828 }
829
830 #[must_use]
832 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
833 self.cache_ttl_hours = hours;
834 self
835 }
836
837 #[must_use]
839 pub const fn with_bypass_cache(mut self) -> Self {
840 self.bypass_cache = true;
841 self
842 }
843
844 #[must_use]
846 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
847 self.timeout_secs = secs;
848 self
849 }
850
851 #[must_use]
853 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
854 self.vex_paths = paths;
855 self
856 }
857}
858
859#[derive(Debug, Default)]
865pub struct DiffConfigBuilder {
866 old: Option<PathBuf>,
867 new: Option<PathBuf>,
868 output: OutputConfig,
869 matching: MatchingConfig,
870 filtering: FilterConfig,
871 behavior: BehaviorConfig,
872 graph_diff: GraphAwareDiffConfig,
873 rules: MatchingRulesPathConfig,
874 ecosystem_rules: EcosystemRulesConfig,
875 enrichment: EnrichmentConfig,
876}
877
878impl DiffConfigBuilder {
879 #[must_use]
880 pub fn new() -> Self {
881 Self::default()
882 }
883
884 #[must_use]
885 pub fn old_path(mut self, path: PathBuf) -> Self {
886 self.old = Some(path);
887 self
888 }
889
890 #[must_use]
891 pub fn new_path(mut self, path: PathBuf) -> Self {
892 self.new = Some(path);
893 self
894 }
895
896 #[must_use]
897 pub const fn output_format(mut self, format: ReportFormat) -> Self {
898 self.output.format = format;
899 self
900 }
901
902 #[must_use]
903 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
904 self.output.file = file;
905 self
906 }
907
908 #[must_use]
909 pub const fn report_types(mut self, types: ReportType) -> Self {
910 self.output.report_types = types;
911 self
912 }
913
914 #[must_use]
915 pub const fn no_color(mut self, no_color: bool) -> Self {
916 self.output.no_color = no_color;
917 self
918 }
919
920 #[must_use]
921 pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
922 self.matching.fuzzy_preset = preset;
923 self
924 }
925
926 #[must_use]
927 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
928 self.matching.threshold = threshold;
929 self
930 }
931
932 #[must_use]
933 pub const fn include_unchanged(mut self, include: bool) -> Self {
934 self.matching.include_unchanged = include;
935 self
936 }
937
938 #[must_use]
939 pub const fn only_changes(mut self, only: bool) -> Self {
940 self.filtering.only_changes = only;
941 self
942 }
943
944 #[must_use]
945 pub fn min_severity(mut self, severity: Option<String>) -> Self {
946 self.filtering.min_severity = severity;
947 self
948 }
949
950 #[must_use]
951 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
952 self.behavior.fail_on_vuln = fail;
953 self
954 }
955
956 #[must_use]
957 pub const fn fail_on_change(mut self, fail: bool) -> Self {
958 self.behavior.fail_on_change = fail;
959 self
960 }
961
962 #[must_use]
963 pub const fn quiet(mut self, quiet: bool) -> Self {
964 self.behavior.quiet = quiet;
965 self
966 }
967
968 #[must_use]
969 pub const fn explain_matches(mut self, explain: bool) -> Self {
970 self.behavior.explain_matches = explain;
971 self
972 }
973
974 #[must_use]
975 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
976 self.behavior.recommend_threshold = recommend;
977 self
978 }
979
980 #[must_use]
981 pub fn graph_diff(mut self, enabled: bool) -> Self {
982 self.graph_diff = if enabled {
983 GraphAwareDiffConfig::enabled()
984 } else {
985 GraphAwareDiffConfig::default()
986 };
987 self
988 }
989
990 #[must_use]
991 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
992 self.rules.rules_file = file;
993 self
994 }
995
996 #[must_use]
997 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
998 self.rules.dry_run = dry_run;
999 self
1000 }
1001
1002 #[must_use]
1003 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1004 self.ecosystem_rules.config_file = file;
1005 self
1006 }
1007
1008 #[must_use]
1009 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1010 self.ecosystem_rules.disabled = disabled;
1011 self
1012 }
1013
1014 #[must_use]
1015 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1016 self.ecosystem_rules.detect_typosquats = detect;
1017 self
1018 }
1019
1020 #[must_use]
1021 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1022 self.enrichment = config;
1023 self
1024 }
1025
1026 #[must_use]
1027 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1028 self.enrichment.enabled = enabled;
1029 self
1030 }
1031
1032 pub fn build(self) -> anyhow::Result<DiffConfig> {
1033 let old = self
1034 .old
1035 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1036 let new = self
1037 .new
1038 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1039
1040 Ok(DiffConfig {
1041 paths: DiffPaths { old, new },
1042 output: self.output,
1043 matching: self.matching,
1044 filtering: self.filtering,
1045 behavior: self.behavior,
1046 graph_diff: self.graph_diff,
1047 rules: self.rules,
1048 ecosystem_rules: self.ecosystem_rules,
1049 enrichment: self.enrichment,
1050 })
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 #[test]
1059 fn theme_name_serde_roundtrip() {
1060 let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1062 assert_eq!(dark, ThemeName::Dark);
1063
1064 let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1065 assert_eq!(light, ThemeName::Light);
1066
1067 let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1068 assert_eq!(hc, ThemeName::HighContrast);
1069
1070 let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1072 assert_eq!(serialized, r#""high-contrast""#);
1073 }
1074
1075 #[test]
1076 fn theme_name_from_str() {
1077 assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1078 assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1079 assert_eq!(
1080 "high-contrast".parse::<ThemeName>().unwrap(),
1081 ThemeName::HighContrast
1082 );
1083 assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1084 assert!("neon".parse::<ThemeName>().is_err());
1085 }
1086
1087 #[test]
1088 fn fuzzy_preset_serde_roundtrip() {
1089 let presets = [
1090 ("strict", FuzzyPreset::Strict),
1091 ("balanced", FuzzyPreset::Balanced),
1092 ("permissive", FuzzyPreset::Permissive),
1093 ("strict-multi", FuzzyPreset::StrictMulti),
1094 ("balanced-multi", FuzzyPreset::BalancedMulti),
1095 ("security-focused", FuzzyPreset::SecurityFocused),
1096 ];
1097
1098 for (json_str, expected) in presets {
1099 let json = format!(r#""{json_str}""#);
1100 let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1101 assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1102
1103 let serialized = serde_json::to_string(&expected).unwrap();
1104 assert_eq!(serialized, json, "failed to serialize {expected:?}");
1105 }
1106 }
1107
1108 #[test]
1109 fn fuzzy_preset_from_str() {
1110 assert_eq!(
1111 "strict".parse::<FuzzyPreset>().unwrap(),
1112 FuzzyPreset::Strict
1113 );
1114 assert_eq!(
1115 "security-focused".parse::<FuzzyPreset>().unwrap(),
1116 FuzzyPreset::SecurityFocused
1117 );
1118 assert_eq!(
1120 "strict_multi".parse::<FuzzyPreset>().unwrap(),
1121 FuzzyPreset::StrictMulti
1122 );
1123 assert!("invalid".parse::<FuzzyPreset>().is_err());
1124 }
1125
1126 #[test]
1127 fn tui_preferences_json_backward_compat() {
1128 let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1130 let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1131 assert_eq!(prefs.theme, ThemeName::HighContrast);
1132 assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1133 }
1134}