1use super::*;
12use crate::git::{GitPluginConfig, GitPluginLoader, GitPluginSource};
13use crate::loader::PluginLoader;
14use crate::metadata::{MetadataStore, PluginMetadata};
15use crate::remote::{RemotePluginConfig, RemotePluginLoader};
16use crate::signature::SignatureVerifier;
17use std::path::{Path, PathBuf};
18
19#[derive(Debug, Clone)]
21pub enum PluginSource {
22 Local(PathBuf),
24 Url {
26 url: String,
28 checksum: Option<String>,
30 },
31 Git(GitPluginSource),
33 Registry {
35 name: String,
37 version: Option<String>,
39 },
40}
41
42impl PluginSource {
43 pub fn parse(input: &str) -> LoaderResult<Self> {
51 let input = input.trim();
52
53 if input.starts_with("http://") || input.starts_with("https://") {
55 if input.contains(".git")
57 || input.contains("github.com")
58 || input.contains("gitlab.com")
59 {
60 let source = GitPluginSource::parse(input)?;
61 return Ok(PluginSource::Git(source));
62 }
63 return Ok(PluginSource::Url {
64 url: input.to_string(),
65 checksum: None,
66 });
67 }
68
69 if input.starts_with("git@") {
71 let source = GitPluginSource::parse(input)?;
72 return Ok(PluginSource::Git(source));
73 }
74
75 if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
77 return Ok(PluginSource::Local(PathBuf::from(input)));
78 }
79
80 let (name, version) = if let Some((n, v)) = input.split_once('@') {
82 (n.to_string(), Some(v.to_string()))
83 } else {
84 (input.to_string(), None)
85 };
86
87 Ok(PluginSource::Registry { name, version })
88 }
89}
90
91impl std::fmt::Display for PluginSource {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 match self {
94 PluginSource::Local(path) => write!(f, "local:{}", path.display()),
95 PluginSource::Url { url, .. } => write!(f, "url:{}", url),
96 PluginSource::Git(source) => write!(f, "git:{}", source),
97 PluginSource::Registry { name, version } => {
98 if let Some(v) = version {
99 write!(f, "registry:{}@{}", name, v)
100 } else {
101 write!(f, "registry:{}", name)
102 }
103 }
104 }
105 }
106}
107
108#[derive(Debug, Clone)]
110pub struct InstallOptions {
111 pub force: bool,
113 pub skip_validation: bool,
115 pub verify_signature: bool,
117 pub expected_checksum: Option<String>,
119}
120
121impl Default for InstallOptions {
122 fn default() -> Self {
123 Self {
124 force: false,
125 skip_validation: false,
126 verify_signature: true,
127 expected_checksum: None,
128 }
129 }
130}
131
132pub struct PluginInstaller {
134 loader: PluginLoader,
135 remote_loader: RemotePluginLoader,
136 git_loader: GitPluginLoader,
137 config: PluginLoaderConfig,
138 metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
139}
140
141impl PluginInstaller {
142 pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
144 let loader = PluginLoader::new(loader_config.clone());
145 let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
146 let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
147
148 let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
150 let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
151
152 Ok(Self {
153 loader,
154 remote_loader,
155 git_loader,
156 config: loader_config,
157 metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
158 })
159 }
160
161 pub async fn init(&self) -> LoaderResult<()> {
163 let mut store = self.metadata_store.write().await;
164 store.load().await
165 }
166
167 pub async fn install(
171 &self,
172 source_str: &str,
173 options: InstallOptions,
174 ) -> LoaderResult<PluginId> {
175 let source = PluginSource::parse(source_str)?;
176 self.install_from_source(&source, options).await
177 }
178
179 pub async fn install_from_source(
181 &self,
182 source: &PluginSource,
183 options: InstallOptions,
184 ) -> LoaderResult<PluginId> {
185 tracing::info!("Installing plugin from source: {}", source);
186
187 let plugin_dir = match source {
189 PluginSource::Local(path) => path.clone(),
190 PluginSource::Url { url, checksum } => {
191 let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
192 self.remote_loader.download_with_checksum(url, checksum_ref).await?
193 }
194 PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
195 PluginSource::Registry { name, version } => {
196 return Err(PluginLoaderError::load(format!(
197 "Registry plugin installation not yet implemented: {}@{}",
198 name,
199 version.as_deref().unwrap_or("latest")
200 )));
201 }
202 };
203
204 if options.verify_signature && !options.skip_validation {
206 if let Err(e) = self.verify_plugin_signature(&plugin_dir) {
207 tracing::warn!("Plugin signature verification failed: {}", e);
208 }
210 }
211
212 if !options.skip_validation {
214 self.loader.validate_plugin(&plugin_dir).await?;
215 }
216
217 let manifest = self.loader.validate_plugin(&plugin_dir).await?;
219 let plugin_id = manifest.info.id.clone();
220
221 if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
223 return Err(PluginLoaderError::already_loaded(plugin_id));
224 }
225
226 self.loader.load_plugin(&plugin_id).await?;
228
229 let version = manifest.info.version.to_string();
231 let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
232 let mut store = self.metadata_store.write().await;
233 store.save(metadata).await?;
234
235 tracing::info!("Plugin installed successfully: {}", plugin_id);
236 Ok(plugin_id)
237 }
238
239 fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
241 let verifier = SignatureVerifier::new(&self.config);
242 verifier.verify_plugin_signature(plugin_dir)
243 }
244
245 pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
247 self.loader.unload_plugin(plugin_id).await?;
248
249 let mut store = self.metadata_store.write().await;
251 store.remove(plugin_id).await?;
252
253 Ok(())
254 }
255
256 pub async fn list_installed(&self) -> Vec<PluginId> {
258 self.loader.list_plugins().await
259 }
260
261 pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
263 tracing::info!("Updating plugin: {}", plugin_id);
264
265 let metadata = {
267 let store = self.metadata_store.read().await;
268 store.get(plugin_id).cloned().ok_or_else(|| {
269 PluginLoaderError::load(format!(
270 "No installation metadata found for plugin {}. Cannot update.",
271 plugin_id
272 ))
273 })?
274 };
275
276 tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
277
278 if self.loader.get_plugin(plugin_id).await.is_some() {
280 self.loader.unload_plugin(plugin_id).await?;
281 }
282
283 let options = InstallOptions {
285 force: true,
286 skip_validation: false,
287 verify_signature: true,
288 expected_checksum: None,
289 };
290
291 let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
292
293 if new_plugin_id != *plugin_id {
295 return Err(PluginLoaderError::load(format!(
296 "Plugin ID mismatch after update: expected {}, got {}",
297 plugin_id, new_plugin_id
298 )));
299 }
300
301 let new_manifest = self
303 .loader
304 .get_plugin(&new_plugin_id)
305 .await
306 .ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
307 .manifest;
308
309 let mut store = self.metadata_store.write().await;
310 if let Some(meta) = store.get(plugin_id).cloned() {
311 let mut updated_meta = meta;
312 updated_meta.mark_updated(new_manifest.info.version.to_string());
313 store.save(updated_meta).await?;
314 }
315
316 tracing::info!("Plugin {} updated successfully", plugin_id);
317 Ok(())
318 }
319
320 pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
322 tracing::info!("Updating all plugins");
323
324 let plugin_ids = {
326 let store = self.metadata_store.read().await;
327 store.list()
328 };
329
330 if plugin_ids.is_empty() {
331 tracing::info!("No plugins found with metadata to update");
332 return Ok(Vec::new());
333 }
334
335 tracing::info!("Found {} plugins to update", plugin_ids.len());
336
337 let mut updated = Vec::new();
338 let mut failed = Vec::new();
339
340 for plugin_id in plugin_ids {
342 match self.update(&plugin_id).await {
343 Ok(_) => {
344 tracing::info!("Successfully updated plugin: {}", plugin_id);
345 updated.push(plugin_id);
346 }
347 Err(e) => {
348 tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
349 failed.push((plugin_id, e.to_string()));
350 }
351 }
352 }
353
354 tracing::info!(
355 "Plugin update complete: {} succeeded, {} failed",
356 updated.len(),
357 failed.len()
358 );
359
360 if !failed.is_empty() {
361 let failed_list = failed
362 .iter()
363 .map(|(id, err)| format!("{}: {}", id, err))
364 .collect::<Vec<_>>()
365 .join(", ");
366 tracing::warn!("Failed updates: {}", failed_list);
367 }
368
369 Ok(updated)
370 }
371
372 pub async fn clear_caches(&self) -> LoaderResult<()> {
374 self.remote_loader.clear_cache().await?;
375 self.git_loader.clear_cache().await?;
376 Ok(())
377 }
378
379 pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
381 let download_cache_size = self.remote_loader.get_cache_size()?;
382 let git_cache_size = self.git_loader.get_cache_size()?;
383
384 Ok(CacheStats {
385 download_cache_size,
386 git_cache_size,
387 total_size: download_cache_size + git_cache_size,
388 })
389 }
390
391 pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
393 let store = self.metadata_store.read().await;
394 store.get(plugin_id).cloned()
395 }
396
397 pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
399 let store = self.metadata_store.read().await;
400 store
401 .list()
402 .into_iter()
403 .filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
404 .collect()
405 }
406}
407
408#[derive(Debug, Clone)]
410pub struct CacheStats {
411 pub download_cache_size: u64,
413 pub git_cache_size: u64,
415 pub total_size: u64,
417}
418
419impl CacheStats {
420 pub fn format_size(bytes: u64) -> String {
422 const KB: u64 = 1024;
423 const MB: u64 = KB * 1024;
424 const GB: u64 = MB * 1024;
425
426 if bytes >= GB {
427 format!("{:.2} GB", bytes as f64 / GB as f64)
428 } else if bytes >= MB {
429 format!("{:.2} MB", bytes as f64 / MB as f64)
430 } else if bytes >= KB {
431 format!("{:.2} KB", bytes as f64 / KB as f64)
432 } else {
433 format!("{} bytes", bytes)
434 }
435 }
436
437 pub fn download_cache_formatted(&self) -> String {
439 Self::format_size(self.download_cache_size)
440 }
441
442 pub fn git_cache_formatted(&self) -> String {
444 Self::format_size(self.git_cache_size)
445 }
446
447 pub fn total_formatted(&self) -> String {
449 Self::format_size(self.total_size)
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 #[test]
460 fn test_plugin_source_parse_url() {
461 let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
462 assert!(matches!(source, PluginSource::Url { .. }));
463 }
464
465 #[test]
466 fn test_plugin_source_parse_git_https() {
467 let source = PluginSource::parse("https://github.com/user/repo").unwrap();
468 assert!(matches!(source, PluginSource::Git(_)));
469 }
470
471 #[test]
472 fn test_plugin_source_parse_git_ssh() {
473 let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
474 assert!(matches!(source, PluginSource::Git(_)));
475 }
476
477 #[test]
478 fn test_plugin_source_parse_gitlab() {
479 let source = PluginSource::parse("https://gitlab.com/user/repo").unwrap();
480 assert!(matches!(source, PluginSource::Git(_)));
481 }
482
483 #[test]
484 fn test_plugin_source_parse_local() {
485 let source = PluginSource::parse("/path/to/plugin").unwrap();
486 assert!(matches!(source, PluginSource::Local(_)));
487
488 let source = PluginSource::parse("./relative/path").unwrap();
489 assert!(matches!(source, PluginSource::Local(_)));
490 }
491
492 #[test]
493 fn test_plugin_source_parse_registry() {
494 let source = PluginSource::parse("auth-jwt").unwrap();
495 assert!(matches!(source, PluginSource::Registry { .. }));
496
497 let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
498 if let PluginSource::Registry { name, version } = source {
499 assert_eq!(name, "auth-jwt");
500 assert_eq!(version, Some("1.0.0".to_string()));
501 } else {
502 panic!("Expected Registry source");
503 }
504 }
505
506 #[test]
507 fn test_plugin_source_parse_registry_without_version() {
508 let source = PluginSource::parse("my-plugin").unwrap();
509 if let PluginSource::Registry { name, version } = source {
510 assert_eq!(name, "my-plugin");
511 assert!(version.is_none());
512 } else {
513 panic!("Expected Registry source");
514 }
515 }
516
517 #[test]
518 fn test_plugin_source_parse_url_with_checksum() {
519 let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
520 if let PluginSource::Url { url, checksum } = source {
521 assert_eq!(url, "https://example.com/plugin.zip");
522 assert!(checksum.is_none());
523 } else {
524 panic!("Expected URL source");
525 }
526 }
527
528 #[test]
529 fn test_plugin_source_parse_empty_string() {
530 let source = PluginSource::parse("").unwrap();
531 assert!(matches!(source, PluginSource::Registry { .. }));
533 }
534
535 #[test]
536 fn test_plugin_source_parse_whitespace() {
537 let source = PluginSource::parse(" https://example.com/plugin.zip ").unwrap();
538 assert!(matches!(source, PluginSource::Url { .. }));
539 }
540
541 #[test]
542 fn test_plugin_source_display() {
543 let source = PluginSource::Local(PathBuf::from("/tmp/plugin"));
544 assert_eq!(source.to_string(), "local:/tmp/plugin");
545
546 let source = PluginSource::Url {
547 url: "https://example.com/plugin.zip".to_string(),
548 checksum: None,
549 };
550 assert_eq!(source.to_string(), "url:https://example.com/plugin.zip");
551
552 let source = PluginSource::Registry {
553 name: "my-plugin".to_string(),
554 version: Some("1.0.0".to_string()),
555 };
556 assert_eq!(source.to_string(), "registry:my-plugin@1.0.0");
557
558 let source = PluginSource::Registry {
559 name: "my-plugin".to_string(),
560 version: None,
561 };
562 assert_eq!(source.to_string(), "registry:my-plugin");
563 }
564
565 #[test]
566 fn test_plugin_source_clone() {
567 let source = PluginSource::Local(PathBuf::from("/tmp"));
568 let cloned = source.clone();
569 assert_eq!(source.to_string(), cloned.to_string());
570 }
571
572 #[test]
575 fn test_install_options_default() {
576 let options = InstallOptions::default();
577 assert!(!options.force);
578 assert!(!options.skip_validation);
579 assert!(options.verify_signature);
580 assert!(options.expected_checksum.is_none());
581 }
582
583 #[test]
584 fn test_install_options_with_force() {
585 let options = InstallOptions {
586 force: true,
587 ..Default::default()
588 };
589 assert!(options.force);
590 }
591
592 #[test]
593 fn test_install_options_with_checksum() {
594 let options = InstallOptions {
595 expected_checksum: Some("abc123".to_string()),
596 ..Default::default()
597 };
598 assert_eq!(options.expected_checksum, Some("abc123".to_string()));
599 }
600
601 #[test]
602 fn test_install_options_skip_validation() {
603 let options = InstallOptions {
604 skip_validation: true,
605 verify_signature: false,
606 ..Default::default()
607 };
608 assert!(options.skip_validation);
609 assert!(!options.verify_signature);
610 }
611
612 #[test]
613 fn test_install_options_clone() {
614 let options = InstallOptions {
615 force: true,
616 skip_validation: false,
617 verify_signature: true,
618 expected_checksum: Some("test".to_string()),
619 };
620 let cloned = options.clone();
621 assert_eq!(options.force, cloned.force);
622 assert_eq!(options.expected_checksum, cloned.expected_checksum);
623 }
624
625 #[test]
628 fn test_cache_stats_formatting() {
629 assert_eq!(CacheStats::format_size(512), "512 bytes");
630 assert_eq!(CacheStats::format_size(1024), "1.00 KB");
631 assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
632 assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
633 }
634
635 #[test]
636 fn test_cache_stats_edge_cases() {
637 assert_eq!(CacheStats::format_size(0), "0 bytes");
638 assert_eq!(CacheStats::format_size(1), "1 bytes");
639 assert_eq!(CacheStats::format_size(1023), "1023 bytes");
640 assert_eq!(CacheStats::format_size(1025), "1.00 KB");
641 }
642
643 #[test]
644 fn test_cache_stats_large_values() {
645 let tb = 1024u64 * 1024 * 1024 * 1024;
646 assert!(CacheStats::format_size(tb).contains("GB"));
647 }
648
649 #[test]
650 fn test_cache_stats_formatted_methods() {
651 let stats = CacheStats {
652 download_cache_size: 1024 * 1024,
653 git_cache_size: 2 * 1024 * 1024,
654 total_size: 3 * 1024 * 1024,
655 };
656
657 assert_eq!(stats.download_cache_formatted(), "1.00 MB");
658 assert_eq!(stats.git_cache_formatted(), "2.00 MB");
659 assert_eq!(stats.total_formatted(), "3.00 MB");
660 }
661
662 #[test]
663 fn test_cache_stats_total_calculation() {
664 let stats = CacheStats {
665 download_cache_size: 100,
666 git_cache_size: 200,
667 total_size: 300,
668 };
669
670 assert_eq!(stats.total_size, stats.download_cache_size + stats.git_cache_size);
671 }
672
673 #[test]
674 fn test_cache_stats_clone() {
675 let stats = CacheStats {
676 download_cache_size: 1024,
677 git_cache_size: 2048,
678 total_size: 3072,
679 };
680
681 let cloned = stats.clone();
682 assert_eq!(stats.download_cache_size, cloned.download_cache_size);
683 assert_eq!(stats.git_cache_size, cloned.git_cache_size);
684 assert_eq!(stats.total_size, cloned.total_size);
685 }
686
687 #[test]
690 fn test_plugin_source_parse_http_url() {
691 let source = PluginSource::parse("http://example.com/plugin.zip").unwrap();
692 assert!(matches!(source, PluginSource::Url { .. }));
693 }
694
695 #[test]
696 fn test_plugin_source_parse_windows_path() {
697 let source = PluginSource::parse("C:\\Users\\plugin").unwrap();
698 assert!(matches!(source, PluginSource::Local(_)));
699 }
700
701 #[test]
702 fn test_plugin_source_parse_registry_with_special_chars() {
703 let source = PluginSource::parse("my-plugin-name_v2@2.0.0-beta").unwrap();
704 if let PluginSource::Registry { name, version } = source {
705 assert_eq!(name, "my-plugin-name_v2");
706 assert_eq!(version, Some("2.0.0-beta".to_string()));
707 } else {
708 panic!("Expected Registry source");
709 }
710 }
711
712 #[test]
713 fn test_plugin_source_parse_github_dotgit_in_url() {
714 let source = PluginSource::parse("https://github.com/user/repo.git").unwrap();
715 assert!(matches!(source, PluginSource::Git(_)));
716 }
717}