morph-cli 0.1.0

AST-based codebase migration and codemod tool for JavaScript and TypeScript projects.
Documentation
use super::manifest::{PluginManifest, ValidationError};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

pub struct PluginLoader {
    pub search_paths: Vec<PathBuf>,
    loaded: Vec<LoadedPlugin>,
}

#[derive(Debug, Clone)]
pub struct LoadedPlugin {
    pub manifest: PluginManifest,
    pub path: PathBuf,
    pub errors: Vec<String>,
}

impl PluginLoader {
    pub fn new() -> Self {
        Self {
            search_paths: Vec::new(),
            loaded: Vec::new(),
        }
    }

    pub fn add_search_path(&mut self, path: PathBuf) {
        self.search_paths.push(path);
    }

    pub fn add_default_paths(&mut self, project_root: &Path) {
        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(),
            PathBuf::from("/usr/local/lib/morph-cli/plugins"),
        ];

        for p in default_paths {
            if p.exists() {
                self.add_search_path(p);
            }
        }
    }

    pub fn discover(&mut self) -> Vec<DiscoveryResult> {
        let mut results = Vec::new();

        for search_path in &self.search_paths {
            if !search_path.exists() {
                continue;
            }

            for entry in WalkDir::new(search_path)
                .max_depth(2)
                .into_iter()
                .filter_map(|e| e.ok())
            {
                let path = entry.path();
                if path.is_file()
                    && path
                        .file_name()
                        .map(|n| n == "morph-cli-plugin.toml")
                        .unwrap_or(false)
                {
                    match self.load_manifest(path) {
                        Ok(manifest) => {
                            results.push(DiscoveryResult {
                                path: path.to_path_buf(),
                                manifest: Some(manifest),
                                status: DiscoveryStatus::Found,
                            });
                        }
                        Err(e) => {
                            results.push(DiscoveryResult {
                                path: path.to_path_buf(),
                                manifest: None,
                                status: DiscoveryStatus::Error(e.to_string()),
                            });
                        }
                    }
                }
            }
        }

        results
    }

    fn load_manifest(&self, path: &Path) -> Result<PluginManifest, String> {
        let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
        let manifest: PluginManifest = toml::from_str(&content).map_err(|e| e.to_string())?;

        let errors = manifest.validate();
        if !errors.is_empty() {
            return Err(format!("Validation failed: {:?}", errors));
        }

        Ok(manifest)
    }

    pub fn load_plugin(&mut self, path: &Path) -> Result<LoadedPlugin, LoadError> {
        let manifest = Self::load_manifest_static(path)?;
        let path_buf = path.to_path_buf();

        self.loaded.push(LoadedPlugin {
            manifest: manifest.clone(),
            path: path_buf.clone(),
            errors: Vec::new(),
        });

        Ok(LoadedPlugin {
            manifest,
            path: path_buf,
            errors: Vec::new(),
        })
    }

    fn load_manifest_static(path: &Path) -> Result<PluginManifest, LoadError> {
        let content = std::fs::read_to_string(path).map_err(LoadError::Io)?;
        let manifest: PluginManifest = toml::from_str(&content).map_err(LoadError::Parse)?;

        let errors = manifest.validate();
        if !errors.is_empty() {
            return Err(LoadError::Validation(errors));
        }

        Ok(manifest)
    }

    pub fn loaded_plugins(&self) -> &[LoadedPlugin] {
        &self.loaded
    }

    pub fn find_recipe(&self, recipe_name: &str) -> Option<&LoadedPlugin> {
        self.loaded
            .iter()
            .find(|p| p.manifest.recipes.iter().any(|r| r.name == recipe_name))
    }

    pub fn incompatible_plugins(&self) -> Vec<Incompatibility> {
        let mut issues = Vec::new();

        for plugin in &self.loaded {
            let version = env!("CARGO_PKG_VERSION");
            let required = &plugin.manifest.compatibility.morph_cli_version;

            if !version_matches(version, required) {
                issues.push(Incompatibility {
                    plugin_name: plugin.manifest.name.clone(),
                    required_version: required.clone(),
                    current_version: version.to_string(),
                });
            }
        }

        issues
    }
}

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

fn version_matches(current: &str, required: &str) -> bool {
    super::manifest::satisfies_version(required, current)
}

#[derive(Debug, Clone)]
pub struct DiscoveryResult {
    pub path: PathBuf,
    pub manifest: Option<PluginManifest>,
    pub status: DiscoveryStatus,
}

#[derive(Debug, Clone)]
pub enum DiscoveryStatus {
    Found,
    Error(String),
}

#[derive(Debug, Clone)]
pub struct Incompatibility {
    pub plugin_name: String,
    pub required_version: String,
    pub current_version: String,
}

#[derive(Debug, thiserror::Error)]
pub enum LoadError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    #[error("Parse error: {0}")]
    Parse(#[from] toml::de::Error),
    #[error("Validation errors: {0:?}")]
    Validation(Vec<ValidationError>),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::plugins::manifest::{Compatibility, RecipeEntry};
    use std::io::Write;
    use tempfile::TempDir;

    fn create_plugin_dir() -> TempDir {
        let dir = tempfile::tempdir().unwrap();
        let manifest = r#"
name = "test-plugin"
version = "1.0.0"

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

[compatibility]
morph_cli_version = ">=0.1.0"
"#;
        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_discover_plugin() {
        let dir = create_plugin_dir();
        let mut loader = PluginLoader::new();
        loader.add_search_path(dir.path().to_path_buf());

        let results = loader.discover();
        assert!(!results.is_empty());
        if let Some(r) = results.first() {
            if let Some(m) = &r.manifest {
                assert_eq!(m.name, "test-plugin");
            }
        }
    }

    #[test]
    fn test_load_plugin() {
        let dir = create_plugin_dir();
        let mut loader = PluginLoader::new();

        let path = dir.path().join("morph-cli-plugin.toml");
        let plugin = loader.load_plugin(&path).unwrap();
        assert_eq!(plugin.manifest.name, "test-plugin");
    }

    #[test]
    fn test_find_recipe() {
        let dir = create_plugin_dir();
        let mut loader = PluginLoader::new();
        let path = dir.path().join("morph-cli-plugin.toml");
        loader.load_plugin(&path).unwrap();

        let found = loader.find_recipe("test-recipe");
        assert!(found.is_some());
    }

    #[test]
    fn test_version_matching() {
        assert!(version_matches("1.0.0", ">=0.1.0"));
        assert!(version_matches("1.0.0", "1.0.0"));
        assert!(version_matches("1.0.0", ">0.9.0"));
        assert!(!version_matches("0.9.0", ">=1.0.0"));
    }

    #[test]
    fn test_incompatible_plugins() {
        let mut loader = PluginLoader::new();
        let manifest = PluginManifest {
            name: "old-plugin".to_string(),
            version: "1.0.0".to_string(),
            description: None,
            author: None,
            recipes: vec![RecipeEntry {
                name: "test".to_string(),
                description: None,
                entry_point: None,
            }],
            compatibility: Compatibility {
                morph_cli_version: ">=99.0.0".to_string(),
                language: None,
                features: None,
            },
            metadata: serde_json::Value::Null,
        };
        loader.loaded.push(LoadedPlugin {
            manifest,
            path: PathBuf::from("/test"),
            errors: vec![],
        });

        let incompatible = loader.incompatible_plugins();
        assert!(!incompatible.is_empty());
    }
}