use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const BACKUP_DIR: &str = ".morph-cli/backups";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupSession {
pub id: String,
pub timestamp: u64,
pub recipe: String,
pub files: Vec<BackupEntry>,
pub status: SessionStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupEntry {
pub original_path: PathBuf,
pub backup_path: PathBuf,
pub checksum: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum SessionStatus {
InProgress,
Completed,
Failed,
RolledBack,
}
pub struct BackupManager {
backup_root: PathBuf,
}
impl BackupManager {
pub fn new(project_root: &Path) -> Result<Self> {
let backup_root = project_root.join(BACKUP_DIR);
fs::create_dir_all(&backup_root).with_context(|| {
format!(
"Failed to create backup directory: {}",
backup_root.display()
)
})?;
Ok(Self { backup_root })
}
pub fn create_session(&self, recipe: &str, files: &[PathBuf]) -> Result<BackupSession> {
let session_id = generate_session_id();
let timestamp = current_timestamp();
let session_dir = self.session_dir(&session_id);
fs::create_dir_all(&session_dir)
.with_context(|| "Failed to create session directory".to_string())?;
let mut entries = Vec::new();
for file_path in files {
if file_path.exists() && file_path.is_file() {
let backup_path = session_dir.join(
file_path
.strip_prefix(self.backup_root.parent().unwrap_or(file_path))
.unwrap_or(file_path)
.strip_prefix("/")
.unwrap_or(file_path.as_path())
.to_string_lossy()
.replace(['/', '\\'], "__"),
);
if let Some(parent) = backup_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(file_path, &backup_path)
.with_context(|| format!("Failed to backup file: {}", file_path.display()))?;
let checksum = compute_checksum(&backup_path)?;
entries.push(BackupEntry {
original_path: file_path.clone(),
backup_path: backup_path.clone(),
checksum,
});
}
}
let session = BackupSession {
id: session_id,
timestamp,
recipe: recipe.to_string(),
files: entries,
status: SessionStatus::InProgress,
};
self.save_session(&session)?;
Ok(session)
}
pub fn complete_session(&self, session: &mut BackupSession) -> Result<()> {
session.status = SessionStatus::Completed;
self.save_session(session)
}
#[allow(dead_code)]
pub fn fail_session(&self, session: &mut BackupSession) -> Result<()> {
session.status = SessionStatus::Failed;
self.save_session(session)
}
pub fn list_sessions(&self) -> Result<Vec<BackupSession>> {
let mut sessions = Vec::new();
if !self.backup_root.exists() {
return Ok(sessions);
}
for entry in fs::read_dir(&self.backup_root)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let manifest_path = path.join("manifest.json");
if manifest_path.exists()
&& let Ok(content) = fs::read_to_string(&manifest_path)
&& let Ok(session) = toml::from_str::<BackupSession>(&content)
{
sessions.push(session);
}
}
}
sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(sessions)
}
pub fn rollback(&self, session_id: &str) -> Result<RollbackResult> {
let session = self
.load_session(session_id)?
.ok_or_else(|| anyhow::anyhow!("Session not found: {}", session_id))?;
let mut restored = Vec::new();
let mut failed = Vec::new();
for entry in &session.files {
if entry.backup_path.exists() {
if let Some(parent) = entry.original_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
match fs::copy(&entry.backup_path, &entry.original_path) {
Ok(_) => {
let checksum = compute_checksum(&entry.original_path)?;
if checksum == entry.checksum {
restored.push(entry.original_path.clone());
} else {
failed.push((
entry.original_path.clone(),
"checksum mismatch after restore".to_string(),
));
}
}
Err(e) => {
failed.push((entry.original_path.clone(), e.to_string()));
}
}
} else {
failed.push((
entry.original_path.clone(),
"backup file missing".to_string(),
));
}
}
let status = if failed.is_empty() {
SessionStatus::RolledBack
} else {
SessionStatus::Failed
};
let mut updated_session = session;
updated_session.status = status;
self.save_session(&updated_session)?;
Ok(RollbackResult { restored, failed })
}
pub fn rollback_files(
&self,
session_id: &str,
files: &[PathBuf],
) -> Result<FileRollbackResult> {
let session = self
.load_session(session_id)?
.ok_or_else(|| anyhow::anyhow!("Backup session not found: {}", session_id))?;
let mut restored = Vec::new();
let mut skipped = Vec::new();
let mut missing_backups = Vec::new();
for file in files {
let Some(entry) = session.files.iter().find(|entry| entry.original_path == *file)
else {
missing_backups.push(file.clone());
continue;
};
if !entry.backup_path.exists() {
missing_backups.push(file.clone());
continue;
}
if let Some(parent) = entry.original_path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
match fs::copy(&entry.backup_path, &entry.original_path) {
Ok(_) => {
let checksum = compute_checksum(&entry.original_path)?;
if checksum == entry.checksum {
restored.push(entry.original_path.clone());
} else {
skipped.push((
entry.original_path.clone(),
"checksum mismatch after restore".to_string(),
));
}
}
Err(error) => skipped.push((entry.original_path.clone(), error.to_string())),
}
}
Ok(FileRollbackResult {
restored,
skipped,
missing_backups,
})
}
pub fn preview_rollback(&self, session_id: &str) -> Result<Option<BackupSession>> {
self.load_session(session_id)
}
fn session_dir(&self, session_id: &str) -> PathBuf {
self.backup_root.join(session_id)
}
fn save_session(&self, session: &BackupSession) -> Result<()> {
let manifest_path = self.session_dir(&session.id).join("manifest.json");
let content =
toml::to_string_pretty(session).with_context(|| "Failed to serialize session")?;
fs::write(&manifest_path, content)
.with_context(|| format!("Failed to write manifest: {}", manifest_path.display()))?;
Ok(())
}
fn load_session(&self, session_id: &str) -> Result<Option<BackupSession>> {
let manifest_path = self.session_dir(session_id).join("manifest.json");
if !manifest_path.exists() {
return Ok(None);
}
let content = fs::read_to_string(&manifest_path)?;
let session =
toml::from_str(&content).with_context(|| "Failed to parse session manifest")?;
Ok(Some(session))
}
#[allow(dead_code)]
pub fn cleanup_old_sessions(&self, keep_recent: usize) -> Result<usize> {
let mut sessions = self.list_sessions()?;
if sessions.len() <= keep_recent {
return Ok(0);
}
let to_remove = sessions.split_off(keep_recent);
let mut cleaned = 0;
for session in to_remove {
if self.remove_session(&session.id)? {
cleaned += 1;
}
}
Ok(cleaned)
}
#[allow(dead_code)]
fn remove_session(&self, session_id: &str) -> Result<bool> {
let session_dir = self.session_dir(session_id);
if session_dir.exists() {
fs::remove_dir_all(&session_dir)?;
Ok(true)
} else {
Ok(false)
}
}
}
#[derive(Debug)]
pub struct RollbackResult {
pub restored: Vec<PathBuf>,
pub failed: Vec<(PathBuf, String)>,
}
#[derive(Debug)]
pub struct FileRollbackResult {
pub restored: Vec<PathBuf>,
pub skipped: Vec<(PathBuf, String)>,
pub missing_backups: Vec<PathBuf>,
}
impl RollbackResult {
pub fn is_full_success(&self) -> bool {
self.failed.is_empty()
}
pub fn is_partial_success(&self) -> bool {
!self.restored.is_empty() && !self.failed.is_empty()
}
}
fn generate_session_id() -> String {
let timestamp = current_timestamp();
let random: u32 = rand_u32();
format!("{}_{:08x}", timestamp, random)
}
fn current_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs()
}
fn rand_u32() -> u32 {
use std::time::Instant;
let start = Instant::now();
(start.elapsed().as_nanos() as u32).wrapping_add(0x9e3779b9)
}
fn compute_checksum(path: &Path) -> Result<String> {
let content = fs::read(path)?;
let mut hash: u32 = 0x811c9dc5;
for byte in content {
hash ^= byte as u32;
hash = hash.wrapping_mul(0x01000193);
}
Ok(format!("{:08x}", hash))
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_backup_manager_creation() {
let temp_dir = env::temp_dir().join("morph_test_backup");
let _ = fs::remove_dir_all(&temp_dir);
fs::create_dir_all(&temp_dir).unwrap();
let manager = BackupManager::new(&temp_dir);
assert!(manager.is_ok());
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_compute_checksum() {
let temp_file = env::temp_dir().join("morph_checksum_test");
fs::write(&temp_file, b"test content").unwrap();
let checksum = compute_checksum(&temp_file);
assert!(checksum.is_ok());
assert_eq!(checksum.unwrap().len(), 8);
let _ = fs::remove_file(&temp_file);
}
}