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