morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use std::path::{Path, PathBuf};
use std::time::Instant;

use anyhow::Result;

use crate::utils::terminal;

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct FilePreview {
    pub path: PathBuf,
    pub original_content: String,
    pub transformed_content: String,
    pub hunks: Vec<DiffHunk>,
    pub is_binary: bool,
    pub was_truncated: bool,
    pub line_count: usize,
}

#[derive(Debug, Clone)]
pub struct DiffHunk {
    pub old_start: usize,
    pub old_count: usize,
    pub new_start: usize,
    pub new_count: usize,
    pub lines: Vec<DiffLine>,
}

#[derive(Debug, Clone)]
pub struct DiffLine {
    pub content: String,
    pub line_type: LineType,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum LineType {
    Context,
    Addition,
    Deletion,
    Header,
}

#[derive(Debug, Clone)]
pub struct TransformationReport {
    pub changed_files: Vec<ChangedFile>,
    pub skipped_files: Vec<SkippedFile>,
    pub execution_time_ms: u64,
    pub start_time: Instant,
}

impl TransformationReport {
    pub fn new() -> Self {
        Self {
            changed_files: Vec::new(),
            skipped_files: Vec::new(),
            execution_time_ms: 0,
            start_time: Instant::now(),
        }
    }

    pub fn finish(&mut self) {
        self.execution_time_ms = self.start_time.elapsed().as_millis() as u64;
    }

    pub fn total_changed(&self) -> usize {
        self.changed_files.len()
    }

    pub fn total_lines_added(&self) -> usize {
        self.changed_files.iter().map(|f| f.lines_added).sum()
    }

    pub fn total_lines_removed(&self) -> usize {
        self.changed_files.iter().map(|f| f.lines_removed).sum()
    }

    pub fn total_files_skipped(&self) -> usize {
        self.skipped_files.len()
    }
}

#[derive(Debug, Clone)]
pub struct ChangedFile {
    pub path: PathBuf,
    pub lines_added: usize,
    pub lines_removed: usize,
    pub preview: Option<FilePreview>,
}

#[derive(Debug, Clone)]
pub struct SkippedFile {
    pub path: PathBuf,
    pub reason: SkipReason,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum SkipReason {
    Binary,
    Empty,
    UnsupportedEncoding,
    ExceedsMaxSize,
    NoChanges,
}

impl std::fmt::Display for SkipReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            SkipReason::Binary => write!(f, "binary file"),
            SkipReason::Empty => write!(f, "empty file"),
            SkipReason::UnsupportedEncoding => write!(f, "unsupported encoding"),
            SkipReason::ExceedsMaxSize => write!(f, "exceeds max size"),
            SkipReason::NoChanges => write!(f, "no changes detected"),
        }
    }
}

#[derive(Debug, Clone, Copy)]
#[allow(dead_code)]
pub struct PreviewConfig {
    pub max_lines: usize,
    pub show_line_numbers: bool,
    pub summary_only: bool,
    pub verbose: bool,
}

impl PreviewConfig {
    #[allow(dead_code)]
    pub fn new(max_lines: usize) -> Self {
        Self {
            max_lines,
            show_line_numbers: true,
            summary_only: false,
            verbose: false,
        }
    }
}

#[allow(dead_code)]
pub fn create_preview(
    path: &Path,
    original: &str,
    transformed: &str,
    config: PreviewConfig,
) -> Result<FilePreview> {
    let is_binary = contains_binary_content(original);
    let mut was_truncated = false;

    let original_lines: Vec<&str> = if config.max_lines > 0 {
        original.lines().take(config.max_lines * 2).collect()
    } else {
        original.lines().collect()
    };

    let line_count = original_lines.len();
    if config.max_lines > 0 && line_count > config.max_lines * 2 {
        was_truncated = true;
    }

    let hunks = if !is_binary && !original.is_empty() && !transformed.is_empty() {
        compute_diff_hunks(
            original_lines,
            transformed.lines().collect::<Vec<_>>(),
            &config,
        )
    } else {
        Vec::new()
    };

    Ok(FilePreview {
        path: path.to_path_buf(),
        original_content: original.to_string(),
        transformed_content: transformed.to_string(),
        hunks,
        is_binary,
        was_truncated,
        line_count,
    })
}

#[allow(dead_code)]
fn compute_diff_hunks(
    old_lines: Vec<&str>,
    new_lines: Vec<&str>,
    config: &PreviewConfig,
) -> Vec<DiffHunk> {
    let mut hunks = Vec::new();
    let max_lines = config.max_lines.saturating_add(1);

    let old_max = if max_lines > 0 && old_lines.len() > max_lines {
        max_lines
    } else {
        old_lines.len()
    };

    let new_max = if max_lines > 0 && new_lines.len() > max_lines {
        max_lines
    } else {
        new_lines.len()
    };

    for i in 0..old_max {
        if old_max == old_lines.len() && i < new_max && old_lines[i] != new_lines[i] {
            let hunk = DiffHunk {
                old_start: i + 1,
                old_count: 1,
                new_start: i + 1,
                new_count: 1,
                lines: vec![
                    DiffLine {
                        content: format!("- {}", old_lines[i]),
                        line_type: LineType::Deletion,
                    },
                    DiffLine {
                        content: format!("+ {}", new_lines[i]),
                        line_type: LineType::Addition,
                    },
                ],
            };
            hunks.push(hunk);
            break;
        }
    }

    hunks
}

#[allow(dead_code)]
fn contains_binary_content(content: &str) -> bool {
    for byte in content.bytes() {
        if byte == 0 {
            return true;
        }
    }
    false
}

#[allow(dead_code)]
pub fn is_valid_utf8(content: &str) -> bool {
    content.is_empty() || std::str::from_utf8(content.as_bytes()).is_ok()
}

#[allow(dead_code)]
pub fn summarize_report(report: &TransformationReport) {
    println!();
    println!("{}", terminal::label("Transformation Summary"));
    println!("  changed files: {}", report.total_changed());
    println!("  lines added:   {}", report.total_lines_added());
    println!("  lines removed: {}", report.total_lines_removed());
    println!("  skipped files:  {}", report.total_files_skipped());
    println!("  execution time: {}ms", report.execution_time_ms);
    println!();
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReviewAction {
    Apply,
    Skip,
    Abort,
}

pub fn prompt_review(
    path: &Path,
    original: &str,
    transformed: &str,
    renderer: &crate::core::diff::renderer::DiffRenderer,
) -> Result<ReviewAction> {
    let preview = create_preview(
        path,
        original,
        transformed,
        crate::core::diff::preview::PreviewConfig {
            max_lines: 100,
            show_line_numbers: true,
            summary_only: false,
            verbose: false,
        },
    )?;

    renderer.render_file_preview(&preview);

    loop {
        use std::io::Write;
        use colored::Colorize;
        println!();
        println!("  ┌──────────────────────────────────────────────────────────┐");
        println!("  │  🔍 {} {:<39} │", "Reviewing file:".dimmed(), path.display().to_string().bold().cyan());
        println!("  ├──────────────────────────────────────────────────────────┤");
        println!("{} [y]  {:<39} │", "".green(), "Apply and write changes to disk safely".bold().green());
        println!("{} [n]  {:<39} │", "".yellow(), "Skip changes for this file cleanly".bold().yellow());
        println!("{} [q]  {:<39} │", "".red(), "Abort the entire execution session safely".bold().red());
        println!("  └──────────────────────────────────────────────────────────┘");
        print!("👉 Choose an action (y/n/q): ");
        std::io::stdout().flush()?;
        let mut input = String::new();
        std::io::stdin().read_line(&mut input)?;
        let ans = input.trim().to_lowercase();
        match ans.as_str() {
            "y" | "yes" | "apply" => return Ok(ReviewAction::Apply),
            "n" | "no" | "skip" => return Ok(ReviewAction::Skip),
            "q" | "quit" | "abort" => return Ok(ReviewAction::Abort),
            _ => println!("⚠️  {}", "Invalid choice. Please enter 'y', 'n', or 'q'.".red().bold()),
        }
    }
}