Skip to main content

morph_cli/core/plugins/
registry.rs

1use super::loader::PluginLoader;
2use super::manifest::PluginManifest;
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6pub struct PluginRegistry {
7    plugins: HashMap<String, PluginEntry>,
8    recipes: HashMap<String, String>,
9    loader: PluginLoader,
10}
11
12#[derive(Debug, Clone)]
13pub struct PluginEntry {
14    pub manifest: PluginManifest,
15    pub path: PathBuf,
16    pub enabled: bool,
17}
18
19impl PluginRegistry {
20    pub fn new() -> Self {
21        Self {
22            plugins: HashMap::new(),
23            recipes: HashMap::new(),
24            loader: PluginLoader::new(),
25        }
26    }
27
28    pub fn discover(&mut self, project_root: &Path) -> Vec<DiscoveryReport> {
29        self.loader.add_default_paths(project_root);
30        let results = self.loader.discover();
31
32        results
33            .into_iter()
34            .map(|r| {
35                let name = r
36                    .manifest
37                    .as_ref()
38                    .map(|m| m.name.clone())
39                    .unwrap_or_default();
40                let (status, error) = match &r.manifest {
41                    Some(m) => {
42                        let errs = m.validate();
43                        if errs.is_empty() {
44                            (DiscoveryStatus::Valid, None)
45                        } else {
46                            let msg = errs.iter()
47                                .map(|e| e.to_string())
48                                .collect::<Vec<_>>()
49                                .join("; ");
50                            (DiscoveryStatus::Invalid, Some(msg))
51                        }
52                    }
53                    None => {
54                        match &r.status {
55                            super::loader::DiscoveryStatus::Error(err) => {
56                                (DiscoveryStatus::Invalid, Some(err.clone()))
57                            }
58                            _ => (DiscoveryStatus::NotFound, None),
59                        }
60                    }
61                };
62                let version = r.manifest.as_ref().map(|m| m.version.clone()).unwrap_or_default();
63                let recipes = r.manifest.as_ref().map(|m| m.recipes.iter().map(|r| r.name.clone()).collect()).unwrap_or_default();
64                DiscoveryReport {
65                    path: r.path,
66                    name,
67                    version,
68                    recipes,
69                    status,
70                    error,
71                }
72            })
73            .collect()
74    }
75
76    pub fn register(&mut self, path: &Path) -> Result<(), RegistryError> {
77        let plugin = self
78            .loader
79            .load_plugin(path)
80            .map_err(|e| RegistryError::LoadError(e.to_string()))?;
81        let name = plugin.manifest.name.clone();
82
83        if self.plugins.contains_key(&name) {
84            return Err(RegistryError::Conflict(name));
85        }
86
87        for recipe in &plugin.manifest.recipes {
88            if self.recipes.contains_key(&recipe.name) {
89                eprintln!(
90                    "Warning: recipe '{}' already registered, conflicts with plugin '{}'",
91                    recipe.name, name
92                );
93            }
94            self.recipes.insert(recipe.name.clone(), name.clone());
95        }
96
97        self.plugins.insert(
98            name,
99            PluginEntry {
100                manifest: plugin.manifest,
101                path: path.to_path_buf(),
102                enabled: true,
103            },
104        );
105
106        Ok(())
107    }
108
109    pub fn unregister(&mut self, name: &str) -> Option<PluginEntry> {
110        if let Some(entry) = self.plugins.remove(name) {
111            let recipe_names: Vec<String> = self
112                .recipes
113                .iter()
114                .filter(|(_, plugin_name)| *plugin_name == name)
115                .map(|(k, _)| k.clone())
116                .collect();
117
118            for recipe in recipe_names {
119                self.recipes.remove(&recipe);
120            }
121            Some(entry)
122        } else {
123            None
124        }
125    }
126
127    pub fn get(&self, name: &str) -> Option<&PluginEntry> {
128        self.plugins.get(name)
129    }
130
131    pub fn get_recipe<'a>(&'a self, recipe_name: &'a str) -> Option<(&'a PluginEntry, &'a str)> {
132        self.recipes
133            .get(recipe_name)
134            .and_then(move |plugin_name| self.plugins.get(plugin_name).map(|p| (p, recipe_name)))
135    }
136
137    pub fn list_plugins(&self) -> Vec<&PluginEntry> {
138        self.plugins.values().collect()
139    }
140
141    pub fn list_recipes(&self) -> Vec<(&String, &String)> {
142        self.recipes.iter().collect()
143    }
144
145    pub fn summary(&self) -> RegistrySummary {
146        let enabled = self.plugins.values().filter(|p| p.enabled).count();
147        let total_recipes = self.recipes.len();
148
149        let conflicts = self.detect_conflicts();
150
151        RegistrySummary {
152            total_plugins: self.plugins.len(),
153            enabled_plugins: enabled,
154            total_recipes,
155            conflicts,
156        }
157    }
158
159    fn detect_conflicts(&self) -> Vec<Conflict> {
160        let mut conflicts = Vec::new();
161        let mut recipe_counts: HashMap<String, Vec<String>> = HashMap::new();
162
163        for plugin in self.plugins.values() {
164            for recipe in &plugin.manifest.recipes {
165                recipe_counts
166                    .entry(recipe.name.clone())
167                    .or_default()
168                    .push(plugin.manifest.name.clone());
169            }
170        }
171
172        for (recipe, plugins) in recipe_counts {
173            if plugins.len() > 1 {
174                conflicts.push(Conflict {
175                    recipe,
176                    plugins,
177                });
178            }
179        }
180
181        conflicts
182    }
183
184    pub fn check_compatibility(&self) -> Vec<CompatibilityIssue> {
185        self.loader
186            .incompatible_plugins()
187            .into_iter()
188            .map(|i| CompatibilityIssue {
189                plugin: i.plugin_name,
190                required: i.required_version,
191                current: i.current_version,
192            })
193            .collect()
194    }
195
196    pub fn diagnostics(&self) -> Diagnostics {
197        let summary = self.summary();
198        let compatibility = self.check_compatibility();
199
200        Diagnostics {
201            summary,
202            compatibility_issues: compatibility,
203            loader_stats: LoaderStats {
204                search_paths: self.loader.search_paths.len(),
205                loaded_count: self.loader.loaded_plugins().len(),
206            },
207        }
208    }
209}
210
211impl Default for PluginRegistry {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217#[derive(Debug, Clone)]
218pub struct DiscoveryReport {
219    pub path: std::path::PathBuf,
220    pub name: String,
221    pub version: String,
222    pub recipes: Vec<String>,
223    pub status: DiscoveryStatus,
224    pub error: Option<String>,
225}
226
227#[derive(Debug, Clone, PartialEq)]
228pub enum DiscoveryStatus {
229    Valid,
230    Invalid,
231    NotFound,
232}
233
234#[derive(Debug, Clone)]
235pub struct RegistrySummary {
236    pub total_plugins: usize,
237    pub enabled_plugins: usize,
238    pub total_recipes: usize,
239    pub conflicts: Vec<Conflict>,
240}
241
242impl std::fmt::Display for RegistrySummary {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        use colored::Colorize;
245        writeln!(
246            f,
247            "Plugins: {}/{}",
248            self.enabled_plugins, self.total_plugins
249        )?;
250        writeln!(f, "Recipes: {}", self.total_recipes)?;
251        if !self.conflicts.is_empty() {
252            writeln!(f, "{}", "⚠️  Recipe Conflicts Detected:".yellow().bold())?;
253            for conflict in &self.conflicts {
254                writeln!(
255                    f,
256                    "  - Recipe '{}' is defined by multiple plugins: {}",
257                    conflict.recipe.cyan().bold(),
258                    conflict.plugins.join(", ")
259                )?;
260            }
261        }
262        Ok(())
263    }
264}
265
266#[derive(Debug, Clone)]
267pub struct Conflict {
268    pub recipe: String,
269    pub plugins: Vec<String>,
270}
271
272#[derive(Debug, Clone)]
273pub struct CompatibilityIssue {
274    pub plugin: String,
275    pub required: String,
276    pub current: String,
277}
278
279#[derive(Debug)]
280pub struct Diagnostics {
281    pub summary: RegistrySummary,
282    pub compatibility_issues: Vec<CompatibilityIssue>,
283    pub loader_stats: LoaderStats,
284}
285
286#[derive(Debug)]
287pub struct LoaderStats {
288    pub search_paths: usize,
289    pub loaded_count: usize,
290}
291
292#[derive(Debug, thiserror::Error)]
293pub enum RegistryError {
294    #[error("Plugin not found: {0}")]
295    NotFound(String),
296    #[error("Plugin conflict: {0} already registered")]
297    Conflict(String),
298    #[error("Load error: {0}")]
299    LoadError(String),
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use std::io::Write;
306    use tempfile::TempDir;
307
308    fn create_test_plugin(name: &str) -> TempDir {
309        let dir = tempfile::tempdir().unwrap();
310        let manifest = format!(
311            r#"
312name = "{}"
313version = "1.0.0"
314
315[[recipes]]
316name = "test-recipe"
317description = "A test recipe"
318
319[compatibility]
320morph_cli_version = ">=0.1.0"
321"#,
322            name
323        );
324        let mut file = std::fs::File::create(dir.path().join("morph-cli-plugin.toml")).unwrap();
325        file.write_all(manifest.as_bytes()).unwrap();
326        dir
327    }
328
329    #[test]
330    fn test_register_plugin() {
331        let dir = create_test_plugin("test-plugin");
332        let mut registry = PluginRegistry::new();
333        let path = dir.path().join("morph-cli-plugin.toml");
334
335        registry.register(&path).unwrap();
336        assert_eq!(registry.plugins.len(), 1);
337    }
338
339    #[test]
340    fn test_unregister_plugin() {
341        let dir = create_test_plugin("test-plugin");
342        let mut registry = PluginRegistry::new();
343        let path = dir.path().join("morph-cli-plugin.toml");
344
345        registry.register(&path).unwrap();
346        let removed = registry.unregister("test-plugin");
347        assert!(removed.is_some());
348        assert!(registry.plugins.is_empty());
349    }
350
351    #[test]
352    fn test_get_plugin() {
353        let dir = create_test_plugin("test-plugin");
354        let mut registry = PluginRegistry::new();
355        let path = dir.path().join("morph-cli-plugin.toml");
356
357        registry.register(&path).unwrap();
358        let plugin = registry.get("test-plugin");
359        assert!(plugin.is_some());
360    }
361
362    #[test]
363    fn test_list_plugins() {
364        let dir = create_test_plugin("test-plugin");
365        let mut registry = PluginRegistry::new();
366        let path = dir.path().join("morph-cli-plugin.toml");
367
368        registry.register(&path).unwrap();
369        let plugins = registry.list_plugins();
370        assert_eq!(plugins.len(), 1);
371    }
372
373    #[test]
374    fn test_summary() {
375        let dir = create_test_plugin("test-plugin");
376        let mut registry = PluginRegistry::new();
377        let path = dir.path().join("morph-cli-plugin.toml");
378
379        registry.register(&path).unwrap();
380        let summary = registry.summary();
381        assert_eq!(summary.total_plugins, 1);
382    }
383
384    #[test]
385    fn test_conflict_detection() {
386        let mut registry = PluginRegistry::new();
387        
388        let dir_a = create_test_plugin("plugin-a");
389        let path_a = dir_a.path().join("morph-cli-plugin.toml");
390        registry.register(&path_a).unwrap();
391        
392        let dir_b = create_test_plugin("plugin-b");
393        let path_b = dir_b.path().join("morph-cli-plugin.toml");
394        registry.register(&path_b).unwrap();
395        
396        let summary = registry.summary();
397        assert_eq!(summary.conflicts.len(), 1);
398        assert_eq!(summary.conflicts[0].recipe, "test-recipe");
399        assert!(summary.conflicts[0].plugins.contains(&"plugin-a".to_string()));
400        assert!(summary.conflicts[0].plugins.contains(&"plugin-b".to_string()));
401    }
402}