Skip to main content

chub_core/team/
snapshots.rs

1use std::fs;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::error::{Error, Result};
7use crate::team::pins::{load_pins, save_pins, PinEntry, PinsFile};
8use crate::team::project::project_chub_dir;
9
10fn validate_name(name: &str) -> Result<()> {
11    crate::util::validate_filename(name, "snapshot")
12}
13
14/// A point-in-time snapshot of all pins.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Snapshot {
17    pub name: String,
18    pub created_at: String,
19    pub pins: Vec<PinEntry>,
20}
21
22/// A diff between two snapshots.
23#[derive(Debug, Clone, Serialize)]
24pub struct SnapshotDiff {
25    pub id: String,
26    pub change: DiffChange,
27}
28
29#[derive(Debug, Clone, Serialize)]
30#[serde(rename_all = "lowercase")]
31pub enum DiffChange {
32    Added {
33        version: Option<String>,
34    },
35    Removed {
36        version: Option<String>,
37    },
38    Changed {
39        from_version: Option<String>,
40        to_version: Option<String>,
41    },
42}
43
44fn snapshots_dir() -> Option<PathBuf> {
45    project_chub_dir().map(|d| d.join("snapshots"))
46}
47
48fn now_iso() -> String {
49    crate::util::now_iso8601()
50}
51
52/// Create a snapshot of the current pins.
53pub fn create_snapshot(name: &str) -> Result<Snapshot> {
54    validate_name(name)?;
55    let dir =
56        snapshots_dir().ok_or_else(|| Error::Config("No .chub/ directory found.".to_string()))?;
57    fs::create_dir_all(&dir)?;
58
59    let pins = load_pins();
60    let snapshot = Snapshot {
61        name: name.to_string(),
62        created_at: now_iso(),
63        pins: pins.pins,
64    };
65
66    let path = dir.join(format!("{}.yaml", name));
67    let yaml = serde_yaml::to_string(&snapshot).map_err(|e| Error::Config(e.to_string()))?;
68    fs::write(&path, yaml)?;
69
70    Ok(snapshot)
71}
72
73/// Restore pins from a snapshot.
74/// Automatically creates a backup snapshot of current pins before restoring.
75pub fn restore_snapshot(name: &str) -> Result<Snapshot> {
76    validate_name(name)?;
77    let dir =
78        snapshots_dir().ok_or_else(|| Error::Config("No .chub/ directory found.".to_string()))?;
79
80    let path = dir.join(format!("{}.yaml", name));
81    if !path.exists() {
82        return Err(Error::Config(format!("Snapshot \"{}\" not found.", name)));
83    }
84
85    let raw = fs::read_to_string(&path)?;
86    let snapshot: Snapshot =
87        serde_yaml::from_str(&raw).map_err(|e| Error::Config(e.to_string()))?;
88
89    // Auto-backup current pins before overwriting
90    let current_pins = load_pins();
91    if !current_pins.pins.is_empty() {
92        let backup_name = format!("pre-restore-{}", name);
93        let backup_path = dir.join(format!("{}.yaml", backup_name));
94        if !backup_path.exists() {
95            let backup = Snapshot {
96                name: backup_name,
97                created_at: now_iso(),
98                pins: current_pins.pins,
99            };
100            if let Ok(yaml) = serde_yaml::to_string(&backup) {
101                let _ = fs::write(&backup_path, yaml);
102            }
103        }
104    }
105
106    let pins_file = PinsFile {
107        pins: snapshot.pins.clone(),
108    };
109    save_pins(&pins_file)?;
110
111    Ok(snapshot)
112}
113
114/// Diff two snapshots.
115pub fn diff_snapshots(name_a: &str, name_b: &str) -> Result<Vec<SnapshotDiff>> {
116    validate_name(name_a)?;
117    validate_name(name_b)?;
118    let dir =
119        snapshots_dir().ok_or_else(|| Error::Config("No .chub/ directory found.".to_string()))?;
120
121    let load = |name: &str| -> Result<Snapshot> {
122        let path = dir.join(format!("{}.yaml", name));
123        if !path.exists() {
124            return Err(Error::Config(format!("Snapshot \"{}\" not found.", name)));
125        }
126        let raw = fs::read_to_string(&path)?;
127        serde_yaml::from_str(&raw).map_err(|e| Error::Config(e.to_string()))
128    };
129
130    let snap_a = load(name_a)?;
131    let snap_b = load(name_b)?;
132
133    let mut diffs = Vec::new();
134
135    // Find added/changed in B
136    for pin_b in &snap_b.pins {
137        if let Some(pin_a) = snap_a.pins.iter().find(|p| p.id == pin_b.id) {
138            if pin_a.version != pin_b.version {
139                diffs.push(SnapshotDiff {
140                    id: pin_b.id.clone(),
141                    change: DiffChange::Changed {
142                        from_version: pin_a.version.clone(),
143                        to_version: pin_b.version.clone(),
144                    },
145                });
146            }
147        } else {
148            diffs.push(SnapshotDiff {
149                id: pin_b.id.clone(),
150                change: DiffChange::Added {
151                    version: pin_b.version.clone(),
152                },
153            });
154        }
155    }
156
157    // Find removed from A
158    for pin_a in &snap_a.pins {
159        if !snap_b.pins.iter().any(|p| p.id == pin_a.id) {
160            diffs.push(SnapshotDiff {
161                id: pin_a.id.clone(),
162                change: DiffChange::Removed {
163                    version: pin_a.version.clone(),
164                },
165            });
166        }
167    }
168
169    Ok(diffs)
170}
171
172/// List all snapshots.
173pub fn list_snapshots() -> Vec<(String, String)> {
174    let dir = match snapshots_dir() {
175        Some(d) if d.exists() => d,
176        _ => return vec![],
177    };
178
179    let entries = match fs::read_dir(&dir) {
180        Ok(e) => e,
181        Err(_) => return vec![],
182    };
183
184    let mut snapshots = Vec::new();
185    for entry in entries.filter_map(|e| e.ok()) {
186        let path = entry.path();
187        let ext = path.extension().and_then(|e| e.to_str());
188        if ext != Some("yaml") && ext != Some("yml") {
189            continue;
190        }
191        let stem = path
192            .file_stem()
193            .unwrap_or_default()
194            .to_string_lossy()
195            .to_string();
196
197        let created_at = fs::read_to_string(&path)
198            .ok()
199            .and_then(|s| serde_yaml::from_str::<Snapshot>(&s).ok())
200            .map(|s| s.created_at)
201            .unwrap_or_default();
202
203        snapshots.push((stem, created_at));
204    }
205
206    snapshots.sort_by(|a, b| a.0.cmp(&b.0));
207    snapshots
208}