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
14pub struct BackupManager {
18 backup_dir: PathBuf,
19}
20
21#[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 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 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 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 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 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 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
199fn unix_timestamp() -> u64 {
203 SystemTime::now()
204 .duration_since(UNIX_EPOCH)
205 .unwrap_or_default()
206 .as_secs()
207}
208
209fn 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
241fn 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
267fn 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}