morph-cli 0.1.0

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

use anyhow::Result;

use crate::core::recipe::FileAnalysis;
use crate::recipes::js_to_ts::types::MigrationMode;

pub struct JsToTsTransform {
    #[allow(dead_code)]
    mode: MigrationMode,
    rename_extensions: bool,
    #[allow(dead_code)]
    infer_types: bool,
}

impl JsToTsTransform {
    pub fn new(mode: MigrationMode, rename_extensions: bool, infer_types: bool) -> Self {
        Self {
            mode,
            rename_extensions,
            infer_types,
        }
    }

    pub fn transform_file(&self, analysis: &FileAnalysis) -> Result<TransformResult> {
        let source_path = &analysis.path;

        if self.rename_extensions {
            let target_path = self.calculate_target_path(source_path)?;
            let from_ext = source_path
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or("")
                .to_string();
            let to_ext = target_path
                .extension()
                .and_then(|e| e.to_str())
                .unwrap_or("")
                .to_string();
            Ok(TransformResult {
                original_path: source_path.clone(),
                target_path,
                changes: vec![Change::RenameExtension {
                    from: from_ext,
                    to: to_ext,
                }],
                warnings: Vec::new(),
            })
        } else {
            Ok(TransformResult {
                original_path: source_path.clone(),
                target_path: source_path.clone(),
                changes: Vec::new(),
                warnings: Vec::new(),
            })
        }
    }

    fn calculate_target_path(&self, path: &Path) -> Result<PathBuf> {
        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");

        let new_ext = match ext {
            "js" => "ts",
            "jsx" => "tsx",
            "mjs" => "mts",
            _ => return Ok(path.to_path_buf()),
        };

        Ok(path.with_extension(new_ext))
    }

    pub fn should_transform(&self, analysis: &FileAnalysis) -> bool {
        if !self.rename_extensions {
            return false;
        }

        let ext = analysis
            .path
            .extension()
            .and_then(|e| e.to_str())
            .unwrap_or("");

        matches!(ext, "js" | "jsx" | "mjs")
    }
}

#[derive(Debug, Clone)]
pub struct TransformResult {
    pub original_path: PathBuf,
    pub target_path: PathBuf,
    pub changes: Vec<Change>,
    pub warnings: Vec<String>,
}

#[derive(Debug, Clone)]
#[allow(dead_code)]
pub enum Change {
    RenameExtension { from: String, to: String },
    AddTypeAnnotations { count: usize },
    ConvertCommonJsToEsm { count: usize },
    RenameJsxToTsx,
}

impl TransformResult {
    pub fn has_changes(&self) -> bool {
        !self.changes.is_empty()
    }

    #[allow(dead_code)]
    pub fn change_summary(&self) -> String {
        if self.changes.is_empty() {
            return "No changes".to_string();
        }

        let mut parts = Vec::new();
        for change in &self.changes {
            match change {
                Change::RenameExtension { from, to } => {
                    parts.push(format!(".{} → .{}", from, to));
                }
                Change::AddTypeAnnotations { count } => {
                    parts.push(format!("Added {} type annotations", count));
                }
                Change::ConvertCommonJsToEsm { count } => {
                    parts.push(format!("Converted {} CommonJS to ESM", count));
                }
                Change::RenameJsxToTsx => {
                    parts.push("JSX → TSX".to_string());
                }
            }
        }

        parts.join(", ")
    }
}

pub fn rename_file(from: &Path, to: &Path) -> Result<()> {
    if from == to {
        return Ok(());
    }

    if to.exists() {
        anyhow::bail!("Target file already exists: {}", to.display());
    }

    fs::rename(from, to).map_err(|e| anyhow::anyhow!("Failed to rename file: {}", e))?;
    Ok(())
}

#[allow(dead_code)]
pub fn get_target_extension(source_ext: &str) -> String {
    match source_ext {
        "js" => "ts".to_string(),
        "jsx" => "tsx".to_string(),
        "mjs" => "mts".to_string(),
        _ => source_ext.to_string(),
    }
}

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

    fn test_analysis(ext: &str) -> FileAnalysis {
        FileAnalysis {
            path: PathBuf::from(format!("test.{}", ext)),
            detected_patterns: vec!["plain JavaScript".to_string()],
            confidence_score: 50,
            classification: crate::core::recipe::FileClassification::Safe,
            is_transform_safe: true,
            tags: Default::default(),
        }
    }

    #[test]
    fn test_target_extension_calculation() {
        assert_eq!(get_target_extension("js"), "ts");
        assert_eq!(get_target_extension("jsx"), "tsx");
        assert_eq!(get_target_extension("mjs"), "mts");
        assert_eq!(get_target_extension("ts"), "ts");
    }

    #[test]
    fn test_should_transform_js() {
        let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
        let analysis = test_analysis("js");
        assert!(transform.should_transform(&analysis));
    }

    #[test]
    fn test_should_transform_ts() {
        let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
        let analysis = test_analysis("ts");
        assert!(!transform.should_transform(&analysis));
    }

    #[test]
    fn test_transform_without_rename() {
        let transform = JsToTsTransform::new(MigrationMode::Conservative, false, false);
        let analysis = test_analysis("js");
        let result = transform.transform_file(&analysis).unwrap();
        assert!(!result.has_changes());
    }

    #[test]
    fn test_transform_with_rename() {
        let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
        let analysis = test_analysis("js");
        let result = transform.transform_file(&analysis).unwrap();
        assert!(result.has_changes());
        assert_eq!(result.target_path.extension().unwrap(), "ts");
    }

    #[test]
    fn test_transform_jsx_to_tsx() {
        let transform = JsToTsTransform::new(MigrationMode::Conservative, true, false);
        let analysis = test_analysis("jsx");
        let result = transform.transform_file(&analysis).unwrap();
        assert!(result.has_changes());
        assert_eq!(result.target_path.extension().unwrap(), "tsx");
    }

    #[test]
    fn test_change_summary() {
        let result = TransformResult {
            original_path: PathBuf::from("app.js"),
            target_path: PathBuf::from("app.ts"),
            changes: vec![Change::RenameExtension {
                from: "js".to_string(),
                to: "ts".to_string(),
            }],
            warnings: Vec::new(),
        };
        assert!(result.change_summary().contains(".js → .ts"));
    }
}