ricecoder_storage/
theme.rs

1//! Theme persistence and storage management
2//!
3//! This module provides storage integration for theme preferences and custom themes.
4//! It handles saving and loading theme preferences to/from configuration files,
5//! and managing custom theme storage in the `.ricecoder/themes/` directory.
6
7use crate::error::{StorageError, StorageResult};
8use crate::manager::PathResolver;
9use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::PathBuf;
12
13/// Theme preference configuration
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct ThemePreference {
16    /// Current theme name
17    pub current_theme: String,
18    /// Last updated timestamp
19    #[serde(default)]
20    pub last_updated: Option<String>,
21}
22
23impl Default for ThemePreference {
24    fn default() -> Self {
25        Self {
26            current_theme: "dark".to_string(),
27            last_updated: None,
28        }
29    }
30}
31
32/// Theme storage manager for persisting theme preferences and custom themes
33pub struct ThemeStorage;
34
35impl ThemeStorage {
36    /// Get the themes directory path
37    ///
38    /// Returns `~/.ricecoder/themes/` or the configured themes directory
39    pub fn themes_directory() -> StorageResult<PathBuf> {
40        let global_path = PathResolver::resolve_global_path()?;
41        let themes_dir = global_path.join("themes");
42        Ok(themes_dir)
43    }
44
45    /// Get the theme preference file path
46    ///
47    /// Returns `~/.ricecoder/theme.yaml`
48    pub fn preference_file() -> StorageResult<PathBuf> {
49        let global_path = PathResolver::resolve_global_path()?;
50        let pref_file = global_path.join("theme.yaml");
51        Ok(pref_file)
52    }
53
54    /// Load theme preference from storage
55    ///
56    /// Returns the saved theme preference or default if not found
57    pub fn load_preference() -> StorageResult<ThemePreference> {
58        let pref_file = Self::preference_file()?;
59
60        if !pref_file.exists() {
61            return Ok(ThemePreference::default());
62        }
63
64        let content = fs::read_to_string(&pref_file).map_err(|e| {
65            StorageError::io_error(
66                pref_file.clone(),
67                crate::error::IoOperation::Read,
68                e,
69            )
70        })?;
71
72        let preference: ThemePreference = serde_yaml::from_str(&content).map_err(|e| {
73            StorageError::parse_error(
74                pref_file.clone(),
75                "yaml",
76                e.to_string(),
77            )
78        })?;
79
80        Ok(preference)
81    }
82
83    /// Save theme preference to storage
84    ///
85    /// Creates the `.ricecoder/` directory if it doesn't exist
86    pub fn save_preference(preference: &ThemePreference) -> StorageResult<()> {
87        let pref_file = Self::preference_file()?;
88
89        // Create parent directory if it doesn't exist
90        if let Some(parent) = pref_file.parent() {
91            fs::create_dir_all(parent).map_err(|e| {
92                StorageError::directory_creation_failed(
93                    parent.to_path_buf(),
94                    e,
95                )
96            })?;
97        }
98
99        let content = serde_yaml::to_string(preference).map_err(|e| {
100            StorageError::internal(format!("Failed to serialize theme preference: {}", e))
101        })?;
102
103        fs::write(&pref_file, content).map_err(|e| {
104            StorageError::io_error(
105                pref_file.clone(),
106                crate::error::IoOperation::Write,
107                e,
108            )
109        })?;
110
111        Ok(())
112    }
113
114    /// Save a custom theme file
115    ///
116    /// Saves the theme YAML content to `~/.ricecoder/themes/{theme_name}.yaml`
117    pub fn save_custom_theme(theme_name: &str, content: &str) -> StorageResult<PathBuf> {
118        let themes_dir = Self::themes_directory()?;
119
120        // Create themes directory if it doesn't exist
121        fs::create_dir_all(&themes_dir).map_err(|e| {
122            StorageError::directory_creation_failed(
123                themes_dir.clone(),
124                e,
125            )
126        })?;
127
128        let theme_file = themes_dir.join(format!("{}.yaml", theme_name));
129
130        fs::write(&theme_file, content).map_err(|e| {
131            StorageError::io_error(
132                theme_file.clone(),
133                crate::error::IoOperation::Write,
134                e,
135            )
136        })?;
137
138        Ok(theme_file)
139    }
140
141    /// Load a custom theme file
142    ///
143    /// Loads the theme YAML content from `~/.ricecoder/themes/{theme_name}.yaml`
144    pub fn load_custom_theme(theme_name: &str) -> StorageResult<String> {
145        let themes_dir = Self::themes_directory()?;
146        let theme_file = themes_dir.join(format!("{}.yaml", theme_name));
147
148        if !theme_file.exists() {
149            return Err(StorageError::validation_error(
150                "theme",
151                format!("Theme file not found: {}", theme_file.display()),
152            ));
153        }
154
155        fs::read_to_string(&theme_file).map_err(|e| {
156            StorageError::io_error(
157                theme_file.clone(),
158                crate::error::IoOperation::Read,
159                e,
160            )
161        })
162    }
163
164    /// Delete a custom theme file
165    pub fn delete_custom_theme(theme_name: &str) -> StorageResult<()> {
166        let themes_dir = Self::themes_directory()?;
167        let theme_file = themes_dir.join(format!("{}.yaml", theme_name));
168
169        if !theme_file.exists() {
170            return Err(StorageError::validation_error(
171                "theme",
172                format!("Theme file not found: {}", theme_file.display()),
173            ));
174        }
175
176        fs::remove_file(&theme_file).map_err(|e| {
177            StorageError::io_error(
178                theme_file.clone(),
179                crate::error::IoOperation::Delete,
180                e,
181            )
182        })?;
183
184        Ok(())
185    }
186
187    /// List all custom theme files
188    pub fn list_custom_themes() -> StorageResult<Vec<String>> {
189        let themes_dir = Self::themes_directory()?;
190
191        if !themes_dir.exists() {
192            return Ok(Vec::new());
193        }
194
195        let mut themes = Vec::new();
196
197        for entry in fs::read_dir(&themes_dir).map_err(|e| {
198            StorageError::io_error(
199                themes_dir.clone(),
200                crate::error::IoOperation::Read,
201                e,
202            )
203        })? {
204            let entry = entry.map_err(|e| {
205                StorageError::io_error(
206                    themes_dir.clone(),
207                    crate::error::IoOperation::Read,
208                    e,
209                )
210            })?;
211
212            let path = entry.path();
213            if path.extension().map_or(false, |ext| ext == "yaml") {
214                if let Some(file_stem) = path.file_stem() {
215                    if let Some(theme_name) = file_stem.to_str() {
216                        themes.push(theme_name.to_string());
217                    }
218                }
219            }
220        }
221
222        Ok(themes)
223    }
224
225    /// Check if a custom theme exists
226    pub fn custom_theme_exists(theme_name: &str) -> StorageResult<bool> {
227        let themes_dir = Self::themes_directory()?;
228        let theme_file = themes_dir.join(format!("{}.yaml", theme_name));
229        Ok(theme_file.exists())
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use tempfile::TempDir;
237    use std::sync::Mutex;
238
239    // Mutex to prevent parallel test execution from interfering with environment variables
240    static TEST_LOCK: Mutex<()> = Mutex::new(());
241
242    #[test]
243    fn test_theme_preference_default() {
244        let pref = ThemePreference::default();
245        assert_eq!(pref.current_theme, "dark");
246    }
247
248    #[test]
249    fn test_save_and_load_preference() {
250        let _lock = TEST_LOCK.lock().unwrap();
251        let temp_dir = TempDir::new().unwrap();
252        let home_path = temp_dir.path().to_string_lossy().to_string();
253        std::env::set_var("RICECODER_HOME", &home_path);
254
255        let pref = ThemePreference {
256            current_theme: "light".to_string(),
257            last_updated: Some("2025-12-09".to_string()),
258        };
259
260        ThemeStorage::save_preference(&pref).unwrap();
261        let loaded = ThemeStorage::load_preference().unwrap();
262
263        assert_eq!(loaded.current_theme, "light");
264        assert_eq!(loaded.last_updated, Some("2025-12-09".to_string()));
265
266        std::env::remove_var("RICECODER_HOME");
267    }
268
269    #[test]
270    fn test_load_preference_default_when_missing() {
271        let _lock = TEST_LOCK.lock().unwrap();
272        let temp_dir = TempDir::new().unwrap();
273        let home_path = temp_dir.path().to_string_lossy().to_string();
274        std::env::set_var("RICECODER_HOME", &home_path);
275
276        let loaded = ThemeStorage::load_preference().unwrap();
277        assert_eq!(loaded.current_theme, "dark");
278
279        std::env::remove_var("RICECODER_HOME");
280    }
281
282    #[test]
283    fn test_save_and_load_custom_theme() {
284        let _lock = TEST_LOCK.lock().unwrap();
285        let temp_dir = TempDir::new().unwrap();
286        let home_path = temp_dir.path().to_string_lossy().to_string();
287        std::env::set_var("RICECODER_HOME", &home_path);
288
289        let theme_content = "name: custom\ncolors:\n  background: '#000000'";
290        let saved_path = ThemeStorage::save_custom_theme("custom", theme_content).unwrap();
291
292        // Verify the file was actually created
293        assert!(saved_path.exists(), "File not found at: {}", saved_path.display());
294
295        // Try to load it back
296        let loaded = ThemeStorage::load_custom_theme("custom").unwrap();
297        assert_eq!(loaded, theme_content);
298
299        std::env::remove_var("RICECODER_HOME");
300    }
301
302    #[test]
303    fn test_delete_custom_theme() {
304        let _lock = TEST_LOCK.lock().unwrap();
305        let temp_dir = TempDir::new().unwrap();
306        let home_path = temp_dir.path().to_string_lossy().to_string();
307        std::env::set_var("RICECODER_HOME", &home_path);
308
309        let theme_content = "name: custom\ncolors:\n  background: '#000000'";
310        ThemeStorage::save_custom_theme("custom", theme_content).unwrap();
311
312        assert!(ThemeStorage::custom_theme_exists("custom").unwrap());
313
314        ThemeStorage::delete_custom_theme("custom").unwrap();
315
316        assert!(!ThemeStorage::custom_theme_exists("custom").unwrap());
317
318        std::env::remove_var("RICECODER_HOME");
319    }
320
321    #[test]
322    fn test_list_custom_themes() {
323        let _lock = TEST_LOCK.lock().unwrap();
324        let temp_dir = TempDir::new().unwrap();
325        let home_path = temp_dir.path().to_string_lossy().to_string();
326        std::env::set_var("RICECODER_HOME", &home_path);
327
328        ThemeStorage::save_custom_theme("theme1", "name: theme1").unwrap();
329        ThemeStorage::save_custom_theme("theme2", "name: theme2").unwrap();
330
331        let themes = ThemeStorage::list_custom_themes().unwrap();
332        assert_eq!(themes.len(), 2);
333        assert!(themes.contains(&"theme1".to_string()));
334        assert!(themes.contains(&"theme2".to_string()));
335
336        std::env::remove_var("RICECODER_HOME");
337    }
338
339    #[test]
340    fn test_custom_theme_exists() {
341        let _lock = TEST_LOCK.lock().unwrap();
342        let temp_dir = TempDir::new().unwrap();
343        let home_path = temp_dir.path().to_string_lossy().to_string();
344        std::env::set_var("RICECODER_HOME", &home_path);
345
346        assert!(!ThemeStorage::custom_theme_exists("nonexistent").unwrap());
347
348        ThemeStorage::save_custom_theme("existing", "name: existing").unwrap();
349        assert!(ThemeStorage::custom_theme_exists("existing").unwrap());
350
351        std::env::remove_var("RICECODER_HOME");
352    }
353}