morph_cli/core/plugins/
loader.rs1use 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}