Skip to main content

mockforge_plugin_loader/
installer.rs

1//! Unified plugin installer
2//!
3//! This module provides a high-level API for installing plugins from various sources:
4//! - Local file paths
5//! - HTTP/HTTPS URLs
6//! - Git repositories
7//! - Plugin registries (future)
8//!
9//! It automatically detects the source type and uses the appropriate loader.
10
11use 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 serde::Deserialize;
18use std::path::{Path, PathBuf};
19
20/// Plugin source specification
21#[derive(Debug, Clone)]
22pub enum PluginSource {
23    /// Local file path or directory
24    Local(PathBuf),
25    /// HTTP/HTTPS URL
26    Url {
27        /// URL to download the plugin from
28        url: String,
29        /// Optional SHA-256 checksum for verification
30        checksum: Option<String>,
31    },
32    /// Git repository
33    Git(GitPluginSource),
34    /// Plugin registry (future)
35    Registry {
36        /// Plugin name in the registry
37        name: String,
38        /// Optional version string (defaults to latest)
39        version: Option<String>,
40    },
41}
42
43impl PluginSource {
44    /// Parse a plugin source from a string
45    ///
46    /// Automatically detects the source type:
47    /// - Starts with "http://" or "https://" → URL
48    /// - Contains ".git" or starts with "git@" → Git
49    /// - Contains "/" or "\" → Local path
50    /// - Otherwise → Registry name
51    pub fn parse(input: &str) -> LoaderResult<Self> {
52        let input = input.trim();
53
54        // Check for URL
55        if input.starts_with("http://") || input.starts_with("https://") {
56            // Check if it's a Git repository URL
57            if input.contains(".git")
58                || input.contains("github.com")
59                || input.contains("gitlab.com")
60            {
61                let source = GitPluginSource::parse(input)?;
62                return Ok(PluginSource::Git(source));
63            }
64            return Ok(PluginSource::Url {
65                url: input.to_string(),
66                checksum: None,
67            });
68        }
69
70        // Check for SSH Git URL
71        if input.starts_with("git@") {
72            let source = GitPluginSource::parse(input)?;
73            return Ok(PluginSource::Git(source));
74        }
75
76        // Check for local path
77        if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
78            return Ok(PluginSource::Local(PathBuf::from(input)));
79        }
80
81        // Parse as registry reference
82        let (name, version) = if let Some((n, v)) = input.split_once('@') {
83            (n.to_string(), Some(v.to_string()))
84        } else {
85            (input.to_string(), None)
86        };
87
88        Ok(PluginSource::Registry { name, version })
89    }
90}
91
92impl std::fmt::Display for PluginSource {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        match self {
95            PluginSource::Local(path) => write!(f, "local:{}", path.display()),
96            PluginSource::Url { url, .. } => write!(f, "url:{}", url),
97            PluginSource::Git(source) => write!(f, "git:{}", source),
98            PluginSource::Registry { name, version } => {
99                if let Some(v) = version {
100                    write!(f, "registry:{}@{}", name, v)
101                } else {
102                    write!(f, "registry:{}", name)
103                }
104            }
105        }
106    }
107}
108
109/// Installation options
110#[derive(Debug, Clone)]
111pub struct InstallOptions {
112    /// Force reinstall even if plugin already exists
113    pub force: bool,
114    /// Skip validation checks
115    pub skip_validation: bool,
116    /// Verify plugin signature (if available)
117    pub verify_signature: bool,
118    /// Expected checksum for verification (URL sources)
119    pub expected_checksum: Option<String>,
120}
121
122impl Default for InstallOptions {
123    fn default() -> Self {
124        Self {
125            force: false,
126            skip_validation: false,
127            verify_signature: true,
128            expected_checksum: None,
129        }
130    }
131}
132
133/// Unified plugin installer
134pub struct PluginInstaller {
135    loader: PluginLoader,
136    remote_loader: RemotePluginLoader,
137    git_loader: GitPluginLoader,
138    config: PluginLoaderConfig,
139    metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
140}
141
142impl PluginInstaller {
143    /// Create a new plugin installer with default configuration
144    pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
145        let loader = PluginLoader::new(loader_config.clone());
146        let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
147        let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
148
149        // Create metadata store in a standard location
150        let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
151        let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
152
153        Ok(Self {
154            loader,
155            remote_loader,
156            git_loader,
157            config: loader_config,
158            metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
159        })
160    }
161
162    /// Initialize the installer (creates directories, loads metadata)
163    pub async fn init(&self) -> LoaderResult<()> {
164        let mut store = self.metadata_store.write().await;
165        store.load().await
166    }
167
168    /// Install a plugin from a source string
169    ///
170    /// Automatically detects and handles the source type
171    pub async fn install(
172        &self,
173        source_str: &str,
174        options: InstallOptions,
175    ) -> LoaderResult<PluginId> {
176        let source = PluginSource::parse(source_str)?;
177        self.install_from_source(&source, options).await
178    }
179
180    /// Install a plugin from a specific source
181    pub async fn install_from_source(
182        &self,
183        source: &PluginSource,
184        options: InstallOptions,
185    ) -> LoaderResult<PluginId> {
186        tracing::info!("Installing plugin from source: {}", source);
187
188        // Get the plugin directory
189        let plugin_dir = match source {
190            PluginSource::Local(path) => path.clone(),
191            PluginSource::Url { url, checksum } => {
192                let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
193                self.remote_loader.download_with_checksum(url, checksum_ref).await?
194            }
195            PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
196            PluginSource::Registry { name, version } => {
197                self.install_from_registry(name, version.as_deref(), &options).await?
198            }
199        };
200
201        // Verify signature if enabled
202        if options.verify_signature && !options.skip_validation {
203            if let Err(e) = self.verify_plugin_signature(&plugin_dir) {
204                tracing::warn!("Plugin signature verification failed: {}", e);
205                // Don't fail installation, just warn
206            }
207        }
208
209        // Validate the plugin
210        if !options.skip_validation {
211            self.loader.validate_plugin(&plugin_dir).await?;
212        }
213
214        // Load the plugin
215        let manifest = self.loader.validate_plugin(&plugin_dir).await?;
216        let plugin_id = manifest.info.id.clone();
217
218        // Check if plugin is already loaded
219        if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
220            return Err(PluginLoaderError::already_loaded(plugin_id));
221        }
222
223        // Load the plugin
224        self.loader.load_plugin(&plugin_id).await?;
225
226        // Save metadata for future updates
227        let version = manifest.info.version.to_string();
228        let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
229        let mut store = self.metadata_store.write().await;
230        store.save(metadata).await?;
231
232        tracing::info!("Plugin installed successfully: {}", plugin_id);
233        Ok(plugin_id)
234    }
235
236    /// Install plugin from a registry source.
237    async fn install_from_registry(
238        &self,
239        name: &str,
240        version: Option<&str>,
241        options: &InstallOptions,
242    ) -> LoaderResult<PathBuf> {
243        let base_url = std::env::var("MOCKFORGE_PLUGIN_REGISTRY_URL")
244            .unwrap_or_else(|_| "https://registry.mockforge.dev".to_string());
245        let client = reqwest::Client::new();
246
247        let (download_url, checksum) = if let Some(v) = version {
248            let version_url = format!("{}/api/v1/plugins/{}/versions/{}", base_url, name, v);
249            let response = client.get(&version_url).send().await.map_err(|e| {
250                PluginLoaderError::load(format!(
251                    "Failed to fetch registry version metadata for {}@{}: {}",
252                    name, v, e
253                ))
254            })?;
255
256            if !response.status().is_success() {
257                return Err(PluginLoaderError::load(format!(
258                    "Registry lookup failed for {}@{}: {}",
259                    name,
260                    v,
261                    response.status()
262                )));
263            }
264
265            let entry: RegistryVersionResponse = response.json().await.map_err(|e| {
266                PluginLoaderError::load(format!(
267                    "Invalid registry response for {}@{}: {}",
268                    name, v, e
269                ))
270            })?;
271            (entry.download_url, entry.checksum)
272        } else {
273            let plugin_url = format!("{}/api/v1/plugins/{}", base_url, name);
274            let response = client.get(&plugin_url).send().await.map_err(|e| {
275                PluginLoaderError::load(format!(
276                    "Failed to fetch registry plugin metadata for {}: {}",
277                    name, e
278                ))
279            })?;
280
281            if !response.status().is_success() {
282                return Err(PluginLoaderError::load(format!(
283                    "Registry lookup failed for {}: {}",
284                    name,
285                    response.status()
286                )));
287            }
288
289            let entry: RegistryPluginResponse = response.json().await.map_err(|e| {
290                PluginLoaderError::load(format!("Invalid registry response for {}: {}", name, e))
291            })?;
292
293            let selected = select_registry_version(&entry).ok_or_else(|| {
294                PluginLoaderError::load(format!(
295                    "No installable versions found for plugin '{}'",
296                    name
297                ))
298            })?;
299            (selected.download_url.clone(), selected.checksum.clone())
300        };
301
302        let checksum_ref = options.expected_checksum.as_deref().or(checksum.as_deref());
303
304        self.remote_loader.download_with_checksum(&download_url, checksum_ref).await
305    }
306
307    /// Verify plugin signature using cryptographic verification
308    fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
309        let verifier = SignatureVerifier::new(&self.config);
310        verifier.verify_plugin_signature(plugin_dir)
311    }
312
313    /// Uninstall a plugin
314    pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
315        self.loader.unload_plugin(plugin_id).await?;
316
317        // Remove metadata
318        let mut store = self.metadata_store.write().await;
319        store.remove(plugin_id).await?;
320
321        Ok(())
322    }
323
324    /// List installed plugins
325    pub async fn list_installed(&self) -> Vec<PluginId> {
326        self.loader.list_plugins().await
327    }
328
329    /// Update a plugin to the latest version
330    pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
331        tracing::info!("Updating plugin: {}", plugin_id);
332
333        // Get plugin metadata to find original source
334        let metadata = {
335            let store = self.metadata_store.read().await;
336            store.get(plugin_id).cloned().ok_or_else(|| {
337                PluginLoaderError::load(format!(
338                    "No installation metadata found for plugin {}. Cannot update.",
339                    plugin_id
340                ))
341            })?
342        };
343
344        tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
345
346        // Unload the plugin first
347        if self.loader.get_plugin(plugin_id).await.is_some() {
348            self.loader.unload_plugin(plugin_id).await?;
349        }
350
351        // Reinstall from original source with force flag
352        let options = InstallOptions {
353            force: true,
354            skip_validation: false,
355            verify_signature: true,
356            expected_checksum: None,
357        };
358
359        let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
360
361        // Verify it's the same plugin
362        if new_plugin_id != *plugin_id {
363            return Err(PluginLoaderError::load(format!(
364                "Plugin ID mismatch after update: expected {}, got {}",
365                plugin_id, new_plugin_id
366            )));
367        }
368
369        // Update metadata with new version
370        let new_manifest = self
371            .loader
372            .get_plugin(&new_plugin_id)
373            .await
374            .ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
375            .manifest;
376
377        let mut store = self.metadata_store.write().await;
378        if let Some(meta) = store.get(plugin_id).cloned() {
379            let mut updated_meta = meta;
380            updated_meta.mark_updated(new_manifest.info.version.to_string());
381            store.save(updated_meta).await?;
382        }
383
384        tracing::info!("Plugin {} updated successfully", plugin_id);
385        Ok(())
386    }
387
388    /// Update all plugins to their latest versions
389    pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
390        tracing::info!("Updating all plugins");
391
392        // Get list of all plugins with metadata
393        let plugin_ids = {
394            let store = self.metadata_store.read().await;
395            store.list()
396        };
397
398        if plugin_ids.is_empty() {
399            tracing::info!("No plugins found with metadata to update");
400            return Ok(Vec::new());
401        }
402
403        tracing::info!("Found {} plugins to update", plugin_ids.len());
404
405        let mut updated = Vec::new();
406        let mut failed = Vec::new();
407
408        // Update each plugin
409        for plugin_id in plugin_ids {
410            match self.update(&plugin_id).await {
411                Ok(_) => {
412                    tracing::info!("Successfully updated plugin: {}", plugin_id);
413                    updated.push(plugin_id);
414                }
415                Err(e) => {
416                    tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
417                    failed.push((plugin_id, e.to_string()));
418                }
419            }
420        }
421
422        tracing::info!(
423            "Plugin update complete: {} succeeded, {} failed",
424            updated.len(),
425            failed.len()
426        );
427
428        if !failed.is_empty() {
429            let failed_list = failed
430                .iter()
431                .map(|(id, err)| format!("{}: {}", id, err))
432                .collect::<Vec<_>>()
433                .join(", ");
434            tracing::warn!("Failed updates: {}", failed_list);
435        }
436
437        Ok(updated)
438    }
439
440    /// Clear all caches (downloads and Git repositories)
441    pub async fn clear_caches(&self) -> LoaderResult<()> {
442        self.remote_loader.clear_cache().await?;
443        self.git_loader.clear_cache().await?;
444        Ok(())
445    }
446
447    /// Get cache statistics
448    pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
449        let download_cache_size = self.remote_loader.get_cache_size()?;
450        let git_cache_size = self.git_loader.get_cache_size()?;
451
452        Ok(CacheStats {
453            download_cache_size,
454            git_cache_size,
455            total_size: download_cache_size + git_cache_size,
456        })
457    }
458
459    /// Get plugin metadata
460    pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
461        let store = self.metadata_store.read().await;
462        store.get(plugin_id).cloned()
463    }
464
465    /// List all plugins with metadata
466    pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
467        let store = self.metadata_store.read().await;
468        store
469            .list()
470            .into_iter()
471            .filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
472            .collect()
473    }
474}
475
476#[derive(Debug, Deserialize)]
477struct RegistryVersionResponse {
478    download_url: String,
479    #[serde(default)]
480    checksum: Option<String>,
481}
482
483#[derive(Debug, Deserialize)]
484struct RegistryPluginResponse {
485    version: String,
486    versions: Vec<RegistryVersionResponseWithVersion>,
487}
488
489#[derive(Debug, Deserialize)]
490struct RegistryVersionResponseWithVersion {
491    version: String,
492    download_url: String,
493    #[serde(default)]
494    checksum: Option<String>,
495    #[serde(default)]
496    yanked: bool,
497}
498
499fn select_registry_version(
500    entry: &RegistryPluginResponse,
501) -> Option<&RegistryVersionResponseWithVersion> {
502    if let Some(preferred) = entry.versions.iter().find(|v| v.version == entry.version && !v.yanked)
503    {
504        return Some(preferred);
505    }
506
507    entry.versions.iter().find(|v| !v.yanked)
508}
509
510/// Cache statistics
511#[derive(Debug, Clone)]
512pub struct CacheStats {
513    /// Download cache size in bytes
514    pub download_cache_size: u64,
515    /// Git repository cache size in bytes
516    pub git_cache_size: u64,
517    /// Total cache size in bytes
518    pub total_size: u64,
519}
520
521impl CacheStats {
522    /// Format cache size as human-readable string
523    pub fn format_size(bytes: u64) -> String {
524        const KB: u64 = 1024;
525        const MB: u64 = KB * 1024;
526        const GB: u64 = MB * 1024;
527
528        if bytes >= GB {
529            format!("{:.2} GB", bytes as f64 / GB as f64)
530        } else if bytes >= MB {
531            format!("{:.2} MB", bytes as f64 / MB as f64)
532        } else if bytes >= KB {
533            format!("{:.2} KB", bytes as f64 / KB as f64)
534        } else {
535            format!("{} bytes", bytes)
536        }
537    }
538
539    /// Get download cache size as human-readable string
540    pub fn download_cache_formatted(&self) -> String {
541        Self::format_size(self.download_cache_size)
542    }
543
544    /// Get Git cache size as human-readable string
545    pub fn git_cache_formatted(&self) -> String {
546        Self::format_size(self.git_cache_size)
547    }
548
549    /// Get total cache size as human-readable string
550    pub fn total_formatted(&self) -> String {
551        Self::format_size(self.total_size)
552    }
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    // ===== PluginSource Tests =====
560
561    #[test]
562    fn test_plugin_source_parse_url() {
563        let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
564        assert!(matches!(source, PluginSource::Url { .. }));
565    }
566
567    #[test]
568    fn test_plugin_source_parse_git_https() {
569        let source = PluginSource::parse("https://github.com/user/repo").unwrap();
570        assert!(matches!(source, PluginSource::Git(_)));
571    }
572
573    #[test]
574    fn test_plugin_source_parse_git_ssh() {
575        let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
576        assert!(matches!(source, PluginSource::Git(_)));
577    }
578
579    #[test]
580    fn test_plugin_source_parse_gitlab() {
581        let source = PluginSource::parse("https://gitlab.com/user/repo").unwrap();
582        assert!(matches!(source, PluginSource::Git(_)));
583    }
584
585    #[test]
586    fn test_plugin_source_parse_local() {
587        let source = PluginSource::parse("/path/to/plugin").unwrap();
588        assert!(matches!(source, PluginSource::Local(_)));
589
590        let source = PluginSource::parse("./relative/path").unwrap();
591        assert!(matches!(source, PluginSource::Local(_)));
592    }
593
594    #[test]
595    fn test_plugin_source_parse_registry() {
596        let source = PluginSource::parse("auth-jwt").unwrap();
597        assert!(matches!(source, PluginSource::Registry { .. }));
598
599        let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
600        if let PluginSource::Registry { name, version } = source {
601            assert_eq!(name, "auth-jwt");
602            assert_eq!(version, Some("1.0.0".to_string()));
603        } else {
604            panic!("Expected Registry source");
605        }
606    }
607
608    #[test]
609    fn test_plugin_source_parse_registry_without_version() {
610        let source = PluginSource::parse("my-plugin").unwrap();
611        if let PluginSource::Registry { name, version } = source {
612            assert_eq!(name, "my-plugin");
613            assert!(version.is_none());
614        } else {
615            panic!("Expected Registry source");
616        }
617    }
618
619    #[test]
620    fn test_plugin_source_parse_url_with_checksum() {
621        let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
622        if let PluginSource::Url { url, checksum } = source {
623            assert_eq!(url, "https://example.com/plugin.zip");
624            assert!(checksum.is_none());
625        } else {
626            panic!("Expected URL source");
627        }
628    }
629
630    #[test]
631    fn test_plugin_source_parse_empty_string() {
632        let source = PluginSource::parse("").unwrap();
633        // Empty string should be treated as registry name
634        assert!(matches!(source, PluginSource::Registry { .. }));
635    }
636
637    #[test]
638    fn test_plugin_source_parse_whitespace() {
639        let source = PluginSource::parse("  https://example.com/plugin.zip  ").unwrap();
640        assert!(matches!(source, PluginSource::Url { .. }));
641    }
642
643    #[test]
644    fn test_plugin_source_display() {
645        let source = PluginSource::Local(PathBuf::from("/tmp/plugin"));
646        assert_eq!(source.to_string(), "local:/tmp/plugin");
647
648        let source = PluginSource::Url {
649            url: "https://example.com/plugin.zip".to_string(),
650            checksum: None,
651        };
652        assert_eq!(source.to_string(), "url:https://example.com/plugin.zip");
653
654        let source = PluginSource::Registry {
655            name: "my-plugin".to_string(),
656            version: Some("1.0.0".to_string()),
657        };
658        assert_eq!(source.to_string(), "registry:my-plugin@1.0.0");
659
660        let source = PluginSource::Registry {
661            name: "my-plugin".to_string(),
662            version: None,
663        };
664        assert_eq!(source.to_string(), "registry:my-plugin");
665    }
666
667    #[test]
668    fn test_plugin_source_clone() {
669        let source = PluginSource::Local(PathBuf::from("/tmp"));
670        let cloned = source.clone();
671        assert_eq!(source.to_string(), cloned.to_string());
672    }
673
674    // ===== InstallOptions Tests =====
675
676    #[test]
677    fn test_install_options_default() {
678        let options = InstallOptions::default();
679        assert!(!options.force);
680        assert!(!options.skip_validation);
681        assert!(options.verify_signature);
682        assert!(options.expected_checksum.is_none());
683    }
684
685    #[test]
686    fn test_install_options_with_force() {
687        let options = InstallOptions {
688            force: true,
689            ..Default::default()
690        };
691        assert!(options.force);
692    }
693
694    #[test]
695    fn test_install_options_with_checksum() {
696        let options = InstallOptions {
697            expected_checksum: Some("abc123".to_string()),
698            ..Default::default()
699        };
700        assert_eq!(options.expected_checksum, Some("abc123".to_string()));
701    }
702
703    #[test]
704    fn test_install_options_skip_validation() {
705        let options = InstallOptions {
706            skip_validation: true,
707            verify_signature: false,
708            ..Default::default()
709        };
710        assert!(options.skip_validation);
711        assert!(!options.verify_signature);
712    }
713
714    #[test]
715    fn test_install_options_clone() {
716        let options = InstallOptions {
717            force: true,
718            skip_validation: false,
719            verify_signature: true,
720            expected_checksum: Some("test".to_string()),
721        };
722        let cloned = options.clone();
723        assert_eq!(options.force, cloned.force);
724        assert_eq!(options.expected_checksum, cloned.expected_checksum);
725    }
726
727    // ===== CacheStats Tests =====
728
729    #[test]
730    fn test_cache_stats_formatting() {
731        assert_eq!(CacheStats::format_size(512), "512 bytes");
732        assert_eq!(CacheStats::format_size(1024), "1.00 KB");
733        assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
734        assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
735    }
736
737    #[test]
738    fn test_cache_stats_edge_cases() {
739        assert_eq!(CacheStats::format_size(0), "0 bytes");
740        assert_eq!(CacheStats::format_size(1), "1 bytes");
741        assert_eq!(CacheStats::format_size(1023), "1023 bytes");
742        assert_eq!(CacheStats::format_size(1025), "1.00 KB");
743    }
744
745    #[test]
746    fn test_cache_stats_large_values() {
747        let tb = 1024u64 * 1024 * 1024 * 1024;
748        assert!(CacheStats::format_size(tb).contains("GB"));
749    }
750
751    #[test]
752    fn test_cache_stats_formatted_methods() {
753        let stats = CacheStats {
754            download_cache_size: 1024 * 1024,
755            git_cache_size: 2 * 1024 * 1024,
756            total_size: 3 * 1024 * 1024,
757        };
758
759        assert_eq!(stats.download_cache_formatted(), "1.00 MB");
760        assert_eq!(stats.git_cache_formatted(), "2.00 MB");
761        assert_eq!(stats.total_formatted(), "3.00 MB");
762    }
763
764    #[test]
765    fn test_cache_stats_total_calculation() {
766        let stats = CacheStats {
767            download_cache_size: 100,
768            git_cache_size: 200,
769            total_size: 300,
770        };
771
772        assert_eq!(stats.total_size, stats.download_cache_size + stats.git_cache_size);
773    }
774
775    #[test]
776    fn test_cache_stats_clone() {
777        let stats = CacheStats {
778            download_cache_size: 1024,
779            git_cache_size: 2048,
780            total_size: 3072,
781        };
782
783        let cloned = stats.clone();
784        assert_eq!(stats.download_cache_size, cloned.download_cache_size);
785        assert_eq!(stats.git_cache_size, cloned.git_cache_size);
786        assert_eq!(stats.total_size, cloned.total_size);
787    }
788
789    // ===== Edge Cases and Error Handling =====
790
791    #[test]
792    fn test_plugin_source_parse_http_url() {
793        let source = PluginSource::parse("http://example.com/plugin.zip").unwrap();
794        assert!(matches!(source, PluginSource::Url { .. }));
795    }
796
797    #[test]
798    fn test_plugin_source_parse_windows_path() {
799        let source = PluginSource::parse("C:\\Users\\plugin").unwrap();
800        assert!(matches!(source, PluginSource::Local(_)));
801    }
802
803    #[test]
804    fn test_plugin_source_parse_registry_with_special_chars() {
805        let source = PluginSource::parse("my-plugin-name_v2@2.0.0-beta").unwrap();
806        if let PluginSource::Registry { name, version } = source {
807            assert_eq!(name, "my-plugin-name_v2");
808            assert_eq!(version, Some("2.0.0-beta".to_string()));
809        } else {
810            panic!("Expected Registry source");
811        }
812    }
813
814    #[test]
815    fn test_plugin_source_parse_github_dotgit_in_url() {
816        let source = PluginSource::parse("https://github.com/user/repo.git").unwrap();
817        assert!(matches!(source, PluginSource::Git(_)));
818    }
819
820    #[test]
821    fn test_select_registry_version_prefers_current_non_yanked() {
822        let entry = RegistryPluginResponse {
823            version: "2.0.0".to_string(),
824            versions: vec![
825                RegistryVersionResponseWithVersion {
826                    version: "1.0.0".to_string(),
827                    download_url: "https://example.com/1.0.0.wasm".to_string(),
828                    checksum: None,
829                    yanked: false,
830                },
831                RegistryVersionResponseWithVersion {
832                    version: "2.0.0".to_string(),
833                    download_url: "https://example.com/2.0.0.wasm".to_string(),
834                    checksum: Some("abc".to_string()),
835                    yanked: false,
836                },
837            ],
838        };
839
840        let selected = select_registry_version(&entry).expect("expected selected version");
841        assert_eq!(selected.version, "2.0.0");
842        assert_eq!(selected.download_url, "https://example.com/2.0.0.wasm");
843    }
844
845    #[test]
846    fn test_select_registry_version_falls_back_to_first_non_yanked() {
847        let entry = RegistryPluginResponse {
848            version: "2.0.0".to_string(),
849            versions: vec![
850                RegistryVersionResponseWithVersion {
851                    version: "2.0.0".to_string(),
852                    download_url: "https://example.com/2.0.0.wasm".to_string(),
853                    checksum: None,
854                    yanked: true,
855                },
856                RegistryVersionResponseWithVersion {
857                    version: "1.9.0".to_string(),
858                    download_url: "https://example.com/1.9.0.wasm".to_string(),
859                    checksum: None,
860                    yanked: false,
861                },
862            ],
863        };
864
865        let selected = select_registry_version(&entry).expect("expected selected version");
866        assert_eq!(selected.version, "1.9.0");
867    }
868}