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        downloader
140            .download_and_install(self.name(), version, &download_url)
141            .await
142    }
143
144    /// Default execution workflow
145    async fn default_execute_workflow(
146        &self,
147        args: &[String],
148        context: &ToolContext,
149    ) -> Result<ToolExecutionResult> {
150        // Find the tool executable
151        let exe_path = if context.use_system_path {
152            which::which(self.name()).map_err(|_| crate::VxError::ToolNotFound {
153                tool_name: self.name().to_string(),
154            })?
155        } else {
156            // Use vx-managed version
157            let active_version = self.get_active_version().await?;
158            let install_dir = self.get_version_install_dir(&active_version);
159            let env = VxEnvironment::new().expect("Failed to create VX environment");
160            env.find_executable_in_dir(&install_dir, self.name())?
161        };
162
163        // Execute the tool
164        let mut cmd = std::process::Command::new(&exe_path);
165        cmd.args(args);
166
167        if let Some(cwd) = &context.working_directory {
168            cmd.current_dir(cwd);
169        }
170
171        for (key, value) in &context.environment_variables {
172            cmd.env(key, value);
173        }
174
175        let status = cmd.status().map_err(|e| crate::VxError::Other {
176            message: format!("Failed to execute {}: {}", self.name(), e),
177        })?;
178
179        Ok(ToolExecutionResult {
180            exit_code: status.code().unwrap_or(1),
181            stdout: None, // Could be enhanced to capture output
182            stderr: None,
183        })
184    }
185
186    /// Get the currently active version (has default implementation)
187    async fn get_active_version(&self) -> Result<String> {
188        let env = VxEnvironment::new().expect("Failed to create VX environment");
189
190        // Try to get from environment config first
191        if let Some(active_version) = env.get_active_version(self.name())? {
192            return Ok(active_version);
193        }
194
195        // Fallback to latest installed
196        let installed_versions = self.get_installed_versions().await?;
197        installed_versions
198            .first()
199            .cloned()
200            .ok_or_else(|| crate::VxError::ToolNotInstalled {
201                tool_name: self.name().to_string(),
202            })
203    }
204
205    /// Get all installed versions
206    async fn get_installed_versions(&self) -> Result<Vec<String>> {
207        let env = VxEnvironment::new().expect("Failed to create VX environment");
208        env.list_installed_versions(self.name())
209    }
210
211    /// Remove a specific version of the tool
212    async fn remove_version(&self, version: &str, force: bool) -> Result<()> {
213        let version_dir = self.get_version_install_dir(version);
214
215        // Check if the directory exists first
216        if !version_dir.exists() {
217            if !force {
218                return Err(crate::VxError::VersionNotInstalled {
219                    tool_name: self.name().to_string(),
220                    version: version.to_string(),
221                });
222            }
223            // In force mode, if directory doesn't exist, consider it already removed
224            return Ok(());
225        }
226
227        // Attempt to remove the directory
228        match std::fs::remove_dir_all(&version_dir) {
229            Ok(()) => Ok(()),
230            Err(e) => {
231                if force {
232                    // In force mode, ignore certain types of errors
233                    match e.kind() {
234                        std::io::ErrorKind::NotFound => {
235                            // Directory was removed between our check and removal attempt
236                            Ok(())
237                        }
238                        std::io::ErrorKind::PermissionDenied => {
239                            // Still report permission errors even in force mode
240                            Err(crate::VxError::PermissionError {
241                                message: format!(
242                                    "Permission denied when removing {} {}: {}",
243                                    self.name(),
244                                    version,
245                                    e
246                                ),
247                            })
248                        }
249                        _ => {
250                            // For other errors in force mode, convert to a more user-friendly message
251                            Err(crate::VxError::IoError {
252                                message: format!(
253                                    "Failed to remove {} {} directory: {}",
254                                    self.name(),
255                                    version,
256                                    e
257                                ),
258                            })
259                        }
260                    }
261                } else {
262                    // In non-force mode, propagate the original error
263                    Err(e.into())
264                }
265            }
266        }
267    }
268
269    /// Get tool status (installed versions, active version, etc.)
270    async fn get_status(&self) -> Result<ToolStatus> {
271        let installed_versions = self.get_installed_versions().await?;
272        let current_version = if !installed_versions.is_empty() {
273            self.get_active_version().await.ok()
274        } else {
275            None
276        };
277
278        Ok(ToolStatus {
279            installed: !installed_versions.is_empty(),
280            current_version,
281            installed_versions,
282        })
283    }
284
285    /// Additional metadata for the tool (optional)
286    fn metadata(&self) -> HashMap<String, String> {
287        HashMap::new()
288    }
289}
290
291/// Trait for URL builders that can generate download URLs
292pub trait UrlBuilder: Send + Sync {
293    fn download_url(&self, version: &str) -> Option<String>;
294    fn versions_url(&self) -> &str;
295}
296
297/// Trait for version parsers that can parse API responses
298pub trait VersionParser: Send + Sync {
299    fn parse_versions(&self, json: &Value, include_prerelease: bool) -> Result<Vec<VersionInfo>>;
300}
301
302/// Simplified trait for implementing package manager support
303///
304/// This trait provides a high-level interface for package managers,
305/// with sensible defaults for common operations.
306#[async_trait::async_trait]
307pub trait VxPackageManager: Send + Sync {
308    /// Package manager name (required)
309    fn name(&self) -> &str;
310
311    /// Ecosystem this package manager belongs to (required)
312    fn ecosystem(&self) -> Ecosystem;
313
314    /// Description of the package manager (optional)
315    fn description(&self) -> &str {
316        "A package manager"
317    }
318
319    /// Check if this package manager is available on the system
320    async fn is_available(&self) -> Result<bool> {
321        // Default: check if the executable exists in PATH
322        Ok(which::which(self.name()).is_ok())
323    }
324
325    /// Check if this package manager should be used for a project
326    /// Override this to detect project-specific files (package.json, Cargo.toml, etc.)
327    fn is_preferred_for_project(&self, project_path: &Path) -> bool {
328        // Default: check for common config files
329        let config_files = self.get_config_files();
330        config_files
331            .iter()
332            .any(|file| project_path.join(file).exists())
333    }
334
335    /// Get configuration files that indicate this package manager should be used
336    fn get_config_files(&self) -> Vec<&str> {
337        vec![]
338    }
339
340    /// Install packages (main method to implement)
341    async fn install_packages(&self, packages: &[PackageSpec], project_path: &Path) -> Result<()>;
342
343    /// Remove packages (has default implementation)
344    async fn remove_packages(&self, packages: &[String], project_path: &Path) -> Result<()> {
345        self.run_command(&["remove"], packages, project_path).await
346    }
347
348    /// Update packages (has default implementation)
349    async fn update_packages(&self, packages: &[String], project_path: &Path) -> Result<()> {
350        if packages.is_empty() {
351            self.run_command(&["update"], &[], project_path).await
352        } else {
353            self.run_command(&["update"], packages, project_path).await
354        }
355    }
356
357    /// List installed packages (has default implementation)
358    async fn list_packages(&self, project_path: &Path) -> Result<Vec<PackageInfo>> {
359        // Default: try to parse from common files or run list command
360        self.default_list_packages(project_path).await
361    }
362
363    /// Search for packages (has default implementation)
364    async fn search_packages(&self, query: &str) -> Result<Vec<PackageInfo>> {
365        self.run_search_command(query).await
366    }
367
368    /// Run a package manager command with arguments
369    async fn run_command(
370        &self,
371        command: &[&str],
372        args: &[String],
373        project_path: &Path,
374    ) -> Result<()> {
375        let mut cmd = std::process::Command::new(self.name());
376        cmd.args(command);
377        cmd.args(args);
378        cmd.current_dir(project_path);
379
380        let status = cmd
381            .status()
382            .map_err(|e| crate::VxError::PackageManagerError {
383                manager: self.name().to_string(),
384                message: format!("Failed to run command: {}", e),
385            })?;
386
387        if !status.success() {
388            return Err(crate::VxError::PackageManagerError {
389                manager: self.name().to_string(),
390                message: format!("Command failed with exit code: {:?}", status.code()),
391            });
392        }
393
394        Ok(())
395    }
396
397    /// Default implementation for listing packages
398    async fn default_list_packages(&self, _project_path: &Path) -> Result<Vec<PackageInfo>> {
399        // This would be implemented based on common patterns
400        // For now, return empty list
401        Ok(vec![])
402    }
403
404    /// Default implementation for searching packages
405    async fn run_search_command(&self, query: &str) -> Result<Vec<PackageInfo>> {
406        // This would run the package manager's search command and parse output
407        // For now, return empty list
408        let _ = query;
409        Ok(vec![])
410    }
411
412    /// Get the command to install packages (override for custom install commands)
413    fn get_install_command(&self) -> Vec<&str> {
414        vec!["install"]
415    }
416
417    /// Get the command to add new packages (override if different from install)
418    fn get_add_command(&self) -> Vec<&str> {
419        vec!["add"]
420    }
421
422    /// Additional metadata for the package manager (optional)
423    fn metadata(&self) -> HashMap<String, String> {
424        HashMap::new()
425    }
426}
427
428/// Combined plugin trait that can provide both tools and package managers
429///
430/// This is the main trait that plugin developers implement to register their functionality.
431#[async_trait::async_trait]
432pub trait VxPlugin: Send + Sync {
433    /// Plugin name (required)
434    fn name(&self) -> &str;
435
436    /// Plugin description (optional)
437    fn description(&self) -> &str {
438        "A vx plugin"
439    }
440
441    /// Plugin version (optional)
442    fn version(&self) -> &str {
443        "0.1.0"
444    }
445
446    /// Get all tools provided by this plugin
447    fn tools(&self) -> Vec<Box<dyn VxTool>> {
448        vec![]
449    }
450
451    /// Get all package managers provided by this plugin
452    fn package_managers(&self) -> Vec<Box<dyn VxPackageManager>> {
453        vec![]
454    }
455
456    /// Initialize the plugin (optional)
457    async fn initialize(&mut self) -> Result<()> {
458        Ok(())
459    }
460
461    /// Check if this plugin supports a specific tool
462    fn supports_tool(&self, tool_name: &str) -> bool {
463        self.tools()
464            .iter()
465            .any(|tool| tool.name() == tool_name || tool.aliases().contains(&tool_name))
466    }
467
468    /// Check if this plugin supports a specific package manager
469    fn supports_package_manager(&self, pm_name: &str) -> bool {
470        self.package_managers()
471            .iter()
472            .any(|pm| pm.name() == pm_name)
473    }
474
475    /// Plugin metadata (optional)
476    fn metadata(&self) -> HashMap<String, String> {
477        HashMap::new()
478    }
479}
480
481/// Standard plugin implementation for single-tool plugins
482pub struct StandardPlugin {
483    name: String,
484    description: String,
485    version: String,
486    tool_factory: Box<dyn Fn() -> Box<dyn VxTool> + Send + Sync>,
487}
488
489impl StandardPlugin {
490    pub fn new<F>(name: String, description: String, version: String, tool_factory: F) -> Self
491    where
492        F: Fn() -> Box<dyn VxTool> + Send + Sync + 'static,
493    {
494        Self {
495            name,
496            description,
497            version,
498            tool_factory: Box::new(tool_factory),
499        }
500    }
501}
502
503#[async_trait::async_trait]
504impl VxPlugin for StandardPlugin {
505    fn name(&self) -> &str {
506        &self.name
507    }
508
509    fn description(&self) -> &str {
510        &self.description
511    }
512
513    fn version(&self) -> &str {
514        &self.version
515    }
516
517    fn tools(&self) -> Vec<Box<dyn VxTool>> {
518        vec![(self.tool_factory)()]
519    }
520}
521
522/// Configuration-driven tool implementation
523///
524/// This tool uses figment configuration to determine download URLs and version sources,
525/// making it highly configurable without code changes.
526pub struct ConfigurableTool {
527    metadata: ToolMetadata,
528    config_manager: FigmentConfigManager,
529    url_builder: Box<dyn UrlBuilder>,
530    version_parser: Box<dyn VersionParser>,
531}
532
533impl ConfigurableTool {
534    pub fn new(
535        metadata: ToolMetadata,
536        url_builder: Box<dyn UrlBuilder>,
537        version_parser: Box<dyn VersionParser>,
538    ) -> Result<Self> {
539        let config_manager =
540            FigmentConfigManager::new().or_else(|_| FigmentConfigManager::minimal())?;
541
542        Ok(Self {
543            metadata,
544            config_manager,
545            url_builder,
546            version_parser,
547        })
548    }
549}
550
551#[async_trait::async_trait]
552impl VxTool for ConfigurableTool {
553    fn name(&self) -> &str {
554        &self.metadata.name
555    }
556
557    fn description(&self) -> &str {
558        &self.metadata.description
559    }
560
561    fn aliases(&self) -> Vec<&str> {
562        self.metadata.aliases.iter().map(|s| s.as_str()).collect()
563    }
564
565    async fn fetch_versions(&self, include_prerelease: bool) -> Result<Vec<VersionInfo>> {
566        let json = HttpUtils::fetch_json(self.url_builder.versions_url()).await?;
567        self.version_parser
568            .parse_versions(&json, include_prerelease)
569    }
570
571    async fn get_download_url(&self, version: &str) -> Result<Option<String>> {
572        // First try to get from figment configuration
573        if let Ok(url) = self
574            .config_manager
575            .get_download_url(&self.metadata.name, version)
576        {
577            return Ok(Some(url));
578        }
579
580        // Fall back to URL builder
581        Ok(self.url_builder.download_url(version))
582    }
583}
584
585/// Basic tool metadata for standard tools
586#[derive(Debug, Clone)]
587pub struct ToolMetadata {
588    pub name: String,
589    pub description: String,
590    pub aliases: Vec<String>,
591}