steer_tui/tui/theme/
loader.rs

1//! Theme loading functionality
2
3use super::{RawTheme, Theme, ThemeError};
4use directories::ProjectDirs;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8/// Bundled themes included with the application
9const BUNDLED_THEMES: &[(&str, &str)] = &[
10    // Dark themes
11    (
12        "catppuccin-mocha",
13        include_str!("../../../themes/catppuccin-mocha.toml"),
14    ),
15    ("dracula", include_str!("../../../themes/dracula.toml")),
16    (
17        "gruvbox-dark",
18        include_str!("../../../themes/gruvbox-dark.toml"),
19    ),
20    ("nord", include_str!("../../../themes/nord.toml")),
21    ("one-dark", include_str!("../../../themes/one-dark.toml")),
22    (
23        "solarized-dark",
24        include_str!("../../../themes/solarized-dark.toml"),
25    ),
26    (
27        "tokyo-night-storm",
28        include_str!("../../../themes/tokyo-night-storm.toml"),
29    ),
30    // Light themes
31    (
32        "catppuccin-latte",
33        include_str!("../../../themes/catppuccin-latte.toml"),
34    ),
35    (
36        "github-light",
37        include_str!("../../../themes/github-light.toml"),
38    ),
39    (
40        "gruvbox-light",
41        include_str!("../../../themes/gruvbox-light.toml"),
42    ),
43    ("one-light", include_str!("../../../themes/one-light.toml")),
44    (
45        "solarized-light",
46        include_str!("../../../themes/solarized-light.toml"),
47    ),
48];
49
50/// Theme loader responsible for finding and loading theme files
51pub struct ThemeLoader {
52    search_paths: Vec<PathBuf>,
53}
54
55impl ThemeLoader {
56    /// Create a new theme loader with default search paths
57    pub fn new() -> Self {
58        let mut search_paths = Vec::new();
59
60        // Add paths from directories crate
61        if let Some(proj_dirs) = ProjectDirs::from("", "", "steer") {
62            // Config directory (e.g., ~/.config/steer/themes on Linux)
63            search_paths.push(proj_dirs.config_dir().join("themes"));
64
65            // Data directory as fallback (e.g., ~/.local/share/steer/themes on Linux)
66            search_paths.push(proj_dirs.data_dir().join("themes"));
67        }
68
69        Self { search_paths }
70    }
71
72    /// Add a custom search path
73    pub fn add_search_path(&mut self, path: PathBuf) {
74        self.search_paths.push(path);
75    }
76
77    /// Load a theme by name
78    pub fn load_theme(&self, name: &str) -> Result<Theme, ThemeError> {
79        // First check if it's a bundled theme
80        for (theme_name, theme_content) in BUNDLED_THEMES {
81            if theme_name == &name {
82                let raw_theme: RawTheme = toml::from_str(theme_content)?;
83                return raw_theme.into_theme();
84            }
85        }
86
87        // Try to find the theme file in the filesystem
88        let theme_file = self.find_theme_file(name)?;
89
90        // Read and parse the theme file
91        let content = fs::read_to_string(&theme_file)?;
92        let raw_theme: RawTheme = toml::from_str(&content)?;
93
94        // Validate theme name matches
95        if raw_theme.name.to_lowercase() != name.to_lowercase() {
96            return Err(ThemeError::Validation(format!(
97                "Theme name mismatch: expected '{}', found '{}'",
98                name, raw_theme.name
99            )));
100        }
101
102        // Convert to usable theme
103        raw_theme.into_theme()
104    }
105
106    /// Load a theme from a specific file path
107    pub fn load_theme_from_path(&self, path: &Path) -> Result<Theme, ThemeError> {
108        let content = fs::read_to_string(path)?;
109        let raw_theme: RawTheme = toml::from_str(&content)?;
110        raw_theme.into_theme()
111    }
112
113    /// List all available themes
114    pub fn list_themes(&self) -> Vec<String> {
115        let mut themes = Vec::new();
116
117        // Add bundled themes
118        for (theme_name, _) in BUNDLED_THEMES {
119            themes.push(theme_name.to_string());
120        }
121
122        // Add filesystem themes
123        for search_path in &self.search_paths {
124            if let Ok(entries) = fs::read_dir(search_path) {
125                for entry in entries.flatten() {
126                    if let Ok(metadata) = entry.metadata() {
127                        if metadata.is_file() {
128                            if let Some(name) = entry.file_name().to_str() {
129                                if name.ends_with(".toml") {
130                                    let theme_name = name.trim_end_matches(".toml");
131                                    if !themes.contains(&theme_name.to_string()) {
132                                        themes.push(theme_name.to_string());
133                                    }
134                                }
135                            }
136                        }
137                    }
138                }
139            }
140        }
141
142        themes.sort();
143        themes
144    }
145
146    /// Find a theme file by name in the search paths
147    fn find_theme_file(&self, name: &str) -> Result<PathBuf, ThemeError> {
148        let filename = format!("{name}.toml");
149
150        for search_path in &self.search_paths {
151            let theme_path = search_path.join(&filename);
152            if theme_path.exists() {
153                return Ok(theme_path);
154            }
155        }
156
157        Err(ThemeError::Validation(format!(
158            "Theme '{name}' not found in bundled themes or filesystem"
159        )))
160    }
161}
162
163impl Default for ThemeLoader {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use tempfile::TempDir;
173
174    #[test]
175    fn test_load_theme() {
176        let temp_dir = TempDir::new().unwrap();
177        let theme_path = temp_dir.path().join("test-theme.toml");
178
179        let theme_content = r##"
180name = "test-theme"
181
182[palette]
183background = "#282828"
184foreground = "#ebdbb2"
185
186[components]
187status_bar = { fg = "foreground", bg = "background" }
188
189[colors]
190bg = "#282828"
191fg = "#ebdbb2"
192
193[styles]
194border = { fg = "fg" }
195text = { fg = "fg" }
196"##;
197        let mut file = std::fs::File::create(&theme_path).unwrap();
198        std::io::Write::write_all(&mut file, theme_content.as_bytes()).unwrap();
199
200        let mut loader = ThemeLoader::new();
201        loader.add_search_path(temp_dir.path().to_path_buf());
202
203        let theme = loader.load_theme("test-theme").unwrap();
204        assert_eq!(theme.name, "test-theme");
205    }
206
207    #[test]
208    fn test_load_bundled_themes() {
209        let loader = ThemeLoader::new();
210
211        // Iterate through all theme files in the themes directory
212        let themes_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
213        let entries = std::fs::read_dir(&themes_dir)
214            .unwrap_or_else(|e| panic!("Failed to read themes directory: {e}"));
215
216        let mut errors = Vec::new();
217
218        for entry in entries {
219            let entry = entry.unwrap();
220            let path = entry.path();
221
222            // Skip non-TOML files
223            if path.extension().is_none_or(|ext| ext != "toml") {
224                continue;
225            }
226
227            let theme_id = path
228                .file_stem()
229                .and_then(|s| s.to_str())
230                .expect("Invalid theme filename");
231
232            match loader.load_theme(theme_id) {
233                Ok(theme) => {
234                    // Verify the theme loaded successfully
235                    if theme.name.is_empty() {
236                        errors.push(format!("Theme '{theme_id}' has empty name"));
237                    }
238                }
239                Err(e) => {
240                    // Provide detailed error information
241                    let error_msg = match &e {
242                        ThemeError::ColorNotFound(color) => {
243                            format!(
244                                "Theme '{theme_id}' references undefined color '{color}'. Check that this color is defined in either the 'palette' or 'colors' section."
245                            )
246                        }
247                        ThemeError::InvalidColor(msg) => {
248                            format!("Theme '{theme_id}' has invalid color: {msg}")
249                        }
250                        ThemeError::Parse(parse_err) => {
251                            format!("Theme '{theme_id}' has invalid TOML syntax: {parse_err}")
252                        }
253                        ThemeError::Validation(msg) => {
254                            format!("Theme '{theme_id}' validation error: {msg}")
255                        }
256                        ThemeError::Io(io_err) => {
257                            format!("Theme '{theme_id}' I/O error: {io_err}")
258                        }
259                    };
260                    errors.push(error_msg);
261                }
262            }
263        }
264
265        if !errors.is_empty() {
266            panic!("Theme loading errors:\n\n{}", errors.join("\n\n"));
267        }
268    }
269
270    #[test]
271    fn test_list_themes() {
272        let temp_dir = TempDir::new().unwrap();
273        let theme1_path = temp_dir.path().join("theme1.toml");
274        let theme2_path = temp_dir.path().join("theme2.toml");
275
276        let theme_content = r#"name = "Test"
277[colors]
278[styles]
279"#;
280        std::fs::write(&theme1_path, theme_content).unwrap();
281        std::fs::write(&theme2_path, theme_content).unwrap();
282
283        let mut loader = ThemeLoader::new();
284        loader.add_search_path(temp_dir.path().to_path_buf());
285
286        let themes = loader.list_themes();
287        assert!(themes.contains(&"theme1".to_string()));
288        assert!(themes.contains(&"theme2".to_string()));
289        // Also check that bundled themes are included
290        assert!(themes.contains(&"catppuccin-mocha".to_string()));
291        assert!(themes.contains(&"catppuccin-latte".to_string()));
292    }
293
294    #[test]
295    fn test_theme_not_found() {
296        let loader = ThemeLoader::new();
297        let result = loader.load_theme("non-existent-theme");
298        assert!(matches!(result, Err(ThemeError::Validation(_))));
299    }
300
301    #[test]
302    fn test_bundled_themes_validation() {
303        use super::super::{ColorValue, Component, RawTheme};
304
305        // Test that all bundled themes are valid and follow best practices
306        for (theme_name, theme_content) in BUNDLED_THEMES {
307            let raw_theme: RawTheme = toml::from_str(theme_content)
308                .unwrap_or_else(|e| panic!("Failed to parse theme '{theme_name}': {e}"));
309
310            // Ensure theme has the required palette colors
311            assert!(
312                raw_theme.palette.contains_key("background"),
313                "Theme '{theme_name}' missing 'background' in palette"
314            );
315            assert!(
316                raw_theme.palette.contains_key("foreground"),
317                "Theme '{theme_name}' missing 'foreground' in palette"
318            );
319
320            // Verify components don't use direct hex colors (should use palette references)
321            for (component_name, style) in &raw_theme.components {
322                if let Some(fg) = &style.fg {
323                    match fg {
324                        ColorValue::Direct(color) if color.starts_with('#') => {
325                            panic!(
326                                "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
327                            );
328                        }
329                        _ => {} // Palette reference or named color is fine
330                    }
331                }
332                if let Some(bg) = &style.bg {
333                    match bg {
334                        ColorValue::Direct(color) if color.starts_with('#') => {
335                            panic!(
336                                "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
337                            );
338                        }
339                        _ => {} // Palette reference or named color is fine
340                    }
341                }
342            }
343
344            // Verify theme can be loaded successfully (includes resolution validation)
345            let theme = raw_theme
346                .into_theme()
347                .unwrap_or_else(|e| panic!("Failed to convert theme '{theme_name}': {e}"));
348
349            // Verify critical components are defined
350            let critical_components = [
351                Component::StatusBar,
352                Component::ErrorText,
353                Component::AssistantMessage,
354                Component::UserMessage,
355                Component::InputPanelBorder,
356                Component::ChatListBorder,
357                Component::SelectionHighlight,
358            ];
359
360            for component in critical_components {
361                assert!(
362                    theme.styles.contains_key(&component),
363                    "Theme '{theme_name}' missing critical component: {component:?}"
364                );
365            }
366        }
367    }
368
369    #[test]
370    fn test_theme_palette_references_resolve() {
371        let loader = ThemeLoader::new();
372
373        // Test a few themes to ensure palette references resolve to actual colors
374        for theme_name in ["catppuccin-mocha", "gruvbox-dark", "solarized-light"] {
375            let theme = loader
376                .load_theme(theme_name)
377                .unwrap_or_else(|e| panic!("Failed to load theme '{theme_name}': {e}"));
378
379            // Check that critical components have resolved colors (not None)
380            let status_bar = theme
381                .styles
382                .get(&super::super::Component::StatusBar)
383                .expect("StatusBar component missing");
384
385            // StatusBar should at least have a foreground color
386            if status_bar.fg.is_none() && status_bar.bg.is_none() {
387                panic!("Theme '{theme_name}' StatusBar has no colors defined");
388            }
389        }
390    }
391}