vx_core/
plugin.rs

1//! High-level plugin traits for easy implementation
2//!
3//! This module provides simplified traits that abstract away most of the complexity,
4//! allowing developers to focus on the core functionality of their tools.
5
6use crate::{
7    Ecosystem, FigmentConfigManager, HttpUtils, PackageInfo, PackageSpec, Result, ToolContext,
8    ToolDownloader, ToolExecutionResult, ToolStatus, VersionInfo, VxEnvironment,
9};
10use serde_json::Value;
11use std::collections::HashMap;
12use std::path::{Path, PathBuf};
13
14/// Simplified trait for implementing tool support
15///
16/// This trait provides sensible defaults for most methods, so developers only need
17/// to implement the essential functionality for their specific tool.
18#[async_trait::async_trait]
19pub trait VxTool: Send + Sync {
20    /// Tool name (required)
21    fn name(&self) -> &str;
22
23    /// Tool description (optional, has default)
24    fn description(&self) -> &str {
25        "A development tool"
26    }
27
28    /// Supported aliases for this tool (optional)
29    fn aliases(&self) -> Vec<&str> {
30        vec![]
31    }
32
33    /// Fetch available versions from the tool's official source
34    /// This is the main method developers need to implement
35    async fn fetch_versions(&self, include_prerelease: bool) -> Result<Vec<VersionInfo>>;
36
37    /// Install a specific version of the tool
38    /// Default implementation provides a basic download-and-extract workflow
39    async fn install_version(&self, version: &str, force: bool) -> Result<()> {
40        if !force && self.is_version_installed(version).await? {
41            return Err(crate::VxError::VersionAlreadyInstalled {
42                tool_name: self.name().to_string(),
43                version: version.to_string(),
44            });
45        }
46
47        let install_dir = self.get_version_install_dir(version);
48        let _exe_path = self.default_install_workflow(version, &install_dir).await?;
49
50        // Verify installation
51        if !self.is_version_installed(version).await? {
52            return Err(crate::VxError::InstallationFailed {
53                tool_name: self.name().to_string(),
54                version: version.to_string(),
55                message: "Installation verification failed".to_string(),
56            });
57        }
58
59        Ok(())
60    }
61
62    /// Check if a version is installed (has sensible default)
63    async fn is_version_installed(&self, version: &str) -> Result<bool> {
64        let env = VxEnvironment::new().expect("Failed to create VX environment");
65        Ok(env.is_version_installed(self.name(), version))
66    }
67
68    /// Execute the tool with given arguments (has default implementation)
69    async fn execute(&self, args: &[String], context: &ToolContext) -> Result<ToolExecutionResult> {
70        self.default_execute_workflow(args, context).await
71    }
72
73    /// Get the executable path within an installation directory
74    /// Override this if your tool has a non-standard layout
75    async fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf> {
76        let exe_name = if cfg!(target_os = "windows") {
77            format!("{}.exe", self.name())
78        } else {
79            self.name().to_string()
80        };
81
82        // Try common locations
83        let candidates = vec![
84            install_dir.join(&exe_name),
85            install_dir.join("bin").join(&exe_name),
86            install_dir.join("Scripts").join(&exe_name), // Windows Python-style
87        ];
88
89        for candidate in candidates {
90            if candidate.exists() {
91                return Ok(candidate);
92            }
93        }
94
95        // Default to bin directory
96        Ok(install_dir.join("bin").join(exe_name))
97    }
98
99    /// Get download URL for a specific version and current platform
100    /// Override this to provide platform-specific URLs
101    async fn get_download_url(&self, version: &str) -> Result<Option<String>> {
102        // Default: try to extract from version info
103        let versions = self.fetch_versions(true).await?;
104        Ok(versions
105            .iter()
106            .find(|v| v.version == version)
107            .and_then(|v| v.download_url.clone()))
108    }
109
110    /// Get installation directory for a specific version
111    fn get_version_install_dir(&self, version: &str) -> PathBuf {
112        let env = VxEnvironment::new().expect("Failed to create VX environment");
113        env.get_version_install_dir(self.name(), version)
114    }
115
116    /// Get base installation directory for this tool
117    fn get_base_install_dir(&self) -> PathBuf {
118        let env = VxEnvironment::new().expect("Failed to create VX environment");
119        env.get_tool_install_dir(self.name())
120    }
121
122    /// Default installation workflow (download + extract)
123    /// Most tools can use this as-is
124    async fn default_install_workflow(
125        &self,
126        version: &str,
127        _install_dir: &Path,
128    ) -> Result<PathBuf> {
129        // Get download URL
130        let download_url = self.get_download_url(version).await?.ok_or_else(|| {
131            crate::VxError::DownloadUrlNotFound {
132                tool_name: self.name().to_string(),
133                version: version.to_string(),
134            }
135        })?;
136
137        // Use the new downloader
138        let downloader = ToolDownloader::new()?;
139        let exe_path = downloader
140            .download_and_install(self.name(), version, &download_url)
141            .await?;
142
143        // Register with global tool manager
144        let global_manager = crate::GlobalToolManager::new()?;
145        let install_dir = self.get_version_install_dir(version);
146        global_manager
147            .register_global_tool(self.name(), version, &install_dir)
148            .await?;
149
150        Ok(exe_path)
151    }
152
153    /// Default execution workflow
154    async fn default_execute_workflow(
155        &self,
156        args: &[String],
157        context: &ToolContext,
158    ) -> Result<ToolExecutionResult> {
159        // Find the tool executable
160        let exe_path = if context.use_system_path {
161            which::which(self.name()).map_err(|_| crate::VxError::ToolNotFound {
162                tool_name: self.name().to_string(),
163            })?
164        } else {
165            // Use vx-managed version
166            let active_version = self.get_active_version().await?;
167            let install_dir = self.get_version_install_dir(&active_version);
168            let env = VxEnvironment::new().expect("Failed to create VX environment");
169            env.find_executable_in_dir(&install_dir, self.name())?
170        };
171
172        // Execute the tool
173        let mut cmd = std::process::Command::new(&exe_path);
174        cmd.args(args);
175
176        if let Some(cwd) = &context.working_directory {
177            cmd.current_dir(cwd);
178        }
179
180        for (key, value) in &context.environment_variables {
181            cmd.env(key, value);
182        }
183
184        let status = cmd.status().map_err(|e| crate::VxError::Other {
185            message: format!("Failed to execute {}: {}", self.name(), e),
186        })?;
187
188        Ok(ToolExecutionResult {
189            exit_code: status.code().unwrap_or(1),
190            stdout: None, // Could be enhanced to capture output
191            stderr: None,
192        })
193    }
194
195    /// Get the currently active version (has default implementation)
196    async fn get_active_version(&self) -> Result<String> {
197        let env = VxEnvironment::new().expect("Failed to create VX environment");
198
199        // Try to get from environment config first
200        if let Some(active_version) = env.get_active_version(self.name())? {
201            return Ok(active_version);
202        }
203
204        // Fallback to latest installed
205        let installed_versions = self.get_installed_versions().await?;
206        installed_versions
207            .first()
208            .cloned()
209            .ok_or_else(|| crate::VxError::ToolNotInstalled {
210                tool_name: self.name().to_string(),
211            })
212    }
213
214    /// Get all installed versions
215    async fn get_installed_versions(&self) -> Result<Vec<String>> {
216        let env = VxEnvironment::new().expect("Failed to create VX environment");
217        env.list_installed_versions(self.name())
218    }
219
220    /// Remove a specific version of the tool
221    async fn remove_version(&self, version: &str, force: bool) -> Result<()> {
222        let version_dir = self.get_version_install_dir(version);
223
224        // Check if the directory exists first
225        if !version_dir.exists() {
226            if !force {
227                return Err(crate::VxError::VersionNotInstalled {
228                    tool_name: self.name().to_string(),
229                    version: version.to_string(),
230                });
231            }
232            // In force mode, if directory doesn't exist, consider it already removed
233            return Ok(());
234        }
235
236        // Check if tool can be safely removed (not referenced by any venv)
237        let global_manager = crate::GlobalToolManager::new()?;
238        if !force && !global_manager.can_remove_tool(self.name()).await? {
239            let dependents = global_manager.get_tool_dependents(self.name()).await?;
240            return Err(crate::VxError::Other {
241                message: format!(
242                    "Cannot remove {} {} - it is referenced by virtual environments: {}. Use --force to override.",
243                    self.name(),
244                    version,
245                    dependents.join(", ")
246                ),
247            });
248        }
249
250        // Attempt to remove the directory
251        match std::fs::remove_dir_all(&version_dir) {
252            Ok(()) => {
253                // Remove from global tool manager
254                if let Err(e) = global_manager.remove_global_tool(self.name()).await {
255                    // Log warning but don't fail the operation
256                    eprintln!("Warning: Failed to remove tool from global registry: {}", e);
257                }
258                Ok(())
259            }
260            Err(e) => {
261                if force {
262                    // In force mode, ignore certain types of errors
263                    match e.kind() {
264                        std::io::ErrorKind::NotFound => {
265                            // Directory was removed between our check and removal attempt
266                            Ok(())
267                        }
268                        std::io::ErrorKind::PermissionDenied => {
269                            // Still report permission errors even in force mode
270                            Err(crate::VxError::PermissionError {
271                                message: format!(
272                                    "Permission denied when removing {} {}: {}",
273                                    self.name(),
274                                    version,
275                                    e
276                                ),
277                            })
278                        }
279                        _ => {
280                            // For other errors in force mode, convert to a more user-friendly message
281                            Err(crate::VxError::IoError {
282                                message: format!(
283                                    "Failed to remove {} {} directory: {}",
284                                    self.name(),
285                                    version,
286                                    e
287                                ),
288                            })
289                        }
290                    }
291                } else {
292                    // In non-force mode, propagate the original error
293                    Err(e.into())
294                }
295            }
296        }
297    }
298
299    /// Get tool status (installed versions, active version, etc.)
300    async fn get_status(&self) -> Result<ToolStatus> {
301        let installed_versions = self.get_installed_versions().await?;
302        let current_version = if !installed_versions.is_empty() {
303            self.get_active_version().await.ok()
304        } else {
305            None
306        };
307
308        Ok(ToolStatus {
309            installed: !installed_versions.is_empty(),
310            current_version,
311            installed_versions,
312        })
313    }
314
315    /// Additional metadata for the tool (optional)
316    fn metadata(&self) -> HashMap<String, String> {
317        HashMap::new()
318    }
319}
320
321/// Trait for URL builders that can generate download URLs
322pub trait UrlBuilder: Send + Sync {
323    fn download_url(&self, version: &str) -> Option<String>;
324    fn versions_url(&self) -> &str;
325}
326
327/// Trait for version parsers that can parse API responses
328pub trait VersionParser: Send + Sync {
329    fn parse_versions(&self, json: &Value, include_prerelease: bool) -> Result<Vec<VersionInfo>>;
330}
331
332/// Simplified trait for implementing package manager support
333///
334/// This trait provides a high-level interface for package managers,
335/// with sensible defaults for common operations.
336#[async_trait::async_trait]
337pub trait VxPackageManager: Send + Sync {
338    /// Package manager name (required)
339    fn name(&self) -> &str;
340
341    /// Ecosystem this package manager belongs to (required)
342    fn ecosystem(&self) -> Ecosystem;
343
344    /// Description of the package manager (optional)
345    fn description(&self) -> &str {
346        "A package manager"
347    }
348
349    /// Check if this package manager is available on the system
350    async fn is_available(&self) -> Result<bool> {
351        // Default: check if the executable exists in PATH
352        Ok(which::which(self.name()).is_ok())
353    }
354
355    /// Check if this package manager should be used for a project
356    /// Override this to detect project-specific files (package.json, Cargo.toml, etc.)
357    fn is_preferred_for_project(&self, project_path: &Path) -> bool {
358        // Default: check for common config files
359        let config_files = self.get_config_files();
360        config_files
361            .iter()
362            .any(|file| project_path.join(file).exists())
363    }
364
365    /// Get configuration files that indicate this package manager should be used
366    fn get_config_files(&self) -> Vec<&str> {
367        vec![]
368    }
369
370    /// Install packages (main method to implement)
371    async fn install_packages(&self, packages: &[PackageSpec], project_path: &Path) -> Result<()>;
372
373    /// Remove packages (has default implementation)
374    async fn remove_packages(&self, packages: &[String], project_path: &Path) -> Result<()> {
375        self.run_command(&["remove"], packages, project_path).await
376    }
377
378    /// Update packages (has default implementation)
379    async fn update_packages(&self, packages: &[String], project_path: &Path) -> Result<()> {
380        if packages.is_empty() {
381            self.run_command(&["update"], &[], project_path).await
382        } else {
383            self.run_command(&["update"], packages, project_path).await
384        }
385    }
386
387    /// List installed packages (has default implementation)
388    async fn list_packages(&self, project_path: &Path) -> Result<Vec<PackageInfo>> {
389        // Default: try to parse from common files or run list command
390        self.default_list_packages(project_path).await
391    }
392
393    /// Search for packages (has default implementation)
394    async fn search_packages(&self, query: &str) -> Result<Vec<PackageInfo>> {
395        self.run_search_command(query).await
396    }
397
398    /// Run a package manager command with arguments
399    async fn run_command(
400        &self,
401        command: &[&str],
402        args: &[String],
403        project_path: &Path,
404    ) -> Result<()> {
405        let mut cmd = std::process::Command::new(self.name());
406        cmd.args(command);
407        cmd.args(args);
408        cmd.current_dir(project_path);
409
410        let status = cmd
411            .status()
412            .map_err(|e| crate::VxError::PackageManagerError {
413                manager: self.name().to_string(),
414                message: format!("Failed to run command: {}", e),
415            })?;
416
417        if !status.success() {
418            return Err(crate::VxError::PackageManagerError {
419                manager: self.name().to_string(),
420                message: format!("Command failed with exit code: {:?}", status.code()),
421            });
422        }
423
424        Ok(())
425    }
426
427    /// Default implementation for listing packages
428    async fn default_list_packages(&self, _project_path: &Path) -> Result<Vec<PackageInfo>> {
429        // This would be implemented based on common patterns
430        // For now, return empty list
431        Ok(vec![])
432    }
433
434    /// Default implementation for searching packages
435    async fn run_search_command(&self, query: &str) -> Result<Vec<PackageInfo>> {
436        // This would run the package manager's search command and parse output
437        // For now, return empty list
438        let _ = query;
439        Ok(vec![])
440    }
441
442    /// Get the command to install packages (override for custom install commands)
443    fn get_install_command(&self) -> Vec<&str> {
444        vec!["install"]
445    }
446
447    /// Get the command to add new packages (override if different from install)
448    fn get_add_command(&self) -> Vec<&str> {
449        vec!["add"]
450    }
451
452    /// Additional metadata for the package manager (optional)
453    fn metadata(&self) -> HashMap<String, String> {
454        HashMap::new()
455    }
456}
457
458/// Combined plugin trait that can provide both tools and package managers
459///
460/// This is the main trait that plugin developers implement to register their functionality.
461#[async_trait::async_trait]
462pub trait VxPlugin: Send + Sync {
463    /// Plugin name (required)
464    fn name(&self) -> &str;
465
466    /// Plugin description (optional)
467    fn description(&self) -> &str {
468        "A vx plugin"
469    }
470
471    /// Plugin version (optional)
472    fn version(&self) -> &str {
473        "0.1.0"
474    }
475
476    /// Get all tools provided by this plugin
477    fn tools(&self) -> Vec<Box<dyn VxTool>> {
478        vec![]
479    }
480
481    /// Get all package managers provided by this plugin
482    fn package_managers(&self) -> Vec<Box<dyn VxPackageManager>> {
483        vec![]
484    }
485
486    /// Initialize the plugin (optional)
487    async fn initialize(&mut self) -> Result<()> {
488        Ok(())
489    }
490
491    /// Check if this plugin supports a specific tool
492    fn supports_tool(&self, tool_name: &str) -> bool {
493        self.tools()
494            .iter()
495            .any(|tool| tool.name() == tool_name || tool.aliases().contains(&tool_name))
496    }
497
498    /// Check if this plugin supports a specific package manager
499    fn supports_package_manager(&self, pm_name: &str) -> bool {
500        self.package_managers()
501            .iter()
502            .any(|pm| pm.name() == pm_name)
503    }
504
505    /// Plugin metadata (optional)
506    fn metadata(&self) -> HashMap<String, String> {
507        HashMap::new()
508    }
509}
510
511/// Standard plugin implementation for single-tool plugins
512pub struct StandardPlugin {
513    name: String,
514    description: String,
515    version: String,
516    tool_factory: Box<dyn Fn() -> Box<dyn VxTool> + Send + Sync>,
517}
518
519impl StandardPlugin {
520    pub fn new<F>(name: String, description: String, version: String, tool_factory: F) -> Self
521    where
522        F: Fn() -> Box<dyn VxTool> + Send + Sync + 'static,
523    {
524        Self {
525            name,
526            description,
527            version,
528            tool_factory: Box::new(tool_factory),
529        }
530    }
531}
532
533#[async_trait::async_trait]
534impl VxPlugin for StandardPlugin {
535    fn name(&self) -> &str {
536        &self.name
537    }
538
539    fn description(&self) -> &str {
540        &self.description
541    }
542
543    fn version(&self) -> &str {
544        &self.version
545    }
546
547    fn tools(&self) -> Vec<Box<dyn VxTool>> {
548        vec![(self.tool_factory)()]
549    }
550}
551
552/// Configuration-driven tool implementation
553///
554/// This tool uses figment configuration to determine download URLs and version sources,
555/// making it highly configurable without code changes.
556pub struct ConfigurableTool {
557    metadata: ToolMetadata,
558    config_manager: FigmentConfigManager,
559    url_builder: Box<dyn UrlBuilder>,
560    version_parser: Box<dyn VersionParser>,
561}
562
563impl ConfigurableTool {
564    pub fn new(
565        metadata: ToolMetadata,
566        url_builder: Box<dyn UrlBuilder>,
567        version_parser: Box<dyn VersionParser>,
568    ) -> Result<Self> {
569        let config_manager =
570            FigmentConfigManager::new().or_else(|_| FigmentConfigManager::minimal())?;
571
572        Ok(Self {
573            metadata,
574            config_manager,
575            url_builder,
576            version_parser,
577        })
578    }
579}
580
581#[async_trait::async_trait]
582impl VxTool for ConfigurableTool {
583    fn name(&self) -> &str {
584        &self.metadata.name
585    }
586
587    fn description(&self) -> &str {
588        &self.metadata.description
589    }
590
591    fn aliases(&self) -> Vec<&str> {
592        self.metadata.aliases.iter().map(|s| s.as_str()).collect()
593    }
594
595    async fn fetch_versions(&self, include_prerelease: bool) -> Result<Vec<VersionInfo>> {
596        let json = HttpUtils::fetch_json(self.url_builder.versions_url()).await?;
597        self.version_parser
598            .parse_versions(&json, include_prerelease)
599    }
600
601    async fn get_download_url(&self, version: &str) -> Result<Option<String>> {
602        // First try to get from figment configuration
603        if let Ok(url) = self
604            .config_manager
605            .get_download_url(&self.metadata.name, version)
606        {
607            return Ok(Some(url));
608        }
609
610        // Fall back to URL builder
611        Ok(self.url_builder.download_url(version))
612    }
613}
614
615/// Basic tool metadata for standard tools
616#[derive(Debug, Clone)]
617pub struct ToolMetadata {
618    pub name: String,
619    pub description: String,
620    pub aliases: Vec<String>,
621}