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 tracing::info;
7
8use crate::error::{CoreError, Result};
9use crate::paths;
10
11// ── Backup Manager ──────────────────────────────────────────────
12
13/// Manages zip-based backups for mods and plugin load orders.
14pub struct BackupManager {
15    backup_dir: PathBuf,
16}
17
18/// Metadata for a single backup.
19#[derive(Debug, Clone)]
20pub struct BackupEntry {
21    pub name: String,
22    pub path: PathBuf,
23    pub created: SystemTime,
24}
25
26impl BackupManager {
27    /// Create a new backup manager rooted at the default backup directory.
28    pub fn new() -> Result<Self> {
29        let backup_dir = paths::data_dir().join("backups");
30        fs::create_dir_all(&backup_dir)?;
31        Ok(Self { backup_dir })
32    }
33
34    /// Create a backup of a mod directory, stored as a zip file.
35    pub fn create_mod_backup(&self, mod_id: &str, mod_dir: &Path) -> Result<BackupEntry> {
36        let dest_dir = self.backup_dir.join("mods").join(mod_id);
37        fs::create_dir_all(&dest_dir)?;
38
39        let timestamp = unix_timestamp();
40        let name = format!("{mod_id}_{timestamp}.zip");
41        let zip_path = dest_dir.join(&name);
42
43        info!(%mod_id, path = %zip_path.display(), "creating mod backup");
44
45        create_zip_from_dir(mod_dir, &zip_path)?;
46
47        let created = fs::metadata(&zip_path)
48            .and_then(|m| m.modified())
49            .unwrap_or(SystemTime::now());
50
51        Ok(BackupEntry {
52            name,
53            path: zip_path,
54            created,
55        })
56    }
57
58    /// Restore a mod from its latest backup zip into `dest_dir`.
59    pub fn restore_mod_backup(&self, mod_id: &str, dest_dir: &Path) -> Result<BackupEntry> {
60        let entries = self.list_mod_backups(mod_id)?;
61        let latest = entries.last().ok_or_else(|| {
62            CoreError::Other(format!("no backups found for mod '{mod_id}'").into())
63        })?;
64
65        info!(%mod_id, backup = %latest.path.display(), "restoring mod backup");
66
67        extract_zip_to_dir(&latest.path, dest_dir)?;
68
69        Ok(latest.clone())
70    }
71
72    /// List available backups for a mod, sorted oldest-first.
73    pub fn list_mod_backups(&self, mod_id: &str) -> Result<Vec<BackupEntry>> {
74        let dir = self.backup_dir.join("mods").join(mod_id);
75        if !dir.exists() {
76            return Ok(Vec::new());
77        }
78
79        let mut entries = Vec::new();
80        for entry in fs::read_dir(&dir)? {
81            let entry = entry?;
82            let path = entry.path();
83            if path.extension().is_some_and(|ext| ext == "zip") {
84                let created = entry
85                    .metadata()
86                    .and_then(|m| m.modified())
87                    .unwrap_or(SystemTime::now());
88                entries.push(BackupEntry {
89                    name: entry.file_name().to_string_lossy().into_owned(),
90                    path,
91                    created,
92                });
93            }
94        }
95
96        entries.sort_by_key(|e| e.created);
97        Ok(entries)
98    }
99
100    /// Backup the plugin load order for a profile+game as a JSON file.
101    pub fn backup_plugin_order(
102        &self,
103        profile: &str,
104        game: &str,
105        plugins: &[String],
106    ) -> Result<PathBuf> {
107        let dir = self.backup_dir.join("plugins").join(game);
108        fs::create_dir_all(&dir)?;
109
110        let timestamp = unix_timestamp();
111        let name = format!("{profile}_{timestamp}.json");
112        let path = dir.join(&name);
113
114        let json = serde_json::to_string_pretty(plugins)?;
115        fs::write(&path, json)?;
116
117        info!(%profile, %game, path = %path.display(), "plugin order backed up");
118        Ok(path)
119    }
120
121    /// Restore the most recent plugin load order backup for a profile+game.
122    pub fn restore_plugin_order(&self, profile: &str, game: &str) -> Result<Vec<String>> {
123        let dir = self.backup_dir.join("plugins").join(game);
124        if !dir.exists() {
125            return Err(CoreError::Other(
126                format!("no plugin backups for game '{game}'").into(),
127            ));
128        }
129
130        let prefix = format!("{profile}_");
131        let mut candidates: Vec<_> = fs::read_dir(&dir)?
132            .filter_map(|e| e.ok())
133            .filter(|e| e.file_name().to_string_lossy().starts_with(&prefix))
134            .collect();
135
136        candidates.sort_by_key(|e| {
137            e.metadata()
138                .and_then(|m| m.modified())
139                .unwrap_or(UNIX_EPOCH)
140        });
141
142        let latest = candidates.last().ok_or_else(|| {
143            CoreError::Other(
144                format!("no plugin backups for profile '{profile}' / game '{game}'").into(),
145            )
146        })?;
147
148        let data = fs::read_to_string(latest.path())?;
149        let plugins: Vec<String> = serde_json::from_str(&data)?;
150
151        Ok(plugins)
152    }
153}
154
155// ── Helpers ─────────────────────────────────────────────────────
156
157/// Produce a Unix timestamp (seconds since epoch).
158fn unix_timestamp() -> u64 {
159    SystemTime::now()
160        .duration_since(UNIX_EPOCH)
161        .unwrap_or_default()
162        .as_secs()
163}
164
165/// Create a zip archive from a directory.
166fn create_zip_from_dir(src: &Path, dest: &Path) -> Result<()> {
167    let file = fs::File::create(dest)?;
168    let mut zip = zip::ZipWriter::new(file);
169    let options = zip::write::SimpleFileOptions::default()
170        .compression_method(zip::CompressionMethod::Deflated);
171
172    let entries = walkdir(src)?;
173    for entry in &entries {
174        let rel = entry
175            .strip_prefix(src)
176            .map_err(|e| CoreError::Other(e.to_string().into()))?;
177        let rel_str = rel.to_string_lossy();
178
179        if entry.is_dir() {
180            zip.add_directory(rel_str.as_ref(), options)
181                .map_err(|e| CoreError::Other(e.to_string().into()))?;
182        } else {
183            zip.start_file(rel_str.as_ref(), options)
184                .map_err(|e| CoreError::Other(e.to_string().into()))?;
185            let mut f = fs::File::open(entry)?;
186            let mut buf = Vec::new();
187            f.read_to_end(&mut buf)?;
188            std::io::Write::write_all(&mut zip, &buf)?;
189        }
190    }
191
192    zip.finish().map_err(|e| CoreError::Other(e.to_string().into()))?;
193    Ok(())
194}
195
196/// Extract a zip archive into `dest`.
197fn extract_zip_to_dir(zip_path: &Path, dest: &Path) -> Result<()> {
198    let file = fs::File::open(zip_path)?;
199    let mut archive =
200        zip::ZipArchive::new(file).map_err(|e| CoreError::Other(e.to_string().into()))?;
201
202    for i in 0..archive.len() {
203        let mut entry = archive
204            .by_index(i)
205            .map_err(|e| CoreError::Other(e.to_string().into()))?;
206        let out_path = dest.join(entry.name());
207
208        if entry.is_dir() {
209            fs::create_dir_all(&out_path)?;
210        } else {
211            if let Some(parent) = out_path.parent() {
212                fs::create_dir_all(parent)?;
213            }
214            let mut outfile = fs::File::create(&out_path)?;
215            std::io::copy(&mut entry, &mut outfile)?;
216        }
217    }
218
219    Ok(())
220}
221
222/// Recursively list all files and directories under `root`.
223fn walkdir(root: &Path) -> Result<Vec<PathBuf>> {
224    let mut result = Vec::new();
225    walk_recursive(root, &mut result)?;
226    result.sort();
227    Ok(result)
228}
229
230fn walk_recursive(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
231    for entry in fs::read_dir(dir)? {
232        let entry = entry?;
233        let path = entry.path();
234        out.push(path.clone());
235        if path.is_dir() {
236            walk_recursive(&path, out)?;
237        }
238    }
239    Ok(())
240}