Skip to main content

stout_bundle/
snapshot.rs

1//! Snapshot management for stout
2//!
3//! Snapshots capture the current state of installed packages for quick
4//! backup and restoration.
5
6use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use tracing::info;
10
11/// A snapshot of installed packages
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Snapshot {
14    /// Snapshot name
15    pub name: String,
16
17    /// Optional description
18    #[serde(default)]
19    pub description: Option<String>,
20
21    /// When the snapshot was created
22    pub created_at: String,
23
24    /// stout version that created this snapshot
25    pub stout_version: String,
26
27    /// Installed formulas
28    #[serde(default)]
29    pub formulas: Vec<FormulaSnapshot>,
30
31    /// Installed casks
32    #[serde(default)]
33    pub casks: Vec<CaskSnapshot>,
34
35    /// Pinned packages
36    #[serde(default)]
37    pub pinned: Vec<String>,
38
39    /// Active taps
40    #[serde(default)]
41    pub taps: Vec<String>,
42}
43
44/// Formula in a snapshot
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct FormulaSnapshot {
47    pub name: String,
48    pub version: String,
49    #[serde(default)]
50    pub revision: u32,
51    /// Whether this was explicitly installed (vs dependency)
52    #[serde(default)]
53    pub requested: bool,
54}
55
56/// Cask in a snapshot
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CaskSnapshot {
59    pub token: String,
60    pub version: String,
61}
62
63impl Snapshot {
64    /// Create a new snapshot
65    pub fn new(name: &str, description: Option<&str>) -> Self {
66        Self {
67            name: name.to_string(),
68            description: description.map(|s| s.to_string()),
69            created_at: current_timestamp(),
70            stout_version: env!("CARGO_PKG_VERSION").to_string(),
71            formulas: Vec::new(),
72            casks: Vec::new(),
73            pinned: Vec::new(),
74            taps: Vec::new(),
75        }
76    }
77
78    /// Add a formula to the snapshot
79    pub fn add_formula(&mut self, name: &str, version: &str, revision: u32, requested: bool) {
80        self.formulas.push(FormulaSnapshot {
81            name: name.to_string(),
82            version: version.to_string(),
83            revision,
84            requested,
85        });
86    }
87
88    /// Add a cask to the snapshot
89    pub fn add_cask(&mut self, token: &str, version: &str) {
90        self.casks.push(CaskSnapshot {
91            token: token.to_string(),
92            version: version.to_string(),
93        });
94    }
95
96    /// Get formula count
97    pub fn formula_count(&self) -> usize {
98        self.formulas.len()
99    }
100
101    /// Get cask count
102    pub fn cask_count(&self) -> usize {
103        self.casks.len()
104    }
105
106    /// Get requested formula names
107    pub fn requested_formulas(&self) -> Vec<&str> {
108        self.formulas
109            .iter()
110            .filter(|f| f.requested)
111            .map(|f| f.name.as_str())
112            .collect()
113    }
114}
115
116/// Manages snapshots on disk
117pub struct SnapshotManager {
118    snapshots_dir: PathBuf,
119}
120
121impl SnapshotManager {
122    /// Create a new snapshot manager
123    pub fn new(stout_dir: &Path) -> Self {
124        Self {
125            snapshots_dir: stout_dir.join("snapshots"),
126        }
127    }
128
129    /// Ensure snapshots directory exists
130    fn ensure_dir(&self) -> Result<()> {
131        std::fs::create_dir_all(&self.snapshots_dir)?;
132        Ok(())
133    }
134
135    /// Get path for a snapshot file
136    fn snapshot_path(&self, name: &str) -> PathBuf {
137        self.snapshots_dir.join(format!("{}.json", name))
138    }
139
140    /// Save a snapshot to disk
141    pub fn save(&self, snapshot: &Snapshot) -> Result<PathBuf> {
142        self.ensure_dir()?;
143
144        let path = self.snapshot_path(&snapshot.name);
145        let json = serde_json::to_string_pretty(snapshot)?;
146        std::fs::write(&path, json)?;
147
148        info!("Saved snapshot '{}' to {}", snapshot.name, path.display());
149        Ok(path)
150    }
151
152    /// Load a snapshot from disk
153    pub fn load(&self, name: &str) -> Result<Snapshot> {
154        let path = self.snapshot_path(name);
155
156        if !path.exists() {
157            return Err(Error::SnapshotNotFound(name.to_string()));
158        }
159
160        let json = std::fs::read_to_string(&path)?;
161        let snapshot: Snapshot = serde_json::from_str(&json)?;
162        Ok(snapshot)
163    }
164
165    /// List all snapshots
166    pub fn list(&self) -> Result<Vec<SnapshotInfo>> {
167        self.ensure_dir()?;
168
169        let mut snapshots = Vec::new();
170
171        for entry in std::fs::read_dir(&self.snapshots_dir)? {
172            let entry = entry?;
173            let path = entry.path();
174
175            if path.extension().map(|e| e == "json").unwrap_or(false) {
176                if let Ok(snapshot) = self.load_info(&path) {
177                    snapshots.push(snapshot);
178                }
179            }
180        }
181
182        // Sort by creation time (newest first)
183        snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
184
185        Ok(snapshots)
186    }
187
188    /// Load basic snapshot info without full data
189    fn load_info(&self, path: &Path) -> Result<SnapshotInfo> {
190        let json = std::fs::read_to_string(path)?;
191        let snapshot: Snapshot = serde_json::from_str(&json)?;
192
193        Ok(SnapshotInfo {
194            name: snapshot.name,
195            description: snapshot.description,
196            created_at: snapshot.created_at,
197            formula_count: snapshot.formulas.len(),
198            cask_count: snapshot.casks.len(),
199        })
200    }
201
202    /// Delete a snapshot
203    pub fn delete(&self, name: &str) -> Result<()> {
204        let path = self.snapshot_path(name);
205
206        if !path.exists() {
207            return Err(Error::SnapshotNotFound(name.to_string()));
208        }
209
210        std::fs::remove_file(&path)?;
211        info!("Deleted snapshot '{}'", name);
212        Ok(())
213    }
214
215    /// Check if a snapshot exists
216    pub fn exists(&self, name: &str) -> bool {
217        self.snapshot_path(name).exists()
218    }
219
220    /// Export a snapshot to a writer
221    pub fn export(&self, name: &str) -> Result<String> {
222        let snapshot = self.load(name)?;
223        Ok(serde_json::to_string_pretty(&snapshot)?)
224    }
225
226    /// Import a snapshot from JSON
227    pub fn import(&self, json: &str) -> Result<String> {
228        let snapshot: Snapshot = serde_json::from_str(json)?;
229        self.save(&snapshot)?;
230        Ok(snapshot.name)
231    }
232}
233
234/// Basic snapshot info for listing
235#[derive(Debug, Clone, Serialize)]
236pub struct SnapshotInfo {
237    pub name: String,
238    pub description: Option<String>,
239    pub created_at: String,
240    pub formula_count: usize,
241    pub cask_count: usize,
242}
243
244/// Generate current timestamp
245fn current_timestamp() -> String {
246    use std::time::{SystemTime, UNIX_EPOCH};
247
248    let duration = SystemTime::now()
249        .duration_since(UNIX_EPOCH)
250        .unwrap_or_default();
251
252    let secs = duration.as_secs();
253    let days_since_epoch = secs / 86400;
254    let remaining_secs = secs % 86400;
255    let hours = remaining_secs / 3600;
256    let minutes = (remaining_secs % 3600) / 60;
257    let seconds = remaining_secs % 60;
258
259    let years = 1970 + (days_since_epoch / 365);
260    let day_of_year = days_since_epoch % 365;
261    let month = (day_of_year / 30).min(11) + 1;
262    let day = (day_of_year % 30) + 1;
263
264    format!(
265        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
266        years, month, day, hours, minutes, seconds
267    )
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273    use tempfile::tempdir;
274
275    #[test]
276    fn test_snapshot_creation() {
277        let mut snapshot = Snapshot::new("test", Some("Test snapshot"));
278
279        snapshot.add_formula("jq", "1.7.1", 0, true);
280        snapshot.add_formula("oniguruma", "6.9.9", 0, false);
281        snapshot.add_cask("firefox", "130.0");
282
283        assert_eq!(snapshot.formula_count(), 2);
284        assert_eq!(snapshot.cask_count(), 1);
285        assert_eq!(snapshot.requested_formulas(), vec!["jq"]);
286    }
287
288    #[test]
289    fn test_snapshot_manager() {
290        let dir = tempdir().unwrap();
291        let manager = SnapshotManager::new(dir.path());
292
293        let mut snapshot = Snapshot::new("test", Some("Test"));
294        snapshot.add_formula("jq", "1.7.1", 0, true);
295
296        // Save
297        manager.save(&snapshot).unwrap();
298        assert!(manager.exists("test"));
299
300        // Load
301        let loaded = manager.load("test").unwrap();
302        assert_eq!(loaded.name, "test");
303        assert_eq!(loaded.formula_count(), 1);
304
305        // List
306        let list = manager.list().unwrap();
307        assert_eq!(list.len(), 1);
308
309        // Delete
310        manager.delete("test").unwrap();
311        assert!(!manager.exists("test"));
312    }
313}