morph-cli 0.1.0

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

use super::preview::{
    ChangedFile, DiffHunk, DiffLine, FilePreview, LineType, PreviewConfig, TransformationReport,
};

#[allow(dead_code)]
const CONTEXT_LINES: usize = 3;

pub struct DiffRenderer {
    config: PreviewConfig,
}

impl DiffRenderer {
    pub fn new(config: PreviewConfig) -> Self {
        Self { config }
    }

    pub fn render_file_preview(&self, preview: &FilePreview) {
        println!();
        self.render_file_header(&preview.path);
        println!();

        if preview.is_binary {
            self.render_binary_notice();
            return;
        }

        if preview.was_truncated {
            self.render_truncation_notice(preview.line_count);
        }

        if self.config.summary_only {
            return;
        }

        self.render_hunks(&preview.hunks);
    }

    fn render_file_header(&self, path: &Path) {
        let display = path.display().to_string();
        println!("{} {}", "diff".bold().cyan(), display.bold());
    }

    fn render_binary_notice(&self) {
        println!("  {} binary file - skipped", "skip".bold().dimmed());
    }

    fn render_truncation_notice(&self, line_count: usize) {
        let max_display = self.config.max_lines * 2;
        println!(
            "  {} output truncated from {} lines",
            "note".bold().yellow(),
            line_count
        );
        println!(
            "  {} use --max-preview-lines={} to adjust",
            "hint".bold().dimmed(),
            max_display.saturating_add(100)
        );
    }

    fn render_hunks(&self, hunks: &[DiffHunk]) {
        for hunk in hunks {
            self.render_hunk(hunk);
        }
    }

    fn render_hunk(&self, hunk: &DiffHunk) {
        println!(
            "@@ -{},{} +{},{} @@",
            hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count
        );

        let visible_lines: Vec<&DiffLine> = hunk.lines.iter().take(self.config.max_lines).collect();
        let mut has_more = false;

        if self.config.max_lines > 0 && hunk.lines.len() > self.config.max_lines {
            has_more = true;
        }

        for line in visible_lines {
            self.render_line(line);
        }

        if has_more {
            println!(
                "  {} {} more lines",
                "note".bold().yellow(),
                hunk.lines.len() - self.config.max_lines
            );
        }
    }

    fn render_line(&self, line: &DiffLine) {
        let content = &line.content;
        match line.line_type {
            LineType::Addition => {
                println!("{}", content.green());
            }
            LineType::Deletion => {
                println!("{}", content.red());
            }
            LineType::Context => {
                println!("{}", content.normal());
            }
            LineType::Header => {
                println!("{}", content.cyan().bold());
            }
        }
    }

    pub fn render_changed_file(&self, file: &ChangedFile) {
        if let Some(preview) = &file.preview {
            self.render_file_preview(preview);
        } else {
            println!();
            println!("{} {}", "changed".bold().green(), file.path.display());
        }
    }

    #[allow(dead_code)]
    pub fn render_changed_list(&self, report: &TransformationReport) {
        for file in &report.changed_files {
            if self.config.verbose || self.config.summary_only {
                println!(
                    "{} {} (+{} -{})",
                    terminal::success_prefix(),
                    file.path.display(),
                    file.lines_added,
                    file.lines_removed
                );
            }
        }
    }

    pub fn render_skipped_file(&self, path: &Path, reason: &str) {
        println!(
            "  {} {} ({})",
            "skip".bold().dimmed(),
            path.display(),
            reason
        );
    }

    pub fn render_report(&self, report: &TransformationReport) {
        println!();
        println!("{}", "=".repeat(60).cyan());
        println!("{}", terminal::label("Transformation Report"));
        println!("{}", "=".repeat(60).cyan());

        if !report.changed_files.is_empty() {
            println!();
            println!("{}", "Changed Files:".bold().green());
            for file in &report.changed_files {
                self.render_changed_file(file);
            }
        }

        if !report.skipped_files.is_empty() {
            println!();
            println!("{}", "⚡ Skipped Files (Grouped by Reason):".bold().yellow());
            let mut grouped_skipped: std::collections::HashMap<String, Vec<&super::preview::SkippedFile>> = std::collections::HashMap::new();
            for skipped in &report.skipped_files {
                grouped_skipped.entry(skipped.reason.to_string()).or_default().push(skipped);
            }
            for (reason, files) in grouped_skipped {
                println!("  └─ {} ({} files):", reason.bold().cyan(), files.len());
                for f in files.iter().take(5) {
                    println!("{}", f.path.display());
                }
                if files.len() > 5 {
                    println!("     • ... and {} more files", files.len() - 5);
                }
            }
        }

        println!();
        println!("{}", terminal::label("Statistics"));
        println!("  total changed: {}", report.total_changed());
        println!("  lines added:   {}", report.total_lines_added());
        println!("  lines removed: {}", report.total_lines_removed());
        println!("  files skipped:  {}", report.total_files_skipped());
        println!("  execution:     {}ms", report.execution_time_ms);
    }
}

mod terminal {
    use colored::Colorize;

    pub fn label(text: &str) -> colored::ColoredString {
        text.bold().cyan()
    }

    #[allow(dead_code)]
    pub fn success_prefix() -> colored::ColoredString {
        "done".bold().green()
    }
}