morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
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);
    }
}