1use crate::models::{
4 InstalledPlugins, KnownMarketplaces, PluginConfig, PluginManifest, PluginMetadata, PluginScope,
5 PluginSource, PluginStatus,
6};
7use chrono::Utc;
8use std::path::{Path, PathBuf};
9use thiserror::Error;
10use tracing::{debug, info, warn};
11
12#[derive(Debug, Error)]
14pub enum PluginError {
15 #[error("Plugin not found: {0}")]
16 NotFound(String),
17 #[error("Plugin already installed: {0}")]
18 AlreadyInstalled(String),
19 #[error("Marketplace not found: {0}")]
20 MarketplaceNotFound(String),
21 #[error("Marketplace already exists: {0}")]
22 MarketplaceAlreadyExists(String),
23 #[error("Invalid plugin: {0}")]
24 InvalidPlugin(String),
25 #[error("IO error: {0}")]
26 Io(#[from] std::io::Error),
27 #[error("JSON error: {0}")]
28 Json(#[from] serde_json::Error),
29 #[error("Git operation failed: {0}")]
30 Git(String),
31 #[error("Plugin manager error: {0}")]
32 Other(String),
33}
34
35pub type Result<T> = std::result::Result<T, PluginError>;
36
37#[derive(Debug, Clone)]
39pub struct PluginPaths {
40 pub global_plugins_dir: PathBuf,
42 pub project_plugins_dir: PathBuf,
44 pub global_marketplaces_dir: PathBuf,
46 pub global_plugin_cache_dir: PathBuf,
48 pub known_marketplaces_file: PathBuf,
50 pub global_installed_plugins_file: PathBuf,
52 pub project_installed_plugins_file: PathBuf,
54}
55
56impl PluginPaths {
57 pub fn new(working_dir: Option<&Path>) -> Self {
59 let home = dirs_next::home_dir().unwrap_or_else(|| PathBuf::from("."));
60 let global_base = home.join(".opendev");
61 let project_base = working_dir
62 .map(|d| d.join(".opendev"))
63 .unwrap_or_else(|| PathBuf::from(".opendev"));
64
65 Self {
66 global_plugins_dir: global_base.join("plugins"),
67 project_plugins_dir: project_base.join("plugins"),
68 global_marketplaces_dir: global_base.join("marketplaces"),
69 global_plugin_cache_dir: global_base.join("plugins").join("cache"),
70 known_marketplaces_file: global_base.join("marketplaces.json"),
71 global_installed_plugins_file: global_base.join("installed_plugins.json"),
72 project_installed_plugins_file: project_base.join("installed_plugins.json"),
73 }
74 }
75}
76
77pub struct PluginManager {
79 pub working_dir: Option<PathBuf>,
81 pub paths: PluginPaths,
83}
84
85impl PluginManager {
86 pub fn new(working_dir: Option<PathBuf>) -> Self {
88 let paths = PluginPaths::new(working_dir.as_deref());
89 Self { working_dir, paths }
90 }
91
92 pub fn discover_plugins(&self) -> Result<Vec<PluginManifest>> {
97 let mut manifests = Vec::new();
98
99 if self.paths.project_plugins_dir.exists() {
101 debug!(
102 path = %self.paths.project_plugins_dir.display(),
103 "Scanning project plugins directory"
104 );
105 self.scan_directory(&self.paths.project_plugins_dir, &mut manifests)?;
106 }
107
108 if self.paths.global_plugins_dir.exists() {
110 debug!(
111 path = %self.paths.global_plugins_dir.display(),
112 "Scanning global plugins directory"
113 );
114 self.scan_directory(&self.paths.global_plugins_dir, &mut manifests)?;
115 }
116
117 info!(count = manifests.len(), "Discovered plugins");
118 Ok(manifests)
119 }
120
121 fn scan_directory(&self, dir: &Path, manifests: &mut Vec<PluginManifest>) -> Result<()> {
123 let entries = std::fs::read_dir(dir)?;
124 for entry in entries {
125 let entry = entry?;
126 let path = entry.path();
127 if path.is_dir() {
128 match self.load_manifest(&path) {
129 Ok(manifest) => {
130 debug!(name = %manifest.name, "Found plugin");
131 manifests.push(manifest);
132 }
133 Err(e) => {
134 warn!(
135 path = %path.display(),
136 error = %e,
137 "Skipping directory: failed to load manifest"
138 );
139 }
140 }
141 }
142 }
143 Ok(())
144 }
145
146 pub fn load_manifest(&self, plugin_dir: &Path) -> Result<PluginManifest> {
149 let possible_paths = [
150 plugin_dir.join(".opendev").join("manifest.json"),
151 plugin_dir.join("manifest.json"),
152 plugin_dir.join("plugin.json"),
153 ];
154
155 for path in &possible_paths {
156 if path.exists() {
157 let content = std::fs::read_to_string(path)?;
158 let manifest: PluginManifest = serde_json::from_str(&content)?;
159 return Ok(manifest);
160 }
161 }
162
163 Err(PluginError::InvalidPlugin(format!(
164 "No manifest.json found in {}",
165 plugin_dir.display()
166 )))
167 }
168
169 pub fn install_plugin(
173 &self,
174 plugin_name: &str,
175 marketplace_name: &str,
176 scope: PluginScope,
177 ) -> Result<PluginConfig> {
178 let marketplaces = self.load_known_marketplaces()?;
180 if !marketplaces.marketplaces.contains_key(marketplace_name) {
181 return Err(PluginError::MarketplaceNotFound(
182 marketplace_name.to_string(),
183 ));
184 }
185
186 let installed = self.load_installed_plugins(scope)?;
188 if installed.get(marketplace_name, plugin_name).is_some() {
189 return Err(PluginError::AlreadyInstalled(format!(
190 "{}:{}",
191 marketplace_name, plugin_name
192 )));
193 }
194
195 let marketplace_dir = self.paths.global_marketplaces_dir.join(marketplace_name);
197 let source_dir = marketplace_dir.join("plugins").join(plugin_name);
198 if !source_dir.exists() {
199 return Err(PluginError::NotFound(format!(
200 "Plugin '{}' not found in marketplace '{}'",
201 plugin_name, marketplace_name
202 )));
203 }
204
205 let manifest = self.load_manifest(&source_dir)?;
207
208 let cache_dir = match scope {
210 PluginScope::Project => self.paths.project_plugins_dir.join("cache"),
211 PluginScope::User => self.paths.global_plugin_cache_dir.clone(),
212 };
213 let target_dir = cache_dir
214 .join(marketplace_name)
215 .join(plugin_name)
216 .join(&manifest.version);
217
218 if target_dir.exists() {
220 std::fs::remove_dir_all(&target_dir)?;
221 }
222 copy_dir_recursive(&source_dir, &target_dir)?;
223
224 let config = PluginConfig {
226 name: plugin_name.to_string(),
227 version: manifest.version.clone(),
228 source: PluginSource::Marketplace {
229 marketplace: marketplace_name.to_string(),
230 },
231 status: PluginStatus::Installed,
232 scope,
233 enabled: true,
234 path: target_dir,
235 installed_at: Utc::now(),
236 marketplace: Some(marketplace_name.to_string()),
237 };
238
239 let mut installed = self.load_installed_plugins(scope)?;
240 installed.add(config.clone());
241 self.save_installed_plugins(&installed, scope)?;
242
243 info!(
244 plugin = plugin_name,
245 marketplace = marketplace_name,
246 "Plugin installed"
247 );
248 Ok(config)
249 }
250
251 pub fn uninstall_plugin(
253 &self,
254 plugin_name: &str,
255 marketplace_name: &str,
256 scope: PluginScope,
257 ) -> Result<()> {
258 let mut installed = self.load_installed_plugins(scope)?;
259 let plugin = installed
260 .remove(marketplace_name, plugin_name)
261 .ok_or_else(|| {
262 PluginError::NotFound(format!(
263 "Plugin '{}:{}' not installed in {:?} scope",
264 marketplace_name, plugin_name, scope
265 ))
266 })?;
267
268 if plugin.path.exists() {
270 std::fs::remove_dir_all(&plugin.path)?;
271 }
272
273 self.save_installed_plugins(&installed, scope)?;
274 info!(plugin = plugin_name, "Plugin uninstalled");
275 Ok(())
276 }
277
278 pub fn enable_plugin(
282 &self,
283 plugin_name: &str,
284 marketplace_name: &str,
285 scope: PluginScope,
286 ) -> Result<()> {
287 let mut installed = self.load_installed_plugins(scope)?;
288 let plugin = installed
289 .get_mut(marketplace_name, plugin_name)
290 .ok_or_else(|| {
291 PluginError::NotFound(format!(
292 "Plugin '{}:{}' not installed in {:?} scope",
293 marketplace_name, plugin_name, scope
294 ))
295 })?;
296
297 plugin.enabled = true;
298 plugin.status = PluginStatus::Installed;
299 self.save_installed_plugins(&installed, scope)?;
300 info!(plugin = plugin_name, "Plugin enabled");
301 Ok(())
302 }
303
304 pub fn disable_plugin(
306 &self,
307 plugin_name: &str,
308 marketplace_name: &str,
309 scope: PluginScope,
310 ) -> Result<()> {
311 let mut installed = self.load_installed_plugins(scope)?;
312 let plugin = installed
313 .get_mut(marketplace_name, plugin_name)
314 .ok_or_else(|| {
315 PluginError::NotFound(format!(
316 "Plugin '{}:{}' not installed in {:?} scope",
317 marketplace_name, plugin_name, scope
318 ))
319 })?;
320
321 plugin.enabled = false;
322 plugin.status = PluginStatus::Disabled;
323 self.save_installed_plugins(&installed, scope)?;
324 info!(plugin = plugin_name, "Plugin disabled");
325 Ok(())
326 }
327
328 pub fn list_installed(&self, scope: Option<PluginScope>) -> Result<Vec<PluginConfig>> {
332 match scope {
333 Some(s) => {
334 let installed = self.load_installed_plugins(s)?;
335 Ok(installed.plugins.into_values().collect())
336 }
337 None => {
338 let project = self.load_installed_plugins(PluginScope::Project)?;
340 let user = self.load_installed_plugins(PluginScope::User)?;
341
342 let mut all: Vec<PluginConfig> = project.plugins.values().cloned().collect();
343
344 let project_keys: std::collections::HashSet<_> =
345 project.plugins.keys().cloned().collect();
346 for (key, plugin) in &user.plugins {
347 if !project_keys.contains(key) {
348 all.push(plugin.clone());
349 }
350 }
351 Ok(all)
352 }
353 }
354 }
355
356 pub fn load_known_marketplaces(&self) -> Result<KnownMarketplaces> {
360 let path = &self.paths.known_marketplaces_file;
361 if !path.exists() {
362 return Ok(KnownMarketplaces::default());
363 }
364 let content = std::fs::read_to_string(path)?;
365 let marketplaces: KnownMarketplaces = serde_json::from_str(&content)?;
366 Ok(marketplaces)
367 }
368
369 pub fn save_known_marketplaces(&self, marketplaces: &KnownMarketplaces) -> Result<()> {
371 let path = &self.paths.known_marketplaces_file;
372 if let Some(parent) = path.parent() {
373 std::fs::create_dir_all(parent)?;
374 }
375 let content = serde_json::to_string_pretty(marketplaces)?;
376 std::fs::write(path, content)?;
377 Ok(())
378 }
379
380 pub fn load_installed_plugins(&self, scope: PluginScope) -> Result<InstalledPlugins> {
382 let path = match scope {
383 PluginScope::User => &self.paths.global_installed_plugins_file,
384 PluginScope::Project => &self.paths.project_installed_plugins_file,
385 };
386 if !path.exists() {
387 return Ok(InstalledPlugins::default());
388 }
389 let content = std::fs::read_to_string(path)?;
390 let plugins: InstalledPlugins = serde_json::from_str(&content)?;
391 Ok(plugins)
392 }
393
394 pub fn save_installed_plugins(
396 &self,
397 plugins: &InstalledPlugins,
398 scope: PluginScope,
399 ) -> Result<()> {
400 let path = match scope {
401 PluginScope::User => &self.paths.global_installed_plugins_file,
402 PluginScope::Project => &self.paths.project_installed_plugins_file,
403 };
404 if let Some(parent) = path.parent() {
405 std::fs::create_dir_all(parent)?;
406 }
407 let content = serde_json::to_string_pretty(plugins)?;
408 std::fs::write(path, content)?;
409 Ok(())
410 }
411
412 pub fn load_plugin_metadata(&self, plugin_dir: &Path) -> Result<PluginMetadata> {
416 let possible_paths = [
417 plugin_dir.join(".opendev").join("plugin.json"),
418 plugin_dir.join("plugin.json"),
419 ];
420
421 for path in &possible_paths {
422 if path.exists() {
423 let content = std::fs::read_to_string(path)?;
424 let metadata: PluginMetadata = serde_json::from_str(&content)?;
425 return Ok(metadata);
426 }
427 }
428
429 Err(PluginError::InvalidPlugin(format!(
430 "No plugin.json found in {}",
431 plugin_dir.display()
432 )))
433 }
434
435 pub fn parse_skill_metadata(skill_file: &Path) -> (String, String) {
437 let content = match std::fs::read_to_string(skill_file) {
438 Ok(c) => c,
439 Err(_) => return (String::new(), String::new()),
440 };
441
442 let mut name = String::new();
443 let mut description = String::new();
444
445 if content.starts_with("---") {
446 let parts: Vec<&str> = content.splitn(3, "---").collect();
447 if parts.len() >= 3 {
448 for line in parts[1].trim().lines() {
449 let line = line.trim();
450 if let Some(val) = line.strip_prefix("name:") {
451 name = val
452 .trim()
453 .trim_matches(|c| c == '"' || c == '\'')
454 .to_string();
455 } else if let Some(val) = line.strip_prefix("description:") {
456 description = val
457 .trim()
458 .trim_matches(|c| c == '"' || c == '\'')
459 .to_string();
460 }
461 }
462 }
463 }
464
465 (name, description)
466 }
467
468 pub fn discover_skills_in_dir(plugin_dir: &Path) -> Vec<String> {
470 let skills_dir = plugin_dir.join("skills");
471 let mut skills = Vec::new();
472 if skills_dir.exists()
473 && skills_dir.is_dir()
474 && let Ok(entries) = std::fs::read_dir(&skills_dir)
475 {
476 for entry in entries.flatten() {
477 let path = entry.path();
478 if path.is_dir()
479 && path.join("SKILL.md").exists()
480 && let Some(name) = path.file_name().and_then(|n| n.to_str())
481 {
482 skills.push(name.to_string());
483 }
484 }
485 }
486 skills
487 }
488
489 pub fn extract_name_from_url(url: &str) -> String {
491 let cleaned = regex::Regex::new(r"\.git$")
493 .unwrap()
494 .replace(url, "")
495 .to_string();
496
497 if let Ok(parsed) = url::Url::parse(&cleaned) {
499 let path = parsed.path().trim_matches('/');
500 if let Some(last) = path.split('/').next_back() {
501 let name = last.to_string();
502 let name = regex::Regex::new(r"^swecli-")
503 .unwrap()
504 .replace(&name, "")
505 .to_string();
506 let name = regex::Regex::new(r"-marketplace$")
507 .unwrap()
508 .replace(&name, "")
509 .to_string();
510 if !name.is_empty() {
511 return name;
512 }
513 }
514 }
515
516 if cleaned.contains('@')
518 && cleaned.contains(':')
519 && let Some(path_part) = cleaned.split(':').next_back()
520 && let Some(last) = path_part.trim_matches('/').split('/').next_back()
521 {
522 let name = last.to_string();
523 let name = regex::Regex::new(r"^swecli-")
524 .unwrap()
525 .replace(&name, "")
526 .to_string();
527 let name = regex::Regex::new(r"-marketplace$")
528 .unwrap()
529 .replace(&name, "")
530 .to_string();
531 if !name.is_empty() {
532 return name;
533 }
534 }
535
536 "default".to_string()
537 }
538}
539
540pub fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
542 std::fs::create_dir_all(dst)?;
543 for entry in std::fs::read_dir(src)? {
544 let entry = entry?;
545 let src_path = entry.path();
546 let dst_path = dst.join(entry.file_name());
547 if src_path.is_dir() {
548 copy_dir_recursive(&src_path, &dst_path)?;
549 } else {
550 std::fs::copy(&src_path, &dst_path)?;
551 }
552 }
553 Ok(())
554}