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_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        // Fallback: if no fresh.themes, scan for JSON files
473        self.scan_directory(
474            pkg_dir,
475            &pack_name,
476            repository.as_deref(),
477            themes,
478            theme_list,
479        );
480    }
481
482    /// Recursively scan a directory for theme files.
483    ///
484    /// If `repository` is provided (from a package.json), it is used to form
485    /// the registry key as `{repository}#{theme_name}`.
486    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                // Skip "packages" subdirectory at top level - it's handled separately
506                // by load_package_themes for proper package metadata
507                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                            // User-saved themes: use file:// URL as key
525                            ThemeInfo::with_key(&name, pack, format!("file://{}", path.display()))
526                        } else {
527                            ThemeInfo::new(&name, pack)
528                        };
529
530                        // Only skip exact key duplicates
531                        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
545// Cursor color methods on Theme (no I/O for theme loading)
546impl Theme {
547    /// Set the terminal cursor color using OSC 12 escape sequence.
548    /// This makes the hardware cursor visible on any background.
549    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            // OSC 12 sets cursor color: \x1b]12;#RRGGBB\x07
554            // Best-effort terminal escape writes
555            #[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    /// Reset the terminal cursor color to default.
569    pub fn reset_terminal_cursor_color() {
570        use std::io::Write;
571        // OSC 112 resets cursor color to default
572        // Best-effort terminal escape writes
573        #[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        // Should find builtin themes
590        assert!(registry.get("dark").is_some());
591        assert!(registry.get("light").is_some());
592        assert!(registry.get("high-contrast").is_some());
593
594        // Name normalization: casing, underscores, spaces
595        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        // Non-existent
601        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); // At least the builtin themes
611
612        // Check some expected themes
613        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")); // normalized
624        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        // Should have loaded all embedded themes
633        assert!(registry.len() >= 7); // 7 root themes (xscriptor moved to external repo)
634
635        // Verify theme content is correct
636        let dark = registry.get("dark").unwrap();
637        assert_eq!(dark.name, "dark");
638    }
639
640    /// Test that custom themes in user themes directory are loaded and available.
641    /// This is a regression test for the macOS bug where themes in ~/.config/fresh/themes/
642    /// were not appearing in the "Select Theme" command because ThemeLoader was using
643    /// the wrong directory path on macOS.
644    #[test]
645    fn test_custom_theme_loading_from_user_dir() {
646        // Create isolated temp directory for this test
647        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
648        let themes_dir = temp_dir.path().to_path_buf();
649
650        // Create a custom theme file directly in the themes directory
651        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        // Load themes with the custom themes directory
663        let loader = ThemeLoader::new(themes_dir.clone());
664        let registry = loader.load_all(&[]);
665
666        // Verify the custom theme is loaded
667        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        // Verify it appears in the theme list (used for "Select Theme" menu)
677        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        // Verify the theme has the correct pack metadata
684        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        // Verify the theme is also available via generate_dynamic_items
694        // (the function used for Select Theme menu items).
695        // The "theme" arg should be the key (file:// URL for user themes).
696        #[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 that custom themes in a package directory (with package.json) are loaded.
717    #[test]
718    fn test_custom_theme_package_loading() {
719        // Create isolated temp directory for this test
720        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
721        let themes_dir = temp_dir.path().to_path_buf();
722
723        // Create packages subdirectory
724        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        // Create package.json manifest
729        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        // Create the theme file referenced in package.json
741        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        // Load themes
753        let loader = ThemeLoader::new(themes_dir);
754        let registry = loader.load_all(&[]);
755
756        // Verify the packaged theme is loaded (name is normalized from "Packaged Theme")
757        assert!(
758            registry.contains("packaged-theme"),
759            "Packaged theme should be loaded"
760        );
761
762        // Verify it appears in the theme list with correct pack name
763        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    /// Regression test for #1001: theme whose JSON "name" field differs from the
785    /// filename (e.g., filename "catppuccin-mocha.json" but JSON name "Catppuccin Mocha")
786    /// should be findable by either name after normalization.
787    #[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        // Simulate a theme where the JSON name has spaces/mixed case
793        // but the filename uses hyphens (common for community themes)
794        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        // Should be findable by the normalized filename
809        assert!(
810            registry.contains("catppuccin-mocha"),
811            "Theme should be found by normalized filename"
812        );
813
814        // Should also be findable by the JSON name (spaces normalized to hyphens)
815        assert!(
816            registry.contains("Catppuccin Mocha"),
817            "Theme should be found by JSON name with spaces (normalized to hyphens)"
818        );
819
820        // Should also be findable with mixed casing
821        assert!(
822            registry.contains("CATPPUCCIN-MOCHA"),
823            "Theme should be found regardless of casing"
824        );
825
826        // The registry key should be the normalized form
827        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 that themes in subdirectories of the user themes directory are loaded.
836    #[test]
837    fn test_custom_theme_in_subdirectory() {
838        // Create isolated temp directory for this test
839        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
840        let themes_dir = temp_dir.path().to_path_buf();
841
842        // Create a subdirectory
843        let subdir = themes_dir.join("my-collection");
844        std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
845
846        // Create a theme in the subdirectory
847        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        // Load themes
859        let loader = ThemeLoader::new(themes_dir);
860        let registry = loader.load_all(&[]);
861
862        // Verify the nested theme is loaded
863        assert!(
864            registry.contains("nested-theme"),
865            "Theme in subdirectory should be loaded"
866        );
867
868        // Verify pack name includes the subdirectory
869        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    /// Regression test for #1621: a `config.json` shared via a dotfiles repo
881    /// must be able to reference user themes without baking in the absolute
882    /// `file://` path of the author's machine. Accepted portable forms:
883    ///   - `builtin://NAME` for built-ins (disambiguates against same-named
884    ///     user themes)
885    ///   - relative paths like `s-dark.json` or `nord/dark.json` resolved
886    ///     from the themes dir
887    ///   - `file://` URIs with `~` / `${HOME}` / `$VAR` expansion so the
888    ///     same config works on any machine
889    ///   - legacy bare names (e.g. `"dark"`) keep resolving to a built-in
890    #[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        // User theme whose name collides with built-in `dark`.
896        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        // User theme in a subdirectory (simulates a shipped theme pack).
908        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        // 1. `builtin://dark` disambiguates against the user theme.
924        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        // 2. Relative path resolves against the themes dir — the user theme,
931        //    not the built-in.
932        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        // 3. Nested relative path with subdirectory.
946        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        // 4. `file://` with `${VAR}` expansion: we route through a test-owned
956        //    env var so the expansion is exercised deterministically.
957        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        // 5. Legacy bare name still resolves to a built-in.
972        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        // 6. Unknown reference yields None (no fuzzy masking of typos).
979        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    /// `portable_form` round-trips a theme's registry key into the value
985    /// that should be persisted to `config.json`. Resolving the persisted
986    /// value must find the same theme again.
987    #[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        // Built-in portable form is `builtin://NAME`.
1004        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        // User-theme portable form is a relative path — never an absolute
1014        // `file://` URI with the author's home dir.
1015        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        // And it resolves back to the same key.
1034        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        // Unknown vars remain literal.
1045        assert_eq!(
1046            expand_env_vars("${FRESH_NO_SUCH_VAR_XYZ}/x"),
1047            "${FRESH_NO_SUCH_VAR_XYZ}/x"
1048        );
1049        // Unterminated `${` stays literal.
1050        assert_eq!(expand_env_vars("${oops/x"), "${oops/x");
1051        // `~` is expanded iff HOME is set (which it is in the test harness).
1052        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        // Should contain all themes
1067        assert_eq!(json_map.len(), registry.len());
1068
1069        // Each entry should be a valid JSON object with a "name" field
1070        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        // Should have the expected section keys
1081        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        // Should have metadata fields
1086        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}