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());
}
}