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: String,
27        checksum: Option<String>,
28    },
29    /// Git repository
30    Git(GitPluginSource),
31    /// Plugin registry (future)
32    Registry {
33        name: String,
34        version: Option<String>,
35    },
36}
37
38impl PluginSource {
39    /// Parse a plugin source from a string
40    ///
41    /// Automatically detects the source type:
42    /// - Starts with "http://" or "https://" → URL
43    /// - Contains ".git" or starts with "git@" → Git
44    /// - Contains "/" or "\" → Local path
45    /// - Otherwise → Registry name
46    pub fn parse(input: &str) -> LoaderResult<Self> {
47        let input = input.trim();
48
49        // Check for URL
50        if input.starts_with("http://") || input.starts_with("https://") {
51            // Check if it's a Git repository URL
52            if input.contains(".git")
53                || input.contains("github.com")
54                || input.contains("gitlab.com")
55            {
56                let source = GitPluginSource::parse(input)?;
57                return Ok(PluginSource::Git(source));
58            }
59            return Ok(PluginSource::Url {
60                url: input.to_string(),
61                checksum: None,
62            });
63        }
64
65        // Check for SSH Git URL
66        if input.starts_with("git@") {
67            let source = GitPluginSource::parse(input)?;
68            return Ok(PluginSource::Git(source));
69        }
70
71        // Check for local path
72        if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
73            return Ok(PluginSource::Local(PathBuf::from(input)));
74        }
75
76        // Parse as registry reference
77        let (name, version) = if let Some((n, v)) = input.split_once('@') {
78            (n.to_string(), Some(v.to_string()))
79        } else {
80            (input.to_string(), None)
81        };
82
83        Ok(PluginSource::Registry { name, version })
84    }
85}
86
87impl std::fmt::Display for PluginSource {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            PluginSource::Local(path) => write!(f, "local:{}", path.display()),
91            PluginSource::Url { url, .. } => write!(f, "url:{}", url),
92            PluginSource::Git(source) => write!(f, "git:{}", source),
93            PluginSource::Registry { name, version } => {
94                if let Some(v) = version {
95                    write!(f, "registry:{}@{}", name, v)
96                } else {
97                    write!(f, "registry:{}", name)
98                }
99            }
100        }
101    }
102}
103
104/// Installation options
105#[derive(Debug, Clone)]
106pub struct InstallOptions {
107    /// Force reinstall even if plugin already exists
108    pub force: bool,
109    /// Skip validation checks
110    pub skip_validation: bool,
111    /// Verify plugin signature (if available)
112    pub verify_signature: bool,
113    /// Expected checksum for verification (URL sources)
114    pub expected_checksum: Option<String>,
115}
116
117impl Default for InstallOptions {
118    fn default() -> Self {
119        Self {
120            force: false,
121            skip_validation: false,
122            verify_signature: true,
123            expected_checksum: None,
124        }
125    }
126}
127
128/// Unified plugin installer
129pub struct PluginInstaller {
130    loader: PluginLoader,
131    remote_loader: RemotePluginLoader,
132    git_loader: GitPluginLoader,
133    config: PluginLoaderConfig,
134    metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
135}
136
137impl PluginInstaller {
138    /// Create a new plugin installer with default configuration
139    pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
140        let loader = PluginLoader::new(loader_config.clone());
141        let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
142        let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
143
144        // Create metadata store in a standard location
145        let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
146        let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
147
148        Ok(Self {
149            loader,
150            remote_loader,
151            git_loader,
152            config: loader_config,
153            metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
154        })
155    }
156
157    /// Initialize the installer (creates directories, loads metadata)
158    pub async fn init(&self) -> LoaderResult<()> {
159        let mut store = self.metadata_store.write().await;
160        store.load().await
161    }
162
163    /// Install a plugin from a source string
164    ///
165    /// Automatically detects and handles the source type
166    pub async fn install(
167        &self,
168        source_str: &str,
169        options: InstallOptions,
170    ) -> LoaderResult<PluginId> {
171        let source = PluginSource::parse(source_str)?;
172        self.install_from_source(&source, options).await
173    }
174
175    /// Install a plugin from a specific source
176    pub async fn install_from_source(
177        &self,
178        source: &PluginSource,
179        options: InstallOptions,
180    ) -> LoaderResult<PluginId> {
181        tracing::info!("Installing plugin from source: {}", source);
182
183        // Get the plugin directory
184        let plugin_dir = match source {
185            PluginSource::Local(path) => path.clone(),
186            PluginSource::Url { url, checksum } => {
187                let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
188                self.remote_loader.download_with_checksum(url, checksum_ref).await?
189            }
190            PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
191            PluginSource::Registry { name, version } => {
192                return Err(PluginLoaderError::load(format!(
193                    "Registry plugin installation not yet implemented: {}@{}",
194                    name,
195                    version.as_deref().unwrap_or("latest")
196                )));
197            }
198        };
199
200        // Verify signature if enabled
201        if options.verify_signature && !options.skip_validation {
202            if let Err(e) = self.verify_plugin_signature(&plugin_dir) {
203                tracing::warn!("Plugin signature verification failed: {}", e);
204                // Don't fail installation, just warn
205            }
206        }
207
208        // Validate the plugin
209        if !options.skip_validation {
210            self.loader.validate_plugin(&plugin_dir).await?;
211        }
212
213        // Load the plugin
214        let manifest = self.loader.validate_plugin(&plugin_dir).await?;
215        let plugin_id = manifest.info.id.clone();
216
217        // Check if plugin is already loaded
218        if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
219            return Err(PluginLoaderError::already_loaded(plugin_id));
220        }
221
222        // Load the plugin
223        self.loader.load_plugin(&plugin_id).await?;
224
225        // Save metadata for future updates
226        let version = manifest.info.version.to_string();
227        let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
228        let mut store = self.metadata_store.write().await;
229        store.save(metadata).await?;
230
231        tracing::info!("Plugin installed successfully: {}", plugin_id);
232        Ok(plugin_id)
233    }
234
235    /// Verify plugin signature using cryptographic verification
236    fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
237        let verifier = SignatureVerifier::new(&self.config);
238        verifier.verify_plugin_signature(plugin_dir)
239    }
240
241    /// Uninstall a plugin
242    pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
243        self.loader.unload_plugin(plugin_id).await?;
244
245        // Remove metadata
246        let mut store = self.metadata_store.write().await;
247        store.remove(plugin_id).await?;
248
249        Ok(())
250    }
251
252    /// List installed plugins
253    pub async fn list_installed(&self) -> Vec<PluginId> {
254        self.loader.list_plugins().await
255    }
256
257    /// Update a plugin to the latest version
258    pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
259        tracing::info!("Updating plugin: {}", plugin_id);
260
261        // Get plugin metadata to find original source
262        let metadata = {
263            let store = self.metadata_store.read().await;
264            store.get(plugin_id).cloned().ok_or_else(|| {
265                PluginLoaderError::load(format!(
266                    "No installation metadata found for plugin {}. Cannot update.",
267                    plugin_id
268                ))
269            })?
270        };
271
272        tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
273
274        // Unload the plugin first
275        if self.loader.get_plugin(plugin_id).await.is_some() {
276            self.loader.unload_plugin(plugin_id).await?;
277        }
278
279        // Reinstall from original source with force flag
280        let options = InstallOptions {
281            force: true,
282            skip_validation: false,
283            verify_signature: true,
284            expected_checksum: None,
285        };
286
287        let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
288
289        // Verify it's the same plugin
290        if new_plugin_id != *plugin_id {
291            return Err(PluginLoaderError::load(format!(
292                "Plugin ID mismatch after update: expected {}, got {}",
293                plugin_id, new_plugin_id
294            )));
295        }
296
297        // Update metadata with new version
298        let new_manifest = self
299            .loader
300            .get_plugin(&new_plugin_id)
301            .await
302            .ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
303            .manifest;
304
305        let mut store = self.metadata_store.write().await;
306        if let Some(meta) = store.get(plugin_id).cloned() {
307            let mut updated_meta = meta;
308            updated_meta.mark_updated(new_manifest.info.version.to_string());
309            store.save(updated_meta).await?;
310        }
311
312        tracing::info!("Plugin {} updated successfully", plugin_id);
313        Ok(())
314    }
315
316    /// Update all plugins to their latest versions
317    pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
318        tracing::info!("Updating all plugins");
319
320        // Get list of all plugins with metadata
321        let plugin_ids = {
322            let store = self.metadata_store.read().await;
323            store.list()
324        };
325
326        if plugin_ids.is_empty() {
327            tracing::info!("No plugins found with metadata to update");
328            return Ok(Vec::new());
329        }
330
331        tracing::info!("Found {} plugins to update", plugin_ids.len());
332
333        let mut updated = Vec::new();
334        let mut failed = Vec::new();
335
336        // Update each plugin
337        for plugin_id in plugin_ids {
338            match self.update(&plugin_id).await {
339                Ok(_) => {
340                    tracing::info!("Successfully updated plugin: {}", plugin_id);
341                    updated.push(plugin_id);
342                }
343                Err(e) => {
344                    tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
345                    failed.push((plugin_id, e.to_string()));
346                }
347            }
348        }
349
350        tracing::info!(
351            "Plugin update complete: {} succeeded, {} failed",
352            updated.len(),
353            failed.len()
354        );
355
356        if !failed.is_empty() {
357            let failed_list = failed
358                .iter()
359                .map(|(id, err)| format!("{}: {}", id, err))
360                .collect::<Vec<_>>()
361                .join(", ");
362            tracing::warn!("Failed updates: {}", failed_list);
363        }
364
365        Ok(updated)
366    }
367
368    /// Clear all caches (downloads and Git repositories)
369    pub async fn clear_caches(&self) -> LoaderResult<()> {
370        self.remote_loader.clear_cache().await?;
371        self.git_loader.clear_cache().await?;
372        Ok(())
373    }
374
375    /// Get cache statistics
376    pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
377        let download_cache_size = self.remote_loader.get_cache_size()?;
378        let git_cache_size = self.git_loader.get_cache_size()?;
379
380        Ok(CacheStats {
381            download_cache_size,
382            git_cache_size,
383            total_size: download_cache_size + git_cache_size,
384        })
385    }
386
387    /// Get plugin metadata
388    pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
389        let store = self.metadata_store.read().await;
390        store.get(plugin_id).cloned()
391    }
392
393    /// List all plugins with metadata
394    pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
395        let store = self.metadata_store.read().await;
396        store
397            .list()
398            .into_iter()
399            .filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
400            .collect()
401    }
402}
403
404/// Cache statistics
405#[derive(Debug, Clone)]
406pub struct CacheStats {
407    /// Download cache size in bytes
408    pub download_cache_size: u64,
409    /// Git repository cache size in bytes
410    pub git_cache_size: u64,
411    /// Total cache size in bytes
412    pub total_size: u64,
413}
414
415impl CacheStats {
416    /// Format cache size as human-readable string
417    pub fn format_size(bytes: u64) -> String {
418        const KB: u64 = 1024;
419        const MB: u64 = KB * 1024;
420        const GB: u64 = MB * 1024;
421
422        if bytes >= GB {
423            format!("{:.2} GB", bytes as f64 / GB as f64)
424        } else if bytes >= MB {
425            format!("{:.2} MB", bytes as f64 / MB as f64)
426        } else if bytes >= KB {
427            format!("{:.2} KB", bytes as f64 / KB as f64)
428        } else {
429            format!("{} bytes", bytes)
430        }
431    }
432
433    /// Get download cache size as human-readable string
434    pub fn download_cache_formatted(&self) -> String {
435        Self::format_size(self.download_cache_size)
436    }
437
438    /// Get Git cache size as human-readable string
439    pub fn git_cache_formatted(&self) -> String {
440        Self::format_size(self.git_cache_size)
441    }
442
443    /// Get total cache size as human-readable string
444    pub fn total_formatted(&self) -> String {
445        Self::format_size(self.total_size)
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452
453    #[test]
454    fn test_plugin_source_parse_url() {
455        let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
456        assert!(matches!(source, PluginSource::Url { .. }));
457    }
458
459    #[test]
460    fn test_plugin_source_parse_git_https() {
461        let source = PluginSource::parse("https://github.com/user/repo").unwrap();
462        assert!(matches!(source, PluginSource::Git(_)));
463    }
464
465    #[test]
466    fn test_plugin_source_parse_git_ssh() {
467        let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
468        assert!(matches!(source, PluginSource::Git(_)));
469    }
470
471    #[test]
472    fn test_plugin_source_parse_local() {
473        let source = PluginSource::parse("/path/to/plugin").unwrap();
474        assert!(matches!(source, PluginSource::Local(_)));
475
476        let source = PluginSource::parse("./relative/path").unwrap();
477        assert!(matches!(source, PluginSource::Local(_)));
478    }
479
480    #[test]
481    fn test_plugin_source_parse_registry() {
482        let source = PluginSource::parse("auth-jwt").unwrap();
483        assert!(matches!(source, PluginSource::Registry { .. }));
484
485        let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
486        if let PluginSource::Registry { name, version } = source {
487            assert_eq!(name, "auth-jwt");
488            assert_eq!(version, Some("1.0.0".to_string()));
489        } else {
490            panic!("Expected Registry source");
491        }
492    }
493
494    #[test]
495    fn test_cache_stats_formatting() {
496        assert_eq!(CacheStats::format_size(512), "512 bytes");
497        assert_eq!(CacheStats::format_size(1024), "1.00 KB");
498        assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
499        assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
500    }
501}