Skip to main content

modde_core/
backup.rs

1use std::fs;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use serde::{Deserialize, Serialize};
7use tracing::info;
8
9use crate::PluginEntry;
10use crate::error::{CoreError, Result};
11use crate::paths;
12use crate::resolver::{GameId, ModId};
13
14// ── Backup Manager ──────────────────────────────────────────────
15
16/// Manages zip-based backups for mods and plugin load orders.
17pub struct BackupManager {
18    backup_dir: PathBuf,
19}
20
21/// Metadata for a single backup.
22#[derive(Debug, Clone)]
23pub struct BackupEntry {
24    pub name: String,
25    pub path: PathBuf,
26    pub created: SystemTime,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(untagged)]
31enum PluginBackupPayload {
32    Entries(Vec<PluginEntryBackup>),
33    Legacy(Vec<String>),
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
37struct PluginEntryBackup {
38    plugin_name: String,
39    enabled: bool,
40}
41
42impl BackupManager {
43    /// Create a new backup manager rooted at the default backup directory.
44    pub fn new() -> Result<Self> {
45        let backup_dir = paths::modde_data_dir().join("backups");
46        fs::create_dir_all(&backup_dir)?;
47        Ok(Self { backup_dir })
48    }
49
50    /// Create a backup of a mod directory, stored as a zip file.
51    pub fn create_mod_backup(&self, mod_id: &ModId, mod_dir: &Path) -> Result<BackupEntry> {
52        let dest_dir = self.backup_dir.join("mods").join(mod_id.as_str());
53        fs::create_dir_all(&dest_dir)?;
54
55        let timestamp = unix_timestamp();
56        let name = format!("{mod_id}_{timestamp}.zip");
57        let zip_path = dest_dir.join(&name);
58
59        info!(%mod_id, path = %zip_path.display(), "creating mod backup");
60
61        create_zip_from_dir(mod_dir, &zip_path)?;
62
63        let created = fs::metadata(&zip_path)
64            .and_then(|m| m.modified())
65            .unwrap_or(SystemTime::now());
66
67        Ok(BackupEntry {
68            name,
69            path: zip_path,
70            created,
71        })
72    }
73
74    /// Restore a mod from its latest backup zip into `dest_dir`.
75    pub fn restore_mod_backup(&self, mod_id: &ModId, dest_dir: &Path) -> Result<BackupEntry> {
76        let entries = self.list_mod_backups(mod_id)?;
77        let latest = entries.last().ok_or_else(|| {
78            CoreError::Other(format!("no backups found for mod '{mod_id}'").into())
79        })?;
80
81        info!(%mod_id, backup = %latest.path.display(), "restoring mod backup");
82
83        extract_zip_to_dir(&latest.path, dest_dir)?;
84
85        Ok(latest.clone())
86    }
87
88    /// List available backups for a mod, sorted oldest-first.
89    pub fn list_mod_backups(&self, mod_id: &ModId) -> Result<Vec<BackupEntry>> {
90        let dir = self.backup_dir.join("mods").join(mod_id.as_str());
91        if !dir.exists() {
92            return Ok(Vec::new());
93        }
94
95        let mut entries = Vec::new();
96        for entry in fs::read_dir(&dir)? {
97            let entry = entry?;
98            let path = entry.path();
99            if path.extension().is_some_and(|ext| ext == "zip") {
100                let created = entry
101                    .metadata()
102                    .and_then(|m| m.modified())
103                    .unwrap_or(SystemTime::now());
104                entries.push(BackupEntry {
105                    name: entry.file_name().to_string_lossy().into_owned(),
106                    path,
107                    created,
108                });
109            }
110        }
111
112        entries.sort_by_key(|e| e.created);
113        Ok(entries)
114    }
115
116    /// Backup the plugin load order for a profile+game as a JSON file.
117    pub fn backup_plugin_order(
118        &self,
119        profile: &str,
120        game: &GameId,
121        plugins: &[PluginEntry],
122    ) -> Result<PathBuf> {
123        let dir = self.backup_dir.join("plugins").join(game.as_str());
124        fs::create_dir_all(&dir)?;
125
126        let timestamp = unix_timestamp();
127        let name = format!("{profile}_{timestamp}.json");
128        let path = dir.join(&name);
129
130        let payload = PluginBackupPayload::Entries(
131            plugins
132                .iter()
133                .map(|plugin| PluginEntryBackup {
134                    plugin_name: plugin.plugin_name.clone(),
135                    enabled: plugin.enabled,
136                })
137                .collect(),
138        );
139        let json = serde_json::to_string_pretty(&payload)?;
140        fs::write(&path, json)?;
141
142        info!(%profile, %game, path = %path.display(), "plugin order backed up");
143        Ok(path)
144    }
145
146    /// Restore the most recent plugin load order backup for a profile+game.
147    pub fn restore_plugin_order(&self, profile: &str, game: &GameId) -> Result<Vec<PluginEntry>> {
148        let dir = self.backup_dir.join("plugins").join(game.as_str());
149        if !dir.exists() {
150            return Err(CoreError::Other(
151                format!("no plugin backups for game '{game}'").into(),
152            ));
153        }
154
155        let prefix = format!("{profile}_");
156        let mut candidates: Vec<_> = fs::read_dir(&dir)?
157            .filter_map(std::result::Result::ok)
158            .filter(|e| e.file_name().to_string_lossy().starts_with(&prefix))
159            .collect();
160
161        candidates.sort_by_key(|e| {
162            e.metadata()
163                .and_then(|m| m.modified())
164                .unwrap_or(UNIX_EPOCH)
165        });
166
167        let latest = candidates.last().ok_or_else(|| {
168            CoreError::Other(
169                format!("no plugin backups for profile '{profile}' / game '{game}'").into(),
170            )
171        })?;
172
173        let data = fs::read_to_string(latest.path())?;
174        let payload: PluginBackupPayload = serde_json::from_str(&data)?;
175
176        Ok(match payload {
177            PluginBackupPayload::Entries(entries) => entries
178                .into_iter()
179                .enumerate()
180                .map(|(sort_index, entry)| PluginEntry {
181                    plugin_name: entry.plugin_name,
182                    sort_index: sort_index as i64,
183                    enabled: entry.enabled,
184                })
185                .collect(),
186            PluginBackupPayload::Legacy(entries) => entries
187                .into_iter()
188                .enumerate()
189                .map(|(sort_index, plugin_name)| PluginEntry {
190                    plugin_name,
191                    sort_index: sort_index as i64,
192                    enabled: true,
193                })
194                .collect(),
195        })
196    }
197}
198
199// ── Helpers ─────────────────────────────────────────────────────
200
201/// Produce a Unix timestamp (seconds since epoch).
202fn unix_timestamp() -> u64 {
203    SystemTime::now()
204        .duration_since(UNIX_EPOCH)
205        .unwrap_or_default()
206        .as_secs()
207}
208
209/// Create a zip archive from a directory.
210fn create_zip_from_dir(src: &Path, dest: &Path) -> Result<()> {
211    let file = fs::File::create(dest)?;
212    let mut zip = zip::ZipWriter::new(file);
213    let options = zip::write::SimpleFileOptions::default()
214        .compression_method(zip::CompressionMethod::Deflated);
215
216    let entries = walkdir(src)?;
217    for entry in &entries {
218        let rel = entry
219            .strip_prefix(src)
220            .map_err(|e| CoreError::Other(e.to_string().into()))?;
221        let rel_str = rel.to_string_lossy();
222
223        if entry.is_dir() {
224            zip.add_directory(rel_str.as_ref(), options)
225                .map_err(|e| CoreError::Other(e.to_string().into()))?;
226        } else {
227            zip.start_file(rel_str.as_ref(), options)
228                .map_err(|e| CoreError::Other(e.to_string().into()))?;
229            let mut f = fs::File::open(entry)?;
230            let mut buf = Vec::new();
231            f.read_to_end(&mut buf)?;
232            std::io::Write::write_all(&mut zip, &buf)?;
233        }
234    }
235
236    zip.finish()
237        .map_err(|e| CoreError::Other(e.to_string().into()))?;
238    Ok(())
239}
240
241/// Extract a zip archive into `dest`.
242fn extract_zip_to_dir(zip_path: &Path, dest: &Path) -> Result<()> {
243    let file = fs::File::open(zip_path)?;
244    let mut archive =
245        zip::ZipArchive::new(file).map_err(|e| CoreError::Other(e.to_string().into()))?;
246
247    for i in 0..archive.len() {
248        let mut entry = archive
249            .by_index(i)
250            .map_err(|e| CoreError::Other(e.to_string().into()))?;
251        let out_path = dest.join(entry.name());
252
253        if entry.is_dir() {
254            fs::create_dir_all(&out_path)?;
255        } else {
256            if let Some(parent) = out_path.parent() {
257                fs::create_dir_all(parent)?;
258            }
259            let mut outfile = fs::File::create(&out_path)?;
260            std::io::copy(&mut entry, &mut outfile)?;
261        }
262    }
263
264    Ok(())
265}
266
267/// Recursively list all files and directories under `root`.
268fn walkdir(root: &Path) -> Result<Vec<PathBuf>> {
269    let mut result = Vec::new();
270    walk_recursive(root, &mut result)?;
271    result.sort();
272    Ok(result)
273}
274
275fn walk_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
276    for entry in fs::read_dir(dir)? {
277        let entry = entry?;
278        let path = entry.path();
279        out.push(path.clone());
280        if path.is_dir() {
281            walk_recursive(&path, out)?;
282        }
283    }
284    Ok(())
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    fn manager_in(dir: &Path) -> BackupManager {
292        BackupManager {
293            backup_dir: dir.to_path_buf(),
294        }
295    }
296
297    #[test]
298    fn plugin_backup_round_trips_enabled_state() {
299        let tmp = tempfile::tempdir().unwrap();
300        let mgr = manager_in(tmp.path());
301
302        let plugins = vec![
303            PluginEntry {
304                plugin_name: "Skyrim.esm".to_string(),
305                sort_index: 0,
306                enabled: true,
307            },
308            PluginEntry {
309                plugin_name: "Optional.esp".to_string(),
310                sort_index: 1,
311                enabled: false,
312            },
313        ];
314
315        mgr.backup_plugin_order("default", &GameId::from("skyrim-se"), &plugins)
316            .unwrap();
317
318        let restored = mgr
319            .restore_plugin_order("default", &GameId::from("skyrim-se"))
320            .unwrap();
321        assert_eq!(restored.len(), 2);
322        assert_eq!(restored[0].plugin_name, "Skyrim.esm");
323        assert!(restored[0].enabled);
324        assert_eq!(restored[1].plugin_name, "Optional.esp");
325        assert!(!restored[1].enabled);
326    }
327
328    #[test]
329    fn restore_plugin_order_accepts_legacy_backups() {
330        let tmp = tempfile::tempdir().unwrap();
331        let mgr = manager_in(tmp.path());
332        let dir = tmp.path().join("plugins").join("skyrim-se");
333        fs::create_dir_all(&dir).unwrap();
334        fs::write(
335            dir.join("default_1.json"),
336            serde_json::to_string(&vec!["One.esm", "Two.esp"]).unwrap(),
337        )
338        .unwrap();
339
340        let restored = mgr
341            .restore_plugin_order("default", &GameId::from("skyrim-se"))
342            .unwrap();
343        assert_eq!(restored.len(), 2);
344        assert_eq!(restored[0].plugin_name, "One.esm");
345        assert!(restored[0].enabled);
346        assert_eq!(restored[1].plugin_name, "Two.esp");
347        assert!(restored[1].enabled);
348    }
349}