use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use vtcode_commons::MultiErrors;
use super::{PluginCache, PluginLoader, PluginManifest, PluginResult, PluginRuntime};
use crate::config::PluginRuntimeConfig;
pub struct PluginManager {
runtime: Arc<PluginRuntime>,
loader: Arc<PluginLoader>,
cache: Arc<RwLock<PluginCache>>,
refresh_worker: Arc<RwLock<RefreshWorkerState>>,
}
#[derive(Debug, Default)]
struct RefreshWorkerState {
is_idle: bool,
}
impl PluginManager {
pub fn new(config: PluginRuntimeConfig, base_dir: PathBuf) -> Result<Self> {
let runtime = Arc::new(PluginRuntime::new(config.clone(), base_dir.join("runtime")));
let cache = Arc::new(RwLock::new(PluginCache::new(base_dir.join("cache"))));
let loader = Arc::new(PluginLoader::new(base_dir.join("installed"), runtime.as_ref().clone()));
Ok(Self {
runtime,
loader,
cache,
refresh_worker: Arc::new(RwLock::new(RefreshWorkerState::default())),
})
}
pub async fn install_plugin(&self, source: super::loader::PluginSource, name: Option<String>) -> PluginResult<()> {
self.loader.install_plugin(source, name).await?;
Ok(())
}
pub async fn uninstall_plugin(&self, plugin_name: &str) -> PluginResult<()> {
self.loader.uninstall_plugin(plugin_name).await?;
Ok(())
}
pub async fn enable_plugin(&self, plugin_name: &str) -> PluginResult<()> {
self.runtime.enable_plugin(plugin_name).await?;
Ok(())
}
pub async fn disable_plugin(&self, plugin_name: &str) -> PluginResult<()> {
self.runtime.disable_plugin(plugin_name).await?;
Ok(())
}
pub async fn load_plugin(&self, plugin_path: &Path) -> PluginResult<()> {
self.runtime.load_plugin(plugin_path).await?;
Ok(())
}
pub async fn get_plugin(&self, plugin_id: &str) -> PluginResult<super::runtime::PluginHandle> {
self.runtime.get_plugin(plugin_id).await
}
pub async fn list_installed_plugins(&self) -> PluginResult<Vec<String>> {
self.loader.list_installed_plugins().await
}
pub async fn list_loaded_plugins(&self) -> Vec<super::runtime::PluginHandle> {
self.runtime.list_plugins().await
}
pub async fn process_plugin_components(
&self,
plugin_path: &Path,
manifest: &PluginManifest,
) -> Result<super::components::PluginComponents> {
super::components::PluginComponentsHandler::process_all_components(plugin_path, manifest).await
}
pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
self.runtime.is_plugin_enabled(plugin_id).await
}
pub async fn cache_plugin(&self, plugin_id: &str, source_path: &Path) -> PluginResult<PathBuf> {
let mut cache = self.cache.write().await;
cache.cache_plugin(plugin_id, source_path).await
}
pub async fn get_cached_plugin(&self, plugin_id: &str) -> Option<PathBuf> {
let cache = self.cache.read().await;
cache.get_cached_plugin(plugin_id).cloned()
}
pub async fn refresh_non_curated_plugin_cache(&self, roots: &[PathBuf]) -> Result<RefreshResult> {
{
let worker = self.refresh_worker.read().await;
if !worker.is_idle {
debug!("non-curated plugin cache refresh already in progress, skipping");
return Ok(RefreshResult::SkippedAlreadyInProgress);
}
}
{
let mut worker = self.refresh_worker.write().await;
if !worker.is_idle {
return Ok(RefreshResult::SkippedAlreadyInProgress);
}
worker.is_idle = false;
}
let result = self.refresh_non_curated_from_roots_impl(roots).await;
let mut worker = self.refresh_worker.write().await;
worker.is_idle = true;
result
}
async fn refresh_non_curated_from_roots_impl(&self, roots: &[PathBuf]) -> Result<RefreshResult> {
if roots.is_empty() {
debug!("no workspace roots provided for non-curated plugin cache refresh");
return Ok(RefreshResult::NoRootsProvided);
}
let mut refreshed_count = 0usize;
let mut errors: MultiErrors<String> = MultiErrors::new();
for root in roots {
if !root.exists() {
debug!("workspace root does not exist, skipping: {}", root.display());
continue;
}
match self.scan_root_for_plugins(root).await {
Ok(plugins) => {
for plugin_info in &plugins {
if let Some(existing) = self.get_cached_plugin(&plugin_info.name).await
&& existing.exists()
&& plugin_info.version_matches_existing(&existing).await
{
debug!("plugin '{}' version unchanged, skipping cache update", plugin_info.name);
continue;
}
if let Err(e) = self.cache_plugin(&plugin_info.name, &plugin_info.path).await {
errors.push(format!("failed to cache plugin '{}': {e}", plugin_info.name));
} else {
refreshed_count += 1;
info!("cached non-curated plugin: {}", plugin_info.name);
}
}
}
Err(e) => {
errors.push(format!("failed to scan root {}: {e}", root.display()));
}
}
}
Ok(RefreshResult::Success { refreshed_count, errors })
}
async fn scan_root_for_plugins(&self, root: &Path) -> Result<Vec<DiscoveredPluginInfo>> {
let mut discovered = Vec::new();
let plugin_roots = vec![root.join(".vtcode").join("plugins"), root.join("plugins")];
for plugin_root in plugin_roots {
if !plugin_root.exists() {
continue;
}
let entries = match tokio::fs::read_dir(&plugin_root).await {
Ok(entries) => entries,
Err(e) => {
warn!("Failed to read plugin root {}: {}", plugin_root.display(), e);
continue;
}
};
let mut dirs = Vec::new();
let mut entries = entries;
while let Ok(Some(entry)) = entries.next_entry().await {
if entry.file_type().await.is_ok_and(|ft| ft.is_dir()) {
dirs.push(entry.path());
}
}
for plugin_dir in dirs {
let manifest_path = plugin_dir.join(".vtcode-plugin").join("plugin.json");
if !manifest_path.exists() {
continue;
}
match self.load_plugin_manifest(&manifest_path).await {
Ok(info) => discovered.push(info),
Err(e) => {
warn!("Failed to load plugin manifest from {}: {}", manifest_path.display(), e);
}
}
}
}
Ok(discovered)
}
async fn load_plugin_manifest(&self, manifest_path: &Path) -> Result<DiscoveredPluginInfo> {
let content = tokio::fs::read_to_string(manifest_path)
.await
.with_context(|| format!("failed to read plugin manifest at {}", manifest_path.display()))?;
let manifest: PluginManifest = serde_json::from_str(&content)
.with_context(|| format!("failed to parse plugin manifest at {}", manifest_path.display()))?;
Ok(DiscoveredPluginInfo {
name: manifest.name.clone(),
version: manifest.version.clone(),
path: manifest_path
.parent()
.and_then(|p| p.parent())
.unwrap_or(manifest_path)
.to_path_buf(),
})
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RefreshResult {
Success {
refreshed_count: usize,
errors: MultiErrors<String>,
},
SkippedAlreadyInProgress,
NoRootsProvided,
}
#[derive(Debug, Clone)]
pub struct DiscoveredPluginInfo {
pub name: String,
pub version: Option<String>,
pub path: PathBuf,
}
impl DiscoveredPluginInfo {
async fn version_matches_existing(&self, existing_path: &Path) -> bool {
let Some(ref current_version) = self.version else {
return false;
};
let cached_manifest_path = existing_path.join(".vtcode-plugin").join("plugin.json");
if !cached_manifest_path.exists() {
return false;
}
match tokio::fs::read_to_string(&cached_manifest_path).await {
Ok(content) => match serde_json::from_str::<PluginManifest>(&content) {
Ok(cached) => cached.version.as_deref() == Some(current_version),
Err(_) => false,
},
Err(_) => false,
}
}
}