Skip to main content

morph_cli/core/plugins/
loader.rs

1use super::manifest::{PluginManifest, ValidationError};
2use std::path::{Path, PathBuf};
3use walkdir::WalkDir;
4
5pub struct PluginLoader {
6    pub search_paths: Vec<PathBuf>,
7    loaded: Vec<LoadedPlugin>,
8}
9
10#[derive(Debug, Clone)]
11pub struct LoadedPlugin {
12    pub manifest: PluginManifest,
13    pub path: PathBuf,
14    pub errors: Vec<String>,
15}
16
17impl PluginLoader {
18    pub fn new() -> Self {
19        Self {
20            search_paths: Vec::new(),
21            loaded: Vec::new(),
22        }
23    }
24
25    pub fn add_search_path(&mut self, path: PathBuf) {
26        self.search_paths.push(path);
27    }
28
29    pub fn add_default_paths(&mut self, project_root: &Path) {
30        let default_paths = vec![
31            project_root.join("plugins"),
32            project_root.join(".morph-cli").join("plugins"),
33            dirs::home_dir()
34                .map(|h| h.join(".morph-cli").join("plugins"))
35                .unwrap_or_default(),
36            PathBuf::from("/usr/local/lib/morph-cli/plugins"),
37        ];
38
39        for p in default_paths {
40            if p.exists() {
41                self.add_search_path(p);
42            }
43        }
44    }
45
46    pub fn discover(&mut self) -> Vec<DiscoveryResult> {
47        let mut results = Vec::new();
48
49        for search_path in &self.search_paths {
50            if !search_path.exists() {
51                continue;
52            }
53
54            for entry in WalkDir::new(search_path)
55                .max_depth(2)
56                .into_iter()
57                .filter_map(|e| e.ok())
58            {
59                let path = entry.path();
60                if path.is_file()
61                    && path
62                        .file_name()
63                        .map(|n| n == "morph-cli-plugin.toml")
64                        .unwrap_or(false)
65                {
66                    match self.load_manifest(path) {
67                        Ok(manifest) => {
68                            results.push(DiscoveryResult {
69                                path: path.to_path_buf(),
70                                manifest: Some(manifest),
71                                status: DiscoveryStatus::Found,
72                            });
73                        }
74                        Err(e) => {
75                            results.push(DiscoveryResult {
76                                path: path.to_path_buf(),
77                                manifest: None,
78                                status: DiscoveryStatus::Error(e.to_string()),
79                            });
80                        }
81                    }
82                }
83            }
84        }
85
86        results
87    }
88
89    fn load_manifest(&self, path: &Path) -> Result<PluginManifest, String> {
90        let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
91        let manifest: PluginManifest = toml::from_str(&content).map_err(|e| e.to_string())?;
92
93        let errors = manifest.validate();
94        if !errors.is_empty() {
95            return Err(format!("Validation failed: {:?}", errors));
96        }
97
98        Ok(manifest)
99    }
100
101    pub fn load_plugin(&mut self, path: &Path) -> Result<LoadedPlugin, LoadError> {
102        let manifest = Self::load_manifest_static(path)?;
103        let path_buf = path.to_path_buf();
104
105        self.loaded.push(LoadedPlugin {
106            manifest: manifest.clone(),
107            path: path_buf.clone(),
108            errors: Vec::new(),
109        });
110
111        Ok(LoadedPlugin {
112            manifest,
113            path: path_buf,
114            errors: Vec::new(),
115        })
116    }
117
118    fn load_manifest_static(path: &Path) -> Result<PluginManifest, LoadError> {
119        let content = std::fs::read_to_string(path).map_err(LoadError::Io)?;
120        let manifest: PluginManifest = toml::from_str(&content).map_err(LoadError::Parse)?;
121
122        let errors = manifest.validate();
123        if !errors.is_empty() {
124            return Err(LoadError::Validation(errors));
125        }
126
127        Ok(manifest)
128    }
129
130    pub fn loaded_plugins(&self) -> &[LoadedPlugin] {
131        &self.loaded
132    }
133
134    pub fn find_recipe(&self, recipe_name: &str) -> Option<&LoadedPlugin> {
135        self.loaded
136            .iter()
137            .find(|p| p.manifest.recipes.iter().any(|r| r.name == recipe_name))
138    }
139
140    pub fn incompatible_plugins(&self) -> Vec<Incompatibility> {
141        let mut issues = Vec::new();
142
143        for plugin in &self.loaded {
144            let version = env!("CARGO_PKG_VERSION");
145            let required = &plugin.manifest.compatibility.morph_cli_version;
146
147            if !version_matches(version, required) {
148                issues.push(Incompatibility {
149                    plugin_name: plugin.manifest.name.clone(),
150                    required_version: required.clone(),
151                    current_version: version.to_string(),
152                });
153            }
154        }
155
156        issues
157    }
158}
159
160impl Default for PluginLoader {
161    fn default() -> Self {
162        Self::new()
163    }
164}
165
166fn version_matches(current: &str, required: &str) -> bool {
167    super::manifest::satisfies_version(required, current)
168}
169
170#[derive(Debug, Clone)]
171pub struct DiscoveryResult {
172    pub path: PathBuf,
173    pub manifest: Option<PluginManifest>,
174    pub status: DiscoveryStatus,
175}
176
177#[derive(Debug, Clone)]
178pub enum DiscoveryStatus {
179    Found,
180    Error(String),
181}
182
183#[derive(Debug, Clone)]
184pub struct Incompatibility {
185    pub plugin_name: String,
186    pub required_version: String,
187    pub current_version: String,
188}
189
190#[derive(Debug, thiserror::Error)]
191pub enum LoadError {
192    #[error("IO error: {0}")]
193    Io(#[from] std::io::Error),
194    #[error("Parse error: {0}")]
195    Parse(#[from] toml::de::Error),
196    #[error("Validation errors: {0:?}")]
197    Validation(Vec<ValidationError>),
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::core::plugins::manifest::{Compatibility, RecipeEntry};
204    use std::io::Write;
205    use tempfile::TempDir;
206
207    fn create_plugin_dir() -> TempDir {
208        let dir = tempfile::tempdir().unwrap();
209        let manifest = r#"
210name = "test-plugin"
211version = "1.0.0"
212
213[[recipes]]
214name = "test-recipe"
215
216[compatibility]
217morph_cli_version = ">=0.1.0"
218"#;
219        let mut file = std::fs::File::create(dir.path().join("morph-cli-plugin.toml")).unwrap();
220        file.write_all(manifest.as_bytes()).unwrap();
221        dir
222    }
223
224    #[test]
225    fn test_discover_plugin() {
226        let dir = create_plugin_dir();
227        let mut loader = PluginLoader::new();
228        loader.add_search_path(dir.path().to_path_buf());
229
230        let results = loader.discover();
231        assert!(!results.is_empty());
232        if let Some(r) = results.first() {
233            if let Some(m) = &r.manifest {
234                assert_eq!(m.name, "test-plugin");
235            }
236        }
237    }
238
239    #[test]
240    fn test_load_plugin() {
241        let dir = create_plugin_dir();
242        let mut loader = PluginLoader::new();
243
244        let path = dir.path().join("morph-cli-plugin.toml");
245        let plugin = loader.load_plugin(&path).unwrap();
246        assert_eq!(plugin.manifest.name, "test-plugin");
247    }
248
249    #[test]
250    fn test_find_recipe() {
251        let dir = create_plugin_dir();
252        let mut loader = PluginLoader::new();
253        let path = dir.path().join("morph-cli-plugin.toml");
254        loader.load_plugin(&path).unwrap();
255
256        let found = loader.find_recipe("test-recipe");
257        assert!(found.is_some());
258    }
259
260    #[test]
261    fn test_version_matching() {
262        assert!(version_matches("1.0.0", ">=0.1.0"));
263        assert!(version_matches("1.0.0", "1.0.0"));
264        assert!(version_matches("1.0.0", ">0.9.0"));
265        assert!(!version_matches("0.9.0", ">=1.0.0"));
266    }
267
268    #[test]
269    fn test_incompatible_plugins() {
270        let mut loader = PluginLoader::new();
271        let manifest = PluginManifest {
272            name: "old-plugin".to_string(),
273            version: "1.0.0".to_string(),
274            description: None,
275            author: None,
276            recipes: vec![RecipeEntry {
277                name: "test".to_string(),
278                description: None,
279                entry_point: None,
280            }],
281            compatibility: Compatibility {
282                morph_cli_version: ">=99.0.0".to_string(),
283                language: None,
284                features: None,
285            },
286            metadata: serde_json::Value::Null,
287        };
288        loader.loaded.push(LoadedPlugin {
289            manifest,
290            path: PathBuf::from("/test"),
291            errors: vec![],
292        });
293
294        let incompatible = loader.incompatible_plugins();
295        assert!(!incompatible.is_empty());
296    }
297}