morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
#![allow(clippy::all)]
pub mod analysis;
pub mod detect;
pub mod transform;

use anyhow::Result;
use indicatif::ProgressBar;
use std::path::Path;

use crate::core::cache::{CachedDetectionOutcome, load_detection, record_miss, save_detection};
use crate::core::recipe::{
    DetectionReport, FileAnalysis, FileClassification, Recipe, RecipeMetadata, TransformOptions,
    TransformReport,
};
use crate::recipes::express_to_fastify::transform as tf;

const METADATA: RecipeMetadata = RecipeMetadata {
    name: "express-to-fastify",
    description: "Analyze Express.js applications for Fastify migration readiness",
    supported_extensions: &["js", "ts", "jsx", "tsx"],
    required_recipes: &[],
    incompatible_recipes: &[],
    should_run_before: &[],
    should_run_after: &[],
    maturity: crate::core::recipe::RecipeMaturity::Beta,
    category: crate::core::recipe::RecipeCategory::Analysis,
    tags: &["risky", "backend", "express", "fastify", "web", "router", "http"],
};

pub struct ExpressToFastifyRecipe;

impl ExpressToFastifyRecipe {
    pub fn new() -> Self {
        Self
    }
}

impl Default for ExpressToFastifyRecipe {
    fn default() -> Self {
        Self::new()
    }
}

impl Recipe for ExpressToFastifyRecipe {
    fn metadata(&self) -> &'static RecipeMetadata {
        &METADATA
    }

    fn detect(&self, root: &Path, progress: &ProgressBar) -> Result<DetectionReport> {
        let mut report = DetectionReport::default();

        for entry in walkdir::WalkDir::new(root)
            .into_iter()
            .filter_entry(|e| {
                let name = e.file_name().to_string_lossy();
                name != "node_modules" && name != ".git" && name != "target" && name != "dist" && name != "build"
            })
            .filter_map(|e| e.ok())
        {
            let path = entry.path();
            if !path.is_file() {
                continue;
            }

            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
            if !["js", "ts", "jsx", "tsx"].contains(&ext) {
                continue;
            }

            progress.set_message(format!("Analyzing {}", path.display()));
            report.total_files += 1;

            if let Some(outcome) = load_detection(METADATA.name, path) {
                apply_cached_outcome(&mut report, outcome);
                continue;
            }
            record_miss();

            let mut detector = detect::ExpressDetector::new();
            let analysis = match detector.detect(path) {
                Some(analysis) => analysis,
                None => {
                    let skipped = path.to_path_buf();
                    let _ = save_detection(
                        METADATA.name,
                        path,
                        &CachedDetectionOutcome::Skipped(skipped.clone()),
                    );
                    report.skipped_files.push(skipped);
                    continue;
                }
            };

            let is_relevant = !analysis.routes.is_empty() || !analysis.express_apps.is_empty();
            if !is_relevant {
                let skipped = path.to_path_buf();
                let _ = save_detection(
                    METADATA.name,
                    path,
                    &CachedDetectionOutcome::Skipped(skipped.clone()),
                );
                report.skipped_files.push(skipped);
                continue;
            }

            let is_safe = analysis.complexity == analysis::ComplexityLevel::Simple
                || analysis.complexity == analysis::ComplexityLevel::Moderate;

            report.parseable_files += 1;
            let file_analysis = FileAnalysis {
                path: path.to_path_buf(),
                detected_patterns: vec![format!("Complexity: {:?}", analysis.complexity)],
                confidence_score: 100 - (analysis.risky_patterns.len() as u8 * 10).min(50),
                classification: if is_safe {
                    FileClassification::Safe
                } else {
                    FileClassification::Risky
                },
                is_transform_safe: is_safe,
                tags: Default::default(),
            };
            let _ = save_detection(
                METADATA.name,
                path,
                &CachedDetectionOutcome::Analysis(file_analysis.clone()),
            );
            report.analyses.push(file_analysis);
        }

        Ok(report)
    }

    fn transform(
        &self,
        report: &DetectionReport,
        options: TransformOptions,
    ) -> Result<TransformReport> {
        let mut transform_report = TransformReport::default();

        for analysis in &report.analyses {
            let source = std::fs::read_to_string(&analysis.path)?;
            let mut transformer = tf::ExpressToFastifyTransform::new();
            let result = transformer.transform_source(&source, &analysis.path);

            let has_actual_changes = result.changed && result.transformed != source;

            if has_actual_changes {
                transform_report.changed_files.push(analysis.path.clone());
                
                if options.mode == crate::core::recipe::TransformMode::Write {
                    std::fs::write(&analysis.path, &result.transformed)?;
                }
            } else {
                transform_report.skipped_files.push(crate::core::recipe::SkippedTransform {
                    path: analysis.path.clone(),
                    reason: "no changes detected".to_string(),
                });
            }

            let mut diagnostics = result.unsupported.clone();
            diagnostics.extend(result.warnings.clone());
            if !diagnostics.is_empty() {
                transform_report.unsupported_patterns.push(crate::core::recipe::UnsupportedPatternReport {
                    path: analysis.path.clone(),
                    patterns: diagnostics,
                });
            }
        }

        Ok(transform_report)
    }
}

fn apply_cached_outcome(report: &mut DetectionReport, outcome: CachedDetectionOutcome) {
    match outcome {
        CachedDetectionOutcome::Analysis(analysis) => {
            report.parseable_files += 1;
            report.analyses.push(analysis);
        }
        CachedDetectionOutcome::Skipped(path) => report.skipped_files.push(path),
        CachedDetectionOutcome::Failure(failure) => report.failed_files.push(failure),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_metadata() {
        let recipe = ExpressToFastifyRecipe::new();
        let meta = recipe.metadata();
        assert_eq!(meta.name, "express-to-fastify");
    }

    #[test]
    fn test_new() {
        let recipe = ExpressToFastifyRecipe::new();
        assert!(recipe.metadata().description.contains("Express"));
    }
}