vtcode-core 0.147.2

Core library for VT Code - a Rust-based terminal coding agent
//! Plugin loader for VT Code
//!
//! Handles the discovery, installation, and loading of plugins from various sources.

use std::path::{Path, PathBuf};
use tokio::fs;
use vtcode_commons::VtCodePaths;
use vtcode_commons::fs::{read_private_file_no_follow, write_private_file_atomic};

use super::{PluginError, PluginManifest, PluginResult, PluginRuntime, validate_plugin_component};

/// Plugin source types
#[derive(Debug, Clone)]
pub enum PluginSource {
    /// Local directory
    Local(PathBuf),
    /// Git repository
    Git(String),
    /// HTTP URL
    Http(String),
    /// Marketplace identifier
    Marketplace(String),
}

/// Plugin loader that handles plugin installation and management
pub struct PluginLoader {
    /// Base directory for plugin installations
    plugins_dir: PathBuf,
    /// Runtime for managing loaded plugins
    runtime: PluginRuntime,
}

impl PluginLoader {
    /// Create a new plugin loader
    pub fn new(plugins_dir: PathBuf, runtime: PluginRuntime) -> Self {
        Self { plugins_dir, runtime }
    }

    /// Install a plugin from a source
    pub async fn install_plugin(&self, source: PluginSource, name: Option<String>) -> PluginResult<()> {
        let plugin_dir = match source {
            PluginSource::Local(path) => self.install_from_local(path).await?,
            PluginSource::Git(url) => self.install_from_git(&url, name.as_deref()).await?,
            PluginSource::Http(url) => self.install_from_http(&url, name.as_deref()).await?,
            PluginSource::Marketplace(id) => self.install_from_marketplace(&id, name.as_deref()).await?,
        };

        // Load the installed plugin
        self.runtime.load_plugin(&plugin_dir).await?;
        Ok(())
    }

    /// Install plugin from local directory
    async fn install_from_local(&self, source_path: PathBuf) -> PluginResult<PathBuf> {
        if !fs::try_exists(&source_path).await.unwrap_or(false) {
            return Err(PluginError::LoadingError(format!("Source path does not exist: {}", source_path.display())));
        }

        // Validate that it contains a plugin manifest
        let manifest_path = source_path.join(".vtcode-plugin/plugin.json");
        let manifest_exists = fs::symlink_metadata(&manifest_path)
            .await
            .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
            .unwrap_or(false);
        if !manifest_exists {
            return Err(PluginError::ManifestValidationError(format!(
                "Plugin manifest not found in source: {}",
                manifest_path.display()
            )));
        }

        // Load the manifest to get the plugin name
        let manifest_content = String::from_utf8(
            read_private_file_no_follow(&manifest_path)
                .await
                .map_err(|e| PluginError::LoadingError(format!("Failed to read manifest: {e}")))?,
        )
        .map_err(|e| PluginError::LoadingError(format!("Plugin manifest is not valid UTF-8: {e}")))?;

        let manifest: PluginManifest = serde_json::from_str(&manifest_content)
            .map_err(|e| PluginError::ManifestValidationError(format!("Invalid manifest JSON: {e}")))?;

        // Create installation directory
        let install_dir = self.install_dir_for(&manifest.name)?;

        // Copy plugin files to installation directory
        self.copy_directory(&source_path, &install_dir).await?;

        Ok(install_dir)
    }

    /// Install plugin from Git repository
    async fn install_from_git(&self, url: &str, name: Option<&str>) -> PluginResult<PathBuf> {
        use tempfile::TempDir;
        use tokio::process::Command;

        // Create a temporary directory for the git clone
        let temp_dir = TempDir::new()
            .map_err(|e| PluginError::LoadingError(format!("Failed to create temporary directory: {e}")))?;
        let temp_path = temp_dir.path();

        // Execute git clone command
        let output = Command::new("git")
            .arg("clone")
            .arg(url)
            .arg(temp_path)
            .output()
            .await
            .map_err(|e| PluginError::LoadingError(format!("Failed to execute git clone: {e}")))?;

        if !output.status.success() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            return Err(PluginError::LoadingError(format!("Git clone failed: {stderr}")));
        }

        // Determine plugin name
        let plugin_name = name
            .map(|s| s.to_string())
            .unwrap_or_else(|| self.extract_name_from_git_url(url));

        // Create installation directory
        let install_dir = self.install_dir_for(&plugin_name)?;

        // Copy the cloned repository contents to the installation directory
        self.copy_directory(temp_path, &install_dir).await?;

        // Verify that the plugin manifest exists in the installed directory
        let manifest_path = install_dir.join(".vtcode-plugin/plugin.json");
        let manifest_exists = fs::symlink_metadata(&manifest_path)
            .await
            .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
            .unwrap_or(false);
        if !manifest_exists {
            return Err(PluginError::ManifestValidationError(
                "Plugin manifest not found in cloned repository".to_string(),
            ));
        }

        Ok(install_dir)
    }

    /// Install plugin from HTTP URL
    async fn install_from_http(&self, url: &str, name: Option<&str>) -> PluginResult<PathBuf> {
        // For now, create a placeholder implementation
        let plugin_name = name.map(|s| s.to_string()).unwrap_or_else(|| self.extract_name_from_url(url));

        let install_dir = self.install_dir_for(&plugin_name)?;

        // Create a placeholder manifest file
        let placeholder_manifest = format!(
            r#"{{
  "name": "{plugin_name}",
  "version": "1.0.0",
  "description": "Placeholder for HTTP-installed plugin from {url}"
}}"#
        );

        let manifest_path = install_dir.join(".vtcode-plugin/plugin.json");
        let manifest_parent = manifest_path.parent().ok_or_else(|| {
            PluginError::LoadingError("Failed to resolve parent directory for plugin manifest path".to_string())
        })?;
        VtCodePaths::ensure_user_dir(manifest_parent)
            .map_err(|e| PluginError::LoadingError(format!("Failed to create plugin manifest directory: {e}")))?;
        write_private_file_atomic(&manifest_path, placeholder_manifest.as_bytes())
            .await
            .map_err(|e| PluginError::LoadingError(format!("Failed to create placeholder manifest: {e}")))?;

        Ok(install_dir)
    }

    /// Install plugin from marketplace
    async fn install_from_marketplace(&self, marketplace_id: &str, name: Option<&str>) -> PluginResult<PathBuf> {
        // For now, create a placeholder implementation
        let plugin_name = name
            .map(|s| s.to_string())
            .unwrap_or_else(|| marketplace_id.split('/').next_back().unwrap_or(marketplace_id).to_string());

        let install_dir = self.install_dir_for(&plugin_name)?;

        // Create a placeholder manifest file
        let placeholder_manifest = format!(
            r#"{{
  "name": "{plugin_name}",
  "version": "1.0.0",
  "description": "Placeholder for marketplace-installed plugin from {marketplace_id}"
}}"#
        );

        let manifest_path = install_dir.join(".vtcode-plugin/plugin.json");
        let manifest_parent = manifest_path.parent().ok_or_else(|| {
            PluginError::LoadingError("Failed to resolve parent directory for plugin manifest path".to_string())
        })?;
        VtCodePaths::ensure_user_dir(manifest_parent)
            .map_err(|e| PluginError::LoadingError(format!("Failed to create plugin manifest directory: {e}")))?;
        write_private_file_atomic(&manifest_path, placeholder_manifest.as_bytes())
            .await
            .map_err(|e| PluginError::LoadingError(format!("Failed to create placeholder manifest: {e}")))?;

        Ok(install_dir)
    }

    /// Uninstall a plugin
    pub async fn uninstall_plugin(&self, plugin_name: &str) -> PluginResult<()> {
        validate_plugin_component(plugin_name)?;
        VtCodePaths::ensure_user_dir(&self.plugins_dir)
            .map_err(|e| PluginError::LoadingError(format!("Failed to validate plugin directory: {e}")))?;
        let plugin_dir = self.plugins_dir.join(plugin_name);
        let metadata = match fs::symlink_metadata(&plugin_dir).await {
            Ok(metadata) => metadata,
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
                return Err(PluginError::NotFound(plugin_name.to_string().into()));
            }
            Err(error) => {
                return Err(PluginError::LoadingError(format!("Failed to inspect plugin directory: {error}")));
            }
        };
        if metadata.file_type().is_symlink() || !metadata.is_dir() {
            return Err(PluginError::LoadingError(format!(
                "Refusing to remove non-directory plugin path: {}",
                plugin_dir.display()
            )));
        }

        // Unload the plugin from runtime first
        self.runtime.unload_plugin(plugin_name).await?;

        // Remove the plugin directory
        fs::remove_dir_all(&plugin_dir)
            .await
            .map_err(|e| PluginError::LoadingError(format!("Failed to remove plugin directory: {e}")))?;

        Ok(())
    }

    /// List installed plugins
    pub async fn list_installed_plugins(&self) -> PluginResult<Vec<String>> {
        let mut plugins = Vec::new();

        if !fs::try_exists(&self.plugins_dir).await.unwrap_or(false) {
            return Ok(plugins);
        }

        let mut entries = fs::read_dir(&self.plugins_dir)
            .await
            .map_err(|e| PluginError::LoadingError(format!("Failed to read plugins directory: {e}")))?;

        while let Some(entry) = entries
            .next_entry()
            .await
            .map_err(|e| PluginError::LoadingError(format!("Failed to read directory entry: {e}")))?
        {
            let path = entry.path();
            let is_directory = fs::symlink_metadata(&path)
                .await
                .map(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
                .unwrap_or(false);
            if is_directory {
                // Check if it contains a plugin manifest
                let manifest_path = path.join(".vtcode-plugin/plugin.json");
                let has_regular_manifest = fs::symlink_metadata(&manifest_path)
                    .await
                    .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
                    .unwrap_or(false);
                if has_regular_manifest && let Some(name) = path.file_name() {
                    plugins.push(name.to_string_lossy().to_string());
                }
            }
        }

        Ok(plugins)
    }

    /// Copy directory recursively
    async fn copy_directory(&self, src: &Path, dst: &Path) -> PluginResult<()> {
        Box::pin(async {
            let source_metadata = fs::symlink_metadata(src).await.map_err(|error| {
                PluginError::LoadingError(format!("Failed to inspect source path {}: {error}", src.display()))
            })?;
            if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() {
                return Err(PluginError::LoadingError(format!("Source is not a directory: {}", src.display())));
            }

            VtCodePaths::ensure_user_dir(dst)
                .map_err(|e| PluginError::LoadingError(format!("Failed to create destination directory: {e}")))?;

            let mut entries = fs::read_dir(src)
                .await
                .map_err(|e| PluginError::LoadingError(format!("Failed to read source directory: {e}")))?;

            while let Some(entry) = entries
                .next_entry()
                .await
                .map_err(|e| PluginError::LoadingError(format!("Failed to read directory entry: {e}")))?
            {
                let src_path = entry.path();
                let dst_path = dst.join(entry.file_name());

                let source_metadata = fs::symlink_metadata(&src_path)
                    .await
                    .map_err(|error| PluginError::LoadingError(format!("Failed to inspect source entry: {error}")))?;
                if source_metadata.file_type().is_symlink() {
                    return Err(PluginError::LoadingError(format!(
                        "Refusing to copy symlinked plugin entry: {}",
                        src_path.display()
                    )));
                }
                if source_metadata.is_dir() {
                    self.copy_directory(&src_path, &dst_path).await?;
                } else if source_metadata.is_file() {
                    if let Ok(destination_metadata) = fs::symlink_metadata(&dst_path).await
                        && destination_metadata.file_type().is_symlink()
                    {
                        return Err(PluginError::LoadingError(format!(
                            "Refusing to replace symlinked plugin entry: {}",
                            dst_path.display()
                        )));
                    }
                    fs::copy(&src_path, &dst_path)
                        .await
                        .map_err(|e| PluginError::LoadingError(format!("Failed to copy file: {e}")))?;
                }
            }

            Ok(())
        })
        .await
    }

    /// Extract plugin name from Git URL
    fn extract_name_from_git_url(&self, url: &str) -> String {
        // Extract name from git URL (e.g., https://github.com/user/repo.git -> repo)
        url.trim_end_matches(".git")
            .split('/')
            .next_back()
            .unwrap_or("unknown-plugin")
            .to_string()
    }

    /// Extract plugin name from URL
    fn extract_name_from_url(&self, url: &str) -> String {
        // Extract name from URL path
        url.split('/').next_back().unwrap_or("unknown-plugin").to_string()
    }

    fn install_dir_for(&self, plugin_name: &str) -> PluginResult<PathBuf> {
        validate_plugin_component(plugin_name)?;
        VtCodePaths::ensure_user_dir(&self.plugins_dir)
            .map_err(|e| PluginError::LoadingError(format!("Failed to validate plugin directory: {e}")))?;
        let install_dir = self.plugins_dir.join(plugin_name);
        VtCodePaths::ensure_user_dir(&install_dir)
            .map_err(|e| PluginError::LoadingError(format!("Failed to create plugin directory: {e}")))?;
        Ok(install_dir)
    }
}