ricecoder_tui/
theme_loader.rs

1//! Custom theme loading from YAML files
2
3use crate::style::{Color, Theme};
4use anyhow::{anyhow, Result};
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9/// YAML theme format for custom themes
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ThemeYaml {
12    /// Theme name
13    pub name: String,
14    /// Primary color (hex format)
15    pub primary: String,
16    /// Secondary color (hex format)
17    pub secondary: String,
18    /// Accent color (hex format)
19    pub accent: String,
20    /// Background color (hex format)
21    pub background: String,
22    /// Foreground color (hex format)
23    pub foreground: String,
24    /// Error color (hex format)
25    pub error: String,
26    /// Warning color (hex format)
27    pub warning: String,
28    /// Success color (hex format)
29    pub success: String,
30}
31
32impl ThemeYaml {
33    /// Convert YAML theme to Theme struct
34    pub fn to_theme(&self) -> Result<Theme> {
35        Ok(Theme {
36            name: self.name.clone(),
37            primary: Color::from_hex(&self.primary)
38                .ok_or_else(|| anyhow!("Invalid primary color: {}", self.primary))?,
39            secondary: Color::from_hex(&self.secondary)
40                .ok_or_else(|| anyhow!("Invalid secondary color: {}", self.secondary))?,
41            accent: Color::from_hex(&self.accent)
42                .ok_or_else(|| anyhow!("Invalid accent color: {}", self.accent))?,
43            background: Color::from_hex(&self.background)
44                .ok_or_else(|| anyhow!("Invalid background color: {}", self.background))?,
45            foreground: Color::from_hex(&self.foreground)
46                .ok_or_else(|| anyhow!("Invalid foreground color: {}", self.foreground))?,
47            error: Color::from_hex(&self.error)
48                .ok_or_else(|| anyhow!("Invalid error color: {}", self.error))?,
49            warning: Color::from_hex(&self.warning)
50                .ok_or_else(|| anyhow!("Invalid warning color: {}", self.warning))?,
51            success: Color::from_hex(&self.success)
52                .ok_or_else(|| anyhow!("Invalid success color: {}", self.success))?,
53        })
54    }
55}
56
57impl From<&Theme> for ThemeYaml {
58    fn from(theme: &Theme) -> Self {
59        Self {
60            name: theme.name.clone(),
61            primary: theme.primary.to_hex(),
62            secondary: theme.secondary.to_hex(),
63            accent: theme.accent.to_hex(),
64            background: theme.background.to_hex(),
65            foreground: theme.foreground.to_hex(),
66            error: theme.error.to_hex(),
67            warning: theme.warning.to_hex(),
68            success: theme.success.to_hex(),
69        }
70    }
71}
72
73/// Custom theme loader
74pub struct ThemeLoader;
75
76impl ThemeLoader {
77    /// Load a theme from a YAML string
78    pub fn load_from_string(content: &str) -> Result<Theme> {
79        let theme_yaml: ThemeYaml = serde_yaml::from_str(content)?;
80
81        // Validate theme
82        Self::validate_theme(&theme_yaml)?;
83
84        theme_yaml.to_theme()
85    }
86
87    /// Load a theme from a YAML file
88    pub fn load_from_file(path: &Path) -> Result<Theme> {
89        if !path.exists() {
90            return Err(anyhow!("Theme file not found: {}", path.display()));
91        }
92
93        if !path
94            .extension()
95            .is_some_and(|ext| ext == "yaml" || ext == "yml")
96        {
97            return Err(anyhow!("Theme file must be YAML format (.yaml or .yml)"));
98        }
99
100        let content = fs::read_to_string(path)?;
101        Self::load_from_string(&content)
102    }
103
104    /// Save a theme to a YAML file
105    pub fn save_to_file(theme: &Theme, path: &Path) -> Result<()> {
106        let theme_yaml = ThemeYaml::from(theme);
107        let content = serde_yaml::to_string(&theme_yaml)?;
108        fs::write(path, content)?;
109        Ok(())
110    }
111
112    /// Load all themes from a directory
113    pub fn load_from_directory(dir: &Path) -> Result<Vec<Theme>> {
114        if !dir.exists() {
115            return Ok(Vec::new());
116        }
117
118        if !dir.is_dir() {
119            return Err(anyhow!("Path is not a directory: {}", dir.display()));
120        }
121
122        let mut themes = Vec::new();
123
124        for entry in fs::read_dir(dir)? {
125            let entry = entry?;
126            let path = entry.path();
127
128            if path.is_file()
129                && (path
130                    .extension()
131                    .is_some_and(|ext| ext == "yaml" || ext == "yml"))
132            {
133                match Self::load_from_file(&path) {
134                    Ok(theme) => themes.push(theme),
135                    Err(e) => {
136                        tracing::warn!("Failed to load theme from {}: {}", path.display(), e);
137                    }
138                }
139            }
140        }
141
142        Ok(themes)
143    }
144
145    /// Get the default themes directory
146    pub fn themes_directory() -> Result<PathBuf> {
147        let config_dir =
148            dirs::config_dir().ok_or_else(|| anyhow!("Could not determine config directory"))?;
149        Ok(config_dir.join("ricecoder").join("themes"))
150    }
151
152    /// Validate a theme YAML
153    fn validate_theme(theme: &ThemeYaml) -> Result<()> {
154        if theme.name.is_empty() {
155            return Err(anyhow!("Theme name cannot be empty"));
156        }
157
158        // Validate all colors are valid hex
159        let colors = vec![
160            ("primary", &theme.primary),
161            ("secondary", &theme.secondary),
162            ("accent", &theme.accent),
163            ("background", &theme.background),
164            ("foreground", &theme.foreground),
165            ("error", &theme.error),
166            ("warning", &theme.warning),
167            ("success", &theme.success),
168        ];
169
170        for (name, color) in colors {
171            if Color::from_hex(color).is_none() {
172                return Err(anyhow!("Invalid {} color: {}", name, color));
173            }
174        }
175
176        Ok(())
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use tempfile::TempDir;
184
185    #[test]
186    fn test_load_from_string() {
187        let yaml_content = r#"name: test
188primary: '#0078ff'
189secondary: '#5ac8fa'
190accent: '#ff2d55'
191background: '#111827'
192foreground: '#f3f4f6'
193error: '#ef4444'
194warning: '#f59e0b'
195success: '#22c55e'
196"#;
197
198        let theme = ThemeLoader::load_from_string(yaml_content).unwrap();
199        assert_eq!(theme.name, "test");
200        assert_eq!(theme.primary.r, 0);
201        assert_eq!(theme.primary.g, 120);
202        assert_eq!(theme.primary.b, 255);
203    }
204
205    #[test]
206    fn test_theme_yaml_to_theme() {
207        let theme_yaml = ThemeYaml {
208            name: "test".to_string(),
209            primary: "#0078ff".to_string(),
210            secondary: "#5ac8fa".to_string(),
211            accent: "#ff2d55".to_string(),
212            background: "#111827".to_string(),
213            foreground: "#f3f4f6".to_string(),
214            error: "#ef4444".to_string(),
215            warning: "#f59e0b".to_string(),
216            success: "#22c55e".to_string(),
217        };
218
219        let theme = theme_yaml.to_theme().unwrap();
220        assert_eq!(theme.name, "test");
221        assert_eq!(theme.primary.r, 0);
222        assert_eq!(theme.primary.g, 120);
223        assert_eq!(theme.primary.b, 255);
224    }
225
226    #[test]
227    fn test_theme_to_yaml() {
228        let theme = Theme::default();
229        let yaml = ThemeYaml::from(&theme);
230        assert_eq!(yaml.name, theme.name);
231        assert_eq!(yaml.primary, theme.primary.to_hex());
232    }
233
234    #[test]
235    fn test_save_and_load_theme() {
236        let temp_dir = TempDir::new().unwrap();
237        let theme_path = temp_dir.path().join("test_theme.yaml");
238
239        let theme = Theme::light();
240        ThemeLoader::save_to_file(&theme, &theme_path).unwrap();
241
242        let loaded = ThemeLoader::load_from_file(&theme_path).unwrap();
243        assert_eq!(loaded.name, theme.name);
244        assert_eq!(loaded.primary, theme.primary);
245    }
246
247    #[test]
248    fn test_load_from_directory() {
249        let temp_dir = TempDir::new().unwrap();
250
251        // Save multiple themes
252        ThemeLoader::save_to_file(&Theme::default(), &temp_dir.path().join("dark.yaml")).unwrap();
253        ThemeLoader::save_to_file(&Theme::light(), &temp_dir.path().join("light.yaml")).unwrap();
254
255        let themes = ThemeLoader::load_from_directory(temp_dir.path()).unwrap();
256        assert_eq!(themes.len(), 2);
257    }
258
259    #[test]
260    fn test_validate_theme_invalid_color() {
261        let theme_yaml = ThemeYaml {
262            name: "test".to_string(),
263            primary: "invalid".to_string(),
264            secondary: "#5ac8fa".to_string(),
265            accent: "#ff2d55".to_string(),
266            background: "#111827".to_string(),
267            foreground: "#f3f4f6".to_string(),
268            error: "#ef4444".to_string(),
269            warning: "#f59e0b".to_string(),
270            success: "#22c55e".to_string(),
271        };
272
273        assert!(ThemeLoader::validate_theme(&theme_yaml).is_err());
274    }
275
276    #[test]
277    fn test_validate_theme_empty_name() {
278        let theme_yaml = ThemeYaml {
279            name: "".to_string(),
280            primary: "#0078ff".to_string(),
281            secondary: "#5ac8fa".to_string(),
282            accent: "#ff2d55".to_string(),
283            background: "#111827".to_string(),
284            foreground: "#f3f4f6".to_string(),
285            error: "#ef4444".to_string(),
286            warning: "#f59e0b".to_string(),
287            success: "#22c55e".to_string(),
288        };
289
290        assert!(ThemeLoader::validate_theme(&theme_yaml).is_err());
291    }
292}