morph-cli 0.1.0

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

use crate::core::recipe::Recipe;
use crate::recipes;

pub struct RecipeRegistry {
    recipes: HashMap<&'static str, Box<dyn Recipe>>,
}

impl RecipeRegistry {
    pub fn new() -> Self {
        let mut registry = Self {
            recipes: HashMap::new(),
        };

        recipes::register_all(&mut registry);
        registry
    }

    pub fn register<R>(&mut self, recipe: R)
    where
        R: Recipe + 'static,
    {
        let name = recipe.metadata().name;
        self.recipes.insert(name, Box::new(recipe));
    }

    pub fn find(&self, name: &str) -> Option<&dyn Recipe> {
        self.recipes.get(name).map(Box::as_ref)
    }

    pub fn all(&self) -> Vec<&dyn Recipe> {
        let mut recipes: Vec<&dyn Recipe> = self.recipes.values().map(Box::as_ref).collect();
        recipes.sort_by(|left, right| left.metadata().name.cmp(right.metadata().name));
        recipes
    }

    pub fn load_plugins(&mut self, project_root: &std::path::Path) {
        // Quick check to see if any potential plugin directory actually exists.
        // This avoids full discovery, instantiation and filesystem walking!
        let default_paths = vec![
            project_root.join("plugins"),
            project_root.join(".morph-cli").join("plugins"),
            dirs::home_dir()
                .map(|h| h.join(".morph-cli").join("plugins"))
                .unwrap_or_default(),
            std::path::PathBuf::from("/usr/local/lib/morph-cli/plugins"),
        ];
        
        let has_any_plugins = default_paths.iter().any(|p| {
            p.exists() && p.is_dir() && std::fs::read_dir(p)
                .map(|mut entries| entries.next().is_some())
                .unwrap_or(false)
        });

        if !has_any_plugins {
            return;
        }

        let mut plugin_registry = crate::core::plugins::registry::PluginRegistry::new();
        let reports = plugin_registry.discover(project_root);
        
        for report in reports {
            if report.status == crate::core::plugins::DiscoveryStatus::Valid {
                // We need to actually register them in the plugin registry to get the manifests
                // But discovery results already have recipes list.
                // For full metadata, we'd need to load the manifest.
                let path = report.path.clone();
                if let Ok(_) = plugin_registry.register(&path) {
                    if let Some(plugin) = plugin_registry.get(&report.name) {
                        for recipe_entry in &plugin.manifest.recipes {
                            self.register_plugin_recipe(recipe_entry, &plugin.manifest.name);
                        }
                    }
                }
            }
        }
    }

    fn register_plugin_recipe(&mut self, entry: &crate::core::plugins::manifest::RecipeEntry, plugin_name: &str) {
        let name = entry.name.clone();
        let description = format!("{} (from plugin: {})", 
            entry.description.as_deref().unwrap_or("No description"), 
            plugin_name
        );
        
        let metadata = create_dynamic_metadata(name, description, vec!["*".to_string()]);
        self.recipes.insert(metadata.name, Box::new(PluginProxyRecipe { metadata }));
    }
}

struct PluginProxyRecipe {
    metadata: &'static crate::core::recipe::RecipeMetadata,
}

impl crate::core::recipe::Recipe for PluginProxyRecipe {
    fn metadata(&self) -> &'static crate::core::recipe::RecipeMetadata {
        self.metadata
    }

    fn detect(&self, _root: &std::path::Path, _progress: &indicatif::ProgressBar) -> anyhow::Result<crate::core::recipe::DetectionReport> {
        Ok(crate::core::recipe::DetectionReport::default())
    }

    fn transform(&self, _report: &crate::core::recipe::DetectionReport, _options: crate::core::recipe::TransformOptions) -> anyhow::Result<crate::core::recipe::TransformReport> {
        anyhow::bail!("Plugin recipes are currently metadata-only and cannot be executed directly.")
    }
}

fn create_dynamic_metadata(name: String, description: String, extensions: Vec<String>) -> &'static crate::core::recipe::RecipeMetadata {
    let name_str = Box::leak(name.into_boxed_str());
    let desc_str = Box::leak(description.into_boxed_str());
    let ext_strs: Vec<&'static str> = extensions.into_iter().map(|s| Box::leak(s.into_boxed_str()) as &str).collect();
    let ext_slice = Box::leak(ext_strs.into_boxed_slice());

    Box::leak(Box::new(crate::core::recipe::RecipeMetadata {
        name: name_str,
        description: desc_str,
        supported_extensions: ext_slice,
        required_recipes: &[],
        incompatible_recipes: &[],
        should_run_before: &[],
        should_run_after: &[],
        maturity: crate::core::recipe::RecipeMaturity::Stable,
        category: crate::core::recipe::RecipeCategory::Migration,
        tags: &[],
    }))
}