Skip to main content

fresh/view/theme/
loader.rs

1//! Theme loading and registry.
2//!
3//! This module provides:
4//! - `ThemeRegistry`: A pure data structure holding all loaded themes
5//! - `ThemeLoader`: Scans and loads themes into a registry
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use super::types::{Theme, ThemeFile, ThemeInfo, BUILTIN_THEMES};
11
12/// Normalize a theme name for consistent lookup and storage.
13///
14/// Converts to lowercase and replaces underscores and spaces with hyphens.
15/// This ensures that theme names can be matched regardless of how they appear
16/// in filenames vs. JSON content (e.g., "Catppuccin Mocha" matches "catppuccin-mocha").
17pub fn normalize_theme_name(name: &str) -> String {
18    name.to_lowercase().replace(['_', ' '], "-")
19}
20
21/// Expand `~`, `$VAR`, and `${VAR}` references in a config-supplied path.
22///
23/// Minimal shell-like expansion (no crate dependency). Unknown variables are
24/// left literal so a typo surfaces as a resolution failure rather than
25/// silently collapsing to an empty path. Used for theme config values so a
26/// shared dotfiles repo can write `file://${HOME}/.config/fresh/themes/x.json`
27/// and have it resolve correctly on any machine.
28pub(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        // `$` at end of string — keep literal.
48        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            // Unterminated `${…` — keep literal.
64            out.push('$');
65            i += 1;
66            continue;
67        }
68        // `$VAR` — consume ASCII alphanumerics and underscores.
69        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/// A registry holding all loaded themes.
90///
91/// This is a pure data structure - no I/O operations.
92/// Use `ThemeLoader` to create and populate a registry.
93///
94/// Themes are keyed by their unique `ThemeInfo::key` (typically a repository
95/// URL or `pack/name` path). Lookups fall back to matching by display name
96/// for backward compatibility with configs that store just e.g. `"dark"`.
97#[derive(Debug, Clone)]
98pub struct ThemeRegistry {
99    /// All loaded themes, keyed by ThemeInfo.key
100    themes: HashMap<String, Theme>,
101    /// Theme metadata for listing
102    theme_list: Vec<ThemeInfo>,
103    /// User themes directory — used to resolve relative-path config values
104    /// (e.g. `"s-dark.json"`, `"packages/nord/dark.json"`) against the
105    /// machine's themes dir, so configs stay portable across machines.
106    themes_dir: Option<PathBuf>,
107}
108
109impl ThemeRegistry {
110    /// Look up a theme by its config value (key, scheme URI, relative path,
111    /// or legacy bare name). See [`ThemeRegistry::resolve_key`].
112    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    /// Get a cloned theme by key or name.
118    pub fn get_cloned(&self, key_or_name: &str) -> Option<Theme> {
119        self.get(key_or_name).cloned()
120    }
121
122    /// Resolve a config-value to the canonical registry key.
123    ///
124    /// Accepted config forms (issue #1621):
125    ///
126    /// | Form | Example | Resolves to |
127    /// |------|---------|-------------|
128    /// | `builtin://NAME` | `builtin://dark` | built-in theme by name |
129    /// | `file://PATH` (env-expanded) | `file://${HOME}/.config/fresh/themes/x.json` | exact user-theme key |
130    /// | `http(s)://...` | `https://github.com/...#dark` | URL-packaged theme |
131    /// | relative `.json` path | `s-dark.json`, `packages/nord/dark.json` | user theme under themes dir |
132    /// | bare name (legacy) | `dark` | exact key, else normalized-name match |
133    ///
134    /// `$HOME`, `${HOME}`, `${XDG_CONFIG_HOME}` and a leading `~` are
135    /// expanded before path matching so shared dotfile configs work on any
136    /// machine. Returns an owned `String` because some forms (e.g. a relative
137    /// path) must be reconstructed as an absolute `file://` key.
138    pub fn resolve_key(&self, value: &str) -> Option<String> {
139        // 1. Exact key match — fast path; covers already-canonical keys
140        //    (repo URLs, `file://…` written as-is, plain built-in names).
141        if self.themes.contains_key(value) {
142            return Some(value.to_string());
143        }
144
145        // 2. `builtin://NAME` — look up a built-in by normalized name.
146        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        // 3. `file://PATH` — env-expand and retry exact match.
156        if let Some(raw_path) = value.strip_prefix("file://") {
157            let expanded = expand_env_vars(raw_path);
158            // Normalize forward slashes to the OS-native separator so the
159            // candidate matches the registry key built from
160            // `entry.path().display()` (backslashes on Windows). No-op on
161            // Unix where `MAIN_SEPARATOR_STR == "/"`.
162            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        // 4. `http(s)://…` — URL-packaged theme. Only exact match is valid;
171        //    don't fall through to name fallback (would mask typos).
172        if value.starts_with("http://") || value.starts_with("https://") {
173            return None;
174        }
175
176        // 5. Relative path (ends with `.json`) — resolve against themes dir.
177        //    This is the portable form for user themes (`s-dark.json` or
178        //    `packages/nord/dark.json`). Env-var expansion is applied for
179        //    the rare case of a hand-edited path like `${HOME}/foo.json`.
180        if value.ends_with(".json") {
181            if let Some(themes_dir) = self.themes_dir.as_deref() {
182                let expanded = expand_env_vars(value);
183                // Registry keys are built from `entry.path().display()`,
184                // which uses the OS-native separator (`\` on Windows).
185                // Normalize the config input's forward slashes to match so
186                // the HashMap lookup hits on Windows too. No-op on Unix
187                // (MAIN_SEPARATOR_STR is `/`).
188                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        // 6. Legacy bare name — keeps `"theme": "dark"` configs working.
204        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    /// Portable config form for the given theme, suitable for persisting to
212    /// `config.json` and sharing across machines.
213    ///
214    /// - Built-ins  → `"builtin://NAME"`
215    /// - User theme under themes dir → relative path (`"s-dark.json"` or
216    ///   `"nord/dark.json"`)
217    /// - User theme outside themes dir → `"file://{abs}"` (user can hand-edit
218    ///   to use `${HOME}` / `${XDG_CONFIG_HOME}` if they want)
219    /// - URL-packaged theme → repo-URL key kept as-is
220    pub fn portable_form(&self, key: &str) -> Option<String> {
221        let info = self.theme_list.iter().find(|i| i.key == key)?;
222
223        // Built-in: empty pack, key is just the name.
224        if info.pack.is_empty() {
225            return Some(format!("builtin://{}", info.name));
226        }
227
228        // User theme (file:// key): rewrite to a path relative to themes_dir
229        // when possible, so the config is portable across machines.
230        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                    // Normalize to forward slashes so the config is
235                    // reproducible across OSes.
236                    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            // Outside themes dir — keep as absolute file:// URI.
245            return Some(info.key.clone());
246        }
247
248        // Anything else (URL-packaged themes) is already portable.
249        Some(info.key.clone())
250    }
251
252    /// List all available themes with metadata.
253    pub fn list(&self) -> &[ThemeInfo] {
254        &self.theme_list
255    }
256
257    /// Get all theme display names.
258    pub fn names(&self) -> Vec<String> {
259        self.theme_list.iter().map(|t| t.name.clone()).collect()
260    }
261
262    /// Check if a theme exists (by key or name).
263    pub fn contains(&self, key_or_name: &str) -> bool {
264        self.get(key_or_name).is_some()
265    }
266
267    /// Number of themes in the registry.
268    pub fn len(&self) -> usize {
269        self.themes.len()
270    }
271
272    /// Check if registry is empty.
273    pub fn is_empty(&self) -> bool {
274        self.themes.is_empty()
275    }
276
277    /// Convert all themes to a JSON map (key → serde_json::Value).
278    ///
279    /// Keyed by the unique registry key. Each value is the theme data with
280    /// added `_key` and `_pack` metadata fields so plugins can distinguish
281    /// themes and show display names.
282    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
302/// Loads themes and creates a ThemeRegistry.
303pub struct ThemeLoader {
304    user_themes_dir: Option<PathBuf>,
305}
306
307impl ThemeLoader {
308    /// Create a ThemeLoader with the given user themes directory.
309    pub fn new(user_themes_dir: PathBuf) -> Self {
310        Self {
311            user_themes_dir: Some(user_themes_dir),
312        }
313    }
314
315    /// Create a ThemeLoader for embedded themes only (no user themes).
316    pub fn embedded_only() -> Self {
317        Self {
318            user_themes_dir: None,
319        }
320    }
321
322    /// Get the user themes directory path.
323    pub fn user_themes_dir(&self) -> Option<&Path> {
324        self.user_themes_dir.as_deref()
325    }
326
327    /// Load all themes (embedded + user + packages + bundle dirs) into a registry.
328    ///
329    /// Pass `&[]` for `bundle_theme_dirs` if there are no bundle themes.
330    /// Each bundle directory should contain a `package.json` with a `fresh.themes`
331    /// array (same format as theme packages).
332    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        // Load all embedded themes (key = name for builtins)
337        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        // Load user themes from ~/.config/fresh/themes/ (recursively)
348        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        // Load theme packages from ~/.config/fresh/themes/packages/*/
353        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        // Load themes from bundle packages
389        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    /// Read the `repository` field from a package.json manifest value.
406    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    /// Load themes from a package with package.json manifest.
414    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        // Check for fresh.themes array in manifest
436        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        // Fallback: if no fresh.themes, scan for JSON files
471        self.scan_directory(
472            pkg_dir,
473            &pack_name,
474            repository.as_deref(),
475            themes,
476            theme_list,
477        );
478    }
479
480    /// Recursively scan a directory for theme files.
481    ///
482    /// If `repository` is provided (from a package.json), it is used to form
483    /// the registry key as `{repository}#{theme_name}`.
484    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                // Skip "packages" subdirectory at top level - it's handled separately
504                // by load_package_themes for proper package metadata
505                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                            // User-saved themes: use file:// URL as key
523                            ThemeInfo::with_key(&name, pack, format!("file://{}", path.display()))
524                        } else {
525                            ThemeInfo::new(&name, pack)
526                        };
527
528                        // Only skip exact key duplicates
529                        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
542// Cursor color methods on Theme (no I/O for theme loading)
543impl Theme {
544    /// Set the terminal cursor color using OSC 12 escape sequence.
545    /// This makes the hardware cursor visible on any background.
546    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            // OSC 12 sets cursor color: \x1b]12;#RRGGBB\x07
551            // Best-effort terminal escape writes
552            #[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    /// Reset the terminal cursor color to default.
566    pub fn reset_terminal_cursor_color() {
567        use std::io::Write;
568        // OSC 112 resets cursor color to default
569        // Best-effort terminal escape writes
570        #[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        // Should find builtin themes
587        assert!(registry.get("dark").is_some());
588        assert!(registry.get("light").is_some());
589        assert!(registry.get("high-contrast").is_some());
590
591        // Name normalization: casing, underscores, spaces
592        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        // Non-existent
598        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); // At least the builtin themes
608
609        // Check some expected themes
610        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")); // normalized
621        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        // Should have loaded all embedded themes
630        assert!(registry.len() >= 7); // 7 root themes (xscriptor moved to external repo)
631
632        // Verify theme content is correct
633        let dark = registry.get("dark").unwrap();
634        assert_eq!(dark.name, "dark");
635    }
636
637    /// Test that custom themes in user themes directory are loaded and available.
638    /// This is a regression test for the macOS bug where themes in ~/.config/fresh/themes/
639    /// were not appearing in the "Select Theme" command because ThemeLoader was using
640    /// the wrong directory path on macOS.
641    #[test]
642    fn test_custom_theme_loading_from_user_dir() {
643        // Create isolated temp directory for this test
644        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
645        let themes_dir = temp_dir.path().to_path_buf();
646
647        // Create a custom theme file directly in the themes directory
648        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        // Load themes with the custom themes directory
660        let loader = ThemeLoader::new(themes_dir.clone());
661        let registry = loader.load_all(&[]);
662
663        // Verify the custom theme is loaded
664        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        // Verify it appears in the theme list (used for "Select Theme" menu)
674        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        // Verify the theme has the correct pack metadata
681        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        // Verify the theme is also available via generate_dynamic_items
691        // (the function used for Select Theme menu items).
692        // The "theme" arg should be the key (file:// URL for user themes).
693        #[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 that custom themes in a package directory (with package.json) are loaded.
714    #[test]
715    fn test_custom_theme_package_loading() {
716        // Create isolated temp directory for this test
717        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
718        let themes_dir = temp_dir.path().to_path_buf();
719
720        // Create packages subdirectory
721        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        // Create package.json manifest
726        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        // Create the theme file referenced in package.json
738        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        // Load themes
750        let loader = ThemeLoader::new(themes_dir);
751        let registry = loader.load_all(&[]);
752
753        // Verify the packaged theme is loaded (name is normalized from "Packaged Theme")
754        assert!(
755            registry.contains("packaged-theme"),
756            "Packaged theme should be loaded"
757        );
758
759        // Verify it appears in the theme list with correct pack name
760        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    /// Regression test for #1001: theme whose JSON "name" field differs from the
782    /// filename (e.g., filename "catppuccin-mocha.json" but JSON name "Catppuccin Mocha")
783    /// should be findable by either name after normalization.
784    #[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        // Simulate a theme where the JSON name has spaces/mixed case
790        // but the filename uses hyphens (common for community themes)
791        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        // Should be findable by the normalized filename
806        assert!(
807            registry.contains("catppuccin-mocha"),
808            "Theme should be found by normalized filename"
809        );
810
811        // Should also be findable by the JSON name (spaces normalized to hyphens)
812        assert!(
813            registry.contains("Catppuccin Mocha"),
814            "Theme should be found by JSON name with spaces (normalized to hyphens)"
815        );
816
817        // Should also be findable with mixed casing
818        assert!(
819            registry.contains("CATPPUCCIN-MOCHA"),
820            "Theme should be found regardless of casing"
821        );
822
823        // The registry key should be the normalized form
824        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 that themes in subdirectories of the user themes directory are loaded.
833    #[test]
834    fn test_custom_theme_in_subdirectory() {
835        // Create isolated temp directory for this test
836        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
837        let themes_dir = temp_dir.path().to_path_buf();
838
839        // Create a subdirectory
840        let subdir = themes_dir.join("my-collection");
841        std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
842
843        // Create a theme in the subdirectory
844        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        // Load themes
856        let loader = ThemeLoader::new(themes_dir);
857        let registry = loader.load_all(&[]);
858
859        // Verify the nested theme is loaded
860        assert!(
861            registry.contains("nested-theme"),
862            "Theme in subdirectory should be loaded"
863        );
864
865        // Verify pack name includes the subdirectory
866        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    /// Regression test for #1621: a `config.json` shared via a dotfiles repo
878    /// must be able to reference user themes without baking in the absolute
879    /// `file://` path of the author's machine. Accepted portable forms:
880    ///   - `builtin://NAME` for built-ins (disambiguates against same-named
881    ///     user themes)
882    ///   - relative paths like `s-dark.json` or `nord/dark.json` resolved
883    ///     from the themes dir
884    ///   - `file://` URIs with `~` / `${HOME}` / `$VAR` expansion so the
885    ///     same config works on any machine
886    ///   - legacy bare names (e.g. `"dark"`) keep resolving to a built-in
887    #[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        // User theme whose name collides with built-in `dark`.
893        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        // User theme in a subdirectory (simulates a shipped theme pack).
905        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        // 1. `builtin://dark` disambiguates against the user theme.
921        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        // 2. Relative path resolves against the themes dir — the user theme,
928        //    not the built-in.
929        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        // 3. Nested relative path with subdirectory.
943        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        // 4. `file://` with `${VAR}` expansion: we route through a test-owned
953        //    env var so the expansion is exercised deterministically.
954        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        // 5. Legacy bare name still resolves to a built-in.
969        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        // 6. Unknown reference yields None (no fuzzy masking of typos).
976        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    /// `portable_form` round-trips a theme's registry key into the value
982    /// that should be persisted to `config.json`. Resolving the persisted
983    /// value must find the same theme again.
984    #[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        // Built-in portable form is `builtin://NAME`.
1001        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        // User-theme portable form is a relative path — never an absolute
1011        // `file://` URI with the author's home dir.
1012        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        // And it resolves back to the same key.
1031        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        // Unknown vars remain literal.
1042        assert_eq!(
1043            expand_env_vars("${FRESH_NO_SUCH_VAR_XYZ}/x"),
1044            "${FRESH_NO_SUCH_VAR_XYZ}/x"
1045        );
1046        // Unterminated `${` stays literal.
1047        assert_eq!(expand_env_vars("${oops/x"), "${oops/x");
1048        // `~` is expanded iff HOME is set (which it is in the test harness).
1049        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        // Should contain all themes
1064        assert_eq!(json_map.len(), registry.len());
1065
1066        // Each entry should be a valid JSON object with a "name" field
1067        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        // Should have the expected section keys
1078        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        // Should have metadata fields
1083        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}