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