Skip to main content

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                        && metadata.is_file()
128                        && let Some(name) = entry.file_name().to_str()
129                        && name.ends_with(".toml")
130                    {
131                        let theme_name = name.trim_end_matches(".toml");
132                        if !themes.contains(&theme_name.to_string()) {
133                            themes.push(theme_name.to_string());
134                        }
135                    }
136                }
137            }
138        }
139
140        themes.sort();
141        themes
142    }
143
144    /// Find a theme file by name in the search paths
145    fn find_theme_file(&self, name: &str) -> Result<PathBuf, ThemeError> {
146        let filename = format!("{name}.toml");
147
148        for search_path in &self.search_paths {
149            let theme_path = search_path.join(&filename);
150            if theme_path.exists() {
151                return Ok(theme_path);
152            }
153        }
154
155        Err(ThemeError::Validation(format!(
156            "Theme '{name}' not found in bundled themes or filesystem"
157        )))
158    }
159}
160
161impl Default for ThemeLoader {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    #[test]
173    fn test_load_theme() {
174        let temp_dir = TempDir::new().unwrap();
175        let theme_path = temp_dir.path().join("test-theme.toml");
176
177        let theme_content = r##"
178name = "test-theme"
179
180[palette]
181background = "#282828"
182foreground = "#ebdbb2"
183
184[components]
185status_bar = { fg = "foreground", bg = "background" }
186
187[colors]
188bg = "#282828"
189fg = "#ebdbb2"
190
191[styles]
192border = { fg = "fg" }
193text = { fg = "fg" }
194"##;
195        let mut file = std::fs::File::create(&theme_path).unwrap();
196        std::io::Write::write_all(&mut file, theme_content.as_bytes()).unwrap();
197
198        let mut loader = ThemeLoader::new();
199        loader.add_search_path(temp_dir.path().to_path_buf());
200
201        let theme = loader.load_theme("test-theme").unwrap();
202        assert_eq!(theme.name, "test-theme");
203    }
204
205    #[test]
206    fn test_load_bundled_themes() {
207        let loader = ThemeLoader::new();
208
209        // Iterate through all theme files in the themes directory
210        let themes_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("themes");
211        let entries = std::fs::read_dir(&themes_dir)
212            .unwrap_or_else(|e| panic!("Failed to read themes directory: {e}"));
213
214        let mut errors = Vec::new();
215
216        for entry in entries {
217            let entry = entry.unwrap();
218            let path = entry.path();
219
220            // Skip non-TOML files
221            if path.extension().is_none_or(|ext| ext != "toml") {
222                continue;
223            }
224
225            let theme_id = path
226                .file_stem()
227                .and_then(|s| s.to_str())
228                .expect("Invalid theme filename");
229
230            match loader.load_theme(theme_id) {
231                Ok(theme) => {
232                    // Verify the theme loaded successfully
233                    if theme.name.is_empty() {
234                        errors.push(format!("Theme '{theme_id}' has empty name"));
235                    }
236                }
237                Err(e) => {
238                    // Provide detailed error information
239                    let error_msg = match &e {
240                        ThemeError::ColorNotFound(color) => {
241                            format!(
242                                "Theme '{theme_id}' references undefined color '{color}'. Check that this color is defined in either the 'palette' or 'colors' section."
243                            )
244                        }
245                        ThemeError::InvalidColor(msg) => {
246                            format!("Theme '{theme_id}' has invalid color: {msg}")
247                        }
248                        ThemeError::Parse(parse_err) => {
249                            format!("Theme '{theme_id}' has invalid TOML syntax: {parse_err}")
250                        }
251                        ThemeError::Validation(msg) => {
252                            format!("Theme '{theme_id}' validation error: {msg}")
253                        }
254                        ThemeError::Io(io_err) => {
255                            format!("Theme '{theme_id}' I/O error: {io_err}")
256                        }
257                    };
258                    errors.push(error_msg);
259                }
260            }
261        }
262
263        assert!(
264            errors.is_empty(),
265            "Theme loading errors:\n\n{}",
266            errors.join("\n\n")
267        );
268    }
269
270    #[test]
271    fn test_bundled_themes_load_via_loader() {
272        let loader = ThemeLoader::new();
273        let mut errors = Vec::new();
274
275        for (theme_name, _) in BUNDLED_THEMES {
276            if let Err(e) = loader.load_theme(theme_name) {
277                errors.push(format!("Theme '{theme_name}' failed to load: {e}"));
278            }
279        }
280
281        assert!(
282            errors.is_empty(),
283            "Bundled theme loading errors:\n\n{}",
284            errors.join("\n\n")
285        );
286    }
287
288    #[test]
289    fn test_list_themes() {
290        let temp_dir = TempDir::new().unwrap();
291        let theme1_path = temp_dir.path().join("theme1.toml");
292        let theme2_path = temp_dir.path().join("theme2.toml");
293
294        let theme_content = r#"name = "Test"
295[colors]
296[styles]
297"#;
298        std::fs::write(&theme1_path, theme_content).unwrap();
299        std::fs::write(&theme2_path, theme_content).unwrap();
300
301        let mut loader = ThemeLoader::new();
302        loader.add_search_path(temp_dir.path().to_path_buf());
303
304        let themes = loader.list_themes();
305        assert!(themes.contains(&"theme1".to_string()));
306        assert!(themes.contains(&"theme2".to_string()));
307        // Also check that bundled themes are included
308        assert!(themes.contains(&"catppuccin-mocha".to_string()));
309        assert!(themes.contains(&"catppuccin-latte".to_string()));
310    }
311
312    #[test]
313    fn test_theme_not_found() {
314        let loader = ThemeLoader::new();
315        let result = loader.load_theme("non-existent-theme");
316        assert!(matches!(result, Err(ThemeError::Validation(_))));
317    }
318
319    #[test]
320    fn test_bundled_themes_validation() {
321        use super::super::{ColorValue, Component, RawTheme};
322
323        // Test that all bundled themes are valid and follow best practices
324        for (theme_name, theme_content) in BUNDLED_THEMES {
325            let raw_theme: RawTheme = toml::from_str(theme_content)
326                .unwrap_or_else(|e| panic!("Failed to parse theme '{theme_name}': {e}"));
327
328            // Ensure theme has the required palette colors
329            assert!(
330                raw_theme.palette.contains_key("background"),
331                "Theme '{theme_name}' missing 'background' in palette"
332            );
333            assert!(
334                raw_theme.palette.contains_key("foreground"),
335                "Theme '{theme_name}' missing 'foreground' in palette"
336            );
337
338            // Verify components don't use direct hex colors (should use palette references)
339            for (component_name, style) in &raw_theme.components {
340                if let Some(fg) = &style.fg {
341                    match fg {
342                        ColorValue::Direct(color) if color.starts_with('#') => {
343                            panic!(
344                                "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
345                            );
346                        }
347                        _ => {} // Palette reference or named color is fine
348                    }
349                }
350                if let Some(bg) = &style.bg {
351                    match bg {
352                        ColorValue::Direct(color) if color.starts_with('#') => {
353                            panic!(
354                                "Theme '{theme_name}' component '{component_name:?}' uses direct hex color '{color}' instead of palette reference"
355                            );
356                        }
357                        _ => {} // Palette reference or named color is fine
358                    }
359                }
360            }
361
362            // Verify theme can be loaded successfully (includes resolution validation)
363            let theme = raw_theme
364                .into_theme()
365                .unwrap_or_else(|e| panic!("Failed to convert theme '{theme_name}': {e}"));
366
367            // Verify critical components are defined
368            let critical_components = [
369                Component::StatusBar,
370                Component::ErrorText,
371                Component::AssistantMessage,
372                Component::UserMessage,
373                Component::InputPanelBorder,
374                Component::InputPanelBackground,
375                Component::ChatListBorder,
376                Component::SelectionHighlight,
377            ];
378
379            for component in critical_components {
380                assert!(
381                    theme.styles.contains_key(&component),
382                    "Theme '{theme_name}' missing critical component: {component:?}"
383                );
384            }
385        }
386    }
387
388    #[test]
389    fn test_theme_palette_references_resolve() {
390        let loader = ThemeLoader::new();
391
392        // Test a few themes to ensure palette references resolve to actual colors
393        for theme_name in ["catppuccin-mocha", "gruvbox-dark", "solarized-light"] {
394            let theme = loader
395                .load_theme(theme_name)
396                .unwrap_or_else(|e| panic!("Failed to load theme '{theme_name}': {e}"));
397
398            // Check that critical components have resolved colors (not None)
399            let status_bar = theme
400                .styles
401                .get(&super::super::Component::StatusBar)
402                .expect("StatusBar component missing");
403
404            // StatusBar should at least have a foreground color
405            assert!(
406                !(status_bar.fg.is_none() && status_bar.bg.is_none()),
407                "Theme '{theme_name}' StatusBar has no colors defined"
408            );
409        }
410    }
411}