1use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use anyhow::{Context, Result};
10use tokio::sync::RwLock;
11use tracing::{debug, info, warn};
12
13use super::{PluginCache, PluginLoader, PluginManifest, PluginResult, PluginRuntime};
14use crate::config::PluginRuntimeConfig;
15
16pub struct PluginManager {
18 runtime: Arc<PluginRuntime>,
20 loader: Arc<PluginLoader>,
22 cache: Arc<RwLock<PluginCache>>,
24 refresh_worker: Arc<RwLock<RefreshWorkerState>>,
26}
27
28#[derive(Debug, Default)]
31struct RefreshWorkerState {
32 is_idle: bool,
34}
35
36impl PluginManager {
37 pub fn new(config: PluginRuntimeConfig, base_dir: PathBuf) -> Result<Self> {
39 let runtime = Arc::new(PluginRuntime::new(config.clone(), base_dir.join("runtime")));
40 let cache = Arc::new(RwLock::new(PluginCache::new(base_dir.join("cache"))));
41
42 let loader = Arc::new(PluginLoader::new(
43 base_dir.join("installed"),
44 runtime.as_ref().clone(),
45 ));
46
47 Ok(Self {
48 runtime,
49 loader,
50 cache,
51 refresh_worker: Arc::new(RwLock::new(RefreshWorkerState::default())),
52 })
53 }
54
55 pub async fn install_plugin(
57 &self,
58 source: super::loader::PluginSource,
59 name: Option<String>,
60 ) -> PluginResult<()> {
61 self.loader.install_plugin(source, name).await?;
63 Ok(())
64 }
65
66 pub async fn uninstall_plugin(&self, plugin_name: &str) -> PluginResult<()> {
68 self.loader.uninstall_plugin(plugin_name).await?;
70 Ok(())
71 }
72
73 pub async fn enable_plugin(&self, plugin_name: &str) -> PluginResult<()> {
75 self.runtime.enable_plugin(plugin_name).await?;
76 Ok(())
77 }
78
79 pub async fn disable_plugin(&self, plugin_name: &str) -> PluginResult<()> {
81 self.runtime.disable_plugin(plugin_name).await?;
82 Ok(())
83 }
84
85 pub async fn load_plugin(&self, plugin_path: &Path) -> PluginResult<()> {
87 self.runtime.load_plugin(plugin_path).await?;
88 Ok(())
89 }
90
91 pub async fn get_plugin(&self, plugin_id: &str) -> PluginResult<super::runtime::PluginHandle> {
93 self.runtime.get_plugin(plugin_id).await
94 }
95
96 pub async fn list_installed_plugins(&self) -> PluginResult<Vec<String>> {
98 self.loader.list_installed_plugins().await
99 }
100
101 pub async fn list_loaded_plugins(&self) -> Vec<super::runtime::PluginHandle> {
103 self.runtime.list_plugins().await
104 }
105
106 pub async fn process_plugin_components(
108 &self,
109 plugin_path: &Path,
110 manifest: &PluginManifest,
111 ) -> Result<super::components::PluginComponents> {
112 super::components::PluginComponentsHandler::process_all_components(plugin_path, manifest)
113 .await
114 }
115
116 pub async fn is_plugin_enabled(&self, plugin_id: &str) -> bool {
118 self.runtime.is_plugin_enabled(plugin_id).await
119 }
120
121 pub async fn cache_plugin(&self, plugin_id: &str, source_path: &Path) -> PluginResult<PathBuf> {
123 let mut cache = self.cache.write().await;
124 cache.cache_plugin(plugin_id, source_path).await
125 }
126
127 pub async fn get_cached_plugin(&self, plugin_id: &str) -> Option<PathBuf> {
129 let cache = self.cache.read().await;
130 cache.get_cached_plugin(plugin_id).cloned()
131 }
132
133 pub async fn refresh_non_curated_plugin_cache(
146 &self,
147 roots: &[PathBuf],
148 ) -> Result<RefreshResult> {
149 {
151 let worker = self.refresh_worker.read().await;
152 if !worker.is_idle {
153 debug!("non-curated plugin cache refresh already in progress, skipping");
154 return Ok(RefreshResult::SkippedAlreadyInProgress);
155 }
156 }
157
158 {
160 let mut worker = self.refresh_worker.write().await;
161 if !worker.is_idle {
162 return Ok(RefreshResult::SkippedAlreadyInProgress);
163 }
164 worker.is_idle = false;
165 }
166
167 let result = self.refresh_non_curated_from_roots_impl(roots).await;
168
169 let mut worker = self.refresh_worker.write().await;
170 worker.is_idle = true;
171
172 result
173 }
174
175 async fn refresh_non_curated_from_roots_impl(
177 &self,
178 roots: &[PathBuf],
179 ) -> Result<RefreshResult> {
180 if roots.is_empty() {
181 debug!("no workspace roots provided for non-curated plugin cache refresh");
182 return Ok(RefreshResult::NoRootsProvided);
183 }
184
185 let mut refreshed_count = 0usize;
186 let mut errors = Vec::with_capacity(roots.len());
187
188 for root in roots {
189 if !root.exists() {
190 debug!(
191 "workspace root does not exist, skipping: {}",
192 root.display()
193 );
194 continue;
195 }
196
197 match self.scan_root_for_plugins(root).await {
198 Ok(plugins) => {
199 for plugin_info in &plugins {
200 if let Some(existing) = self.get_cached_plugin(&plugin_info.name).await
202 && existing.exists()
203 && plugin_info.version_matches_existing(&existing).await
204 {
205 debug!(
206 "plugin '{}' version unchanged, skipping cache update",
207 plugin_info.name
208 );
209 continue;
210 }
211
212 if let Err(e) = self
213 .cache_plugin(&plugin_info.name, &plugin_info.path)
214 .await
215 {
216 errors.push(format!(
217 "failed to cache plugin '{}': {e}",
218 plugin_info.name
219 ));
220 } else {
221 refreshed_count += 1;
222 info!("cached non-curated plugin: {}", plugin_info.name);
223 }
224 }
225 }
226 Err(e) => {
227 errors.push(format!("failed to scan root {}: {e}", root.display()));
228 }
229 }
230 }
231
232 if errors.is_empty() {
233 Ok(RefreshResult::Success {
234 refreshed_count,
235 errors: Vec::new(),
236 })
237 } else {
238 Ok(RefreshResult::SuccessWithErrors {
239 refreshed_count,
240 errors,
241 })
242 }
243 }
244
245 async fn scan_root_for_plugins(&self, root: &Path) -> Result<Vec<DiscoveredPluginInfo>> {
247 let mut discovered = Vec::new();
248
249 let plugin_roots = vec![root.join(".vtcode").join("plugins"), root.join("plugins")];
251
252 for plugin_root in plugin_roots {
253 if !plugin_root.exists() {
254 continue;
255 }
256
257 let entries = match tokio::fs::read_dir(&plugin_root).await {
259 Ok(entries) => entries,
260 Err(e) => {
261 warn!(
262 "Failed to read plugin root {}: {}",
263 plugin_root.display(),
264 e
265 );
266 continue;
267 }
268 };
269
270 let mut dirs = Vec::new();
272 let mut entries = entries;
273 while let Ok(Some(entry)) = entries.next_entry().await {
274 if entry.file_type().await.is_ok_and(|ft| ft.is_dir()) {
275 dirs.push(entry.path());
276 }
277 }
278
279 for plugin_dir in dirs {
280 let manifest_path = plugin_dir.join(".vtcode-plugin").join("plugin.json");
281
282 if !manifest_path.exists() {
283 continue;
284 }
285
286 match self.load_plugin_manifest(&manifest_path).await {
287 Ok(info) => discovered.push(info),
288 Err(e) => {
289 warn!(
290 "Failed to load plugin manifest from {}: {}",
291 manifest_path.display(),
292 e
293 );
294 }
295 }
296 }
297 }
298
299 Ok(discovered)
300 }
301
302 async fn load_plugin_manifest(&self, manifest_path: &Path) -> Result<DiscoveredPluginInfo> {
304 let content = tokio::fs::read_to_string(manifest_path)
305 .await
306 .with_context(|| {
307 format!(
308 "failed to read plugin manifest at {}",
309 manifest_path.display()
310 )
311 })?;
312 let manifest: PluginManifest = serde_json::from_str(&content).with_context(|| {
313 format!(
314 "failed to parse plugin manifest at {}",
315 manifest_path.display()
316 )
317 })?;
318
319 Ok(DiscoveredPluginInfo {
320 name: manifest.name.clone(),
321 version: manifest.version.clone(),
322 path: manifest_path
323 .parent()
324 .and_then(|p| p.parent())
325 .unwrap_or(manifest_path)
326 .to_path_buf(),
327 })
328 }
329}
330
331#[derive(Debug)]
333#[non_exhaustive]
334pub enum RefreshResult {
335 Success {
337 refreshed_count: usize,
338 errors: Vec<String>,
339 },
340 SuccessWithErrors {
342 refreshed_count: usize,
343 errors: Vec<String>,
344 },
345 SkippedAlreadyInProgress,
347 NoRootsProvided,
349}
350
351#[derive(Debug, Clone)]
353pub struct DiscoveredPluginInfo {
354 pub name: String,
356 pub version: Option<String>,
358 pub path: PathBuf,
360}
361
362impl DiscoveredPluginInfo {
363 async fn version_matches_existing(&self, existing_path: &Path) -> bool {
365 let Some(ref current_version) = self.version else {
367 return false;
368 };
369
370 let cached_manifest_path = existing_path.join(".vtcode-plugin").join("plugin.json");
372 if !cached_manifest_path.exists() {
373 return false;
374 }
375
376 match tokio::fs::read_to_string(&cached_manifest_path).await {
377 Ok(content) => match serde_json::from_str::<PluginManifest>(&content) {
378 Ok(cached) => cached.version.as_deref() == Some(current_version),
379 Err(_) => false,
380 },
381 Err(_) => false,
382 }
383 }
384}