Skip to main content

morph_cli/core/backup/
manager.rs

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}