morph-cli 0.1.0

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

use anyhow::{Context, Result, bail};
use indicatif::ProgressBar;

use crate::core::recipe::FileClassification;
use crate::core::registry::RecipeRegistry;
use crate::utils::terminal;

pub fn execute(path: &Path) -> Result<()> {
    if !path.exists() {
        bail!("File does not exist: {}", path.display());
    }

    if !path.is_file() {
        bail!("Explain expects a file path: {}", path.display());
    }

    let path = if path.is_relative() {
        std::env::current_dir()?.join(path)
    } else {
        path.to_path_buf()
    };

    println!("{}", terminal::label("Migration Explanation"));
    println!("file: {}", path.display());

    let registry = RecipeRegistry::new();
    let progress = ProgressBar::hidden();
    let mut explained = false;

    for recipe in registry.all() {
        let metadata = recipe.metadata();

        if !has_supported_extension(&path, metadata.supported_extensions) {
            continue;
        }

        let report = recipe
            .detect(&path, &progress)
            .with_context(|| format!("Failed to run detection for `{}`", metadata.name))?;

        println!();
        println!("recipe: {}", metadata.name);

        if let Some(failure) = report.failed_files.iter().find(|failure| failure.path == path) {
            println!("status: skipped");
            println!("reason: detection failed ({})", failure.error);
            explained = true;
            continue;
        }

        if let Some(analysis) = report.analyses.iter().find(|analysis| analysis.path == path) {
            println!(
                "status: {}",
                if analysis.is_transform_safe {
                    "eligible"
                } else {
                    "skipped"
                }
            );
            println!(
                "reason: {}",
                explanation_reason(analysis.classification, analysis.is_transform_safe)
            );
            println!("confidence: {}%", analysis.confidence_score);
            println!("patterns: {}", analysis.detected_patterns.join(", "));
            explained = true;
        } else {
            println!("status: skipped");
            println!("reason: no migration patterns matched");
            println!("confidence: 0%");
            explained = true;
        }
    }

    if !explained {
        println!();
        println!("status: skipped");
        println!("reason: no registered recipe supports this file extension");
        println!("confidence: 0%");
    }

    Ok(())
}

fn has_supported_extension(path: &Path, supported_extensions: &[&str]) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| supported_extensions.contains(&extension))
}

fn explanation_reason(classification: FileClassification, is_transform_safe: bool) -> &'static str {
    match (classification, is_transform_safe) {
        (FileClassification::Safe, true) => {
            "matched supported migration patterns and is within the safe transform subset"
        }
        (FileClassification::Safe, false) => {
            "matched migration patterns, but this recipe does not mark it transform-safe"
        }
        (FileClassification::Risky, _) => {
            "matched migration patterns, but risky or unsupported patterns were detected"
        }
    }
}