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 std::path::{Path, PathBuf};
18
19/// Plugin source specification
20#[derive(Debug, Clone)]
21pub enum PluginSource {
22    /// Local file path or directory
23    Local(PathBuf),
24    /// HTTP/HTTPS URL
25    Url {
26        /// URL to download the plugin from
27        url: String,
28        /// Optional SHA-256 checksum for verification
29        checksum: Option<String>,
30    },
31    /// Git repository
32    Git(GitPluginSource),
33    /// Plugin registry (future)
34    Registry {
35        /// Plugin name in the registry
36        name: String,
37        /// Optional version string (defaults to latest)
38        version: Option<String>,
39    },
40}
41
42impl PluginSource {
43    /// Parse a plugin source from a string
44    ///
45    /// Automatically detects the source type:
46    /// - Starts with "http://" or "https://" → URL
47    /// - Contains ".git" or starts with "git@" → Git
48    /// - Contains "/" or "\" → Local path
49    /// - Otherwise → Registry name
50    pub fn parse(input: &str) -> LoaderResult<Self> {
51        let input = input.trim();
52
53        // Check for URL
54        if input.starts_with("http://") || input.starts_with("https://") {
55            // Check if it's a Git repository URL
56            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        // Check for SSH Git URL
70        if input.starts_with("git@") {
71            let source = GitPluginSource::parse(input)?;
72            return Ok(PluginSource::Git(source));
73        }
74
75        // Check for local path
76        if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
77            return Ok(PluginSource::Local(PathBuf::from(input)));
78        }
79
80        // Parse as registry reference
81        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/// Installation options
109#[derive(Debug, Clone)]
110pub struct InstallOptions {
111    /// Force reinstall even if plugin already exists
112    pub force: bool,
113    /// Skip validation checks
114    pub skip_validation: bool,
115    /// Verify plugin signature (if available)
116    pub verify_signature: bool,
117    /// Expected checksum for verification (URL sources)
118    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
132/// Unified plugin installer
133pub 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    /// Create a new plugin installer with default configuration
143    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        // Create metadata store in a standard location
149        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    /// Initialize the installer (creates directories, loads metadata)
162    pub async fn init(&self) -> LoaderResult<()> {
163        let mut store = self.metadata_store.write().await;
164        store.load().await
165    }
166
167    /// Install a plugin from a source string
168    ///
169    /// Automatically detects and handles the source type
170    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    /// Install a plugin from a specific source
180    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        // Get the plugin directory
188        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        // Verify signature if enabled
205        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                // Don't fail installation, just warn
209            }
210        }
211
212        // Validate the plugin
213        if !options.skip_validation {
214            self.loader.validate_plugin(&plugin_dir).await?;
215        }
216
217        // Load the plugin
218        let manifest = self.loader.validate_plugin(&plugin_dir).await?;
219        let plugin_id = manifest.info.id.clone();
220
221        // Check if plugin is already loaded
222        if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
223            return Err(PluginLoaderError::already_loaded(plugin_id));
224        }
225
226        // Load the plugin
227        self.loader.load_plugin(&plugin_id).await?;
228
229        // Save metadata for future updates
230        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    /// Verify plugin signature using cryptographic verification
240    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    /// Uninstall a plugin
246    pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
247        self.loader.unload_plugin(plugin_id).await?;
248
249        // Remove metadata
250        let mut store = self.metadata_store.write().await;
251        store.remove(plugin_id).await?;
252
253        Ok(())
254    }
255
256    /// List installed plugins
257    pub async fn list_installed(&self) -> Vec<PluginId> {
258        self.loader.list_plugins().await
259    }
260
261    /// Update a plugin to the latest version
262    pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
263        tracing::info!("Updating plugin: {}", plugin_id);
264
265        // Get plugin metadata to find original source
266        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        // Unload the plugin first
279        if self.loader.get_plugin(plugin_id).await.is_some() {
280            self.loader.unload_plugin(plugin_id).await?;
281        }
282
283        // Reinstall from original source with force flag
284        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        // Verify it's the same plugin
294        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        // Update metadata with new version
302        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    /// Update all plugins to their latest versions
321    pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
322        tracing::info!("Updating all plugins");
323
324        // Get list of all plugins with metadata
325        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        // Update each plugin
341        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    /// Clear all caches (downloads and Git repositories)
373    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    /// Get cache statistics
380    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    /// Get plugin metadata
392    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    /// List all plugins with metadata
398    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/// Cache statistics
409#[derive(Debug, Clone)]
410pub struct CacheStats {
411    /// Download cache size in bytes
412    pub download_cache_size: u64,
413    /// Git repository cache size in bytes
414    pub git_cache_size: u64,
415    /// Total cache size in bytes
416    pub total_size: u64,
417}
418
419impl CacheStats {
420    /// Format cache size as human-readable string
421    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    /// Get download cache size as human-readable string
438    pub fn download_cache_formatted(&self) -> String {
439        Self::format_size(self.download_cache_size)
440    }
441
442    /// Get Git cache size as human-readable string
443    pub fn git_cache_formatted(&self) -> String {
444        Self::format_size(self.git_cache_size)
445    }
446
447    /// Get total cache size as human-readable string
448    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]
458    fn test_plugin_source_parse_url() {
459        let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
460        assert!(matches!(source, PluginSource::Url { .. }));
461    }
462
463    #[test]
464    fn test_plugin_source_parse_git_https() {
465        let source = PluginSource::parse("https://github.com/user/repo").unwrap();
466        assert!(matches!(source, PluginSource::Git(_)));
467    }
468
469    #[test]
470    fn test_plugin_source_parse_git_ssh() {
471        let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
472        assert!(matches!(source, PluginSource::Git(_)));
473    }
474
475    #[test]
476    fn test_plugin_source_parse_local() {
477        let source = PluginSource::parse("/path/to/plugin").unwrap();
478        assert!(matches!(source, PluginSource::Local(_)));
479
480        let source = PluginSource::parse("./relative/path").unwrap();
481        assert!(matches!(source, PluginSource::Local(_)));
482    }
483
484    #[test]
485    fn test_plugin_source_parse_registry() {
486        let source = PluginSource::parse("auth-jwt").unwrap();
487        assert!(matches!(source, PluginSource::Registry { .. }));
488
489        let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
490        if let PluginSource::Registry { name, version } = source {
491            assert_eq!(name, "auth-jwt");
492            assert_eq!(version, Some("1.0.0".to_string()));
493        } else {
494            panic!("Expected Registry source");
495        }
496    }
497
498    #[test]
499    fn test_cache_stats_formatting() {
500        assert_eq!(CacheStats::format_size(512), "512 bytes");
501        assert_eq!(CacheStats::format_size(1024), "1.00 KB");
502        assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
503        assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
504    }
505}