envx_core/
snapshot_manager.rs

1use crate::snapshot::Snapshot;
2use crate::{EnvVar, EnvVarManager};
3use color_eyre::Result;
4use color_eyre::eyre::eyre;
5use std::collections::HashMap;
6use std::fs;
7use std::path::PathBuf;
8
9pub struct SnapshotManager {
10    storage_dir: PathBuf,
11}
12
13impl SnapshotManager {
14    /// Creates a new `SnapshotManager`.
15    ///
16    /// # Errors
17    ///
18    /// This function will return an error if:
19    /// - The system data/config directory cannot be found
20    /// - The snapshots directory cannot be created due to filesystem errors
21    pub fn new() -> Result<Self> {
22        let storage_dir = if cfg!(windows) {
23            dirs::data_dir()
24                .ok_or_else(|| eyre!("Could not find data directory"))?
25                .join("envx")
26                .join("snapshots")
27        } else {
28            dirs::config_dir()
29                .ok_or_else(|| eyre!("Could not find config directory"))?
30                .join("envx")
31                .join("snapshots")
32        };
33
34        fs::create_dir_all(&storage_dir)?;
35        Ok(Self { storage_dir })
36    }
37
38    /// Creates a new snapshot with the given name, description, and environment variables.
39    ///
40    /// # Errors
41    ///
42    /// This function will return an error if:
43    /// - There are file system errors when writing the snapshot file to disk
44    /// - JSON serialization of the snapshot fails
45    pub fn create(&self, name: String, description: Option<String>, vars: Vec<EnvVar>) -> Result<Snapshot> {
46        let snapshot = Snapshot::from_vars(name, description, vars);
47        self.save_snapshot(&snapshot)?;
48        Ok(snapshot)
49    }
50
51    /// Lists all snapshots sorted by creation date (newest first).
52    ///
53    /// # Errors
54    ///
55    /// This function will return an error if:
56    /// - There are file system errors when reading the snapshots directory
57    /// - There are file system errors when reading individual snapshot files
58    pub fn list(&self) -> Result<Vec<Snapshot>> {
59        let mut snapshots = Vec::new();
60
61        for entry in fs::read_dir(&self.storage_dir)? {
62            let entry = entry?;
63            if entry.path().extension().and_then(|s| s.to_str()) == Some("json") {
64                let content = fs::read_to_string(entry.path())?;
65                if let Ok(snapshot) = serde_json::from_str::<Snapshot>(&content) {
66                    snapshots.push(snapshot);
67                }
68            }
69        }
70
71        // Sort by creation date (newest first)
72        snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
73        Ok(snapshots)
74    }
75
76    /// Gets a snapshot by ID or name.
77    ///
78    /// # Errors
79    ///
80    /// This function will return an error if:
81    /// - The snapshot cannot be found by ID or name
82    /// - There are file system errors when reading the snapshot file
83    /// - JSON deserialization fails for the snapshot file
84    pub fn get(&self, id_or_name: &str) -> Result<Snapshot> {
85        // Try by ID first
86        let id_path = self.storage_dir.join(format!("{id_or_name}.json"));
87        if id_path.exists() {
88            let content = fs::read_to_string(&id_path)?;
89            return Ok(serde_json::from_str(&content)?);
90        }
91
92        // Try by name
93        for snapshot in self.list()? {
94            if snapshot.name == id_or_name {
95                return Ok(snapshot);
96            }
97        }
98
99        Err(eyre!("Snapshot not found: {}", id_or_name))
100    }
101
102    /// Deletes a snapshot by ID or name.
103    ///
104    /// # Errors
105    ///
106    /// This function will return an error if:
107    /// - The snapshot cannot be found by ID or name
108    /// - There are file system errors when deleting the snapshot file
109    pub fn delete(&self, id_or_name: &str) -> Result<()> {
110        let snapshot = self.get(id_or_name)?;
111        let path = self.storage_dir.join(format!("{}.json", snapshot.id));
112        fs::remove_file(path)?;
113        Ok(())
114    }
115
116    /// Restores environment variables from a snapshot by clearing current variables and applying snapshot values.
117    ///
118    /// # Errors
119    ///
120    /// This function will return an error if:
121    /// - The snapshot cannot be found by ID or name
122    /// - There are file system errors when reading the snapshot file
123    /// - JSON deserialization fails for the snapshot file
124    /// - Setting environment variables in the manager fails
125    pub fn restore(&self, id_or_name: &str, manager: &mut EnvVarManager) -> Result<()> {
126        let snapshot = self.get(id_or_name)?;
127
128        // Clear current variables
129        manager.clear();
130
131        // Restore from snapshot
132        for (_, var) in snapshot.variables {
133            manager.set(&var.name, &var.value, true)?;
134        }
135
136        Ok(())
137    }
138
139    /// Compares two snapshots and returns the differences between them.
140    ///
141    /// # Errors
142    ///
143    /// This function will return an error if:
144    /// - Either snapshot cannot be found by ID or name
145    /// - There are file system errors when reading snapshot files
146    /// - JSON deserialization fails for the snapshot files
147    pub fn diff(&self, snapshot1: &str, snapshot2: &str) -> Result<SnapshotDiff> {
148        let snap1 = self.get(snapshot1)?;
149        let snap2 = self.get(snapshot2)?;
150
151        let mut diff = SnapshotDiff::default();
152
153        // Find added and modified
154        for (name, var2) in &snap2.variables {
155            match snap1.variables.get(name) {
156                Some(var1) => {
157                    if var1.value != var2.value {
158                        diff.modified.insert(name.clone(), (var1.clone(), var2.clone()));
159                    }
160                }
161                None => {
162                    diff.added.insert(name.clone(), var2.clone());
163                }
164            }
165        }
166
167        // Find removed
168        for (name, var1) in &snap1.variables {
169            if !snap2.variables.contains_key(name) {
170                diff.removed.insert(name.clone(), var1.clone());
171            }
172        }
173
174        Ok(diff)
175    }
176
177    fn save_snapshot(&self, snapshot: &Snapshot) -> color_eyre::Result<()> {
178        let path = self.storage_dir.join(format!("{}.json", snapshot.id));
179        let content = serde_json::to_string_pretty(snapshot)?;
180        fs::write(path, content)?;
181        Ok(())
182    }
183}
184
185#[derive(Debug, Default)]
186pub struct SnapshotDiff {
187    pub added: HashMap<String, EnvVar>,
188    pub removed: HashMap<String, EnvVar>,
189    pub modified: HashMap<String, (EnvVar, EnvVar)>, // (old, new)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::{EnvVar, EnvVarSource};
196    use chrono::Utc;
197    use tempfile::TempDir;
198
199    fn create_test_snapshot_manager() -> (SnapshotManager, TempDir) {
200        let temp_dir = TempDir::new().unwrap();
201        let storage_dir = temp_dir.path().join("snapshots");
202        fs::create_dir_all(&storage_dir).unwrap();
203
204        let manager = SnapshotManager { storage_dir };
205        (manager, temp_dir)
206    }
207
208    fn create_test_env_var(name: &str, value: &str) -> EnvVar {
209        EnvVar {
210            name: name.to_string(),
211            value: value.to_string(),
212            source: EnvVarSource::User,
213            modified: Utc::now(),
214            original_value: None,
215        }
216    }
217
218    fn create_test_env_manager() -> EnvVarManager {
219        let mut manager = EnvVarManager::new();
220        manager.set("VAR1", "value1", false).unwrap();
221        manager.set("VAR2", "value2", false).unwrap();
222        manager.set("VAR3", "value3", false).unwrap();
223        manager
224    }
225
226    #[test]
227    fn test_snapshot_manager_new() {
228        // Test with temporary directory to avoid system dependencies
229        let temp_dir = TempDir::new().unwrap();
230        let storage_dir = temp_dir.path().join("envx").join("snapshots");
231
232        // Manually create the manager with test directory
233        let manager = SnapshotManager {
234            storage_dir: storage_dir.clone(),
235        };
236
237        // Verify storage directory is set correctly
238        assert_eq!(manager.storage_dir, storage_dir);
239    }
240
241    #[test]
242    fn test_create_snapshot() {
243        let (manager, _temp) = create_test_snapshot_manager();
244
245        let vars = vec![
246            create_test_env_var("TEST_VAR1", "test_value1"),
247            create_test_env_var("TEST_VAR2", "test_value2"),
248        ];
249
250        let result = manager.create("test-snapshot".to_string(), Some("Test description".to_string()), vars);
251
252        assert!(result.is_ok());
253        let snapshot = result.unwrap();
254
255        assert_eq!(snapshot.name, "test-snapshot");
256        assert_eq!(snapshot.description, Some("Test description".to_string()));
257        assert_eq!(snapshot.variables.len(), 2);
258        assert!(snapshot.variables.contains_key("TEST_VAR1"));
259        assert!(snapshot.variables.contains_key("TEST_VAR2"));
260
261        // Verify snapshot was saved to disk
262        let snapshot_path = manager.storage_dir.join(format!("{}.json", snapshot.id));
263        assert!(snapshot_path.exists());
264    }
265
266    #[test]
267    fn test_create_snapshot_without_description() {
268        let (manager, _temp) = create_test_snapshot_manager();
269
270        let vars = vec![create_test_env_var("TEST_VAR", "test_value")];
271        let result = manager.create("no-desc".to_string(), None, vars);
272
273        assert!(result.is_ok());
274        assert!(result.unwrap().description.is_none());
275    }
276
277    #[test]
278    fn test_list_snapshots_empty() {
279        let (manager, _temp) = create_test_snapshot_manager();
280
281        let result = manager.list();
282        assert!(result.is_ok());
283        assert!(result.unwrap().is_empty());
284    }
285
286    #[test]
287    fn test_list_snapshots_multiple() {
288        let (manager, _temp) = create_test_snapshot_manager();
289
290        // Create multiple snapshots
291        let vars = vec![create_test_env_var("VAR", "value")];
292        manager.create("snap1".to_string(), None, vars.clone()).unwrap();
293
294        // Add a small delay to ensure different timestamps
295        std::thread::sleep(std::time::Duration::from_millis(10));
296
297        manager.create("snap2".to_string(), None, vars.clone()).unwrap();
298        manager.create("snap3".to_string(), None, vars).unwrap();
299
300        let snapshots = manager.list().unwrap();
301        assert_eq!(snapshots.len(), 3);
302
303        // Verify they are sorted by creation date (newest first)
304        assert_eq!(snapshots[0].name, "snap3");
305        assert_eq!(snapshots[1].name, "snap2");
306        assert_eq!(snapshots[2].name, "snap1");
307    }
308
309    #[test]
310    fn test_list_snapshots_handles_invalid_files() {
311        let (manager, _temp) = create_test_snapshot_manager();
312
313        // Create a valid snapshot
314        let vars = vec![create_test_env_var("VAR", "value")];
315        manager.create("valid".to_string(), None, vars).unwrap();
316
317        // Create an invalid JSON file
318        let invalid_path = manager.storage_dir.join("invalid.json");
319        fs::write(invalid_path, "{ invalid json }").unwrap();
320
321        // Create a non-JSON file
322        let non_json_path = manager.storage_dir.join("not-json.txt");
323        fs::write(non_json_path, "some content").unwrap();
324
325        // List should only return valid snapshots
326        let snapshots = manager.list().unwrap();
327        assert_eq!(snapshots.len(), 1);
328        assert_eq!(snapshots[0].name, "valid");
329    }
330
331    #[test]
332    fn test_get_snapshot_by_id() {
333        let (manager, _temp) = create_test_snapshot_manager();
334
335        let vars = vec![create_test_env_var("VAR", "value")];
336        let created = manager.create("test".to_string(), None, vars).unwrap();
337
338        let retrieved = manager.get(&created.id).unwrap();
339        assert_eq!(retrieved.id, created.id);
340        assert_eq!(retrieved.name, created.name);
341    }
342
343    #[test]
344    fn test_get_snapshot_by_name() {
345        let (manager, _temp) = create_test_snapshot_manager();
346
347        let vars = vec![create_test_env_var("VAR", "value")];
348        manager.create("test-name".to_string(), None, vars).unwrap();
349
350        let retrieved = manager.get("test-name").unwrap();
351        assert_eq!(retrieved.name, "test-name");
352    }
353
354    #[test]
355    fn test_get_snapshot_not_found() {
356        let (manager, _temp) = create_test_snapshot_manager();
357
358        let result = manager.get("nonexistent");
359        assert!(result.is_err());
360        assert!(result.unwrap_err().to_string().contains("Snapshot not found"));
361    }
362
363    #[test]
364    fn test_get_snapshot_prefers_id_over_name() {
365        let (manager, _temp) = create_test_snapshot_manager();
366
367        // Create two snapshots where one's name matches another's ID
368        let vars = vec![create_test_env_var("VAR", "value")];
369        let snap1 = manager.create("first".to_string(), None, vars.clone()).unwrap();
370
371        // Create second snapshot with name equal to first snapshot's ID
372        manager.create(snap1.id.clone(), None, vars).unwrap();
373
374        // Getting by snap1.id should return snap1, not the one named with snap1.id
375        let retrieved = manager.get(&snap1.id).unwrap();
376        assert_eq!(retrieved.name, "first");
377    }
378
379    #[test]
380    fn test_delete_snapshot() {
381        let (manager, _temp) = create_test_snapshot_manager();
382
383        let vars = vec![create_test_env_var("VAR", "value")];
384        let snapshot = manager.create("to-delete".to_string(), None, vars).unwrap();
385
386        // Verify it exists
387        assert!(manager.get(&snapshot.id).is_ok());
388
389        // Delete it
390        let result = manager.delete(&snapshot.id);
391        assert!(result.is_ok());
392
393        // Verify it's gone
394        assert!(manager.get(&snapshot.id).is_err());
395
396        // Verify file is deleted
397        let snapshot_path = manager.storage_dir.join(format!("{}.json", snapshot.id));
398        assert!(!snapshot_path.exists());
399    }
400
401    #[test]
402    fn test_delete_snapshot_by_name() {
403        let (manager, _temp) = create_test_snapshot_manager();
404
405        let vars = vec![create_test_env_var("VAR", "value")];
406        manager.create("delete-by-name".to_string(), None, vars).unwrap();
407
408        let result = manager.delete("delete-by-name");
409        assert!(result.is_ok());
410        assert!(manager.get("delete-by-name").is_err());
411    }
412
413    #[test]
414    fn test_delete_nonexistent_snapshot() {
415        let (manager, _temp) = create_test_snapshot_manager();
416
417        let result = manager.delete("nonexistent");
418        assert!(result.is_err());
419    }
420
421    #[test]
422    fn test_restore_snapshot() {
423        let (manager, _temp) = create_test_snapshot_manager();
424        let mut env_manager = create_test_env_manager();
425
426        // Create snapshot
427        let vars = vec![
428            create_test_env_var("NEW_VAR1", "new_value1"),
429            create_test_env_var("NEW_VAR2", "new_value2"),
430        ];
431        let snapshot = manager.create("to-restore".to_string(), None, vars).unwrap();
432
433        // Restore it
434        let result = manager.restore(&snapshot.id, &mut env_manager);
435        assert!(result.is_ok());
436
437        // Verify old variables are cleared and new ones are set
438        assert!(env_manager.get("VAR1").is_none());
439        assert!(env_manager.get("VAR2").is_none());
440        assert!(env_manager.get("VAR3").is_none());
441
442        assert_eq!(env_manager.get("NEW_VAR1").unwrap().value, "new_value1");
443        assert_eq!(env_manager.get("NEW_VAR2").unwrap().value, "new_value2");
444    }
445
446    #[test]
447    fn test_restore_nonexistent_snapshot() {
448        let (manager, _temp) = create_test_snapshot_manager();
449        let mut env_manager = create_test_env_manager();
450
451        let result = manager.restore("nonexistent", &mut env_manager);
452        assert!(result.is_err());
453    }
454
455    #[test]
456    fn test_diff_snapshots_no_changes() {
457        let (manager, _temp) = create_test_snapshot_manager();
458
459        let vars = vec![
460            create_test_env_var("VAR1", "value1"),
461            create_test_env_var("VAR2", "value2"),
462        ];
463
464        let snap1 = manager.create("snap1".to_string(), None, vars.clone()).unwrap();
465        let snap2 = manager.create("snap2".to_string(), None, vars).unwrap();
466
467        let diff = manager.diff(&snap1.id, &snap2.id).unwrap();
468        assert!(diff.added.is_empty());
469        assert!(diff.removed.is_empty());
470        assert!(diff.modified.is_empty());
471    }
472
473    #[test]
474    fn test_diff_snapshots_with_changes() {
475        let (manager, _temp) = create_test_snapshot_manager();
476
477        let vars1 = vec![
478            create_test_env_var("VAR1", "value1"),
479            create_test_env_var("VAR2", "old_value"),
480            create_test_env_var("VAR3", "value3"),
481        ];
482
483        let vars2 = vec![
484            create_test_env_var("VAR1", "value1"),    // Same
485            create_test_env_var("VAR2", "new_value"), // Modified
486            create_test_env_var("VAR4", "value4"),    // Added
487        ];
488
489        let snap1 = manager.create("snap1".to_string(), None, vars1).unwrap();
490        let snap2 = manager.create("snap2".to_string(), None, vars2).unwrap();
491
492        let diff = manager.diff(&snap1.id, &snap2.id).unwrap();
493
494        // Check added
495        assert_eq!(diff.added.len(), 1);
496        assert!(diff.added.contains_key("VAR4"));
497        assert_eq!(diff.added.get("VAR4").unwrap().value, "value4");
498
499        // Check removed
500        assert_eq!(diff.removed.len(), 1);
501        assert!(diff.removed.contains_key("VAR3"));
502        assert_eq!(diff.removed.get("VAR3").unwrap().value, "value3");
503
504        // Check modified
505        assert_eq!(diff.modified.len(), 1);
506        assert!(diff.modified.contains_key("VAR2"));
507        let (old, new) = diff.modified.get("VAR2").unwrap();
508        assert_eq!(old.value, "old_value");
509        assert_eq!(new.value, "new_value");
510    }
511
512    #[test]
513    fn test_diff_nonexistent_snapshots() {
514        let (manager, _temp) = create_test_snapshot_manager();
515
516        let result = manager.diff("nonexistent1", "nonexistent2");
517        assert!(result.is_err());
518    }
519
520    #[test]
521    fn test_save_snapshot_creates_pretty_json() {
522        let (manager, _temp) = create_test_snapshot_manager();
523
524        let vars = vec![create_test_env_var("TEST_VAR", "test_value")];
525        let snapshot = manager
526            .create("pretty-test".to_string(), Some("Pretty JSON test".to_string()), vars)
527            .unwrap();
528
529        // Read the saved file
530        let snapshot_path = manager.storage_dir.join(format!("{}.json", snapshot.id));
531        let content = fs::read_to_string(snapshot_path).unwrap();
532
533        // Verify it's pretty-printed (contains indentation)
534        assert!(content.contains("\n  "));
535        assert!(content.contains("\"name\": \"pretty-test\""));
536        assert!(content.contains("\"description\": \"Pretty JSON test\""));
537    }
538
539    #[test]
540    fn test_concurrent_operations() {
541        let (manager, _temp) = create_test_snapshot_manager();
542
543        // Create multiple snapshots in quick succession
544        let mut snapshot_ids = Vec::new();
545        for i in 0..5 {
546            let vars = vec![create_test_env_var(&format!("VAR{i}"), &format!("value{i}"))];
547            let snapshot = manager.create(format!("concurrent-{i}"), None, vars).unwrap();
548            snapshot_ids.push(snapshot.id);
549        }
550
551        // Verify all can be retrieved
552        for id in &snapshot_ids {
553            assert!(manager.get(id).is_ok());
554        }
555
556        // Verify list returns all
557        let snapshots = manager.list().unwrap();
558        assert_eq!(snapshots.len(), 5);
559    }
560}