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
11pub struct BackupManager {
15 backup_dir: PathBuf,
16}
17
18#[derive(Debug, Clone)]
20pub struct BackupEntry {
21 pub name: String,
22 pub path: PathBuf,
23 pub created: SystemTime,
24}
25
26impl BackupManager {
27 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 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 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 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 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 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
155fn unix_timestamp() -> u64 {
159 SystemTime::now()
160 .duration_since(UNIX_EPOCH)
161 .unwrap_or_default()
162 .as_secs()
163}
164
165fn 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
196fn 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
222fn 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}