use super::*;
use crate::git::{GitPluginConfig, GitPluginLoader, GitPluginSource};
use crate::loader::PluginLoader;
use crate::metadata::{MetadataStore, PluginMetadata};
use crate::remote::{RemotePluginConfig, RemotePluginLoader};
use crate::signature::SignatureVerifier;
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub enum PluginSource {
Local(PathBuf),
Url {
url: String,
checksum: Option<String>,
},
Git(GitPluginSource),
Registry {
name: String,
version: Option<String>,
},
}
impl PluginSource {
pub fn parse(input: &str) -> LoaderResult<Self> {
let input = input.trim();
if input.starts_with("http://") || input.starts_with("https://") {
if input.contains(".git")
|| input.contains("github.com")
|| input.contains("gitlab.com")
{
let source = GitPluginSource::parse(input)?;
return Ok(PluginSource::Git(source));
}
return Ok(PluginSource::Url {
url: input.to_string(),
checksum: None,
});
}
if input.starts_with("git@") {
let source = GitPluginSource::parse(input)?;
return Ok(PluginSource::Git(source));
}
if input.contains('/') || input.contains('\\') || Path::new(input).exists() {
return Ok(PluginSource::Local(PathBuf::from(input)));
}
let (name, version) = if let Some((n, v)) = input.split_once('@') {
(n.to_string(), Some(v.to_string()))
} else {
(input.to_string(), None)
};
Ok(PluginSource::Registry { name, version })
}
}
impl std::fmt::Display for PluginSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PluginSource::Local(path) => write!(f, "local:{}", path.display()),
PluginSource::Url { url, .. } => write!(f, "url:{}", url),
PluginSource::Git(source) => write!(f, "git:{}", source),
PluginSource::Registry { name, version } => {
if let Some(v) = version {
write!(f, "registry:{}@{}", name, v)
} else {
write!(f, "registry:{}", name)
}
}
}
}
}
#[derive(Debug, Clone)]
pub struct InstallOptions {
pub force: bool,
pub skip_validation: bool,
pub verify_signature: bool,
pub expected_checksum: Option<String>,
}
impl Default for InstallOptions {
fn default() -> Self {
Self {
force: false,
skip_validation: false,
verify_signature: true,
expected_checksum: None,
}
}
}
pub struct PluginInstaller {
loader: PluginLoader,
remote_loader: RemotePluginLoader,
git_loader: GitPluginLoader,
config: PluginLoaderConfig,
metadata_store: std::sync::Arc<tokio::sync::RwLock<MetadataStore>>,
}
impl PluginInstaller {
pub fn new(loader_config: PluginLoaderConfig) -> LoaderResult<Self> {
let loader = PluginLoader::new(loader_config.clone());
let remote_loader = RemotePluginLoader::new(RemotePluginConfig::default())?;
let git_loader = GitPluginLoader::new(GitPluginConfig::default())?;
let metadata_dir = shellexpand::tilde("~/.mockforge/plugin-metadata");
let metadata_store = MetadataStore::new(PathBuf::from(metadata_dir.as_ref()));
Ok(Self {
loader,
remote_loader,
git_loader,
config: loader_config,
metadata_store: std::sync::Arc::new(tokio::sync::RwLock::new(metadata_store)),
})
}
pub async fn init(&self) -> LoaderResult<()> {
let mut store = self.metadata_store.write().await;
store.load().await
}
pub async fn install(
&self,
source_str: &str,
options: InstallOptions,
) -> LoaderResult<PluginId> {
let source = PluginSource::parse(source_str)?;
self.install_from_source(&source, options).await
}
pub async fn install_from_source(
&self,
source: &PluginSource,
options: InstallOptions,
) -> LoaderResult<PluginId> {
tracing::info!("Installing plugin from source: {}", source);
let plugin_dir = match source {
PluginSource::Local(path) => path.clone(),
PluginSource::Url { url, checksum } => {
let checksum_ref = checksum.as_deref().or(options.expected_checksum.as_deref());
self.remote_loader.download_with_checksum(url, checksum_ref).await?
}
PluginSource::Git(git_source) => self.git_loader.clone_from_git(git_source).await?,
PluginSource::Registry { name, version } => {
self.install_from_registry(name, version.as_deref(), &options).await?
}
};
if options.verify_signature && !options.skip_validation {
if let Err(e) = self.verify_plugin_signature(&plugin_dir) {
tracing::warn!("Plugin signature verification failed: {}", e);
}
}
if !options.skip_validation {
self.loader.validate_plugin(&plugin_dir).await?;
}
let manifest = self.loader.validate_plugin(&plugin_dir).await?;
let plugin_id = manifest.info.id.clone();
if self.loader.get_plugin(&plugin_id).await.is_some() && !options.force {
return Err(PluginLoaderError::already_loaded(plugin_id));
}
self.loader.load_plugin(&plugin_id).await?;
let version = manifest.info.version.to_string();
let metadata = PluginMetadata::new(plugin_id.clone(), source.clone(), version);
let mut store = self.metadata_store.write().await;
store.save(metadata).await?;
tracing::info!("Plugin installed successfully: {}", plugin_id);
Ok(plugin_id)
}
async fn install_from_registry(
&self,
name: &str,
version: Option<&str>,
options: &InstallOptions,
) -> LoaderResult<PathBuf> {
let base_url = std::env::var("MOCKFORGE_PLUGIN_REGISTRY_URL")
.unwrap_or_else(|_| "https://registry.mockforge.dev".to_string());
let client = reqwest::Client::new();
let (download_url, checksum) = if let Some(v) = version {
let version_url = format!("{}/api/v1/plugins/{}/versions/{}", base_url, name, v);
let response = client.get(&version_url).send().await.map_err(|e| {
PluginLoaderError::load(format!(
"Failed to fetch registry version metadata for {}@{}: {}",
name, v, e
))
})?;
if !response.status().is_success() {
return Err(PluginLoaderError::load(format!(
"Registry lookup failed for {}@{}: {}",
name,
v,
response.status()
)));
}
let entry: RegistryVersionResponse = response.json().await.map_err(|e| {
PluginLoaderError::load(format!(
"Invalid registry response for {}@{}: {}",
name, v, e
))
})?;
(entry.download_url, entry.checksum)
} else {
let plugin_url = format!("{}/api/v1/plugins/{}", base_url, name);
let response = client.get(&plugin_url).send().await.map_err(|e| {
PluginLoaderError::load(format!(
"Failed to fetch registry plugin metadata for {}: {}",
name, e
))
})?;
if !response.status().is_success() {
return Err(PluginLoaderError::load(format!(
"Registry lookup failed for {}: {}",
name,
response.status()
)));
}
let entry: RegistryPluginResponse = response.json().await.map_err(|e| {
PluginLoaderError::load(format!("Invalid registry response for {}: {}", name, e))
})?;
let selected = select_registry_version(&entry).ok_or_else(|| {
PluginLoaderError::load(format!(
"No installable versions found for plugin '{}'",
name
))
})?;
(selected.download_url.clone(), selected.checksum.clone())
};
let checksum_ref = options.expected_checksum.as_deref().or(checksum.as_deref());
self.remote_loader.download_with_checksum(&download_url, checksum_ref).await
}
fn verify_plugin_signature(&self, plugin_dir: &Path) -> LoaderResult<()> {
let verifier = SignatureVerifier::new(&self.config);
verifier.verify_plugin_signature(plugin_dir)
}
pub async fn uninstall(&self, plugin_id: &PluginId) -> LoaderResult<()> {
self.loader.unload_plugin(plugin_id).await?;
let mut store = self.metadata_store.write().await;
store.remove(plugin_id).await?;
Ok(())
}
pub async fn list_installed(&self) -> Vec<PluginId> {
self.loader.list_plugins().await
}
pub async fn update(&self, plugin_id: &PluginId) -> LoaderResult<()> {
tracing::info!("Updating plugin: {}", plugin_id);
let metadata = {
let store = self.metadata_store.read().await;
store.get(plugin_id).cloned().ok_or_else(|| {
PluginLoaderError::load(format!(
"No installation metadata found for plugin {}. Cannot update.",
plugin_id
))
})?
};
tracing::info!("Updating plugin {} from source: {}", plugin_id, metadata.source);
if self.loader.get_plugin(plugin_id).await.is_some() {
self.loader.unload_plugin(plugin_id).await?;
}
let options = InstallOptions {
force: true,
skip_validation: false,
verify_signature: true,
expected_checksum: None,
};
let new_plugin_id = self.install_from_source(&metadata.source, options).await?;
if new_plugin_id != *plugin_id {
return Err(PluginLoaderError::load(format!(
"Plugin ID mismatch after update: expected {}, got {}",
plugin_id, new_plugin_id
)));
}
let new_manifest = self
.loader
.get_plugin(&new_plugin_id)
.await
.ok_or_else(|| PluginLoaderError::load("Failed to get updated plugin"))?
.manifest;
let mut store = self.metadata_store.write().await;
if let Some(meta) = store.get(plugin_id).cloned() {
let mut updated_meta = meta;
updated_meta.mark_updated(new_manifest.info.version.to_string());
store.save(updated_meta).await?;
}
tracing::info!("Plugin {} updated successfully", plugin_id);
Ok(())
}
pub async fn update_all(&self) -> LoaderResult<Vec<PluginId>> {
tracing::info!("Updating all plugins");
let plugin_ids = {
let store = self.metadata_store.read().await;
store.list()
};
if plugin_ids.is_empty() {
tracing::info!("No plugins found with metadata to update");
return Ok(Vec::new());
}
tracing::info!("Found {} plugins to update", plugin_ids.len());
let mut updated = Vec::new();
let mut failed = Vec::new();
for plugin_id in plugin_ids {
match self.update(&plugin_id).await {
Ok(_) => {
tracing::info!("Successfully updated plugin: {}", plugin_id);
updated.push(plugin_id);
}
Err(e) => {
tracing::warn!("Failed to update plugin {}: {}", plugin_id, e);
failed.push((plugin_id, e.to_string()));
}
}
}
tracing::info!(
"Plugin update complete: {} succeeded, {} failed",
updated.len(),
failed.len()
);
if !failed.is_empty() {
let failed_list = failed
.iter()
.map(|(id, err)| format!("{}: {}", id, err))
.collect::<Vec<_>>()
.join(", ");
tracing::warn!("Failed updates: {}", failed_list);
}
Ok(updated)
}
pub async fn clear_caches(&self) -> LoaderResult<()> {
self.remote_loader.clear_cache().await?;
self.git_loader.clear_cache().await?;
Ok(())
}
pub async fn get_cache_stats(&self) -> LoaderResult<CacheStats> {
let download_cache_size = self.remote_loader.get_cache_size()?;
let git_cache_size = self.git_loader.get_cache_size()?;
Ok(CacheStats {
download_cache_size,
git_cache_size,
total_size: download_cache_size + git_cache_size,
})
}
pub async fn get_plugin_metadata(&self, plugin_id: &PluginId) -> Option<PluginMetadata> {
let store = self.metadata_store.read().await;
store.get(plugin_id).cloned()
}
pub async fn list_plugins_with_metadata(&self) -> Vec<(PluginId, PluginMetadata)> {
let store = self.metadata_store.read().await;
store
.list()
.into_iter()
.filter_map(|id| store.get(&id).map(|meta| (id, meta.clone())))
.collect()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegistryVersionResponse {
download_url: String,
#[serde(default)]
checksum: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegistryPluginResponse {
version: String,
versions: Vec<RegistryVersionResponseWithVersion>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RegistryVersionResponseWithVersion {
version: String,
download_url: String,
#[serde(default)]
checksum: Option<String>,
#[serde(default)]
yanked: bool,
}
fn select_registry_version(
entry: &RegistryPluginResponse,
) -> Option<&RegistryVersionResponseWithVersion> {
if let Some(preferred) = entry.versions.iter().find(|v| v.version == entry.version && !v.yanked)
{
return Some(preferred);
}
entry.versions.iter().find(|v| !v.yanked)
}
#[derive(Debug, Clone)]
pub struct CacheStats {
pub download_cache_size: u64,
pub git_cache_size: u64,
pub total_size: u64,
}
impl CacheStats {
pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} bytes", bytes)
}
}
pub fn download_cache_formatted(&self) -> String {
Self::format_size(self.download_cache_size)
}
pub fn git_cache_formatted(&self) -> String {
Self::format_size(self.git_cache_size)
}
pub fn total_formatted(&self) -> String {
Self::format_size(self.total_size)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plugin_source_parse_url() {
let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
assert!(matches!(source, PluginSource::Url { .. }));
}
#[test]
fn test_plugin_source_parse_git_https() {
let source = PluginSource::parse("https://github.com/user/repo").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_plugin_source_parse_git_ssh() {
let source = PluginSource::parse("git@github.com:user/repo.git").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_plugin_source_parse_gitlab() {
let source = PluginSource::parse("https://gitlab.com/user/repo").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_plugin_source_parse_local() {
let source = PluginSource::parse("/path/to/plugin").unwrap();
assert!(matches!(source, PluginSource::Local(_)));
let source = PluginSource::parse("./relative/path").unwrap();
assert!(matches!(source, PluginSource::Local(_)));
}
#[test]
fn test_plugin_source_parse_registry() {
let source = PluginSource::parse("auth-jwt").unwrap();
assert!(matches!(source, PluginSource::Registry { .. }));
let source = PluginSource::parse("auth-jwt@1.0.0").unwrap();
if let PluginSource::Registry { name, version } = source {
assert_eq!(name, "auth-jwt");
assert_eq!(version, Some("1.0.0".to_string()));
} else {
panic!("Expected Registry source");
}
}
#[test]
fn test_plugin_source_parse_registry_without_version() {
let source = PluginSource::parse("my-plugin").unwrap();
if let PluginSource::Registry { name, version } = source {
assert_eq!(name, "my-plugin");
assert!(version.is_none());
} else {
panic!("Expected Registry source");
}
}
#[test]
fn test_plugin_source_parse_url_with_checksum() {
let source = PluginSource::parse("https://example.com/plugin.zip").unwrap();
if let PluginSource::Url { url, checksum } = source {
assert_eq!(url, "https://example.com/plugin.zip");
assert!(checksum.is_none());
} else {
panic!("Expected URL source");
}
}
#[test]
fn test_plugin_source_parse_empty_string() {
let source = PluginSource::parse("").unwrap();
assert!(matches!(source, PluginSource::Registry { .. }));
}
#[test]
fn test_plugin_source_parse_whitespace() {
let source = PluginSource::parse(" https://example.com/plugin.zip ").unwrap();
assert!(matches!(source, PluginSource::Url { .. }));
}
#[test]
fn test_plugin_source_display() {
let source = PluginSource::Local(PathBuf::from("/tmp/plugin"));
assert_eq!(source.to_string(), "local:/tmp/plugin");
let source = PluginSource::Url {
url: "https://example.com/plugin.zip".to_string(),
checksum: None,
};
assert_eq!(source.to_string(), "url:https://example.com/plugin.zip");
let source = PluginSource::Registry {
name: "my-plugin".to_string(),
version: Some("1.0.0".to_string()),
};
assert_eq!(source.to_string(), "registry:my-plugin@1.0.0");
let source = PluginSource::Registry {
name: "my-plugin".to_string(),
version: None,
};
assert_eq!(source.to_string(), "registry:my-plugin");
}
#[test]
fn test_plugin_source_clone() {
let source = PluginSource::Local(PathBuf::from("/tmp"));
let cloned = source.clone();
assert_eq!(source.to_string(), cloned.to_string());
}
#[test]
fn test_install_options_default() {
let options = InstallOptions::default();
assert!(!options.force);
assert!(!options.skip_validation);
assert!(options.verify_signature);
assert!(options.expected_checksum.is_none());
}
#[test]
fn test_install_options_with_force() {
let options = InstallOptions {
force: true,
..Default::default()
};
assert!(options.force);
}
#[test]
fn test_install_options_with_checksum() {
let options = InstallOptions {
expected_checksum: Some("abc123".to_string()),
..Default::default()
};
assert_eq!(options.expected_checksum, Some("abc123".to_string()));
}
#[test]
fn test_install_options_skip_validation() {
let options = InstallOptions {
skip_validation: true,
verify_signature: false,
..Default::default()
};
assert!(options.skip_validation);
assert!(!options.verify_signature);
}
#[test]
fn test_install_options_clone() {
let options = InstallOptions {
force: true,
skip_validation: false,
verify_signature: true,
expected_checksum: Some("test".to_string()),
};
let cloned = options.clone();
assert_eq!(options.force, cloned.force);
assert_eq!(options.expected_checksum, cloned.expected_checksum);
}
#[test]
fn test_cache_stats_formatting() {
assert_eq!(CacheStats::format_size(512), "512 bytes");
assert_eq!(CacheStats::format_size(1024), "1.00 KB");
assert_eq!(CacheStats::format_size(1024 * 1024), "1.00 MB");
assert_eq!(CacheStats::format_size(1024 * 1024 * 1024), "1.00 GB");
}
#[test]
fn test_cache_stats_edge_cases() {
assert_eq!(CacheStats::format_size(0), "0 bytes");
assert_eq!(CacheStats::format_size(1), "1 bytes");
assert_eq!(CacheStats::format_size(1023), "1023 bytes");
assert_eq!(CacheStats::format_size(1025), "1.00 KB");
}
#[test]
fn test_cache_stats_large_values() {
let tb = 1024u64 * 1024 * 1024 * 1024;
assert!(CacheStats::format_size(tb).contains("GB"));
}
#[test]
fn test_cache_stats_formatted_methods() {
let stats = CacheStats {
download_cache_size: 1024 * 1024,
git_cache_size: 2 * 1024 * 1024,
total_size: 3 * 1024 * 1024,
};
assert_eq!(stats.download_cache_formatted(), "1.00 MB");
assert_eq!(stats.git_cache_formatted(), "2.00 MB");
assert_eq!(stats.total_formatted(), "3.00 MB");
}
#[test]
fn test_cache_stats_total_calculation() {
let stats = CacheStats {
download_cache_size: 100,
git_cache_size: 200,
total_size: 300,
};
assert_eq!(stats.total_size, stats.download_cache_size + stats.git_cache_size);
}
#[test]
fn test_cache_stats_clone() {
let stats = CacheStats {
download_cache_size: 1024,
git_cache_size: 2048,
total_size: 3072,
};
let cloned = stats.clone();
assert_eq!(stats.download_cache_size, cloned.download_cache_size);
assert_eq!(stats.git_cache_size, cloned.git_cache_size);
assert_eq!(stats.total_size, cloned.total_size);
}
#[test]
fn test_plugin_source_parse_http_url() {
let source = PluginSource::parse("http://example.com/plugin.zip").unwrap();
assert!(matches!(source, PluginSource::Url { .. }));
}
#[test]
fn test_plugin_source_parse_windows_path() {
let source = PluginSource::parse("C:\\Users\\plugin").unwrap();
assert!(matches!(source, PluginSource::Local(_)));
}
#[test]
fn test_plugin_source_parse_registry_with_special_chars() {
let source = PluginSource::parse("my-plugin-name_v2@2.0.0-beta").unwrap();
if let PluginSource::Registry { name, version } = source {
assert_eq!(name, "my-plugin-name_v2");
assert_eq!(version, Some("2.0.0-beta".to_string()));
} else {
panic!("Expected Registry source");
}
}
#[test]
fn test_plugin_source_parse_github_dotgit_in_url() {
let source = PluginSource::parse("https://github.com/user/repo.git").unwrap();
assert!(matches!(source, PluginSource::Git(_)));
}
#[test]
fn test_select_registry_version_prefers_current_non_yanked() {
let entry = RegistryPluginResponse {
version: "2.0.0".to_string(),
versions: vec![
RegistryVersionResponseWithVersion {
version: "1.0.0".to_string(),
download_url: "https://example.com/1.0.0.wasm".to_string(),
checksum: None,
yanked: false,
},
RegistryVersionResponseWithVersion {
version: "2.0.0".to_string(),
download_url: "https://example.com/2.0.0.wasm".to_string(),
checksum: Some("abc".to_string()),
yanked: false,
},
],
};
let selected = select_registry_version(&entry).expect("expected selected version");
assert_eq!(selected.version, "2.0.0");
assert_eq!(selected.download_url, "https://example.com/2.0.0.wasm");
}
#[test]
fn test_select_registry_version_falls_back_to_first_non_yanked() {
let entry = RegistryPluginResponse {
version: "2.0.0".to_string(),
versions: vec![
RegistryVersionResponseWithVersion {
version: "2.0.0".to_string(),
download_url: "https://example.com/2.0.0.wasm".to_string(),
checksum: None,
yanked: true,
},
RegistryVersionResponseWithVersion {
version: "1.9.0".to_string(),
download_url: "https://example.com/1.9.0.wasm".to_string(),
checksum: None,
yanked: false,
},
],
};
let selected = select_registry_version(&entry).expect("expected selected version");
assert_eq!(selected.version, "1.9.0");
}
}