morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::path::Path;

use anyhow::Result;

use chrono::{DateTime, Utc};
use colored::Colorize;

use crate::core::backup::manager::BackupManager;
use crate::core::session::{MigrationSession, SessionStore};
use crate::utils::terminal;

pub fn execute(project_root: &Path, args: &SessionsArgs) -> Result<()> {
    match args {
        SessionsArgs::List => list_sessions(project_root),
        SessionsArgs::Show { id } => show_session(project_root, id),
        SessionsArgs::Rollback {
            session_id,
            preview,
            force,
        } => rollback_session(project_root, session_id, *preview, *force),
        SessionsArgs::Replay {
            session_id,
            write,
        } => replay_session(project_root, session_id, *write),
    }
}

fn list_sessions(project_root: &Path) -> Result<()> {
    let store = SessionStore::new(project_root);
    let sessions = store.list()?;

    if sessions.is_empty() {
        println!("{}", terminal::label("No migration sessions found"));
        return Ok(());
    }

    println!();
    println!("{}", terminal::label("Migration Sessions"));
    println!("{}", "=".repeat(60));

    for session in &sessions {
        println!();
        println!("{} {}", "Session:".bold(), session.id);
        println!("  {} {}", "recipes:".cyan(), session.recipe_names.join(", "));
        println!("  {} {}", "mode:".cyan(), session.mode);
        println!("  {} {}", "modified files:".cyan(), session.modified_files.len());

        let datetime = format_timestamp(session.started_at);
        println!("  {} {}", "time:".cyan(), datetime.to_rfc3339());
    }

    Ok(())
}

fn show_session(project_root: &Path, id: &str) -> Result<()> {
    let store = SessionStore::new(project_root);
    let Some(session) = store.load(id)? else {
        anyhow::bail!("Session not found: {}", id);
    };

    print_session_detail(&session);

    if let Ok(Some(fp)) = crate::core::fingerprint::ProjectFingerprint::load(project_root) {
        let dt = DateTime::from_timestamp(fp.timestamp as i64, 0).unwrap();
        println!();
        println!("  {}", terminal::label("Project Fingerprint at Last Scan:"));
        println!("    Frameworks:   {}", fp.detected_frameworks.join(", "));
        println!("    Workspaces:   {}", if fp.workspaces.is_empty() { "none".to_string() } else { fp.workspaces.join(", ") });
        println!("    Total Files:  {}", fp.file_statistics.total_files);
        println!("    Last Scanned: {}", dt.format("%Y-%m-%d %H:%M:%S UTC"));
    }

    Ok(())
}

fn print_session_detail(session: &MigrationSession) {
    println!();
    println!("{} {}", "Session:".bold(), session.id);
    println!("{}", "=".repeat(60));
    println!("{} {}", "recipes:".cyan(), session.recipe_names.join(", "));
    println!("{} {}", "mode:".cyan(), session.mode);
    println!("{} {}", "target:".cyan(), session.target_path.display());
    println!(
        "{} {}",
        "backup:".cyan(),
        session.backup_session_id.as_deref().unwrap_or("none")
    );
    println!(
        "{} {}",
        "started:".cyan(),
        format_timestamp(session.started_at).to_rfc3339()
    );
    println!(
        "{} {}",
        "completed:".cyan(),
        format_timestamp(session.completed_at).to_rfc3339()
    );
    println!("{} {}", "modified files:".cyan(), session.modified_files.len());

    for path in &session.modified_files {
        println!("  - {}", path.display());
    }
}

fn format_timestamp(timestamp: u64) -> DateTime<Utc> {
    DateTime::from_timestamp(timestamp as i64, 0)
        .unwrap_or_else(|| DateTime::from_timestamp(0, 0).unwrap())
}

fn rollback_session(
    project_root: &Path,
    session_id: &str,
    preview: bool,
    _force: bool,
) -> Result<()> {
    let store = SessionStore::new(project_root);
    let Some(session) = store.load(session_id)? else {
        anyhow::bail!("Session not found: {}", session_id);
    };

    if preview {
        println!();
        println!(
            "{} Rollback preview for session: {}",
            "info".bold().cyan(),
            session.id
        );
        print_session_detail(&session);
        return Ok(());
    }

    let backup_session_id = resolve_backup_session_id(project_root, &session)?;
    println!("Rolling back session: {}", session.id);

    let Some(backup_session_id) = backup_session_id else {
        print_rollback_summary(&[], &[], &session.modified_files);
        return Ok(());
    };

    let result = BackupManager::new(project_root)?
        .rollback_files(&backup_session_id, &session.modified_files)?;
    print_rollback_summary(&result.restored, &result.skipped, &result.missing_backups);

    Ok(())
}

fn resolve_backup_session_id(
    project_root: &Path,
    session: &MigrationSession,
) -> Result<Option<String>> {
    if session.mode != "write" {
        return Ok(None);
    }

    if let Some(id) = &session.backup_session_id {
        return Ok(Some(id.clone()));
    }

    let expected_recipe = if session.recipe_names.len() == 1 {
        session.recipe_names[0].as_str()
    } else {
        "pipeline"
    };

    Ok(BackupManager::new(project_root)?
        .list_sessions()?
        .into_iter()
        .filter(|backup| backup.recipe == expected_recipe)
        .filter(|backup| {
            backup.timestamp >= session.started_at && backup.timestamp <= session.completed_at
        })
        .max_by_key(|backup| backup.timestamp)
        .map(|backup| backup.id))
}

fn replay_session(project_root: &Path, session_id: &str, write_override: bool) -> Result<()> {
    let store = SessionStore::new(project_root);
    let Some(session) = store.load(session_id)? else {
        anyhow::bail!("Session not found: {}", session_id);
    };

    println!();
    println!("{} Replaying session: {}", terminal::label("info"), session.id);
    println!("Original Recipes: {}", session.recipe_names.join(", "));

    // 1. Changed Repository Fingerprints Warning
    if let Ok(Some(recorded_fp)) = crate::core::fingerprint::ProjectFingerprint::load(project_root) {
        if let Ok(current_fp) = crate::core::fingerprint::ProjectFingerprint::generate(project_root) {
            if let Err(warning) = recorded_fp.compatibility_check(&current_fp) {
                println!();
                println!(
                    "{} {} Replaying on a significantly different repository state:",
                    terminal::warning_prefix(),
                    "UNSAFE REPLAY WARNING:".bold().yellow()
                );
                println!("  {}", warning.yellow());
                println!("  {} Replaying on an altered repository layout can cause conflicting AST transforms or duplicate imports.", "Reason:".dimmed());
                println!();
            }
        }
    }

    // 2. Partially Completed Session Check
    let checkpoint_store = crate::core::session::CheckpointStore::new(project_root);
    let checkpoints = checkpoint_store.list().unwrap_or_default();
    let has_checkpoint = checkpoints.iter().any(|c| c.session_id == session.id);
    let is_partial = session.completed_at == 0 || session.completed_at == session.started_at || has_checkpoint;

    if is_partial {
        println!(
            "{} {} Partially Completed Session Detected:",
            terminal::warning_prefix(),
            "UNSAFE RECOVERY WARNING:".bold().yellow()
        );
        println!("  Session was interrupted, cancelled, or failed to complete fully.");
        println!("  {} Replaying or recovering from an incomplete pipeline could apply transforms onto unstable intermediate files.", "Reason:".dimmed());
        if has_checkpoint {
            println!("  {} A checkpoint exists with remaining recipes. Replaying may conflict with current files.", "Hint:".dimmed());
        }
        println!();
    }

    // 3. Missing or Renamed Files / Target Path Check
    let mut missing_files = Vec::new();
    let mut renamed_files = Vec::new();

    let mut target_path = session.target_path.clone();
    let target_exists = target_path.exists();
    if !target_exists {
        if let Some(similar) = crate::commands::run::find_similar_file(project_root, &target_path) {
            println!(
                "{} {} Original target path '{}' is missing, but a matching path was found at '{}'.",
                terminal::warning_prefix(),
                "[RENAMED TARGET]".yellow().bold(),
                target_path.display(),
                similar.display()
            );
            println!("  └─ {} Automatically adapting target path to the resolved location.", "Action:".dimmed());
            target_path = similar;
        } else {
            println!(
                "{} {} Original target path '{}' is completely missing and could not be resolved.",
                terminal::warning_prefix(),
                "[MISSING TARGET]".red().bold(),
                target_path.display()
            );
            println!("  └─ {} Replay execution might fail or run on an empty path scope.", "Reason:".dimmed());
        }
    }

    for file in &session.modified_files {
        if !file.exists() {
            if let Some(similar) = crate::commands::run::find_similar_file(project_root, file) {
                renamed_files.push((file.clone(), similar));
            } else {
                missing_files.push(file.clone());
            }
        }
    }

    if !missing_files.is_empty() || !renamed_files.is_empty() {
        println!(
            "{} {} File structure divergence detected since the session run:",
            terminal::warning_prefix(),
            "WARNING:".bold().yellow()
        );
        for file in &missing_files {
            println!("  {} Original file '{}' is missing and could not be resolved.", "[MISSING]".red().bold(), file.display());
        }
        for (old_file, new_file) in &renamed_files {
            let rel_new = new_file.strip_prefix(project_root).unwrap_or(new_file);
            println!("  {} Original file '{}' is missing, but a matching file was found at '{}'.", "[RENAMED]".yellow().bold(), old_file.display(), rel_new.display());
            println!("    └─ {} Verify and target the new file path in your manual execution.", "Hint:".dimmed());
        }
        println!("  {} Unresolved missing files will be skipped during this replay run.", "Reason:".dimmed());
        println!();
    }

    let options = &session.options;
    let write = write_override || options.write;
    let dry_run = !write;

    crate::commands::run::execute(
        &session.recipe_names,
        &target_path,
        dry_run,
        write,
        options.review,
        options.autofix,
        false, // verbose
        false, // summary_only
        None,  // max_preview_lines
        options.allow_risky,
        options.strict,
        false, // report_json
        false, // report_md
        Path::new(".morph-cli/reports"),
        options.format,
        options.prettier,
        options.no_format,
        options.jobs,
        options.sequential,
        project_root,
        None, // package
        None, // profile
        None, // output_style
        None, // tag
    )
}

fn print_rollback_summary(
    restored: &[std::path::PathBuf],
    skipped: &[(std::path::PathBuf, String)],
    missing_backups: &[std::path::PathBuf],
) {
    println!();
    println!("{}", terminal::label("Rollback Summary"));
    println!("  restored files: {}", restored.len());
    println!("  skipped files: {}", skipped.len());
    println!("  missing backups: {}", missing_backups.len());

    for path in restored {
        println!("  {} restored {}", terminal::success_prefix(), path.display());
    }

    for (path, reason) in skipped {
        println!(
            "  {} skipped {} ({})",
            terminal::muted_prefix(),
            path.display(),
            reason
        );
    }

    for path in missing_backups {
        println!(
            "  {} missing backup {}",
            terminal::warning_prefix(),
            path.display()
        );
    }
}

#[derive(Debug)]
pub enum SessionsArgs {
    List,
    Show { id: String },
    Rollback {
        session_id: String,
        preview: bool,
        force: bool,
    },
    Replay {
        session_id: String,
        write: bool,
    },
}