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/// A registry holding all loaded themes.
22///
23/// This is a pure data structure - no I/O operations.
24/// Use `ThemeLoader` to create and populate a registry.
25///
26/// Themes are keyed by their unique `ThemeInfo::key` (typically a repository
27/// URL or `pack/name` path). Lookups fall back to matching by display name
28/// for backward compatibility with configs that store just e.g. `"dark"`.
29#[derive(Debug, Clone)]
30pub struct ThemeRegistry {
31    /// All loaded themes, keyed by ThemeInfo.key
32    themes: HashMap<String, Theme>,
33    /// Theme metadata for listing
34    theme_list: Vec<ThemeInfo>,
35}
36
37impl ThemeRegistry {
38    /// Look up a theme by key or name.
39    ///
40    /// Tries exact key match first, then falls back to matching by normalized
41    /// display name (for backward compat with configs that just say `"dark"`).
42    pub fn get(&self, key_or_name: &str) -> Option<&Theme> {
43        // Exact key match
44        if let Some(theme) = self.themes.get(key_or_name) {
45            return Some(theme);
46        }
47        // Fallback: match by normalized display name
48        let normalized = normalize_theme_name(key_or_name);
49        self.theme_list
50            .iter()
51            .find(|info| normalize_theme_name(&info.name) == normalized)
52            .and_then(|info| self.themes.get(&info.key))
53    }
54
55    /// Get a cloned theme by key or name.
56    pub fn get_cloned(&self, key_or_name: &str) -> Option<Theme> {
57        self.get(key_or_name).cloned()
58    }
59
60    /// Resolve a key-or-name to the canonical registry key.
61    pub fn resolve_key<'a>(&'a self, key_or_name: &'a str) -> Option<&'a str> {
62        if self.themes.contains_key(key_or_name) {
63            return Some(key_or_name);
64        }
65        let normalized = normalize_theme_name(key_or_name);
66        self.theme_list
67            .iter()
68            .find(|info| normalize_theme_name(&info.name) == normalized)
69            .map(|info| info.key.as_str())
70    }
71
72    /// List all available themes with metadata.
73    pub fn list(&self) -> &[ThemeInfo] {
74        &self.theme_list
75    }
76
77    /// Get all theme display names.
78    pub fn names(&self) -> Vec<String> {
79        self.theme_list.iter().map(|t| t.name.clone()).collect()
80    }
81
82    /// Check if a theme exists (by key or name).
83    pub fn contains(&self, key_or_name: &str) -> bool {
84        self.get(key_or_name).is_some()
85    }
86
87    /// Number of themes in the registry.
88    pub fn len(&self) -> usize {
89        self.themes.len()
90    }
91
92    /// Check if registry is empty.
93    pub fn is_empty(&self) -> bool {
94        self.themes.is_empty()
95    }
96
97    /// Convert all themes to a JSON map (key → serde_json::Value).
98    ///
99    /// Keyed by the unique registry key. Each value is the theme data with
100    /// added `_key` and `_pack` metadata fields so plugins can distinguish
101    /// themes and show display names.
102    pub fn to_json_map(&self) -> HashMap<String, serde_json::Value> {
103        use super::types::ThemeFile;
104
105        let mut map = HashMap::new();
106        for info in &self.theme_list {
107            if let Some(theme) = self.themes.get(&info.key) {
108                let theme_file: ThemeFile = theme.clone().into();
109                if let Ok(mut v) = serde_json::to_value(theme_file) {
110                    if let Some(obj) = v.as_object_mut() {
111                        obj.insert("_key".to_string(), serde_json::json!(info.key));
112                        obj.insert("_pack".to_string(), serde_json::json!(info.pack));
113                    }
114                    map.insert(info.key.clone(), v);
115                }
116            }
117        }
118        map
119    }
120}
121
122/// Loads themes and creates a ThemeRegistry.
123pub struct ThemeLoader {
124    user_themes_dir: Option<PathBuf>,
125}
126
127impl ThemeLoader {
128    /// Create a ThemeLoader with the given user themes directory.
129    pub fn new(user_themes_dir: PathBuf) -> Self {
130        Self {
131            user_themes_dir: Some(user_themes_dir),
132        }
133    }
134
135    /// Create a ThemeLoader for embedded themes only (no user themes).
136    pub fn embedded_only() -> Self {
137        Self {
138            user_themes_dir: None,
139        }
140    }
141
142    /// Get the user themes directory path.
143    pub fn user_themes_dir(&self) -> Option<&Path> {
144        self.user_themes_dir.as_deref()
145    }
146
147    /// Load all themes (embedded + user + packages + bundle dirs) into a registry.
148    ///
149    /// Pass `&[]` for `bundle_theme_dirs` if there are no bundle themes.
150    /// Each bundle directory should contain a `package.json` with a `fresh.themes`
151    /// array (same format as theme packages).
152    pub fn load_all(&self, bundle_theme_dirs: &[PathBuf]) -> ThemeRegistry {
153        let mut themes = HashMap::new();
154        let mut theme_list = Vec::new();
155
156        // Load all embedded themes (key = name for builtins)
157        for builtin in BUILTIN_THEMES {
158            if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(builtin.json) {
159                let theme: Theme = theme_file.into();
160                let normalized = normalize_theme_name(builtin.name);
161                let info = ThemeInfo::new(&normalized, builtin.pack);
162                themes.insert(info.key.clone(), theme);
163                theme_list.push(info);
164            }
165        }
166
167        // Load user themes from ~/.config/fresh/themes/ (recursively)
168        if let Some(ref user_dir) = self.user_themes_dir {
169            self.scan_directory(user_dir, "user", None, &mut themes, &mut theme_list);
170        }
171
172        // Load theme packages from ~/.config/fresh/themes/packages/*/
173        if let Some(ref user_dir) = self.user_themes_dir {
174            let packages_dir = user_dir.join("packages");
175            if packages_dir.exists() {
176                if let Ok(entries) = std::fs::read_dir(&packages_dir) {
177                    for entry in entries.flatten() {
178                        let path = entry.path();
179                        if path.is_dir() {
180                            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
181                                if !name.starts_with('.') {
182                                    let manifest_path = path.join("package.json");
183                                    if manifest_path.exists() {
184                                        self.load_package_themes(
185                                            &path,
186                                            name,
187                                            &mut themes,
188                                            &mut theme_list,
189                                        );
190                                    } else {
191                                        let pack_name = format!("pkg/{}", name);
192                                        self.scan_directory(
193                                            &path,
194                                            &pack_name,
195                                            None,
196                                            &mut themes,
197                                            &mut theme_list,
198                                        );
199                                    }
200                                }
201                            }
202                        }
203                    }
204                }
205            }
206        }
207
208        // Load themes from bundle packages
209        for bundle_dir in bundle_theme_dirs {
210            if let Some(name) = bundle_dir.file_name().and_then(|n| n.to_str()) {
211                let manifest_path = bundle_dir.join("package.json");
212                if manifest_path.exists() {
213                    self.load_package_themes(bundle_dir, name, &mut themes, &mut theme_list);
214                }
215            }
216        }
217
218        ThemeRegistry { themes, theme_list }
219    }
220
221    /// Read the `repository` field from a package.json manifest value.
222    fn read_repository(manifest: &serde_json::Value) -> Option<String> {
223        manifest
224            .get("repository")
225            .and_then(|v| v.as_str())
226            .map(|s| s.to_string())
227    }
228
229    /// Load themes from a package with package.json manifest.
230    fn load_package_themes(
231        &self,
232        pkg_dir: &Path,
233        pkg_name: &str,
234        themes: &mut HashMap<String, Theme>,
235        theme_list: &mut Vec<ThemeInfo>,
236    ) {
237        let manifest_path = pkg_dir.join("package.json");
238        let manifest_content = match std::fs::read_to_string(&manifest_path) {
239            Ok(c) => c,
240            Err(_) => return,
241        };
242
243        let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
244            Ok(v) => v,
245            Err(_) => return,
246        };
247
248        let repository = Self::read_repository(&manifest);
249        let pack_name = format!("pkg/{}", pkg_name);
250
251        // Check for fresh.themes array in manifest
252        if let Some(fresh) = manifest.get("fresh") {
253            if let Some(theme_entries) = fresh.get("themes").and_then(|t| t.as_array()) {
254                for entry in theme_entries {
255                    if let (Some(file), Some(name)) = (
256                        entry.get("file").and_then(|f| f.as_str()),
257                        entry.get("name").and_then(|n| n.as_str()),
258                    ) {
259                        let theme_path = pkg_dir.join(file);
260                        if theme_path.exists() {
261                            if let Ok(content) = std::fs::read_to_string(&theme_path) {
262                                if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content)
263                                {
264                                    let theme: Theme = theme_file.into();
265                                    let normalized_name = normalize_theme_name(name);
266                                    let info = if let Some(ref repo) = repository {
267                                        ThemeInfo::with_key(
268                                            &normalized_name,
269                                            &pack_name,
270                                            format!("{}#{}", repo, normalized_name),
271                                        )
272                                    } else {
273                                        ThemeInfo::new(&normalized_name, &pack_name)
274                                    };
275                                    if !themes.contains_key(&info.key) {
276                                        themes.insert(info.key.clone(), theme);
277                                        theme_list.push(info);
278                                    }
279                                }
280                            }
281                        }
282                    }
283                }
284                return;
285            }
286        }
287
288        // Fallback: if no fresh.themes, scan for JSON files
289        self.scan_directory(
290            pkg_dir,
291            &pack_name,
292            repository.as_deref(),
293            themes,
294            theme_list,
295        );
296    }
297
298    /// Recursively scan a directory for theme files.
299    ///
300    /// If `repository` is provided (from a package.json), it is used to form
301    /// the registry key as `{repository}#{theme_name}`.
302    fn scan_directory(
303        &self,
304        dir: &Path,
305        pack: &str,
306        repository: Option<&str>,
307        themes: &mut HashMap<String, Theme>,
308        theme_list: &mut Vec<ThemeInfo>,
309    ) {
310        let entries = match std::fs::read_dir(dir) {
311            Ok(e) => e,
312            Err(_) => return,
313        };
314
315        for entry in entries.flatten() {
316            let path = entry.path();
317
318            if path.is_dir() {
319                let subdir_name = path.file_name().unwrap().to_string_lossy();
320
321                // Skip "packages" subdirectory at top level - it's handled separately
322                // by load_package_themes for proper package metadata
323                if pack == "user" && subdir_name == "packages" {
324                    continue;
325                }
326
327                let new_pack = if pack == "user" {
328                    format!("user/{}", subdir_name)
329                } else {
330                    format!("{}/{}", pack, subdir_name)
331                };
332                self.scan_directory(&path, &new_pack, repository, themes, theme_list);
333            } else if path.extension().is_some_and(|ext| ext == "json") {
334                if let Ok(content) = std::fs::read_to_string(&path) {
335                    if let Ok(theme_file) = serde_json::from_str::<ThemeFile>(&content) {
336                        let name = normalize_theme_name(&theme_file.name);
337                        let info = if let Some(repo) = repository {
338                            ThemeInfo::with_key(&name, pack, format!("{}#{}", repo, name))
339                        } else if pack.starts_with("user") {
340                            // User-saved themes: use file:// URL as key
341                            ThemeInfo::with_key(&name, pack, format!("file://{}", path.display()))
342                        } else {
343                            ThemeInfo::new(&name, pack)
344                        };
345
346                        // Only skip exact key duplicates
347                        if themes.contains_key(&info.key) {
348                            continue;
349                        }
350
351                        let theme: Theme = theme_file.into();
352                        themes.insert(info.key.clone(), theme);
353                        theme_list.push(info);
354                    }
355                }
356            }
357        }
358    }
359}
360
361// Cursor color methods on Theme (no I/O for theme loading)
362impl Theme {
363    /// Set the terminal cursor color using OSC 12 escape sequence.
364    /// This makes the hardware cursor visible on any background.
365    pub fn set_terminal_cursor_color(&self) {
366        use super::types::color_to_rgb;
367        use std::io::Write;
368        if let Some((r, g, b)) = color_to_rgb(self.cursor) {
369            // OSC 12 sets cursor color: \x1b]12;#RRGGBB\x07
370            // Best-effort terminal escape writes
371            #[allow(clippy::let_underscore_must_use)]
372            let _ = write!(
373                std::io::stdout(),
374                "\x1b]12;#{:02x}{:02x}{:02x}\x07",
375                r,
376                g,
377                b
378            );
379            #[allow(clippy::let_underscore_must_use)]
380            let _ = std::io::stdout().flush();
381        }
382    }
383
384    /// Reset the terminal cursor color to default.
385    pub fn reset_terminal_cursor_color() {
386        use std::io::Write;
387        // OSC 112 resets cursor color to default
388        // Best-effort terminal escape writes
389        #[allow(clippy::let_underscore_must_use)]
390        let _ = write!(std::io::stdout(), "\x1b]112\x07");
391        #[allow(clippy::let_underscore_must_use)]
392        let _ = std::io::stdout().flush();
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_theme_registry_get() {
402        let loader = ThemeLoader::embedded_only();
403        let registry = loader.load_all(&[]);
404
405        // Should find builtin themes
406        assert!(registry.get("dark").is_some());
407        assert!(registry.get("light").is_some());
408        assert!(registry.get("high-contrast").is_some());
409
410        // Name normalization: casing, underscores, spaces
411        assert!(registry.get("Dark").is_some());
412        assert!(registry.get("DARK").is_some());
413        assert!(registry.get("high_contrast").is_some());
414        assert!(registry.get("high contrast").is_some());
415
416        // Non-existent
417        assert!(registry.get("nonexistent-theme").is_none());
418    }
419
420    #[test]
421    fn test_theme_registry_list() {
422        let loader = ThemeLoader::embedded_only();
423        let registry = loader.load_all(&[]);
424
425        let list = registry.list();
426        assert!(list.len() >= 7); // At least the builtin themes
427
428        // Check some expected themes
429        assert!(list.iter().any(|t| t.name == "dark"));
430        assert!(list.iter().any(|t| t.name == "light"));
431    }
432
433    #[test]
434    fn test_theme_registry_contains() {
435        let loader = ThemeLoader::embedded_only();
436        let registry = loader.load_all(&[]);
437
438        assert!(registry.contains("dark"));
439        assert!(registry.contains("Dark")); // normalized
440        assert!(!registry.contains("nonexistent"));
441    }
442
443    #[test]
444    fn test_theme_loader_load_all() {
445        let loader = ThemeLoader::embedded_only();
446        let registry = loader.load_all(&[]);
447
448        // Should have loaded all embedded themes
449        assert!(registry.len() >= 7); // 7 root themes (xscriptor moved to external repo)
450
451        // Verify theme content is correct
452        let dark = registry.get("dark").unwrap();
453        assert_eq!(dark.name, "dark");
454    }
455
456    /// Test that custom themes in user themes directory are loaded and available.
457    /// This is a regression test for the macOS bug where themes in ~/.config/fresh/themes/
458    /// were not appearing in the "Select Theme" command because ThemeLoader was using
459    /// the wrong directory path on macOS.
460    #[test]
461    fn test_custom_theme_loading_from_user_dir() {
462        // Create isolated temp directory for this test
463        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
464        let themes_dir = temp_dir.path().to_path_buf();
465
466        // Create a custom theme file directly in the themes directory
467        let theme_json = r#"{
468            "name": "my-custom-theme",
469            "editor": {},
470            "ui": {},
471            "search": {},
472            "diagnostic": {},
473            "syntax": {}
474        }"#;
475        std::fs::write(themes_dir.join("my-custom-theme.json"), theme_json)
476            .expect("Failed to write theme file");
477
478        // Load themes with the custom themes directory
479        let loader = ThemeLoader::new(themes_dir.clone());
480        let registry = loader.load_all(&[]);
481
482        // Verify the custom theme is loaded
483        assert!(
484            registry.contains("my-custom-theme"),
485            "Custom theme should be loaded from user themes directory"
486        );
487        assert!(
488            registry.get("my-custom-theme").is_some(),
489            "Custom theme should be retrievable"
490        );
491
492        // Verify it appears in the theme list (used for "Select Theme" menu)
493        let theme_list = registry.list();
494        assert!(
495            theme_list.iter().any(|t| t.name == "my-custom-theme"),
496            "Custom theme should appear in theme list for Select Theme menu"
497        );
498
499        // Verify the theme has the correct pack metadata
500        let theme_info = theme_list
501            .iter()
502            .find(|t| t.name == "my-custom-theme")
503            .unwrap();
504        assert_eq!(
505            theme_info.pack, "user",
506            "Custom theme should have 'user' pack"
507        );
508
509        // Verify the theme is also available via generate_dynamic_items
510        // (the function used for Select Theme menu items).
511        // The "theme" arg should be the key (file:// URL for user themes).
512        #[cfg(not(target_arch = "wasm32"))]
513        {
514            let menu_items = crate::config::generate_dynamic_items("copy_with_theme", &themes_dir);
515            let theme_keys: Vec<_> = menu_items
516                .iter()
517                .filter_map(|item| match item {
518                    crate::config::MenuItem::Action { args, .. } => args
519                        .get("theme")
520                        .map(|v| v.as_str().unwrap_or_default().to_string()),
521                    _ => None,
522                })
523                .collect();
524            assert!(
525                theme_keys.iter().any(|k| k.contains("my-custom-theme")),
526                "Custom theme key should appear in dynamic menu items, got: {:?}",
527                theme_keys
528            );
529        }
530    }
531
532    /// Test that custom themes in a package directory (with package.json) are loaded.
533    #[test]
534    fn test_custom_theme_package_loading() {
535        // Create isolated temp directory for this test
536        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
537        let themes_dir = temp_dir.path().to_path_buf();
538
539        // Create packages subdirectory
540        let packages_dir = themes_dir.join("packages");
541        let pkg_dir = packages_dir.join("my-theme-pack");
542        std::fs::create_dir_all(&pkg_dir).expect("Failed to create package dir");
543
544        // Create package.json manifest
545        let manifest = r#"{
546            "name": "my-theme-pack",
547            "fresh": {
548                "themes": [
549                    { "name": "Packaged Theme", "file": "packaged-theme.json" }
550                ]
551            }
552        }"#;
553        std::fs::write(pkg_dir.join("package.json"), manifest)
554            .expect("Failed to write package.json");
555
556        // Create the theme file referenced in package.json
557        let theme_json = r#"{
558            "name": "packaged-theme",
559            "editor": {},
560            "ui": {},
561            "search": {},
562            "diagnostic": {},
563            "syntax": {}
564        }"#;
565        std::fs::write(pkg_dir.join("packaged-theme.json"), theme_json)
566            .expect("Failed to write theme file");
567
568        // Load themes
569        let loader = ThemeLoader::new(themes_dir);
570        let registry = loader.load_all(&[]);
571
572        // Verify the packaged theme is loaded (name is normalized from "Packaged Theme")
573        assert!(
574            registry.contains("packaged-theme"),
575            "Packaged theme should be loaded"
576        );
577
578        // Verify it appears in the theme list with correct pack name
579        let theme_list = registry.list();
580        let theme_info = theme_list
581            .iter()
582            .find(|t| t.name == "packaged-theme")
583            .expect("Packaged theme should be in theme list");
584        assert_eq!(
585            theme_info.pack, "pkg/my-theme-pack",
586            "Packaged theme should have correct pack name"
587        );
588    }
589
590    #[test]
591    fn test_normalize_theme_name() {
592        assert_eq!(normalize_theme_name("dark"), "dark");
593        assert_eq!(normalize_theme_name("Dark"), "dark");
594        assert_eq!(normalize_theme_name("high_contrast"), "high-contrast");
595        assert_eq!(normalize_theme_name("Catppuccin Mocha"), "catppuccin-mocha");
596        assert_eq!(normalize_theme_name("My_Custom Theme"), "my-custom-theme");
597        assert_eq!(normalize_theme_name("SOLARIZED_DARK"), "solarized-dark");
598    }
599
600    /// Regression test for #1001: theme whose JSON "name" field differs from the
601    /// filename (e.g., filename "catppuccin-mocha.json" but JSON name "Catppuccin Mocha")
602    /// should be findable by either name after normalization.
603    #[test]
604    fn test_theme_name_mismatch_json_vs_filename() {
605        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
606        let themes_dir = temp_dir.path().to_path_buf();
607
608        // Simulate a theme where the JSON name has spaces/mixed case
609        // but the filename uses hyphens (common for community themes)
610        let theme_json = r#"{
611            "name": "Catppuccin Mocha",
612            "editor": {},
613            "ui": {},
614            "search": {},
615            "diagnostic": {},
616            "syntax": {}
617        }"#;
618        std::fs::write(themes_dir.join("catppuccin-mocha.json"), theme_json)
619            .expect("Failed to write theme file");
620
621        let loader = ThemeLoader::new(themes_dir);
622        let registry = loader.load_all(&[]);
623
624        // Should be findable by the normalized filename
625        assert!(
626            registry.contains("catppuccin-mocha"),
627            "Theme should be found by normalized filename"
628        );
629
630        // Should also be findable by the JSON name (spaces normalized to hyphens)
631        assert!(
632            registry.contains("Catppuccin Mocha"),
633            "Theme should be found by JSON name with spaces (normalized to hyphens)"
634        );
635
636        // Should also be findable with mixed casing
637        assert!(
638            registry.contains("CATPPUCCIN-MOCHA"),
639            "Theme should be found regardless of casing"
640        );
641
642        // The registry key should be the normalized form
643        let theme_list = registry.list();
644        let theme_info = theme_list
645            .iter()
646            .find(|t| t.name == "catppuccin-mocha")
647            .expect("Theme should appear with normalized name in theme list");
648        assert_eq!(theme_info.pack, "user");
649    }
650
651    /// Test that themes in subdirectories of the user themes directory are loaded.
652    #[test]
653    fn test_custom_theme_in_subdirectory() {
654        // Create isolated temp directory for this test
655        let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
656        let themes_dir = temp_dir.path().to_path_buf();
657
658        // Create a subdirectory
659        let subdir = themes_dir.join("my-collection");
660        std::fs::create_dir_all(&subdir).expect("Failed to create subdir");
661
662        // Create a theme in the subdirectory
663        let theme_json = r#"{
664            "name": "nested-theme",
665            "editor": {},
666            "ui": {},
667            "search": {},
668            "diagnostic": {},
669            "syntax": {}
670        }"#;
671        std::fs::write(subdir.join("nested-theme.json"), theme_json)
672            .expect("Failed to write theme file");
673
674        // Load themes
675        let loader = ThemeLoader::new(themes_dir);
676        let registry = loader.load_all(&[]);
677
678        // Verify the nested theme is loaded
679        assert!(
680            registry.contains("nested-theme"),
681            "Theme in subdirectory should be loaded"
682        );
683
684        // Verify pack name includes the subdirectory
685        let theme_list = registry.list();
686        let theme_info = theme_list
687            .iter()
688            .find(|t| t.name == "nested-theme")
689            .expect("Nested theme should be in theme list");
690        assert_eq!(
691            theme_info.pack, "user/my-collection",
692            "Nested theme should have subdirectory in pack name"
693        );
694    }
695
696    #[test]
697    fn test_to_json_map() {
698        let loader = ThemeLoader::embedded_only();
699        let registry = loader.load_all(&[]);
700
701        let json_map = registry.to_json_map();
702
703        // Should contain all themes
704        assert_eq!(json_map.len(), registry.len());
705
706        // Each entry should be a valid JSON object with a "name" field
707        let dark = json_map
708            .get("dark")
709            .expect("dark theme should be in json map");
710        assert!(dark.is_object(), "theme should serialize to a JSON object");
711        assert_eq!(
712            dark.get("name").and_then(|v| v.as_str()),
713            Some("dark"),
714            "theme JSON should have correct name"
715        );
716
717        // Should have the expected section keys
718        assert!(dark.get("editor").is_some(), "should have editor section");
719        assert!(dark.get("ui").is_some(), "should have ui section");
720        assert!(dark.get("syntax").is_some(), "should have syntax section");
721
722        // Should have metadata fields
723        assert_eq!(
724            dark.get("_key").and_then(|v| v.as_str()),
725            Some("dark"),
726            "theme JSON should have _key metadata"
727        );
728        assert!(
729            dark.get("_pack").is_some(),
730            "theme JSON should have _pack metadata"
731        );
732    }
733}