1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8const BACKUP_DIR: &str = ".morph-cli/backups";
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct BackupSession {
12 pub id: String,
13 pub timestamp: u64,
14 pub recipe: String,
15 pub files: Vec<BackupEntry>,
16 pub status: SessionStatus,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct BackupEntry {
21 pub original_path: PathBuf,
22 pub backup_path: PathBuf,
23 pub checksum: String,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27pub enum SessionStatus {
28 InProgress,
29 Completed,
30 Failed,
31 RolledBack,
32}
33
34pub struct BackupManager {
35 backup_root: PathBuf,
36}
37
38impl BackupManager {
39 pub fn new(project_root: &Path) -> Result<Self> {
40 let backup_root = project_root.join(BACKUP_DIR);
41 fs::create_dir_all(&backup_root).with_context(|| {
42 format!(
43 "Failed to create backup directory: {}",
44 backup_root.display()
45 )
46 })?;
47 Ok(Self { backup_root })
48 }
49
50 pub fn create_session(&self, recipe: &str, files: &[PathBuf]) -> Result<BackupSession> {
51 let session_id = generate_session_id();
52 let timestamp = current_timestamp();
53 let session_dir = self.session_dir(&session_id);
54
55 fs::create_dir_all(&session_dir)
56 .with_context(|| "Failed to create session directory".to_string())?;
57
58 let mut entries = Vec::new();
59
60 for file_path in files {
61 if file_path.exists() && file_path.is_file() {
62 let backup_path = session_dir.join(
63 file_path
64 .strip_prefix(self.backup_root.parent().unwrap_or(file_path))
65 .unwrap_or(file_path)
66 .strip_prefix("/")
67 .unwrap_or(file_path.as_path())
68 .to_string_lossy()
69 .replace(['/', '\\'], "__"),
70 );
71
72 if let Some(parent) = backup_path.parent() {
73 fs::create_dir_all(parent)?;
74 }
75
76 fs::copy(file_path, &backup_path)
77 .with_context(|| format!("Failed to backup file: {}", file_path.display()))?;
78
79 let checksum = compute_checksum(&backup_path)?;
80
81 entries.push(BackupEntry {
82 original_path: file_path.clone(),
83 backup_path: backup_path.clone(),
84 checksum,
85 });
86 }
87 }
88
89 let session = BackupSession {
90 id: session_id,
91 timestamp,
92 recipe: recipe.to_string(),
93 files: entries,
94 status: SessionStatus::InProgress,
95 };
96
97 self.save_session(&session)?;
98 Ok(session)
99 }
100
101 pub fn complete_session(&self, session: &mut BackupSession) -> Result<()> {
102 session.status = SessionStatus::Completed;
103 self.save_session(session)
104 }
105
106 #[allow(dead_code)]
107 pub fn fail_session(&self, session: &mut BackupSession) -> Result<()> {
108 session.status = SessionStatus::Failed;
109 self.save_session(session)
110 }
111
112 pub fn list_sessions(&self) -> Result<Vec<BackupSession>> {
113 let mut sessions = Vec::new();
114
115 if !self.backup_root.exists() {
116 return Ok(sessions);
117 }
118
119 for entry in fs::read_dir(&self.backup_root)? {
120 let entry = entry?;
121 let path = entry.path();
122
123 if path.is_dir() {
124 let manifest_path = path.join("manifest.json");
125 if manifest_path.exists()
126 && let Ok(content) = fs::read_to_string(&manifest_path)
127 && let Ok(session) = toml::from_str::<BackupSession>(&content)
128 {
129 sessions.push(session);
130 }
131 }
132 }
133
134 sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
135 Ok(sessions)
136 }
137
138 pub fn rollback(&self, session_id: &str) -> Result<RollbackResult> {
139 let session = self
140 .load_session(session_id)?
141 .ok_or_else(|| anyhow::anyhow!("Session not found: {}", session_id))?;
142
143 let mut restored = Vec::new();
144 let mut failed = Vec::new();
145
146 for entry in &session.files {
147 if entry.backup_path.exists() {
148 if let Some(parent) = entry.original_path.parent()
149 && !parent.exists()
150 {
151 fs::create_dir_all(parent)?;
152 }
153
154 match fs::copy(&entry.backup_path, &entry.original_path) {
155 Ok(_) => {
156 let checksum = compute_checksum(&entry.original_path)?;
157 if checksum == entry.checksum {
158 restored.push(entry.original_path.clone());
159 } else {
160 failed.push((
161 entry.original_path.clone(),
162 "checksum mismatch after restore".to_string(),
163 ));
164 }
165 }
166 Err(e) => {
167 failed.push((entry.original_path.clone(), e.to_string()));
168 }
169 }
170 } else {
171 failed.push((
172 entry.original_path.clone(),
173 "backup file missing".to_string(),
174 ));
175 }
176 }
177
178 let status = if failed.is_empty() {
179 SessionStatus::RolledBack
180 } else {
181 SessionStatus::Failed
182 };
183
184 let mut updated_session = session;
185 updated_session.status = status;
186 self.save_session(&updated_session)?;
187
188 Ok(RollbackResult { restored, failed })
189 }
190
191 pub fn rollback_files(
192 &self,
193 session_id: &str,
194 files: &[PathBuf],
195 ) -> Result<FileRollbackResult> {
196 let session = self
197 .load_session(session_id)?
198 .ok_or_else(|| anyhow::anyhow!("Backup session not found: {}", session_id))?;
199
200 let mut restored = Vec::new();
201 let mut skipped = Vec::new();
202 let mut missing_backups = Vec::new();
203
204 for file in files {
205 let Some(entry) = session.files.iter().find(|entry| entry.original_path == *file)
206 else {
207 missing_backups.push(file.clone());
208 continue;
209 };
210
211 if !entry.backup_path.exists() {
212 missing_backups.push(file.clone());
213 continue;
214 }
215
216 if let Some(parent) = entry.original_path.parent()
217 && !parent.exists()
218 {
219 fs::create_dir_all(parent)?;
220 }
221
222 match fs::copy(&entry.backup_path, &entry.original_path) {
223 Ok(_) => {
224 let checksum = compute_checksum(&entry.original_path)?;
225 if checksum == entry.checksum {
226 restored.push(entry.original_path.clone());
227 } else {
228 skipped.push((
229 entry.original_path.clone(),
230 "checksum mismatch after restore".to_string(),
231 ));
232 }
233 }
234 Err(error) => skipped.push((entry.original_path.clone(), error.to_string())),
235 }
236 }
237
238 Ok(FileRollbackResult {
239 restored,
240 skipped,
241 missing_backups,
242 })
243 }
244
245 pub fn preview_rollback(&self, session_id: &str) -> Result<Option<BackupSession>> {
246 self.load_session(session_id)
247 }
248
249 fn session_dir(&self, session_id: &str) -> PathBuf {
250 self.backup_root.join(session_id)
251 }
252
253 fn save_session(&self, session: &BackupSession) -> Result<()> {
254 let manifest_path = self.session_dir(&session.id).join("manifest.json");
255 let content =
256 toml::to_string_pretty(session).with_context(|| "Failed to serialize session")?;
257 fs::write(&manifest_path, content)
258 .with_context(|| format!("Failed to write manifest: {}", manifest_path.display()))?;
259 Ok(())
260 }
261
262 fn load_session(&self, session_id: &str) -> Result<Option<BackupSession>> {
263 let manifest_path = self.session_dir(session_id).join("manifest.json");
264 if !manifest_path.exists() {
265 return Ok(None);
266 }
267 let content = fs::read_to_string(&manifest_path)?;
268 let session =
269 toml::from_str(&content).with_context(|| "Failed to parse session manifest")?;
270 Ok(Some(session))
271 }
272
273 #[allow(dead_code)]
274 pub fn cleanup_old_sessions(&self, keep_recent: usize) -> Result<usize> {
275 let mut sessions = self.list_sessions()?;
276 if sessions.len() <= keep_recent {
277 return Ok(0);
278 }
279
280 let to_remove = sessions.split_off(keep_recent);
281 let mut cleaned = 0;
282
283 for session in to_remove {
284 if self.remove_session(&session.id)? {
285 cleaned += 1;
286 }
287 }
288
289 Ok(cleaned)
290 }
291
292 #[allow(dead_code)]
293 fn remove_session(&self, session_id: &str) -> Result<bool> {
294 let session_dir = self.session_dir(session_id);
295 if session_dir.exists() {
296 fs::remove_dir_all(&session_dir)?;
297 Ok(true)
298 } else {
299 Ok(false)
300 }
301 }
302}
303
304#[derive(Debug)]
305pub struct RollbackResult {
306 pub restored: Vec<PathBuf>,
307 pub failed: Vec<(PathBuf, String)>,
308}
309
310#[derive(Debug)]
311pub struct FileRollbackResult {
312 pub restored: Vec<PathBuf>,
313 pub skipped: Vec<(PathBuf, String)>,
314 pub missing_backups: Vec<PathBuf>,
315}
316
317impl RollbackResult {
318 pub fn is_full_success(&self) -> bool {
319 self.failed.is_empty()
320 }
321
322 pub fn is_partial_success(&self) -> bool {
323 !self.restored.is_empty() && !self.failed.is_empty()
324 }
325}
326
327fn generate_session_id() -> String {
328 let timestamp = current_timestamp();
329 let random: u32 = rand_u32();
330 format!("{}_{:08x}", timestamp, random)
331}
332
333fn current_timestamp() -> u64 {
334 SystemTime::now()
335 .duration_since(UNIX_EPOCH)
336 .unwrap()
337 .as_secs()
338}
339
340fn rand_u32() -> u32 {
341 use std::time::Instant;
342 let start = Instant::now();
343 (start.elapsed().as_nanos() as u32).wrapping_add(0x9e3779b9)
344}
345
346fn compute_checksum(path: &Path) -> Result<String> {
347 let content = fs::read(path)?;
348 let mut hash: u32 = 0x811c9dc5;
349 for byte in content {
350 hash ^= byte as u32;
351 hash = hash.wrapping_mul(0x01000193);
352 }
353 Ok(format!("{:08x}", hash))
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359 use std::env;
360
361 #[test]
362 fn test_backup_manager_creation() {
363 let temp_dir = env::temp_dir().join("morph_test_backup");
364 let _ = fs::remove_dir_all(&temp_dir);
365 fs::create_dir_all(&temp_dir).unwrap();
366
367 let manager = BackupManager::new(&temp_dir);
368 assert!(manager.is_ok());
369
370 let _ = fs::remove_dir_all(&temp_dir);
371 }
372
373 #[test]
374 fn test_compute_checksum() {
375 let temp_file = env::temp_dir().join("morph_checksum_test");
376 fs::write(&temp_file, b"test content").unwrap();
377
378 let checksum = compute_checksum(&temp_file);
379 assert!(checksum.is_ok());
380 assert_eq!(checksum.unwrap().len(), 8);
381
382 let _ = fs::remove_file(&temp_file);
383 }
384}