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) = Theme::from_json(&content) {
447 let normalized_name = normalize_theme_name(name);
448 let info = if let Some(ref repo) = repository {
449 ThemeInfo::with_key(
450 &normalized_name,
451 &pack_name,
452 format!("{}#{}", repo, normalized_name),
453 )
454 } else {
455 ThemeInfo::new(&normalized_name, &pack_name)
456 };
457 if !themes.contains_key(&info.key) {
458 themes.insert(info.key.clone(), theme);
459 theme_list.push(info);
460 }
461 }
462 }
463 }
464 }
465 }
466 return;
467 }
468 }
469
470 self.scan_directory(
472 pkg_dir,
473 &pack_name,
474 repository.as_deref(),
475 themes,
476 theme_list,
477 );
478 }
479
480 fn scan_directory(
485 &self,
486 dir: &Path,
487 pack: &str,
488 repository: Option<&str>,
489 themes: &mut HashMap<String, Theme>,
490 theme_list: &mut Vec<ThemeInfo>,
491 ) {
492 let entries = match std::fs::read_dir(dir) {
493 Ok(e) => e,
494 Err(_) => return,
495 };
496
497 for entry in entries.flatten() {
498 let path = entry.path();
499
500 if path.is_dir() {
501 let subdir_name = path.file_name().unwrap().to_string_lossy();
502
503 if pack == "user" && subdir_name == "packages" {
506 continue;
507 }
508
509 let new_pack = if pack == "user" {
510 format!("user/{}", subdir_name)
511 } else {
512 format!("{}/{}", pack, subdir_name)
513 };
514 self.scan_directory(&path, &new_pack, repository, themes, theme_list);
515 } else if path.extension().is_some_and(|ext| ext == "json") {
516 if let Ok(content) = std::fs::read_to_string(&path) {
517 if let Ok(theme) = Theme::from_json(&content) {
518 let name = normalize_theme_name(&theme.name);
519 let info = if let Some(repo) = repository {
520 ThemeInfo::with_key(&name, pack, format!("{}#{}", repo, name))
521 } else if pack.starts_with("user") {
522 ThemeInfo::with_key(&name, pack, format!("file://{}", path.display()))
524 } else {
525 ThemeInfo::new(&name, pack)
526 };
527
528 if themes.contains_key(&info.key) {
530 continue;
531 }
532
533 themes.insert(info.key.clone(), theme);
534 theme_list.push(info);
535 }
536 }
537 }
538 }
539 }
540}
541
542impl Theme {
544 pub fn set_terminal_cursor_color(&self) {
547 use super::types::color_to_rgb;
548 use std::io::Write;
549 if let Some((r, g, b)) = color_to_rgb(self.cursor) {
550 #[allow(clippy::let_underscore_must_use)]
553 let _ = write!(
554 std::io::stdout(),
555 "\x1b]12;#{:02x}{:02x}{:02x}\x07",
556 r,
557 g,
558 b
559 );
560 #[allow(clippy::let_underscore_must_use)]
561 let _ = std::io::stdout().flush();
562 }
563 }
564
565 pub fn reset_terminal_cursor_color() {
567 use std::io::Write;
568 #[allow(clippy::let_underscore_must_use)]
571 let _ = write!(std::io::stdout(), "\x1b]112\x07");
572 #[allow(clippy::let_underscore_must_use)]
573 let _ = std::io::stdout().flush();
574 }
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580
581 #[test]
582 fn test_theme_registry_get() {
583 let loader = ThemeLoader::embedded_only();
584 let registry = loader.load_all(&[]);
585
586 assert!(registry.get("dark").is_some());
588 assert!(registry.get("light").is_some());
589 assert!(registry.get("high-contrast").is_some());
590
591 assert!(registry.get("Dark").is_some());
593 assert!(registry.get("DARK").is_some());
594 assert!(registry.get("high_contrast").is_some());
595 assert!(registry.get("high contrast").is_some());
596
597 assert!(registry.get("nonexistent-theme").is_none());
599 }
600
601 #[test]
602 fn test_theme_registry_list() {
603 let loader = ThemeLoader::embedded_only();
604 let registry = loader.load_all(&[]);
605
606 let list = registry.list();
607 assert!(list.len() >= 7); assert!(list.iter().any(|t| t.name == "dark"));
611 assert!(list.iter().any(|t| t.name == "light"));
612 }
613
614 #[test]
615 fn test_theme_registry_contains() {
616 let loader = ThemeLoader::embedded_only();
617 let registry = loader.load_all(&[]);
618
619 assert!(registry.contains("dark"));
620 assert!(registry.contains("Dark")); assert!(!registry.contains("nonexistent"));
622 }
623
624 #[test]
625 fn test_theme_loader_load_all() {
626 let loader = ThemeLoader::embedded_only();
627 let registry = loader.load_all(&[]);
628
629 assert!(registry.len() >= 7); let dark = registry.get("dark").unwrap();
634 assert_eq!(dark.name, "dark");
635 }
636
637 #[test]
642 fn test_custom_theme_loading_from_user_dir() {
643 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
645 let themes_dir = temp_dir.path().to_path_buf();
646
647 let theme_json = r#"{
649 "name": "my-custom-theme",
650 "editor": {},
651 "ui": {},
652 "search": {},
653 "diagnostic": {},
654 "syntax": {}
655 }"#;
656 std::fs::write(themes_dir.join("my-custom-theme.json"), theme_json)
657 .expect("Failed to write theme file");
658
659 let loader = ThemeLoader::new(themes_dir.clone());
661 let registry = loader.load_all(&[]);
662
663 assert!(
665 registry.contains("my-custom-theme"),
666 "Custom theme should be loaded from user themes directory"
667 );
668 assert!(
669 registry.get("my-custom-theme").is_some(),
670 "Custom theme should be retrievable"
671 );
672
673 let theme_list = registry.list();
675 assert!(
676 theme_list.iter().any(|t| t.name == "my-custom-theme"),
677 "Custom theme should appear in theme list for Select Theme menu"
678 );
679
680 let theme_info = theme_list
682 .iter()
683 .find(|t| t.name == "my-custom-theme")
684 .unwrap();
685 assert_eq!(
686 theme_info.pack, "user",
687 "Custom theme should have 'user' pack"
688 );
689
690 #[cfg(not(target_arch = "wasm32"))]
694 {
695 let menu_items = crate::config::generate_dynamic_items("copy_with_theme", &themes_dir);
696 let theme_keys: Vec<_> = menu_items
697 .iter()
698 .filter_map(|item| match item {
699 crate::config::MenuItem::Action { args, .. } => args
700 .get("theme")
701 .map(|v| v.as_str().unwrap_or_default().to_string()),
702 _ => None,
703 })
704 .collect();
705 assert!(
706 theme_keys.iter().any(|k| k.contains("my-custom-theme")),
707 "Custom theme key should appear in dynamic menu items, got: {:?}",
708 theme_keys
709 );
710 }
711 }
712
713 #[test]
715 fn test_custom_theme_package_loading() {
716 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
718 let themes_dir = temp_dir.path().to_path_buf();
719
720 let packages_dir = themes_dir.join("packages");
722 let pkg_dir = packages_dir.join("my-theme-pack");
723 std::fs::create_dir_all(&pkg_dir).expect("Failed to create package dir");
724
725 let manifest = r#"{
727 "name": "my-theme-pack",
728 "fresh": {
729 "themes": [
730 { "name": "Packaged Theme", "file": "packaged-theme.json" }
731 ]
732 }
733 }"#;
734 std::fs::write(pkg_dir.join("package.json"), manifest)
735 .expect("Failed to write package.json");
736
737 let theme_json = r#"{
739 "name": "packaged-theme",
740 "editor": {},
741 "ui": {},
742 "search": {},
743 "diagnostic": {},
744 "syntax": {}
745 }"#;
746 std::fs::write(pkg_dir.join("packaged-theme.json"), theme_json)
747 .expect("Failed to write theme file");
748
749 let loader = ThemeLoader::new(themes_dir);
751 let registry = loader.load_all(&[]);
752
753 assert!(
755 registry.contains("packaged-theme"),
756 "Packaged theme should be loaded"
757 );
758
759 let theme_list = registry.list();
761 let theme_info = theme_list
762 .iter()
763 .find(|t| t.name == "packaged-theme")
764 .expect("Packaged theme should be in theme list");
765 assert_eq!(
766 theme_info.pack, "pkg/my-theme-pack",
767 "Packaged theme should have correct pack name"
768 );
769 }
770
771 #[test]
772 fn test_normalize_theme_name() {
773 assert_eq!(normalize_theme_name("dark"), "dark");
774 assert_eq!(normalize_theme_name("Dark"), "dark");
775 assert_eq!(normalize_theme_name("high_contrast"), "high-contrast");
776 assert_eq!(normalize_theme_name("Catppuccin Mocha"), "catppuccin-mocha");
777 assert_eq!(normalize_theme_name("My_Custom Theme"), "my-custom-theme");
778 assert_eq!(normalize_theme_name("SOLARIZED_DARK"), "solarized-dark");
779 }
780
781 #[test]
785 fn test_theme_name_mismatch_json_vs_filename() {
786 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
787 let themes_dir = temp_dir.path().to_path_buf();
788
789 let theme_json = r#"{
792 "name": "Catppuccin Mocha",
793 "editor": {},
794 "ui": {},
795 "search": {},
796 "diagnostic": {},
797 "syntax": {}
798 }"#;
799 std::fs::write(themes_dir.join("catppuccin-mocha.json"), theme_json)
800 .expect("Failed to write theme file");
801
802 let loader = ThemeLoader::new(themes_dir);
803 let registry = loader.load_all(&[]);
804
805 assert!(
807 registry.contains("catppuccin-mocha"),
808 "Theme should be found by normalized filename"
809 );
810
811 assert!(
813 registry.contains("Catppuccin Mocha"),
814 "Theme should be found by JSON name with spaces (normalized to hyphens)"
815 );
816
817 assert!(
819 registry.contains("CATPPUCCIN-MOCHA"),
820 "Theme should be found regardless of casing"
821 );
822
823 let theme_list = registry.list();
825 let theme_info = theme_list
826 .iter()
827 .find(|t| t.name == "catppuccin-mocha")
828 .expect("Theme should appear with normalized name in theme list");
829 assert_eq!(theme_info.pack, "user");
830 }
831
832 #[test]
834 fn test_custom_theme_in_subdirectory() {
835 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
837 let themes_dir = temp_dir.path().to_path_buf();
838
839 let subdir = themes_dir.join("my-collection");
841 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
842
843 let theme_json = r#"{
845 "name": "nested-theme",
846 "editor": {},
847 "ui": {},
848 "search": {},
849 "diagnostic": {},
850 "syntax": {}
851 }"#;
852 std::fs::write(subdir.join("nested-theme.json"), theme_json)
853 .expect("Failed to write theme file");
854
855 let loader = ThemeLoader::new(themes_dir);
857 let registry = loader.load_all(&[]);
858
859 assert!(
861 registry.contains("nested-theme"),
862 "Theme in subdirectory should be loaded"
863 );
864
865 let theme_list = registry.list();
867 let theme_info = theme_list
868 .iter()
869 .find(|t| t.name == "nested-theme")
870 .expect("Nested theme should be in theme list");
871 assert_eq!(
872 theme_info.pack, "user/my-collection",
873 "Nested theme should have subdirectory in pack name"
874 );
875 }
876
877 #[test]
888 fn test_resolve_key_portable_config_forms_for_user_themes() {
889 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
890 let themes_dir = temp_dir.path().to_path_buf();
891
892 let user_dark = r#"{
894 "name": "dark",
895 "editor": {},
896 "ui": {},
897 "search": {},
898 "diagnostic": {},
899 "syntax": {}
900 }"#;
901 std::fs::write(themes_dir.join("dark.json"), user_dark)
902 .expect("Failed to write user dark theme");
903
904 let subdir = themes_dir.join("my-collection");
906 std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
907 let nested = r#"{
908 "name": "s-dark",
909 "editor": {},
910 "ui": {},
911 "search": {},
912 "diagnostic": {},
913 "syntax": {}
914 }"#;
915 std::fs::write(subdir.join("s-dark.json"), nested).expect("Failed to write nested theme");
916
917 let loader = ThemeLoader::new(themes_dir.clone());
918 let registry = loader.load_all(&[]);
919
920 assert_eq!(
922 registry.resolve_key("builtin://dark").as_deref(),
923 Some("dark"),
924 "`builtin://dark` must resolve to the built-in"
925 );
926
927 let user_key = registry
930 .resolve_key("dark.json")
931 .expect("`dark.json` should resolve");
932 assert!(
933 user_key.starts_with("file://") && user_key.ends_with("dark.json"),
934 "`dark.json` must resolve to the user theme file, got: {}",
935 user_key
936 );
937 let theme = registry
938 .get("dark.json")
939 .expect("theme should be retrievable by relative path");
940 assert_eq!(theme.name, "dark");
941
942 let nested_key = registry
944 .resolve_key("my-collection/s-dark.json")
945 .expect("nested relative path should resolve");
946 assert!(
947 nested_key.starts_with("file://") && nested_key.contains("my-collection"),
948 "nested path should resolve under themes dir, got: {}",
949 nested_key
950 );
951
952 std::env::set_var(
955 "FRESH_TEST_THEMES_ROOT",
956 themes_dir.to_string_lossy().to_string(),
957 );
958 let uri = "file://${FRESH_TEST_THEMES_ROOT}/dark.json";
959 let resolved = registry
960 .resolve_key(uri)
961 .expect("env-var-expanded file:// URI should resolve");
962 assert_eq!(
963 resolved, user_key,
964 "env-expanded URI should match user theme"
965 );
966 std::env::remove_var("FRESH_TEST_THEMES_ROOT");
967
968 assert_eq!(
970 registry.resolve_key("high-contrast").as_deref(),
971 Some("high-contrast"),
972 "legacy bare-name config must keep working"
973 );
974
975 assert!(registry.resolve_key("does-not-exist").is_none());
977 assert!(registry.resolve_key("builtin://no-such-theme").is_none());
978 assert!(registry.resolve_key("missing.json").is_none());
979 }
980
981 #[test]
985 fn test_portable_form_round_trip() {
986 let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
987 let themes_dir = temp_dir.path().to_path_buf();
988 let theme_json = r#"{
989 "name": "s-dark",
990 "editor": {},
991 "ui": {},
992 "search": {},
993 "diagnostic": {},
994 "syntax": {}
995 }"#;
996 std::fs::write(themes_dir.join("s-dark.json"), theme_json).expect("Failed to write theme");
997 let loader = ThemeLoader::new(themes_dir.clone());
998 let registry = loader.load_all(&[]);
999
1000 let builtin_portable = registry
1002 .portable_form("dark")
1003 .expect("built-in should have a portable form");
1004 assert_eq!(builtin_portable, "builtin://dark");
1005 assert_eq!(
1006 registry.resolve_key(&builtin_portable).as_deref(),
1007 Some("dark")
1008 );
1009
1010 let user_info = registry
1013 .list()
1014 .iter()
1015 .find(|i| i.name == "s-dark")
1016 .expect("user theme should be listed")
1017 .clone();
1018 let user_portable = registry
1019 .portable_form(&user_info.key)
1020 .expect("user theme should have a portable form");
1021 assert_eq!(
1022 user_portable, "s-dark.json",
1023 "user theme must persist as a relative path, got: {}",
1024 user_portable
1025 );
1026 assert!(
1027 !user_portable.contains(themes_dir.to_string_lossy().as_ref()),
1028 "portable form must not embed the absolute themes dir path"
1029 );
1030 assert_eq!(registry.resolve_key(&user_portable), Some(user_info.key));
1032 }
1033
1034 #[test]
1035 fn test_expand_env_vars() {
1036 std::env::set_var("FRESH_TEST_VAR_A", "/foo/bar");
1037 std::env::set_var("FRESH_TEST_VAR_B", "baz");
1038 assert_eq!(expand_env_vars("${FRESH_TEST_VAR_A}/x"), "/foo/bar/x");
1039 assert_eq!(expand_env_vars("$FRESH_TEST_VAR_A/x"), "/foo/bar/x");
1040 assert_eq!(expand_env_vars("a/${FRESH_TEST_VAR_B}/c"), "a/baz/c");
1041 assert_eq!(
1043 expand_env_vars("${FRESH_NO_SUCH_VAR_XYZ}/x"),
1044 "${FRESH_NO_SUCH_VAR_XYZ}/x"
1045 );
1046 assert_eq!(expand_env_vars("${oops/x"), "${oops/x");
1048 if let Ok(home) = std::env::var("HOME") {
1050 assert_eq!(expand_env_vars("~/foo"), format!("{}/foo", home));
1051 }
1052 std::env::remove_var("FRESH_TEST_VAR_A");
1053 std::env::remove_var("FRESH_TEST_VAR_B");
1054 }
1055
1056 #[test]
1057 fn test_to_json_map() {
1058 let loader = ThemeLoader::embedded_only();
1059 let registry = loader.load_all(&[]);
1060
1061 let json_map = registry.to_json_map();
1062
1063 assert_eq!(json_map.len(), registry.len());
1065
1066 let dark = json_map
1068 .get("dark")
1069 .expect("dark theme should be in json map");
1070 assert!(dark.is_object(), "theme should serialize to a JSON object");
1071 assert_eq!(
1072 dark.get("name").and_then(|v| v.as_str()),
1073 Some("dark"),
1074 "theme JSON should have correct name"
1075 );
1076
1077 assert!(dark.get("editor").is_some(), "should have editor section");
1079 assert!(dark.get("ui").is_some(), "should have ui section");
1080 assert!(dark.get("syntax").is_some(), "should have syntax section");
1081
1082 assert_eq!(
1084 dark.get("_key").and_then(|v| v.as_str()),
1085 Some("dark"),
1086 "theme JSON should have _key metadata"
1087 );
1088 assert!(
1089 dark.get("_pack").is_some(),
1090 "theme JSON should have _pack metadata"
1091 );
1092 }
1093}