opendev_plugins/
marketplace.rs1use crate::manager::{PluginError, PluginManager, Result};
4use crate::models::{MarketplaceCatalog, MarketplaceInfo, PluginMetadata};
5use chrono::Utc;
6use std::path::Path;
7use tracing::info;
8
9impl PluginManager {
10 pub fn add_marketplace(
16 &self,
17 url: &str,
18 name: Option<&str>,
19 branch: &str,
20 ) -> Result<MarketplaceInfo> {
21 let name = name
22 .map(String::from)
23 .unwrap_or_else(|| Self::extract_name_from_url(url));
24
25 let mut marketplaces = self.load_known_marketplaces()?;
27 if marketplaces.marketplaces.contains_key(&name) {
28 return Err(PluginError::MarketplaceAlreadyExists(name));
29 }
30
31 let target_dir = self.paths.global_marketplaces_dir.join(&name);
33 if target_dir.exists() {
34 std::fs::remove_dir_all(&target_dir)?;
35 }
36
37 self.git_clone(url, branch, &target_dir)?;
39
40 if !self.validate_marketplace(&target_dir) {
42 let _ = std::fs::remove_dir_all(&target_dir);
43 return Err(PluginError::InvalidPlugin(
44 "Invalid marketplace: no marketplace.json, plugins/, or skills/ directory found"
45 .to_string(),
46 ));
47 }
48
49 let info = MarketplaceInfo {
51 name: name.clone(),
52 url: url.to_string(),
53 branch: branch.to_string(),
54 added_at: Utc::now(),
55 last_updated: Some(Utc::now()),
56 };
57 marketplaces.marketplaces.insert(name.clone(), info.clone());
58 self.save_known_marketplaces(&marketplaces)?;
59
60 info!(marketplace = %name, "Marketplace added");
61 Ok(info)
62 }
63
64 pub fn remove_marketplace(&self, name: &str) -> Result<()> {
66 let mut marketplaces = self.load_known_marketplaces()?;
67 if !marketplaces.marketplaces.contains_key(name) {
68 return Err(PluginError::MarketplaceNotFound(name.to_string()));
69 }
70
71 let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
73 if marketplace_dir.exists() {
74 std::fs::remove_dir_all(&marketplace_dir)?;
75 }
76
77 marketplaces.marketplaces.remove(name);
78 self.save_known_marketplaces(&marketplaces)?;
79 info!(marketplace = name, "Marketplace removed");
80 Ok(())
81 }
82
83 pub fn list_marketplaces(&self) -> Result<Vec<MarketplaceInfo>> {
85 let marketplaces = self.load_known_marketplaces()?;
86 Ok(marketplaces.marketplaces.into_values().collect())
87 }
88
89 pub fn sync_marketplace(&self, name: &str) -> Result<()> {
91 let mut marketplaces = self.load_known_marketplaces()?;
92 if !marketplaces.marketplaces.contains_key(name) {
93 return Err(PluginError::MarketplaceNotFound(name.to_string()));
94 }
95
96 let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
97 if !marketplace_dir.exists() {
98 return Err(PluginError::Other(format!(
99 "Marketplace directory missing: {}",
100 marketplace_dir.display()
101 )));
102 }
103
104 self.git_pull(&marketplace_dir)?;
105
106 if let Some(info) = marketplaces.marketplaces.get_mut(name) {
108 info.last_updated = Some(Utc::now());
109 }
110 self.save_known_marketplaces(&marketplaces)?;
111 info!(marketplace = name, "Marketplace synced");
112 Ok(())
113 }
114
115 pub fn sync_all_marketplaces(
117 &self,
118 ) -> Result<std::collections::HashMap<String, Option<String>>> {
119 let mut results = std::collections::HashMap::new();
120 let marketplaces = self.list_marketplaces()?;
121 for m in marketplaces {
122 match self.sync_marketplace(&m.name) {
123 Ok(()) => {
124 results.insert(m.name, None);
125 }
126 Err(e) => {
127 results.insert(m.name, Some(e.to_string()));
128 }
129 }
130 }
131 Ok(results)
132 }
133
134 pub fn get_marketplace_catalog(&self, name: &str) -> Result<MarketplaceCatalog> {
139 let marketplaces = self.load_known_marketplaces()?;
140 if !marketplaces.marketplaces.contains_key(name) {
141 return Err(PluginError::MarketplaceNotFound(name.to_string()));
142 }
143
144 let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
145 if let Some(catalog_path) = self.get_marketplace_json_path(&marketplace_dir) {
146 let content = std::fs::read_to_string(catalog_path)?;
147 let catalog: MarketplaceCatalog = serde_json::from_str(&content)?;
148 return Ok(catalog);
149 }
150
151 Ok(self.auto_discover_catalog(&marketplace_dir))
153 }
154
155 pub fn list_marketplace_plugins(&self, name: &str) -> Result<Vec<PluginMetadata>> {
157 let catalog = self.get_marketplace_catalog(name)?;
158 let marketplace_dir = self.paths.global_marketplaces_dir.join(name);
159 let mut plugins = Vec::new();
160
161 let plugins_dir = marketplace_dir.join("plugins");
163 if plugins_dir.exists() {
164 for plugin_name in &catalog.plugins {
165 let plugin_dir = plugins_dir.join(plugin_name);
166 if plugin_dir.exists() {
167 match self.load_plugin_metadata(&plugin_dir) {
168 Ok(metadata) => plugins.push(metadata),
169 Err(_) => {
170 plugins.push(PluginMetadata {
171 name: plugin_name.clone(),
172 version: "0.0.0".to_string(),
173 description: format!("Plugin: {}", plugin_name),
174 author: None,
175 skills: Self::discover_skills_in_dir(&plugin_dir),
176 repository: None,
177 license: None,
178 });
179 }
180 }
181 }
182 }
183 }
184
185 let skills_dir = marketplace_dir.join("skills");
187 if skills_dir.exists() && catalog.auto_discovered {
188 for skill_name in &catalog.plugins {
189 let skill_dir = skills_dir.join(skill_name);
190 if skill_dir.exists() && skill_dir.join("SKILL.md").exists() {
191 if plugins.iter().any(|p| p.name == *skill_name) {
193 continue;
194 }
195 let (_name, desc) = Self::parse_skill_metadata(&skill_dir.join("SKILL.md"));
196 plugins.push(PluginMetadata {
197 name: skill_name.clone(),
198 version: "0.0.0".to_string(),
199 description: if desc.is_empty() {
200 format!("Skill: {}", skill_name)
201 } else {
202 desc
203 },
204 author: None,
205 skills: vec![skill_name.clone()],
206 repository: None,
207 license: None,
208 });
209 }
210 }
211 }
212
213 Ok(plugins)
214 }
215
216 pub fn search_marketplace(
218 &self,
219 marketplace_name: &str,
220 query: &str,
221 ) -> Result<Vec<PluginMetadata>> {
222 let plugins = self.list_marketplace_plugins(marketplace_name)?;
223 let query_lower = query.to_lowercase();
224 Ok(plugins
225 .into_iter()
226 .filter(|p| {
227 p.name.to_lowercase().contains(&query_lower)
228 || p.description.to_lowercase().contains(&query_lower)
229 })
230 .collect())
231 }
232
233 pub async fn fetch_remote_catalog(registry_url: &str) -> Result<MarketplaceCatalog> {
237 let client = reqwest::Client::new();
238 let response = client
239 .get(registry_url)
240 .header("Accept", "application/json")
241 .send()
242 .await
243 .map_err(|e| PluginError::Other(format!("HTTP request failed: {}", e)))?;
244
245 if !response.status().is_success() {
246 return Err(PluginError::Other(format!(
247 "Registry returned status {}",
248 response.status()
249 )));
250 }
251
252 let text = response
253 .text()
254 .await
255 .map_err(|e| PluginError::Other(format!("Failed to read response body: {}", e)))?;
256
257 let catalog: MarketplaceCatalog = serde_json::from_str(&text)?;
258
259 Ok(catalog)
260 }
261
262 fn validate_marketplace(&self, directory: &Path) -> bool {
266 if self.get_marketplace_json_path(directory).is_some() {
268 return true;
269 }
270 let plugins_dir = directory.join("plugins");
272 if plugins_dir.exists() && plugins_dir.is_dir() {
273 return true;
274 }
275 let skills_dir = directory.join("skills");
276 if skills_dir.exists() && skills_dir.is_dir() {
277 return true;
278 }
279 false
280 }
281
282 fn get_marketplace_json_path(&self, directory: &Path) -> Option<std::path::PathBuf> {
284 let possible_paths = [
285 directory.join(".opendev").join("marketplace.json"),
286 directory.join("marketplace.json"),
287 ];
288 possible_paths.into_iter().find(|p| p.exists())
289 }
290
291 fn auto_discover_catalog(&self, marketplace_dir: &Path) -> MarketplaceCatalog {
293 let mut plugin_names = Vec::new();
294
295 let plugins_dir = marketplace_dir.join("plugins");
297 if plugins_dir.exists()
298 && plugins_dir.is_dir()
299 && let Ok(entries) = std::fs::read_dir(&plugins_dir)
300 {
301 for entry in entries.flatten() {
302 if entry.path().is_dir()
303 && let Some(name) = entry.file_name().to_str()
304 {
305 plugin_names.push(name.to_string());
306 }
307 }
308 }
309
310 let skills_dir = marketplace_dir.join("skills");
312 if skills_dir.exists()
313 && skills_dir.is_dir()
314 && let Ok(entries) = std::fs::read_dir(&skills_dir)
315 {
316 for entry in entries.flatten() {
317 let path = entry.path();
318 if path.is_dir()
319 && path.join("SKILL.md").exists()
320 && let Some(name) = entry.file_name().to_str()
321 {
322 plugin_names.push(name.to_string());
323 }
324 }
325 }
326
327 MarketplaceCatalog {
328 plugins: plugin_names,
329 auto_discovered: true,
330 }
331 }
332
333 fn git_clone(&self, url: &str, branch: &str, target_dir: &Path) -> Result<()> {
335 let output = std::process::Command::new("git")
336 .args([
337 "clone",
338 "--depth",
339 "1",
340 "--branch",
341 branch,
342 url,
343 &target_dir.to_string_lossy(),
344 ])
345 .output()
346 .map_err(|e| PluginError::Git(format!("Failed to run git: {}", e)))?;
347
348 if !output.status.success() {
349 let stderr = String::from_utf8_lossy(&output.stderr);
350 return Err(PluginError::Git(format!("Git clone failed: {}", stderr)));
351 }
352 Ok(())
353 }
354
355 fn git_pull(&self, dir: &Path) -> Result<()> {
357 let output = std::process::Command::new("git")
358 .args(["pull"])
359 .current_dir(dir)
360 .output()
361 .map_err(|e| PluginError::Git(format!("Failed to run git: {}", e)))?;
362
363 if !output.status.success() {
364 let stderr = String::from_utf8_lossy(&output.stderr);
365 return Err(PluginError::Git(format!("Git pull failed: {}", stderr)));
366 }
367 Ok(())
368 }
369}