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 pub cra_sidecar_path: Option<PathBuf>,
410 pub cra_product_class: Option<String>,
414}
415
416#[derive(Debug, Clone)]
418pub struct MultiDiffConfig {
419 pub baseline: PathBuf,
421 pub targets: Vec<PathBuf>,
423 pub output: OutputConfig,
425 pub matching: MatchingConfig,
427 pub filtering: FilterConfig,
429 pub behavior: BehaviorConfig,
431 pub graph_diff: GraphAwareDiffConfig,
433 pub rules: MatchingRulesPathConfig,
435 pub ecosystem_rules: EcosystemRulesConfig,
437 pub enrichment: EnrichmentConfig,
439}
440
441#[derive(Debug, Clone)]
443pub struct TimelineConfig {
444 pub sbom_paths: Vec<PathBuf>,
446 pub output: OutputConfig,
448 pub matching: MatchingConfig,
450 pub filtering: FilterConfig,
452 pub behavior: BehaviorConfig,
454 pub graph_diff: GraphAwareDiffConfig,
456 pub rules: MatchingRulesPathConfig,
458 pub ecosystem_rules: EcosystemRulesConfig,
460 pub enrichment: EnrichmentConfig,
462}
463
464#[derive(Debug, Clone)]
466pub struct QueryConfig {
467 pub sbom_paths: Vec<PathBuf>,
469 pub output: OutputConfig,
471 pub enrichment: EnrichmentConfig,
473 pub limit: Option<usize>,
475 pub group_by_sbom: bool,
477}
478
479#[derive(Debug, Clone)]
481pub struct MatrixConfig {
482 pub sbom_paths: Vec<PathBuf>,
484 pub output: OutputConfig,
486 pub matching: MatchingConfig,
488 pub cluster_threshold: f64,
490 pub filtering: FilterConfig,
492 pub behavior: BehaviorConfig,
494 pub graph_diff: GraphAwareDiffConfig,
496 pub rules: MatchingRulesPathConfig,
498 pub ecosystem_rules: EcosystemRulesConfig,
500 pub enrichment: EnrichmentConfig,
502}
503
504#[derive(Debug, Clone)]
506pub struct VexConfig {
507 pub sbom_path: PathBuf,
509 pub vex_paths: Vec<PathBuf>,
511 pub output_format: ReportFormat,
513 pub output_file: Option<PathBuf>,
515 pub quiet: bool,
517 pub actionable_only: bool,
519 pub filter_state: Option<String>,
521 pub enrichment: EnrichmentConfig,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
531#[serde(default)]
532pub struct OutputConfig {
533 pub format: ReportFormat,
535 #[serde(skip_serializing_if = "Option::is_none")]
537 pub file: Option<PathBuf>,
538 pub report_types: ReportType,
540 pub no_color: bool,
542 pub streaming: StreamingConfig,
544 #[serde(skip_serializing_if = "Option::is_none")]
549 pub export_template: Option<String>,
550}
551
552impl Default for OutputConfig {
553 fn default() -> Self {
554 Self {
555 format: ReportFormat::Auto,
556 file: None,
557 report_types: ReportType::All,
558 no_color: false,
559 streaming: StreamingConfig::default(),
560 export_template: None,
561 }
562 }
563}
564
565#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
571#[serde(default)]
572pub struct StreamingConfig {
573 #[schemars(range(min = 0))]
576 pub threshold_bytes: u64,
577 pub force: bool,
580 pub disabled: bool,
582 pub stream_stdin: bool,
585}
586
587impl Default for StreamingConfig {
588 fn default() -> Self {
589 Self {
590 threshold_bytes: 10 * 1024 * 1024, force: false,
592 disabled: false,
593 stream_stdin: true,
594 }
595 }
596}
597
598impl StreamingConfig {
599 #[must_use]
601 pub fn should_stream(&self, file_size: Option<u64>, is_stdin: bool) -> bool {
602 if self.disabled {
603 return false;
604 }
605 if self.force {
606 return true;
607 }
608 if is_stdin && self.stream_stdin {
609 return true;
610 }
611 file_size.map_or(self.stream_stdin, |size| size >= self.threshold_bytes)
612 }
613
614 #[must_use]
616 pub fn always() -> Self {
617 Self {
618 force: true,
619 ..Default::default()
620 }
621 }
622
623 #[must_use]
625 pub fn never() -> Self {
626 Self {
627 disabled: true,
628 ..Default::default()
629 }
630 }
631
632 #[must_use]
634 pub const fn with_threshold_mb(mut self, mb: u64) -> Self {
635 self.threshold_bytes = mb * 1024 * 1024;
636 self
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
642#[serde(default)]
643pub struct MatchingConfig {
644 pub fuzzy_preset: FuzzyPreset,
646 #[serde(skip_serializing_if = "Option::is_none")]
648 #[schemars(range(min = 0.0, max = 1.0))]
649 pub threshold: Option<f64>,
650 pub include_unchanged: bool,
652}
653
654impl Default for MatchingConfig {
655 fn default() -> Self {
656 Self {
657 fuzzy_preset: FuzzyPreset::Balanced,
658 threshold: None,
659 include_unchanged: false,
660 }
661 }
662}
663
664impl MatchingConfig {
665 #[must_use]
667 pub fn to_fuzzy_config(&self) -> FuzzyMatchConfig {
668 let mut config =
669 FuzzyMatchConfig::from_preset(self.fuzzy_preset.as_str()).unwrap_or_else(|| {
670 FuzzyMatchConfig::balanced()
672 });
673
674 if let Some(threshold) = self.threshold {
676 config = config.with_threshold(threshold);
677 }
678
679 config
680 }
681}
682
683#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
685#[serde(default)]
686pub struct FilterConfig {
687 pub only_changes: bool,
689 #[serde(skip_serializing_if = "Option::is_none")]
691 pub min_severity: Option<String>,
692 #[serde(alias = "exclude_vex_not_affected")]
694 pub exclude_vex_resolved: bool,
695 pub fail_on_vex_gap: bool,
697}
698
699#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
701#[serde(default)]
702pub struct BehaviorConfig {
703 pub fail_on_vuln: bool,
705 pub fail_on_change: bool,
707 pub quiet: bool,
709 pub explain_matches: bool,
711 pub recommend_threshold: bool,
713}
714
715#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
717#[serde(default)]
718pub struct GraphAwareDiffConfig {
719 pub enabled: bool,
721 pub detect_reparenting: bool,
723 pub detect_depth_changes: bool,
725 pub max_depth: u32,
727 pub impact_threshold: Option<String>,
729 pub relation_filter: Vec<String>,
731}
732
733impl GraphAwareDiffConfig {
734 #[must_use]
736 pub const fn enabled() -> Self {
737 Self {
738 enabled: true,
739 detect_reparenting: true,
740 detect_depth_changes: true,
741 max_depth: 0,
742 impact_threshold: None,
743 relation_filter: Vec::new(),
744 }
745 }
746}
747
748#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
750#[serde(default)]
751pub struct MatchingRulesPathConfig {
752 #[serde(skip_serializing_if = "Option::is_none")]
754 pub rules_file: Option<PathBuf>,
755 pub dry_run: bool,
757}
758
759#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
761#[serde(default)]
762pub struct EcosystemRulesConfig {
763 #[serde(skip_serializing_if = "Option::is_none")]
765 pub config_file: Option<PathBuf>,
766 pub disabled: bool,
768 pub detect_typosquats: bool,
770}
771
772#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
777#[serde(default)]
778pub struct EnrichmentConfig {
779 pub enabled: bool,
781 pub provider: String,
783 #[schemars(range(min = 1))]
785 pub cache_ttl_hours: u64,
786 #[schemars(range(min = 1))]
788 pub max_concurrent: usize,
789 #[serde(skip_serializing_if = "Option::is_none")]
791 pub cache_dir: Option<std::path::PathBuf>,
792 pub bypass_cache: bool,
794 #[schemars(range(min = 1))]
796 pub timeout_secs: u64,
797 pub enable_eol: bool,
799 #[serde(default, skip_serializing_if = "Vec::is_empty")]
801 pub vex_paths: Vec<std::path::PathBuf>,
802}
803
804impl Default for EnrichmentConfig {
805 fn default() -> Self {
806 Self {
807 enabled: false,
808 provider: "osv".to_string(),
809 cache_ttl_hours: 24,
810 max_concurrent: 10,
811 cache_dir: None,
812 bypass_cache: false,
813 timeout_secs: 30,
814 enable_eol: false,
815 vex_paths: Vec::new(),
816 }
817 }
818}
819
820impl EnrichmentConfig {
821 #[must_use]
823 pub fn osv() -> Self {
824 Self {
825 enabled: true,
826 provider: "osv".to_string(),
827 ..Default::default()
828 }
829 }
830
831 #[must_use]
833 pub fn with_cache_dir(mut self, dir: std::path::PathBuf) -> Self {
834 self.cache_dir = Some(dir);
835 self
836 }
837
838 #[must_use]
840 pub const fn with_cache_ttl_hours(mut self, hours: u64) -> Self {
841 self.cache_ttl_hours = hours;
842 self
843 }
844
845 #[must_use]
847 pub const fn with_bypass_cache(mut self) -> Self {
848 self.bypass_cache = true;
849 self
850 }
851
852 #[must_use]
854 pub const fn with_timeout_secs(mut self, secs: u64) -> Self {
855 self.timeout_secs = secs;
856 self
857 }
858
859 #[must_use]
861 pub fn with_vex_paths(mut self, paths: Vec<std::path::PathBuf>) -> Self {
862 self.vex_paths = paths;
863 self
864 }
865}
866
867#[derive(Debug, Default)]
873pub struct DiffConfigBuilder {
874 old: Option<PathBuf>,
875 new: Option<PathBuf>,
876 output: OutputConfig,
877 matching: MatchingConfig,
878 filtering: FilterConfig,
879 behavior: BehaviorConfig,
880 graph_diff: GraphAwareDiffConfig,
881 rules: MatchingRulesPathConfig,
882 ecosystem_rules: EcosystemRulesConfig,
883 enrichment: EnrichmentConfig,
884}
885
886impl DiffConfigBuilder {
887 #[must_use]
888 pub fn new() -> Self {
889 Self::default()
890 }
891
892 #[must_use]
893 pub fn old_path(mut self, path: PathBuf) -> Self {
894 self.old = Some(path);
895 self
896 }
897
898 #[must_use]
899 pub fn new_path(mut self, path: PathBuf) -> Self {
900 self.new = Some(path);
901 self
902 }
903
904 #[must_use]
905 pub const fn output_format(mut self, format: ReportFormat) -> Self {
906 self.output.format = format;
907 self
908 }
909
910 #[must_use]
911 pub fn output_file(mut self, file: Option<PathBuf>) -> Self {
912 self.output.file = file;
913 self
914 }
915
916 #[must_use]
917 pub const fn report_types(mut self, types: ReportType) -> Self {
918 self.output.report_types = types;
919 self
920 }
921
922 #[must_use]
923 pub const fn no_color(mut self, no_color: bool) -> Self {
924 self.output.no_color = no_color;
925 self
926 }
927
928 #[must_use]
929 pub fn fuzzy_preset(mut self, preset: FuzzyPreset) -> Self {
930 self.matching.fuzzy_preset = preset;
931 self
932 }
933
934 #[must_use]
935 pub const fn matching_threshold(mut self, threshold: Option<f64>) -> Self {
936 self.matching.threshold = threshold;
937 self
938 }
939
940 #[must_use]
941 pub const fn include_unchanged(mut self, include: bool) -> Self {
942 self.matching.include_unchanged = include;
943 self
944 }
945
946 #[must_use]
947 pub const fn only_changes(mut self, only: bool) -> Self {
948 self.filtering.only_changes = only;
949 self
950 }
951
952 #[must_use]
953 pub fn min_severity(mut self, severity: Option<String>) -> Self {
954 self.filtering.min_severity = severity;
955 self
956 }
957
958 #[must_use]
959 pub const fn fail_on_vuln(mut self, fail: bool) -> Self {
960 self.behavior.fail_on_vuln = fail;
961 self
962 }
963
964 #[must_use]
965 pub const fn fail_on_change(mut self, fail: bool) -> Self {
966 self.behavior.fail_on_change = fail;
967 self
968 }
969
970 #[must_use]
971 pub const fn quiet(mut self, quiet: bool) -> Self {
972 self.behavior.quiet = quiet;
973 self
974 }
975
976 #[must_use]
977 pub const fn explain_matches(mut self, explain: bool) -> Self {
978 self.behavior.explain_matches = explain;
979 self
980 }
981
982 #[must_use]
983 pub const fn recommend_threshold(mut self, recommend: bool) -> Self {
984 self.behavior.recommend_threshold = recommend;
985 self
986 }
987
988 #[must_use]
989 pub fn graph_diff(mut self, enabled: bool) -> Self {
990 self.graph_diff = if enabled {
991 GraphAwareDiffConfig::enabled()
992 } else {
993 GraphAwareDiffConfig::default()
994 };
995 self
996 }
997
998 #[must_use]
999 pub fn matching_rules_file(mut self, file: Option<PathBuf>) -> Self {
1000 self.rules.rules_file = file;
1001 self
1002 }
1003
1004 #[must_use]
1005 pub const fn dry_run_rules(mut self, dry_run: bool) -> Self {
1006 self.rules.dry_run = dry_run;
1007 self
1008 }
1009
1010 #[must_use]
1011 pub fn ecosystem_rules_file(mut self, file: Option<PathBuf>) -> Self {
1012 self.ecosystem_rules.config_file = file;
1013 self
1014 }
1015
1016 #[must_use]
1017 pub const fn disable_ecosystem_rules(mut self, disabled: bool) -> Self {
1018 self.ecosystem_rules.disabled = disabled;
1019 self
1020 }
1021
1022 #[must_use]
1023 pub const fn detect_typosquats(mut self, detect: bool) -> Self {
1024 self.ecosystem_rules.detect_typosquats = detect;
1025 self
1026 }
1027
1028 #[must_use]
1029 pub fn enrichment(mut self, config: EnrichmentConfig) -> Self {
1030 self.enrichment = config;
1031 self
1032 }
1033
1034 #[must_use]
1035 pub const fn enable_enrichment(mut self, enabled: bool) -> Self {
1036 self.enrichment.enabled = enabled;
1037 self
1038 }
1039
1040 pub fn build(self) -> anyhow::Result<DiffConfig> {
1041 let old = self
1042 .old
1043 .ok_or_else(|| anyhow::anyhow!("old path is required"))?;
1044 let new = self
1045 .new
1046 .ok_or_else(|| anyhow::anyhow!("new path is required"))?;
1047
1048 Ok(DiffConfig {
1049 paths: DiffPaths { old, new },
1050 output: self.output,
1051 matching: self.matching,
1052 filtering: self.filtering,
1053 behavior: self.behavior,
1054 graph_diff: self.graph_diff,
1055 rules: self.rules,
1056 ecosystem_rules: self.ecosystem_rules,
1057 enrichment: self.enrichment,
1058 })
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 #[test]
1067 fn theme_name_serde_roundtrip() {
1068 let dark: ThemeName = serde_json::from_str(r#""dark""#).unwrap();
1070 assert_eq!(dark, ThemeName::Dark);
1071
1072 let light: ThemeName = serde_json::from_str(r#""light""#).unwrap();
1073 assert_eq!(light, ThemeName::Light);
1074
1075 let hc: ThemeName = serde_json::from_str(r#""high-contrast""#).unwrap();
1076 assert_eq!(hc, ThemeName::HighContrast);
1077
1078 let serialized = serde_json::to_string(&ThemeName::HighContrast).unwrap();
1080 assert_eq!(serialized, r#""high-contrast""#);
1081 }
1082
1083 #[test]
1084 fn theme_name_from_str() {
1085 assert_eq!("dark".parse::<ThemeName>().unwrap(), ThemeName::Dark);
1086 assert_eq!("light".parse::<ThemeName>().unwrap(), ThemeName::Light);
1087 assert_eq!(
1088 "high-contrast".parse::<ThemeName>().unwrap(),
1089 ThemeName::HighContrast
1090 );
1091 assert_eq!("hc".parse::<ThemeName>().unwrap(), ThemeName::HighContrast);
1092 assert!("neon".parse::<ThemeName>().is_err());
1093 }
1094
1095 #[test]
1096 fn fuzzy_preset_serde_roundtrip() {
1097 let presets = [
1098 ("strict", FuzzyPreset::Strict),
1099 ("balanced", FuzzyPreset::Balanced),
1100 ("permissive", FuzzyPreset::Permissive),
1101 ("strict-multi", FuzzyPreset::StrictMulti),
1102 ("balanced-multi", FuzzyPreset::BalancedMulti),
1103 ("security-focused", FuzzyPreset::SecurityFocused),
1104 ];
1105
1106 for (json_str, expected) in presets {
1107 let json = format!(r#""{json_str}""#);
1108 let parsed: FuzzyPreset = serde_json::from_str(&json).unwrap();
1109 assert_eq!(parsed, expected, "failed to deserialize {json_str}");
1110
1111 let serialized = serde_json::to_string(&expected).unwrap();
1112 assert_eq!(serialized, json, "failed to serialize {expected:?}");
1113 }
1114 }
1115
1116 #[test]
1117 fn fuzzy_preset_from_str() {
1118 assert_eq!(
1119 "strict".parse::<FuzzyPreset>().unwrap(),
1120 FuzzyPreset::Strict
1121 );
1122 assert_eq!(
1123 "security-focused".parse::<FuzzyPreset>().unwrap(),
1124 FuzzyPreset::SecurityFocused
1125 );
1126 assert_eq!(
1128 "strict_multi".parse::<FuzzyPreset>().unwrap(),
1129 FuzzyPreset::StrictMulti
1130 );
1131 assert!("invalid".parse::<FuzzyPreset>().is_err());
1132 }
1133
1134 #[test]
1135 fn tui_preferences_json_backward_compat() {
1136 let old_json = r#"{"theme":"high-contrast","last_tab":"components"}"#;
1138 let prefs: TuiPreferences = serde_json::from_str(old_json).unwrap();
1139 assert_eq!(prefs.theme, ThemeName::HighContrast);
1140 assert_eq!(prefs.last_tab.as_deref(), Some("components"));
1141 }
1142}