ricecoder_keybinds/
persistence.rs

1//! Persistence layer for saving and loading keybind profiles
2//!
3//! # Storage Location
4//!
5//! Keybind profiles are stored in `projects/ricecoder/config/keybinds/` with the following structure:
6//!
7//! ```text
8//! projects/ricecoder/config/keybinds/
9//! ├── defaults.json          # Default keybinds (read-only)
10//! ├── active_profile.txt     # Name of the currently active profile
11//! ├── default.json           # Default profile (auto-created)
12//! ├── vim.json               # Example custom profile
13//! └── emacs.json             # Example custom profile
14//! ```
15//!
16//! # File Format
17//!
18//! Each profile is stored as a JSON file with the following structure:
19//!
20//! ```json
21//! {
22//!   "name": "default",
23//!   "keybinds": [
24//!     {
25//!       "action_id": "editor.save",
26//!       "key": "Ctrl+S",
27//!       "category": "editing",
28//!       "description": "Save current file",
29//!       "is_default": true
30//!     }
31//!   ]
32//! }
33//! ```
34//!
35//! The `active_profile.txt` file contains just the name of the active profile:
36//!
37//! ```text
38//! default
39//! ```
40//!
41//! # Usage
42//!
43//! To use the default storage location:
44//!
45//! ```no_run
46//! use ricecoder_keybinds::{FileSystemPersistence, KeybindPersistence};
47//!
48//! let persistence = FileSystemPersistence::with_default_location()?;
49//! let profiles = persistence.list_profiles()?;
50//! # Ok::<(), Box<dyn std::error::Error>>(())
51//! ```
52
53use std::fs;
54use std::path::{Path, PathBuf};
55
56use crate::error::PersistenceError;
57use crate::profile::Profile;
58
59/// Trait for persisting keybind profiles
60pub trait KeybindPersistence: Send + Sync {
61    /// Save a profile to storage
62    fn save_profile(&self, profile: &Profile) -> Result<(), PersistenceError>;
63
64    /// Load a profile from storage
65    fn load_profile(&self, name: &str) -> Result<Profile, PersistenceError>;
66
67    /// Delete a profile from storage
68    fn delete_profile(&self, name: &str) -> Result<(), PersistenceError>;
69
70    /// List all saved profiles
71    fn list_profiles(&self) -> Result<Vec<String>, PersistenceError>;
72}
73
74/// File system based persistence
75pub struct FileSystemPersistence {
76    config_dir: PathBuf,
77}
78
79impl FileSystemPersistence {
80    /// Create a new file system persistence with the given config directory
81    pub fn new(config_dir: impl AsRef<Path>) -> Result<Self, PersistenceError> {
82        let config_dir = config_dir.as_ref().to_path_buf();
83
84        // Create directory if it doesn't exist
85        if !config_dir.exists() {
86            fs::create_dir_all(&config_dir).map_err(|e| {
87                PersistenceError::IoError(std::io::Error::new(
88                    e.kind(),
89                    format!("Failed to create config directory: {}", e),
90                ))
91            })?;
92        }
93
94        Ok(FileSystemPersistence { config_dir })
95    }
96
97    /// Get the path for a profile file
98    fn profile_path(&self, name: &str) -> PathBuf {
99        self.config_dir.join(format!("{}.json", name))
100    }
101
102    /// Get the active profile file path
103    fn active_profile_path(&self) -> PathBuf {
104        self.config_dir.join("active_profile.txt")
105    }
106}
107
108impl KeybindPersistence for FileSystemPersistence {
109    fn save_profile(&self, profile: &Profile) -> Result<(), PersistenceError> {
110        let path = self.profile_path(&profile.name);
111
112        let json = serde_json::to_string_pretty(profile).map_err(|e| {
113            PersistenceError::SerializationError(format!("Failed to serialize profile: {}", e))
114        })?;
115
116        fs::write(&path, json).map_err(|e| {
117            PersistenceError::IoError(std::io::Error::new(
118                e.kind(),
119                format!("Failed to write profile file: {}", e),
120            ))
121        })?;
122
123        Ok(())
124    }
125
126    fn load_profile(&self, name: &str) -> Result<Profile, PersistenceError> {
127        let path = self.profile_path(name);
128
129        if !path.exists() {
130            return Err(PersistenceError::ProfileNotFound(name.to_string()));
131        }
132
133        let content = fs::read_to_string(&path).map_err(|e| {
134            if e.kind() == std::io::ErrorKind::NotFound {
135                PersistenceError::ProfileNotFound(name.to_string())
136            } else if e.kind() == std::io::ErrorKind::PermissionDenied {
137                PersistenceError::PermissionDenied(path.to_string_lossy().to_string())
138            } else {
139                PersistenceError::IoError(e)
140            }
141        })?;
142
143        let profile: Profile = serde_json::from_str(&content).map_err(|e| {
144            PersistenceError::CorruptedJson(format!("Failed to parse profile: {}", e))
145        })?;
146
147        Ok(profile)
148    }
149
150    fn delete_profile(&self, name: &str) -> Result<(), PersistenceError> {
151        let path = self.profile_path(name);
152
153        if !path.exists() {
154            return Err(PersistenceError::ProfileNotFound(name.to_string()));
155        }
156
157        fs::remove_file(&path).map_err(|e| {
158            if e.kind() == std::io::ErrorKind::PermissionDenied {
159                PersistenceError::PermissionDenied(path.to_string_lossy().to_string())
160            } else {
161                PersistenceError::IoError(e)
162            }
163        })?;
164
165        Ok(())
166    }
167
168    fn list_profiles(&self) -> Result<Vec<String>, PersistenceError> {
169        let mut profiles = Vec::new();
170
171        if !self.config_dir.exists() {
172            return Ok(profiles);
173        }
174
175        let entries = fs::read_dir(&self.config_dir).map_err(|e| {
176            PersistenceError::IoError(std::io::Error::new(
177                e.kind(),
178                format!("Failed to read config directory: {}", e),
179            ))
180        })?;
181
182        for entry in entries {
183            let entry = entry.map_err(|e| {
184                PersistenceError::IoError(std::io::Error::new(
185                    e.kind(),
186                    format!("Failed to read directory entry: {}", e),
187                ))
188            })?;
189
190            let path = entry.path();
191            if path.extension().is_some_and(|ext| ext == "json") {
192                if let Some(name) = path.file_stem().and_then(|n| n.to_str()) {
193                    profiles.push(name.to_string());
194                }
195            }
196        }
197
198        profiles.sort();
199        Ok(profiles)
200    }
201}
202
203impl FileSystemPersistence {
204    /// Save the active profile name
205    pub fn save_active_profile(&self, name: &str) -> Result<(), PersistenceError> {
206        let path = self.active_profile_path();
207        fs::write(&path, name).map_err(|e| {
208            PersistenceError::IoError(std::io::Error::new(
209                e.kind(),
210                format!("Failed to write active profile: {}", e),
211            ))
212        })?;
213        Ok(())
214    }
215
216    /// Load the active profile name
217    pub fn load_active_profile(&self) -> Result<Option<String>, PersistenceError> {
218        let path = self.active_profile_path();
219
220        if !path.exists() {
221            return Ok(None);
222        }
223
224        let content = fs::read_to_string(&path).map_err(|e| {
225            PersistenceError::IoError(std::io::Error::new(
226                e.kind(),
227                format!("Failed to read active profile: {}", e),
228            ))
229        })?;
230
231        Ok(Some(content.trim().to_string()))
232    }
233
234    /// Create a new file system persistence with the default storage location
235    /// 
236    /// The default storage location is `projects/ricecoder/config/keybinds/`
237    /// This function will create the directory if it doesn't exist.
238    pub fn with_default_location() -> Result<Self, PersistenceError> {
239        // Try multiple possible paths to find the config directory
240        let possible_paths = vec![
241            PathBuf::from("projects/ricecoder/config/keybinds"),
242            PathBuf::from("config/keybinds"),
243            PathBuf::from("../../config/keybinds"),
244            PathBuf::from("../../../config/keybinds"),
245            PathBuf::from("../../../../config/keybinds"),
246        ];
247
248        for path in possible_paths {
249            if let Ok(persistence) = FileSystemPersistence::new(&path) {
250                return Ok(persistence);
251            }
252        }
253
254        // If none of the paths work, try to create the default path
255        let default_path = PathBuf::from("projects/ricecoder/config/keybinds");
256        FileSystemPersistence::new(&default_path)
257    }
258
259    /// Get the config directory path
260    pub fn config_dir(&self) -> &Path {
261        &self.config_dir
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268    use crate::models::Keybind;
269    use crate::profile::Profile;
270
271    #[test]
272    fn test_save_and_load_profile() {
273        let temp_dir = tempfile::tempdir().unwrap();
274        let persistence = FileSystemPersistence::new(temp_dir.path()).unwrap();
275
276        let keybinds = vec![Keybind::new("editor.save", "Ctrl+S", "editing", "Save")];
277        let profile = Profile::new("default", keybinds);
278
279        assert!(persistence.save_profile(&profile).is_ok());
280
281        let loaded = persistence.load_profile("default").unwrap();
282        assert_eq!(loaded.name, "default");
283        assert_eq!(loaded.keybinds.len(), 1);
284    }
285
286    #[test]
287    fn test_delete_profile() {
288        let temp_dir = tempfile::tempdir().unwrap();
289        let persistence = FileSystemPersistence::new(temp_dir.path()).unwrap();
290
291        let keybinds = vec![Keybind::new("editor.save", "Ctrl+S", "editing", "Save")];
292        let profile = Profile::new("default", keybinds);
293
294        persistence.save_profile(&profile).unwrap();
295        assert!(persistence.delete_profile("default").is_ok());
296        assert!(persistence.load_profile("default").is_err());
297    }
298
299    #[test]
300    fn test_list_profiles() {
301        let temp_dir = tempfile::tempdir().unwrap();
302        let persistence = FileSystemPersistence::new(temp_dir.path()).unwrap();
303
304        let keybinds = vec![Keybind::new("editor.save", "Ctrl+S", "editing", "Save")];
305
306        let profile1 = Profile::new("default", keybinds.clone());
307        let profile2 = Profile::new("vim", keybinds);
308
309        persistence.save_profile(&profile1).unwrap();
310        persistence.save_profile(&profile2).unwrap();
311
312        let profiles = persistence.list_profiles().unwrap();
313        assert_eq!(profiles.len(), 2);
314        assert!(profiles.contains(&"default".to_string()));
315        assert!(profiles.contains(&"vim".to_string()));
316    }
317
318    #[test]
319    fn test_save_active_profile() {
320        let temp_dir = tempfile::tempdir().unwrap();
321        let persistence = FileSystemPersistence::new(temp_dir.path()).unwrap();
322
323        assert!(persistence.save_active_profile("default").is_ok());
324
325        let loaded = persistence.load_active_profile().unwrap();
326        assert_eq!(loaded, Some("default".to_string()));
327    }
328
329    #[test]
330    fn test_with_default_location() {
331        // This test verifies that with_default_location can find or create the default storage location
332        let result = FileSystemPersistence::with_default_location();
333        assert!(result.is_ok());
334
335        let persistence = result.unwrap();
336        
337        // Verify the config directory exists
338        assert!(persistence.config_dir().exists());
339    }
340
341    #[test]
342    fn test_with_default_location_creates_directory() {
343        // This test verifies that with_default_location creates the directory if needed
344        let persistence = FileSystemPersistence::with_default_location().unwrap();
345        
346        // Verify we can save and load profiles with the default location
347        let keybinds = vec![Keybind::new("editor.save", "Ctrl+S", "editing", "Save")];
348        let profile = Profile::new("test_profile", keybinds);
349
350        assert!(persistence.save_profile(&profile).is_ok());
351        assert!(persistence.load_profile("test_profile").is_ok());
352
353        // Clean up
354        let _ = persistence.delete_profile("test_profile");
355    }
356
357    #[test]
358    fn test_config_dir_path() {
359        let temp_dir = tempfile::tempdir().unwrap();
360        let persistence = FileSystemPersistence::new(temp_dir.path()).unwrap();
361
362        assert_eq!(persistence.config_dir(), temp_dir.path());
363    }
364}