morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use super::loader::PluginLoader;
use super::manifest::PluginManifest;
use std::collections::HashMap;
use std::path::{Path, PathBuf};

pub struct PluginRegistry {
    plugins: HashMap<String, PluginEntry>,
    recipes: HashMap<String, String>,
    loader: PluginLoader,
}

#[derive(Debug, Clone)]
pub struct PluginEntry {
    pub manifest: PluginManifest,
    pub path: PathBuf,
    pub enabled: bool,
}

impl PluginRegistry {
    pub fn new() -> Self {
        Self {
            plugins: HashMap::new(),
            recipes: HashMap::new(),
            loader: PluginLoader::new(),
        }
    }

    pub fn discover(&mut self, project_root: &Path) -> Vec<DiscoveryReport> {
        self.loader.add_default_paths(project_root);
        let results = self.loader.discover();

        results
            .into_iter()
            .map(|r| {
                let name = r
                    .manifest
                    .as_ref()
                    .map(|m| m.name.clone())
                    .unwrap_or_default();
                let (status, error) = match &r.manifest {
                    Some(m) => {
                        let errs = m.validate();
                        if errs.is_empty() {
                            (DiscoveryStatus::Valid, None)
                        } else {
                            let msg = errs.iter()
                                .map(|e| e.to_string())
                                .collect::<Vec<_>>()
                                .join("; ");
                            (DiscoveryStatus::Invalid, Some(msg))
                        }
                    }
                    None => {
                        match &r.status {
                            super::loader::DiscoveryStatus::Error(err) => {
                                (DiscoveryStatus::Invalid, Some(err.clone()))
                            }
                            _ => (DiscoveryStatus::NotFound, None),
                        }
                    }
                };
                let version = r.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default();
                let recipes = r.manifest.as_ref().map(|m| m.recipes.iter().map(|r| r.name.clone()).collect()).unwrap_or_default();
                DiscoveryReport {
                    path: r.path,
                    name,
                    version,
                    recipes,
                    status,
                    error,
                }
            })
            .collect()
    }

    pub fn register(&mut self, path: &Path) -> Result<(), RegistryError> {
        let plugin = self
            .loader
            .load_plugin(path)
            .map_err(|e| RegistryError::LoadError(e.to_string()))?;
        let name = plugin.manifest.name.clone();

        if self.plugins.contains_key(&name) {
            return Err(RegistryError::Conflict(name));
        }

        for recipe in &plugin.manifest.recipes {
            if self.recipes.contains_key(&recipe.name) {
                eprintln!(
                    "Warning: recipe '{}' already registered, conflicts with plugin '{}'",
                    recipe.name, name
                );
            }
            self.recipes.insert(recipe.name.clone(), name.clone());
        }

        self.plugins.insert(
            name,
            PluginEntry {
                manifest: plugin.manifest,
                path: path.to_path_buf(),
                enabled: true,
            },
        );

        Ok(())
    }

    pub fn unregister(&mut self, name: &str) -> Option<PluginEntry> {
        if let Some(entry) = self.plugins.remove(name) {
            let recipe_names: Vec<String> = self
                .recipes
                .iter()
                .filter(|(_, plugin_name)| *plugin_name == name)
                .map(|(k, _)| k.clone())
                .collect();

            for recipe in recipe_names {
                self.recipes.remove(&recipe);
            }
            Some(entry)
        } else {
            None
        }
    }

    pub fn get(&self, name: &str) -> Option<&PluginEntry> {
        self.plugins.get(name)
    }

    pub fn get_recipe<'a>(&'a self, recipe_name: &'a str) -> Option<(&'a PluginEntry, &'a str)> {
        self.recipes
            .get(recipe_name)
            .and_then(move |plugin_name| self.plugins.get(plugin_name).map(|p| (p, recipe_name)))
    }

    pub fn list_plugins(&self) -> Vec<&PluginEntry> {
        self.plugins.values().collect()
    }

    pub fn list_recipes(&self) -> Vec<(&String, &String)> {
        self.recipes.iter().collect()
    }

    pub fn summary(&self) -> RegistrySummary {
        let enabled = self.plugins.values().filter(|p| p.enabled).count();
        let total_recipes = self.recipes.len();

        let conflicts = self.detect_conflicts();

        RegistrySummary {
            total_plugins: self.plugins.len(),
            enabled_plugins: enabled,
            total_recipes,
            conflicts,
        }
    }

    fn detect_conflicts(&self) -> Vec<Conflict> {
        let mut conflicts = Vec::new();
        let mut recipe_counts: HashMap<String, Vec<String>> = HashMap::new();

        for plugin in self.plugins.values() {
            for recipe in &plugin.manifest.recipes {
                recipe_counts
                    .entry(recipe.name.clone())
                    .or_default()
                    .push(plugin.manifest.name.clone());
            }
        }

        for (recipe, plugins) in recipe_counts {
            if plugins.len() > 1 {
                conflicts.push(Conflict {
                    recipe,
                    plugins,
                });
            }
        }

        conflicts
    }

    pub fn check_compatibility(&self) -> Vec<CompatibilityIssue> {
        self.loader
            .incompatible_plugins()
            .into_iter()
            .map(|i| CompatibilityIssue {
                plugin: i.plugin_name,
                required: i.required_version,
                current: i.current_version,
            })
            .collect()
    }

    pub fn diagnostics(&self) -> Diagnostics {
        let summary = self.summary();
        let compatibility = self.check_compatibility();

        Diagnostics {
            summary,
            compatibility_issues: compatibility,
            loader_stats: LoaderStats {
                search_paths: self.loader.search_paths.len(),
                loaded_count: self.loader.loaded_plugins().len(),
            },
        }
    }
}

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

#[derive(Debug, Clone)]
pub struct DiscoveryReport {
    pub path: std::path::PathBuf,
    pub name: String,
    pub version: String,
    pub recipes: Vec<String>,
    pub status: DiscoveryStatus,
    pub error: Option<String>,
}

#[derive(Debug, Clone, PartialEq)]
pub enum DiscoveryStatus {
    Valid,
    Invalid,
    NotFound,
}

#[derive(Debug, Clone)]
pub struct RegistrySummary {
    pub total_plugins: usize,
    pub enabled_plugins: usize,
    pub total_recipes: usize,
    pub conflicts: Vec<Conflict>,
}

impl std::fmt::Display for RegistrySummary {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        use colored::Colorize;
        writeln!(
            f,
            "Plugins: {}/{}",
            self.enabled_plugins, self.total_plugins
        )?;
        writeln!(f, "Recipes: {}", self.total_recipes)?;
        if !self.conflicts.is_empty() {
            writeln!(f, "{}", "⚠️  Recipe Conflicts Detected:".yellow().bold())?;
            for conflict in &self.conflicts {
                writeln!(
                    f,
                    "  - Recipe '{}' is defined by multiple plugins: {}",
                    conflict.recipe.cyan().bold(),
                    conflict.plugins.join(", ")
                )?;
            }
        }
        Ok(())
    }
}

#[derive(Debug, Clone)]
pub struct Conflict {
    pub recipe: String,
    pub plugins: Vec<String>,
}

#[derive(Debug, Clone)]
pub struct CompatibilityIssue {
    pub plugin: String,
    pub required: String,
    pub current: String,
}

#[derive(Debug)]
pub struct Diagnostics {
    pub summary: RegistrySummary,
    pub compatibility_issues: Vec<CompatibilityIssue>,
    pub loader_stats: LoaderStats,
}

#[derive(Debug)]
pub struct LoaderStats {
    pub search_paths: usize,
    pub loaded_count: usize,
}

#[derive(Debug, thiserror::Error)]
pub enum RegistryError {
    #[error("Plugin not found: {0}")]
    NotFound(String),
    #[error("Plugin conflict: {0} already registered")]
    Conflict(String),
    #[error("Load error: {0}")]
    LoadError(String),
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write;
    use tempfile::TempDir;

    fn create_test_plugin(name: &str) -> TempDir {
        let dir = tempfile::tempdir().unwrap();
        let manifest = format!(
            r#"
name = "{}"
version = "1.0.0"

[[recipes]]
name = "test-recipe"
description = "A test recipe"

[compatibility]
morph_cli_version = ">=0.1.0"
"#,
            name
        );
        let mut file = std::fs::File::create(dir.path().join("morph-cli-plugin.toml")).unwrap();
        file.write_all(manifest.as_bytes()).unwrap();
        dir
    }

    #[test]
    fn test_register_plugin() {
        let dir = create_test_plugin("test-plugin");
        let mut registry = PluginRegistry::new();
        let path = dir.path().join("morph-cli-plugin.toml");

        registry.register(&path).unwrap();
        assert_eq!(registry.plugins.len(), 1);
    }

    #[test]
    fn test_unregister_plugin() {
        let dir = create_test_plugin("test-plugin");
        let mut registry = PluginRegistry::new();
        let path = dir.path().join("morph-cli-plugin.toml");

        registry.register(&path).unwrap();
        let removed = registry.unregister("test-plugin");
        assert!(removed.is_some());
        assert!(registry.plugins.is_empty());
    }

    #[test]
    fn test_get_plugin() {
        let dir = create_test_plugin("test-plugin");
        let mut registry = PluginRegistry::new();
        let path = dir.path().join("morph-cli-plugin.toml");

        registry.register(&path).unwrap();
        let plugin = registry.get("test-plugin");
        assert!(plugin.is_some());
    }

    #[test]
    fn test_list_plugins() {
        let dir = create_test_plugin("test-plugin");
        let mut registry = PluginRegistry::new();
        let path = dir.path().join("morph-cli-plugin.toml");

        registry.register(&path).unwrap();
        let plugins = registry.list_plugins();
        assert_eq!(plugins.len(), 1);
    }

    #[test]
    fn test_summary() {
        let dir = create_test_plugin("test-plugin");
        let mut registry = PluginRegistry::new();
        let path = dir.path().join("morph-cli-plugin.toml");

        registry.register(&path).unwrap();
        let summary = registry.summary();
        assert_eq!(summary.total_plugins, 1);
    }

    #[test]
    fn test_conflict_detection() {
        let mut registry = PluginRegistry::new();
        
        let dir_a = create_test_plugin("plugin-a");
        let path_a = dir_a.path().join("morph-cli-plugin.toml");
        registry.register(&path_a).unwrap();
        
        let dir_b = create_test_plugin("plugin-b");
        let path_b = dir_b.path().join("morph-cli-plugin.toml");
        registry.register(&path_b).unwrap();
        
        let summary = registry.summary();
        assert_eq!(summary.conflicts.len(), 1);
        assert_eq!(summary.conflicts[0].recipe, "test-recipe");
        assert!(summary.conflicts[0].plugins.contains(&"plugin-a".to_string()));
        assert!(summary.conflicts[0].plugins.contains(&"plugin-b".to_string()));
    }
}