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 colored::Colorize;

use crate::core::detection::scanner::Scanner;
use crate::core::planner::{Plan, build_plan};
use crate::core::registry::RecipeRegistry;
use crate::utils::terminal;

pub fn execute(path: &Path, tag: Option<&str>) -> Result<()> {
    let path = if path.exists() && path.is_relative() {
        std::env::current_dir()?.join(path)
    } else {
        path.to_path_buf()
    };

    println!("Planning migrations for {}...", path.display());

    let mut scanner = Scanner::new(path);
    let scan = scanner.scan();
    let mut registry = RecipeRegistry::new();
    registry.load_plugins(&scan.root);
    let plan = build_plan(&scan, &registry);

    render_plan(&plan, tag, &scan, &registry);

    Ok(())
}

fn render_plan(
    plan: &Plan,
    tag_filter: Option<&str>,
    scan: &crate::core::detection::scanner::ScanResult,
    registry: &RecipeRegistry,
) {
    if scan.scanned_files.is_empty() {
        println!();
        println!("{}", "✨ Welcome to Morph CLI! ✨".bold().cyan());
        println!("{}", "".repeat(60).cyan());
        println!("It looks like you've initialized morph-cli in an empty project or a directory with no supported files.");
        println!();
        println!("{}", "💡 Quick Onboarding Guide:".bold().yellow());
        println!("  1. Place some Javascript or TypeScript files in this directory.");
        println!("  2. Run `morph init` to generate a `morph-cli.toml` config file.");
        println!("  3. Run `morph list` to see all available recipes.");
        println!();
        println!("{}", "🚀 Beginner-Safe Recommendations:".bold().green());
        println!("  - To migrate CJS require statements to modern ESM imports:");
        println!("    {}", "morph run commonjs-to-esm . --dry-run".bold().cyan());
        println!("  - To upgrade JavaScript files to TypeScript:");
        println!("    {}", "morph run js-to-ts . --dry-run".bold().cyan());
        println!("  - To preview a preset workflow impact:");
        println!("    {}", "morph preset run modern-js .".bold().cyan());
        println!();
        println!("{}", "👉 Next-Step Hints:".bold().magenta());
        println!("  Run `morph magic` to start a guided, step-by-step interactive assistant!");
        println!("{}", "".repeat(60).cyan());
        println!();
        return;
    }

    println!();
    println!("{}", terminal::label("Project tag summary"));
    let mut tag_counts = std::collections::BTreeMap::new();
    for f in &scan.scanned_files {
        for t in &f.tags {
            *tag_counts.entry(t.clone()).or_insert(0) += 1;
        }
    }
    for (t, count) in &tag_counts {
        println!("  - {}: {} file(s)", t, count);
    }

    println!();
    println!("{}", terminal::label("Recommended recipes"));

    if plan.recommendations.is_empty() {
        println!("  No automatic migration recommendations generated for this directory.");
        println!();
        println!("{}", "🚀 Beginner-Safe Recipes to explore manually:".bold().green());
        println!("  - To migrate CJS require statements to modern ESM imports:");
        println!("    {}", "morph run commonjs-to-esm . --dry-run --review".bold().cyan());
        println!("  - To upgrade JavaScript files to TypeScript safely:");
        println!("    {}", "morph run js-to-ts . --dry-run --review".bold().cyan());
        println!();
        println!("{}", "💡 Suggested Commands:".bold().yellow());
        println!("  - Run `morph list` to inspect all built-in recipes.");
        println!("  - Run `morph doctor` to check your setup and environment health.");
        println!();
        println!("{}", "👉 Next-Step Hints:".bold().magenta());
        println!("  - Run `morph magic` to launch our guided interactive migration assistant!");
        return;
    }

    for recommendation in &plan.recommendations {
        let maturity_colored = match recommendation.maturity.to_lowercase().as_str() {
            "stable" => recommendation.maturity.green().bold(),
            "beta" => recommendation.maturity.yellow().bold(),
            _ => recommendation.maturity.red().bold(),
        };

        let safety_colored = match recommendation.safety_level.as_str() {
            "High" => "High 🟢".green().bold(),
            "Medium" => "Medium 🟡".yellow().bold(),
            _ => "Low 🔴".red().bold(),
        };

        let usefulness_colored = match recommendation.usefulness.as_str() {
            "High" => "High 🔥".green().bold(),
            "Medium" => "Medium ⚡".yellow().bold(),
            _ => "Low ❄️".red().bold(),
        };

        println!();
        println!("  ┌──────────────────────────────────────────────────────────────────────────────┐");
        println!(
            "  │  🚀 {:<30} [{:<18}] (confidence: {:>3}%) │",
            recommendation.recipe.cyan().bold(),
            maturity_colored,
            recommendation.confidence
        );
        println!("  ├──────────────────────────────────────────────────────────────────────────────┤");
        println!("  │  {:<15} {:<58} │", "Category:".dimmed(), recommendation.category.magenta().bold());
        
        // Clean word wrapping of explainable reasons inside the 78-character wide card
        let max_len = 54;
        let mut words = recommendation.reason.split_whitespace();
        let mut line = String::new();
        let mut is_first = true;
        
        while let Some(word) = words.next() {
            if line.len() + word.len() + 1 > max_len {
                if is_first {
                    println!("  │  {:<15} {:<58} │", "Why Suggested:".dimmed(), line);
                    is_first = false;
                } else {
                    println!("  │  {:<15} {:<58} │", "".dimmed(), line);
                }
                line = word.to_string();
            } else {
                if !line.is_empty() {
                    line.push(' ');
                }
                line.push_str(word);
            }
        }
        if !line.is_empty() {
            if is_first {
                println!("  │  {:<15} {:<58} │", "Why Suggested:".dimmed(), line);
            } else {
                println!("  │  {:<15} {:<58} │", "".dimmed(), line);
            }
        }

        println!("  │  {:<15} {:<58} │", "Safety Level:".dimmed(), safety_colored);
        println!("  │  {:<15} {:<58} │", "Usefulness:".dimmed(), usefulness_colored);

        let mut meta_tags = Vec::new();
        if let Some(r) = registry.find(&recommendation.recipe) {
            let meta = r.metadata();
            if !meta.tags.is_empty() {
                meta_tags = meta.tags.iter().map(|s| s.to_string()).collect();
            }
        }
        if !meta_tags.is_empty() {
            println!("  │  {:<15} {:<58} │", "Tags:".dimmed(), meta_tags.join(", ").dimmed());
        }

        if let Some(ref note) = recommendation.ordering_note {
            println!("  │  {:<15} {:<58} │", "Order Hints:".dimmed(), note.dimmed());
        }

        if let Some(tag) = tag_filter {
            let matching_files: Vec<_> = scan.scanned_files.iter()
                .filter(|f| f.tags.iter().any(|t| t == tag))
                .collect();
            println!("  ├──────────────────────────────────────────────────────────────────────────────┤");
            println!("  │  Matching files ({}):", tag);
            if !matching_files.is_empty() {
                for f in matching_files.iter().take(5) {
                    println!("  │    - {:<72} │", f.path.display().to_string().cyan());
                }
                if matching_files.len() > 5 {
                    println!("  │    ... and {} more", matching_files.len() - 5);
                }
            } else {
                println!("  │    No files match the specified tag filter '{}'.", tag);
            }
        }
        println!("  └──────────────────────────────────────────────────────────────────────────────┘");
    }
}