1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use super::types::{Theme, ThemeFile, ThemeInfo, BUILTIN_THEMES};
11
12pub fn normalize_theme_name(name: &str) -> String {
18 name.to_lowercase().replace(['_', ' '], "-")
19}
20
21pub(crate) fn expand_env_vars(input: &str) -> String {
29 let input = if let Some(rest) = input.strip_prefix('~') {
30 match std::env::var("HOME") {
31 Ok(home) => format!("{}{}", home, rest),
32 Err(_) => input.to_string(),
33 }
34 } else {
35 input.to_string()
36 };
37
38 let bytes = input.as_bytes();
39 let mut out = String::with_capacity(input.len());
40 let mut i = 0;
41 while i < bytes.len() {
42 if bytes[i] != b'$' {
43 out.push(bytes[i] as char);
44 i += 1;
45 continue;
46 }
47 if i + 1 >= bytes.len() {
49 out.push('$');
50 i += 1;
51 continue;
52 }
53 if bytes[i + 1] == b'{' {
54 if let Some(close) = input[i + 2..].find('}') {
55 let name = &input[i + 2..i + 2 + close];
56 match std::env::var(name) {
57 Ok(v) => out.push_str(&v),
58 Err(_) => out.push_str(&input[i..i + 2 + close + 1]),
59 }
60 i += 2 + close + 1;
61 continue;
62 }
63 out.push('$');
65 i += 1;
66 continue;
67 }
68 let start = i + 1;
70 let mut end = start;
71 while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
72 end += 1;
73 }
74 if end == start {
75 out.push('$');
76 i += 1;
77 continue;
78 }
79 let name = &input[start..end];
80 match std::env::var(name) {
81 Ok(v) => out.push_str(&v),
82 Err(_) => out.push_str(&input[i..end]),
83 }
84 i = end;
85 }
86 out
87}
88
89#[derive(Debug, Clone)]
98pub struct ThemeRegistry {
99 themes: HashMap<String, Theme>,
101 theme_list: Vec<ThemeInfo>,
103 themes_dir: Option<PathBuf>,
107}
108
109impl ThemeRegistry {
110 pub fn get(&self, key_or_name: &str) -> Option<&Theme> {
113 self.resolve_key(key_or_name)
114 .and_then(|key| self.themes.get(&key))
115 }
116
117 pub fn get_cloned(&self, key_or_name: &str) -> Option<Theme> {
119 self.get(key_or_name).cloned()
120 }
121
122 pub fn resolve_key(&self, value: &str) -> Option<String> {
139 if self.themes.contains_key(value) {
142 return Some(value.to_string());
143 }
144
145 if let Some(name) = value.strip_prefix("builtin://") {
147 let normalized = normalize_theme_name(name);
148 return self
149 .theme_list
150 .iter()
151 .find(|info| info.pack.is_empty() && normalize_theme_name(&info.name) == normalized)
152 .map(|info| info.key.clone());
153 }
154
155 if let Some(raw_path) = value.strip_prefix("file://") {
157 let expanded = expand_env_vars(raw_path);
158 let expanded_native = expanded.replace('/', std::path::MAIN_SEPARATOR_STR);
163 let candidate = format!("file://{}", expanded_native);
164 if self.themes.contains_key(&candidate) {
165 return Some(candidate);
166 }
167 return None;
168 }
169
170 if value.starts_with("http://") || value.starts_with("https://") {
173 return None;
174 }
175
176 if value.ends_with(".json") {
181 if let Some(themes_dir) = self.themes_dir.as_deref() {
182 let expanded = expand_env_vars(value);
183 let expanded_native = expanded.replace('/', std::path::MAIN_SEPARATOR_STR);
189 let expanded_path = std::path::Path::new(&expanded_native);
190 let abs = if expanded_path.is_absolute() {
191 expanded_path.to_path_buf()
192 } else {
193 themes_dir.join(expanded_path)
194 };
195 let candidate = format!("file://{}", abs.display());
196 if self.themes.contains_key(&candidate) {
197 return Some(candidate);
198 }
199 }
200 return None;
201 }
202
203 let normalized = normalize_theme_name(value);
205 self.theme_list
206 .iter()
207 .find(|info| normalize_theme_name(&info.name) == normalized)
208 .map(|info| info.key.clone())
209 }
210
211 pub fn portable_form(&self, key: &str) -> Option<String> {
221 let info = self.theme_list.iter().find(|i| i.key == key)?;
222
223 if info.pack.is_empty() {
225 return Some(format!("builtin://{}", info.name));
226 }
227
228 if let Some(path_str) = info.key.strip_prefix("file://") {
231 let path = std::path::Path::new(path_str);
232 if let Some(themes_dir) = self.themes_dir.as_deref() {
233 if let Ok(rel) = path.strip_prefix(themes_dir) {
234 let rel_str = rel
237 .components()
238 .map(|c| c.as_os_str().to_string_lossy())
239 .collect::<Vec<_>>()
240 .join("/");
241 return Some(rel_str);
242 }
243 }
244 return Some(info.key.clone());
246 }
247
248 Some(info.key.clone())
250 }
251
252 pub fn list(&self) -> &[ThemeInfo] {
254 &self.theme_list
255 }
256
257 pub fn names(&self) -> Vec<String> {
259 self.theme_list.iter().map(|t| t.name.clone()).collect()
260 }
261
262 pub fn contains(&self, key_or_name: &str) -> bool {
264 self.get(key_or_name).is_some()
265 }
266
267 pub fn len(&self) -> usize {
269 self.themes.len()
270 }
271
272 pub fn is_empty(&self) -> bool {
274 self.themes.is_empty()
275 }
276
277 pub fn to_json_map(&self) -> HashMap<String, serde_json::Value> {
283 use super::types::ThemeFile;
284
285 let mut map = HashMap::new();
286 for info in &self.theme_list {
287 if let Some(theme) = self.themes.get(&info.key) {
288 let theme_file: ThemeFile = theme.clone().into();
289 if let Ok(mut v) = serde_json::to_value(theme_file) {
290 if let Some(obj) = v.as_object_mut() {
291 obj.insert("_key".to_string(), serde_json::json!(info.key));
292 obj.insert("_pack".to_string(), serde_json::json!(info.pack));
293 }
294 map.insert(info.key.clone(), v);
295 }
296 }
297 }
298 map
299 }
300}
301
302pub struct ThemeLoader {
304 user_themes_dir: Option<PathBuf>,
305}
306
307impl ThemeLoader {
308 pub fn new(user_themes_dir: PathBuf) -> Self {
310 Self {
311 user_themes_dir: Some(user_themes_dir),
312 }
313 }
314
315 pub fn embedded_only() -> Self {
317 Self {
318 user_themes_dir: None,
319 }
320 }
321
322 pub fn user_themes_dir(&self) -> Option<&Path> {
324 self.user_themes_dir.as_deref()
325 }
326
327 pub fn load_all(&self, bundle_theme_dirs: &[PathBuf]) -> ThemeRegistry {
333 let mut themes = HashMap::new();
334 let mut theme_list = Vec::new();
335
336 for builtin in BUILTIN_THEMES {
338 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(builtin.json) {
339 let theme: Theme = theme_file.into();
340 let normalized = normalize_theme_name(builtin.name);
341 let info = ThemeInfo::new(&normalized, builtin.pack);
342 themes.insert(info.key.clone(), theme);
343 theme_list.push(info);
344 }
345 }
346
347 if let Some(ref user_dir) = self.user_themes_dir {
349 self.scan_directory(user_dir, "user", None, &mut themes, &mut theme_list);
350 }
351
352 if let Some(ref user_dir) = self.user_themes_dir {
354 let packages_dir = user_dir.join("packages");
355 if packages_dir.exists() {
356 if let Ok(entries) = std::fs::read_dir(&packages_dir) {
357 for entry in entries.flatten() {
358 let path = entry.path();
359 if path.is_dir() {
360 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
361 if !name.starts_with('.') {
362 let manifest_path = path.join("package.json");
363 if manifest_path.exists() {
364 self.load_package_themes(
365 &path,
366 name,
367 &mut themes,
368 &mut theme_list,
369 );
370 } else {
371 let pack_name = format!("pkg/{}", name);
372 self.scan_directory(
373 &path,
374 &pack_name,
375 None,
376 &mut themes,
377 &mut theme_list,
378 );
379 }
380 }
381 }
382 }
383 }
384 }
385 }
386 }
387
388 for bundle_dir in bundle_theme_dirs {
390 if let Some(name) = bundle_dir.file_name().and_then(|n| n.to_str()) {
391 let manifest_path = bundle_dir.join("package.json");
392 if manifest_path.exists() {
393 self.load_package_themes(bundle_dir, name, &mut themes, &mut theme_list);
394 }
395 }
396 }
397
398 ThemeRegistry {
399 themes,
400 theme_list,
401 themes_dir: self.user_themes_dir.clone(),
402 }
403 }
404
405 fn read_repository(manifest: &serde_json::Value) -> Option<String> {
407 manifest
408 .get("repository")
409 .and_then(|v| v.as_str())
410 .map(|s| s.to_string())
411 }
412
413 fn load_package_themes(
415 &self,
416 pkg_dir: &Path,
417 pkg_name: &str,
418 themes: &mut HashMap<String, Theme>,
419 theme_list: &mut Vec<ThemeInfo>,
420 ) {
421 let manifest_path = pkg_dir.join("package.json");
422 let manifest_content = match std::fs::read_to_string(&manifest_path) {
423 Ok(c) => c,
424 Err(_) => return,
425 };
426
427 let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
428 Ok(v) => v,
429 Err(_) => return,
430 };
431
432 let repository = Self::read_repository(&manifest);
433 let pack_name = format!("pkg/{}", pkg_name);
434
435 if let Some(fresh) = manifest.get("fresh") {
437 if let Some(theme_entries) = fresh.get("themes").and_then(|t| t.as_array()) {
438 for entry in theme_entries {
439 if let (Some(file), Some(name)) = (
440 entry.get("file").and_then(|f| f.as_str()),
441 entry.get("name").and_then(|n| n.as_str()),
442 ) {
443 let theme_path = pkg_dir.join(file);
444 if theme_path.exists() {
445 if let Ok(content) = std::fs::read_to_string(&theme_path) {
446 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content)
447 {
448 let theme: Theme = theme_file.into();
449 let normalized_name = normalize_theme_name(name);
450 let info = if let Some(ref repo) = repository {
451 ThemeInfo::with_key(
452 &normalized_name,
453 &pack_name,
454 format!("{}#{}", repo, normalized_name),
455 )
456 } else {
457 ThemeInfo::new(&normalized_name, &pack_name)
458 };
459 if !themes.contains_key(&info.key) {
460 themes.insert(info.key.clone(), theme);
461 theme_list.push(info);
462 }
463 }
464 }
465 }
466 }
467 }
468 return;
469 }
470 }
471
472 self.scan_directory(
474 pkg_dir,
475 &pack_name,
476 repository.as_deref(),
477 themes,
478 theme_list,
479 );
480 }
481
482 fn scan_directory(
487 &self,
488 dir: &Path,
489 pack: &str,
490 repository: Option<&str>,
491 themes: &mut HashMap<String, Theme>,
492 theme_list: &mut Vec<ThemeInfo>,
493 ) {
494 let entries = match std::fs::read_dir(dir) {
495 Ok(e) => e,
496 Err(_) => return,
497 };
498
499 for entry in entries.flatten() {
500 let path = entry.path();
501
502 if path.is_dir() {
503 let subdir_name = path.file_name().unwrap().to_string_lossy();
504
505 if pack == "user" && subdir_name == "packages" {
508 continue;
509 }
510
511 let new_pack = if pack == "user" {
512 format!("user/{}", subdir_name)
513 } else {
514 format!("{}/{}", pack, subdir_name)
515 };
516 self.scan_directory(&path, &new_pack, repository, themes, theme_list);
517 } else if path.extension().is_some_and(|ext| ext == "json") {
518 if let Ok(content) = std::fs::read_to_string(&path) {
519 if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content) {
520 let name = normalize_theme_name(&theme_file.name);
521 let info = if let Some(repo) = repository {
522 ThemeInfo::with_key(&name, pack, format!("{}#{}", repo, name))
523 } else if pack.starts_with("user") {
524 ThemeInfo::with_key(&name, pack, format!("file://{}", path.display()))
526 } else {
527 ThemeInfo::new(&name, pack)
528 };
529
530 if themes.contains_key(&info.key) {
532 continue;
533 }
534
535 let theme: Theme = theme_file.into();
536 themes.insert(info.key.clone(), theme);
537 theme_list.push(info);
538 }
539 }
540 }
541 }
542 }
543}
544
545impl Theme {
547 pub fn set_terminal_cursor_color(&self) {
550 use super::types::color_to_rgb;
551 use std::io::Write;
552 if let Some((r, g, b)) = color_to_rgb(self.cursor) {
553 #[allow(clippy::let_underscore_must_use)]
556 let _ = write!(
557 std::io::stdout(),
558 "\x1b]12;#{:02x}{:02x}{:02x}\x07",
559 r,
560 g,
561 b
562 );
563 #[allow(clippy::let_underscore_must_use)]
564 let _ = std::io::stdout().flush();
565 }
566 }
567
568 pub fn reset_terminal_cursor_color() {
570 use std::io::Write;
571 #[allow(clippy::let_underscore_must_use)]
574 let _ = write!(std::io::stdout(), "\x1b]112\x07");
575 #[allow(clippy::let_underscore_must_use)]
576 let _ = std::io::stdout().flush();
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 #[test]
585 fn test_theme_registry_get() {
586 let loader = ThemeLoader::embedded_only();
587 let registry = loader.load_all(&[]);
588
589 assert!(registry.get("dark").is_some());
591 assert!(registry.get("light").is_some());
592 assert!(registry.get("high-contrast").is_some());
593
594 assert!(registry.get("Dark").is_some());
596 assert!(registry.get("DARK").is_some());
597 assert!(registry.get("high_contrast").is_some());
598 assert!(registry.get("high contrast").is_some());
599
600 assert!(registry.get("nonexistent-theme").is_none());
602 }
603
604 #[test]
605 fn test_theme_registry_list() {
606 let loader = ThemeLoader::embedded_only();
607 let registry = loader.load_all(&[]);
608
609 let list = registry.list();
610 assert!(list.len() >= 7); assert!(list.iter().any(|t| t.name == "dark"));
614 assert!(list.iter().any(|t| t.name == "light"));
615 }
616
617 #[test]
618 fn test_theme_registry_contains() {
619 let loader = ThemeLoader::embedded_only();
620 let registry = loader.load_all(&[]);
621
622 assert!(registry.contains("dark"));
623 assert!(registry.contains("Dark")); assert!(!registry.contains("nonexistent"));
625 }
626
627 #[test]
628 fn test_theme_loader_load_all() {
629 let loader = ThemeLoader::embedded_only();
630 let registry = loader.load_all(&[]);
631
632 assert!(registry.len() >= 7); let dark = registry.get("dark").unwrap();
637 assert_eq!(dark.name, "dark");
638 }
639
640 #[test]
645 fn test_custom_theme_loading_from_user_dir() {
646 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
648 let themes_dir = temp_dir.path().to_path_buf();
649
650 let theme_json = r#"{
652 "name": "my-custom-theme",
653 "editor": {},
654 "ui": {},
655 "search": {},
656 "diagnostic": {},
657 "syntax": {}
658 }"#;
659 std::fs::write(themes_dir.join("my-custom-theme.json"), theme_json)
660 .expect("Failed to write theme file");
661
662 let loader = ThemeLoader::new(themes_dir.clone());
664 let registry = loader.load_all(&[]);
665
666 assert!(
668 registry.contains("my-custom-theme"),
669 "Custom theme should be loaded from user themes directory"
670 );
671 assert!(
672 registry.get("my-custom-theme").is_some(),
673 "Custom theme should be retrievable"
674 );
675
676 let theme_list = registry.list();
678 assert!(
679 theme_list.iter().any(|t| t.name == "my-custom-theme"),
680 "Custom theme should appear in theme list for Select Theme menu"
681 );
682
683 let theme_info = theme_list
685 .iter()
686 .find(|t| t.name == "my-custom-theme")
687 .unwrap();
688 assert_eq!(
689 theme_info.pack, "user",
690 "Custom theme should have 'user' pack"
691 );
692
693 #[cfg(not(target_arch = "wasm32"))]
697 {
698 let menu_items = crate::config::generate_dynamic_items("copy_with_theme", &themes_dir);
699 let theme_keys: Vec<_> = menu_items
700 .iter()
701 .filter_map(|item| match item {
702 crate::config::MenuItem::Action { args, .. } => args
703 .get("theme")
704 .map(|v| v.as_str().unwrap_or_default().to_string()),
705 _ => None,
706 })
707 .collect();
708 assert!(
709 theme_keys.iter().any(|k| k.contains("my-custom-theme")),
710 "Custom theme key should appear in dynamic menu items, got: {:?}",
711 theme_keys
712 );
713 }
714 }
715
716 #[test]
718 fn test_custom_theme_package_loading() {
719 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
721 let themes_dir = temp_dir.path().to_path_buf();
722
723 let packages_dir = themes_dir.join("packages");
725 let pkg_dir = packages_dir.join("my-theme-pack");
726 std::fs::create_dir_all(&pkg_dir).expect("Failed to create package dir");
727
728 let manifest = r#"{
730 "name": "my-theme-pack",
731 "fresh": {
732 "themes": [
733 { "name": "Packaged Theme", "file": "packaged-theme.json" }
734 ]
735 }
736 }"#;
737 std::fs::write(pkg_dir.join("package.json"), manifest)
738 .expect("Failed to write package.json");
739
740 let theme_json = r#"{
742 "name": "packaged-theme",
743 "editor": {},
744 "ui": {},
745 "search": {},
746 "diagnostic": {},
747 "syntax": {}
748 }"#;
749 std::fs::write(pkg_dir.join("packaged-theme.json"), theme_json)
750 .expect("Failed to write theme file");
751
752 let loader = ThemeLoader::new(themes_dir);
754 let registry = loader.load_all(&[]);
755
756 assert!(
758 registry.contains("packaged-theme"),
759 "Packaged theme should be loaded"
760 );
761
762 let theme_list = registry.list();
764 let theme_info = theme_list
765 .iter()
766 .find(|t| t.name == "packaged-theme")
767 .expect("Packaged theme should be in theme list");
768 assert_eq!(
769 theme_info.pack, "pkg/my-theme-pack",
770 "Packaged theme should have correct pack name"
771 );
772 }
773
774 #[test]
775 fn test_normalize_theme_name() {
776 assert_eq!(normalize_theme_name("dark"), "dark");
777 assert_eq!(normalize_theme_name("Dark"), "dark");
778 assert_eq!(normalize_theme_name("high_contrast"), "high-contrast");
779 assert_eq!(normalize_theme_name("Catppuccin Mocha"), "catppuccin-mocha");
780 assert_eq!(normalize_theme_name("My_Custom Theme"), "my-custom-theme");
781 assert_eq!(normalize_theme_name("SOLARIZED_DARK"), "solarized-dark");
782 }
783
784 #[test]
788 fn test_theme_name_mismatch_json_vs_filename() {
789 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
790 let themes_dir = temp_dir.path().to_path_buf();
791
792 let theme_json = r#"{
795 "name": "Catppuccin Mocha",
796 "editor": {},
797 "ui": {},
798 "search": {},
799 "diagnostic": {},
800 "syntax": {}
801 }"#;
802 std::fs::write(themes_dir.join("catppuccin-mocha.json"), theme_json)
803 .expect("Failed to write theme file");
804
805 let loader = ThemeLoader::new(themes_dir);
806 let registry = loader.load_all(&[]);
807
808 assert!(
810 registry.contains("catppuccin-mocha"),
811 "Theme should be found by normalized filename"
812 );
813
814 assert!(
816 registry.contains("Catppuccin Mocha"),
817 "Theme should be found by JSON name with spaces (normalized to hyphens)"
818 );
819
820 assert!(
822 registry.contains("CATPPUCCIN-MOCHA"),
823 "Theme should be found regardless of casing"
824 );
825
826 let theme_list = registry.list();
828 let theme_info = theme_list
829 .iter()
830 .find(|t| t.name == "catppuccin-mocha")
831 .expect("Theme should appear with normalized name in theme list");
832 assert_eq!(theme_info.pack, "user");
833 }
834
835 #[test]
837 fn test_custom_theme_in_subdirectory() {
838 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
840 let themes_dir = temp_dir.path().to_path_buf();
841
842 let subdir = themes_dir.join("my-collection");
844 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
845
846 let theme_json = r#"{
848 "name": "nested-theme",
849 "editor": {},
850 "ui": {},
851 "search": {},
852 "diagnostic": {},
853 "syntax": {}
854 }"#;
855 std::fs::write(subdir.join("nested-theme.json"), theme_json)
856 .expect("Failed to write theme file");
857
858 let loader = ThemeLoader::new(themes_dir);
860 let registry = loader.load_all(&[]);
861
862 assert!(
864 registry.contains("nested-theme"),
865 "Theme in subdirectory should be loaded"
866 );
867
868 let theme_list = registry.list();
870 let theme_info = theme_list
871 .iter()
872 .find(|t| t.name == "nested-theme")
873 .expect("Nested theme should be in theme list");
874 assert_eq!(
875 theme_info.pack, "user/my-collection",
876 "Nested theme should have subdirectory in pack name"
877 );
878 }
879
880 #[test]
891 fn test_resolve_key_portable_config_forms_for_user_themes() {
892 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
893 let themes_dir = temp_dir.path().to_path_buf();
894
895 let user_dark = r#"{
897 "name": "dark",
898 "editor": {},
899 "ui": {},
900 "search": {},
901 "diagnostic": {},
902 "syntax": {}
903 }"#;
904 std::fs::write(themes_dir.join("dark.json"), user_dark)
905 .expect("Failed to write user dark theme");
906
907 let subdir = themes_dir.join("my-collection");
909 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
910 let nested = r#"{
911 "name": "s-dark",
912 "editor": {},
913 "ui": {},
914 "search": {},
915 "diagnostic": {},
916 "syntax": {}
917 }"#;
918 std::fs::write(subdir.join("s-dark.json"), nested).expect("Failed to write nested theme");
919
920 let loader = ThemeLoader::new(themes_dir.clone());
921 let registry = loader.load_all(&[]);
922
923 assert_eq!(
925 registry.resolve_key("builtin://dark").as_deref(),
926 Some("dark"),
927 "`builtin://dark` must resolve to the built-in"
928 );
929
930 let user_key = registry
933 .resolve_key("dark.json")
934 .expect("`dark.json` should resolve");
935 assert!(
936 user_key.starts_with("file://") && user_key.ends_with("dark.json"),
937 "`dark.json` must resolve to the user theme file, got: {}",
938 user_key
939 );
940 let theme = registry
941 .get("dark.json")
942 .expect("theme should be retrievable by relative path");
943 assert_eq!(theme.name, "dark");
944
945 let nested_key = registry
947 .resolve_key("my-collection/s-dark.json")
948 .expect("nested relative path should resolve");
949 assert!(
950 nested_key.starts_with("file://") && nested_key.contains("my-collection"),
951 "nested path should resolve under themes dir, got: {}",
952 nested_key
953 );
954
955 std::env::set_var(
958 "FRESH_TEST_THEMES_ROOT",
959 themes_dir.to_string_lossy().to_string(),
960 );
961 let uri = "file://${FRESH_TEST_THEMES_ROOT}/dark.json";
962 let resolved = registry
963 .resolve_key(uri)
964 .expect("env-var-expanded file:// URI should resolve");
965 assert_eq!(
966 resolved, user_key,
967 "env-expanded URI should match user theme"
968 );
969 std::env::remove_var("FRESH_TEST_THEMES_ROOT");
970
971 assert_eq!(
973 registry.resolve_key("high-contrast").as_deref(),
974 Some("high-contrast"),
975 "legacy bare-name config must keep working"
976 );
977
978 assert!(registry.resolve_key("does-not-exist").is_none());
980 assert!(registry.resolve_key("builtin://no-such-theme").is_none());
981 assert!(registry.resolve_key("missing.json").is_none());
982 }
983
984 #[test]
988 fn test_portable_form_round_trip() {
989 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
990 let themes_dir = temp_dir.path().to_path_buf();
991 let theme_json = r#"{
992 "name": "s-dark",
993 "editor": {},
994 "ui": {},
995 "search": {},
996 "diagnostic": {},
997 "syntax": {}
998 }"#;
999 std::fs::write(themes_dir.join("s-dark.json"), theme_json).expect("Failed to write theme");
1000 let loader = ThemeLoader::new(themes_dir.clone());
1001 let registry = loader.load_all(&[]);
1002
1003 let builtin_portable = registry
1005 .portable_form("dark")
1006 .expect("built-in should have a portable form");
1007 assert_eq!(builtin_portable, "builtin://dark");
1008 assert_eq!(
1009 registry.resolve_key(&builtin_portable).as_deref(),
1010 Some("dark")
1011 );
1012
1013 let user_info = registry
1016 .list()
1017 .iter()
1018 .find(|i| i.name == "s-dark")
1019 .expect("user theme should be listed")
1020 .clone();
1021 let user_portable = registry
1022 .portable_form(&user_info.key)
1023 .expect("user theme should have a portable form");
1024 assert_eq!(
1025 user_portable, "s-dark.json",
1026 "user theme must persist as a relative path, got: {}",
1027 user_portable
1028 );
1029 assert!(
1030 !user_portable.contains(themes_dir.to_string_lossy().as_ref()),
1031 "portable form must not embed the absolute themes dir path"
1032 );
1033 assert_eq!(registry.resolve_key(&user_portable), Some(user_info.key));
1035 }
1036
1037 #[test]
1038 fn test_expand_env_vars() {
1039 std::env::set_var("FRESH_TEST_VAR_A", "/foo/bar");
1040 std::env::set_var("FRESH_TEST_VAR_B", "baz");
1041 assert_eq!(expand_env_vars("${FRESH_TEST_VAR_A}/x"), "/foo/bar/x");
1042 assert_eq!(expand_env_vars("$FRESH_TEST_VAR_A/x"), "/foo/bar/x");
1043 assert_eq!(expand_env_vars("a/${FRESH_TEST_VAR_B}/c"), "a/baz/c");
1044 assert_eq!(
1046 expand_env_vars("${FRESH_NO_SUCH_VAR_XYZ}/x"),
1047 "${FRESH_NO_SUCH_VAR_XYZ}/x"
1048 );
1049 assert_eq!(expand_env_vars("${oops/x"), "${oops/x");
1051 if let Ok(home) = std::env::var("HOME") {
1053 assert_eq!(expand_env_vars("~/foo"), format!("{}/foo", home));
1054 }
1055 std::env::remove_var("FRESH_TEST_VAR_A");
1056 std::env::remove_var("FRESH_TEST_VAR_B");
1057 }
1058
1059 #[test]
1060 fn test_to_json_map() {
1061 let loader = ThemeLoader::embedded_only();
1062 let registry = loader.load_all(&[]);
1063
1064 let json_map = registry.to_json_map();
1065
1066 assert_eq!(json_map.len(), registry.len());
1068
1069 let dark = json_map
1071 .get("dark")
1072 .expect("dark theme should be in json map");
1073 assert!(dark.is_object(), "theme should serialize to a JSON object");
1074 assert_eq!(
1075 dark.get("name").and_then(|v| v.as_str()),
1076 Some("dark"),
1077 "theme JSON should have correct name"
1078 );
1079
1080 assert!(dark.get("editor").is_some(), "should have editor section");
1082 assert!(dark.get("ui").is_some(), "should have ui section");
1083 assert!(dark.get("syntax").is_some(), "should have syntax section");
1084
1085 assert_eq!(
1087 dark.get("_key").and_then(|v| v.as_str()),
1088 Some("dark"),
1089 "theme JSON should have _key metadata"
1090 );
1091 assert!(
1092 dark.get("_pack").is_some(),
1093 "theme JSON should have _pack metadata"
1094 );
1095 }
1096}