1use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::path::{Path, PathBuf};
9use tracing::info;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Snapshot {
14 pub name: String,
16
17 #[serde(default)]
19 pub description: Option<String>,
20
21 pub created_at: String,
23
24 pub stout_version: String,
26
27 #[serde(default)]
29 pub formulas: Vec<FormulaSnapshot>,
30
31 #[serde(default)]
33 pub casks: Vec<CaskSnapshot>,
34
35 #[serde(default)]
37 pub pinned: Vec<String>,
38
39 #[serde(default)]
41 pub taps: Vec<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct FormulaSnapshot {
47 pub name: String,
48 pub version: String,
49 #[serde(default)]
50 pub revision: u32,
51 #[serde(default)]
53 pub requested: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct CaskSnapshot {
59 pub token: String,
60 pub version: String,
61}
62
63impl Snapshot {
64 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 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 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 pub fn formula_count(&self) -> usize {
98 self.formulas.len()
99 }
100
101 pub fn cask_count(&self) -> usize {
103 self.casks.len()
104 }
105
106 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
116pub struct SnapshotManager {
118 snapshots_dir: PathBuf,
119}
120
121impl SnapshotManager {
122 pub fn new(stout_dir: &Path) -> Self {
124 Self {
125 snapshots_dir: stout_dir.join("snapshots"),
126 }
127 }
128
129 fn ensure_dir(&self) -> Result<()> {
131 std::fs::create_dir_all(&self.snapshots_dir)?;
132 Ok(())
133 }
134
135 fn snapshot_path(&self, name: &str) -> PathBuf {
137 self.snapshots_dir.join(format!("{}.json", name))
138 }
139
140 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 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 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 snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
184
185 Ok(snapshots)
186 }
187
188 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 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 pub fn exists(&self, name: &str) -> bool {
217 self.snapshot_path(name).exists()
218 }
219
220 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 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#[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
244fn 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 manager.save(&snapshot).unwrap();
298 assert!(manager.exists("test"));
299
300 let loaded = manager.load("test").unwrap();
302 assert_eq!(loaded.name, "test");
303 assert_eq!(loaded.formula_count(), 1);
304
305 let list = manager.list().unwrap();
307 assert_eq!(list.len(), 1);
308
309 manager.delete("test").unwrap();
311 assert!(!manager.exists("test"));
312 }
313}