1use crate::style::{Color, Theme};
4use anyhow::{anyhow, Result};
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ThemeYaml {
12 pub name: String,
14 pub primary: String,
16 pub secondary: String,
18 pub accent: String,
20 pub background: String,
22 pub foreground: String,
24 pub error: String,
26 pub warning: String,
28 pub success: String,
30}
31
32impl ThemeYaml {
33 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
73pub struct ThemeLoader;
75
76impl ThemeLoader {
77 pub fn load_from_string(content: &str) -> Result<Theme> {
79 let theme_yaml: ThemeYaml = serde_yaml::from_str(content)?;
80
81 Self::validate_theme(&theme_yaml)?;
83
84 theme_yaml.to_theme()
85 }
86
87 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 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 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 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 fn validate_theme(theme: &ThemeYaml) -> Result<()> {
154 if theme.name.is_empty() {
155 return Err(anyhow!("Theme name cannot be empty"));
156 }
157
158 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 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}