Skip to main content

pro_core/tool/
runner.rs

1//! Tool runner for executing tools in ephemeral virtual environments
2//!
3//! Provides functionality similar to `uvx` or `pipx run` - running Python tools
4//! without permanently installing them.
5
6use std::path::PathBuf;
7use std::process::{Command, ExitStatus, Stdio};
8
9use crate::installer::{default_cache_dir, Installer};
10use crate::lockfile::LockedPackage;
11use crate::pep::Requirement;
12use crate::resolver::Resolver;
13use crate::venv::VenvManager;
14use crate::{Error, Result};
15
16use super::cache::{CachedTool, ToolCache};
17
18/// Tool runner for executing Python tools
19pub struct ToolRunner {
20    cache: ToolCache,
21    /// Optional Python executable to use
22    python: Option<PathBuf>,
23}
24
25impl ToolRunner {
26    /// Create a new tool runner
27    pub fn new() -> Result<Self> {
28        Ok(Self {
29            cache: ToolCache::new()?,
30            python: None,
31        })
32    }
33
34    /// Create a tool runner with a specific Python executable
35    pub fn with_python(python: PathBuf) -> Result<Self> {
36        Ok(Self {
37            cache: ToolCache::new()?,
38            python: Some(python),
39        })
40    }
41
42    /// Create a tool runner with a custom cache
43    pub fn with_cache(cache: ToolCache) -> Self {
44        Self {
45            cache,
46            python: None,
47        }
48    }
49
50    /// Get the tool cache
51    pub fn cache(&self) -> &ToolCache {
52        &self.cache
53    }
54
55    /// Run a tool with the given arguments
56    ///
57    /// The package will be installed if not already cached.
58    /// The tool executable is determined by the package name (e.g., "black" -> "black").
59    pub async fn run(&self, package: &str, args: &[String]) -> Result<ExitStatus> {
60        self.run_with_command(package, package, args).await
61    }
62
63    /// Run a specific command from a package
64    ///
65    /// Useful when the command name differs from the package name
66    /// (e.g., package "Pillow" provides command "PIL").
67    pub async fn run_with_command(
68        &self,
69        package: &str,
70        command: &str,
71        args: &[String],
72    ) -> Result<ExitStatus> {
73        // Check cache first
74        let tool = if let Some(cached) = self.cache.get(package) {
75            if cached.has_executable(command) {
76                tracing::debug!("Using cached tool: {}", package);
77                cached
78            } else {
79                // Cache exists but doesn't have the command - reinstall
80                self.install_tool(package).await?
81            }
82        } else {
83            // Not cached - install
84            self.install_tool(package).await?
85        };
86
87        // Execute the tool
88        self.execute(&tool, command, args)
89    }
90
91    /// Install a tool into the cache
92    async fn install_tool(&self, package: &str) -> Result<CachedTool> {
93        tracing::info!("Installing {}...", package);
94
95        // Prepare cache directory
96        let venv_path = self.cache.prepare(package)?;
97
98        // Create virtual environment
99        let venv = VenvManager::new(&venv_path);
100        venv.create(self.python.as_deref())?;
101
102        // Parse package as requirement
103        let requirement = Requirement::parse(package).map_err(|e| {
104            Error::ToolExecutionFailed(format!("invalid package name {}: {}", package, e))
105        })?;
106
107        // Resolve the package
108        let resolver = Resolver::new();
109        let resolution = resolver.resolve(&[requirement]).await?;
110
111        // Convert to LockedPackage format for installer
112        let mut packages = std::collections::HashMap::new();
113        for pkg in &resolution.packages {
114            packages.insert(
115                pkg.name.clone(),
116                LockedPackage {
117                    version: pkg.version.clone(),
118                    url: if pkg.url.is_empty() {
119                        None
120                    } else {
121                        Some(pkg.url.clone())
122                    },
123                    hash: if pkg.hash.is_empty() {
124                        None
125                    } else {
126                        Some(pkg.hash.clone())
127                    },
128                    dependencies: pkg.dependencies.clone(),
129                    markers: pkg.markers.clone(),
130                    files: vec![],
131                },
132            );
133        }
134
135        // Install into the venv
136        let site_packages = venv.site_packages()?;
137        let installer = Installer::new(default_cache_dir());
138        let results = installer.install(&packages, &site_packages).await?;
139        tracing::debug!("Installed {} packages for {}", results.len(), package);
140
141        // Record the installed version
142        if let Some(pkg) = resolution.packages.iter().find(|p| {
143            p.name.to_lowercase() == package.to_lowercase()
144                || p.name.to_lowercase().replace('-', "_")
145                    == package.to_lowercase().replace('-', "_")
146        }) {
147            self.cache.record_version(package, &pkg.version)?;
148        }
149
150        // Return the cached tool info
151        self.cache.get(package).ok_or_else(|| {
152            Error::ToolExecutionFailed(format!("failed to install tool: {}", package))
153        })
154    }
155
156    /// Execute a tool
157    fn execute(&self, tool: &CachedTool, command: &str, args: &[String]) -> Result<ExitStatus> {
158        let executable = tool.executable(command);
159
160        if !executable.exists() {
161            // Try to find similar executables
162            let bin_dir = tool.bin_dir();
163            let available: Vec<_> = std::fs::read_dir(&bin_dir)
164                .map(|entries| {
165                    entries
166                        .filter_map(|e| e.ok())
167                        .filter_map(|e| e.file_name().into_string().ok())
168                        .filter(|n| {
169                            !n.starts_with("python")
170                                && !n.starts_with("pip")
171                                && !n.starts_with("activate")
172                        })
173                        .collect()
174                })
175                .unwrap_or_default();
176
177            return Err(Error::ToolNotFound {
178                tool: format!(
179                    "{} (available: {})",
180                    command,
181                    if available.is_empty() {
182                        "none".to_string()
183                    } else {
184                        available.join(", ")
185                    }
186                ),
187            });
188        }
189
190        // Set up environment
191        let bin_dir = tool.bin_dir();
192        let current_path = std::env::var("PATH").unwrap_or_default();
193        let new_path = format!(
194            "{}{}{}",
195            bin_dir.display(),
196            if cfg!(windows) { ";" } else { ":" },
197            current_path
198        );
199
200        let status = Command::new(&executable)
201            .args(args)
202            .env("PATH", &new_path)
203            .env("VIRTUAL_ENV", &tool.venv_path)
204            .env_remove("PYTHONHOME")
205            .stdin(Stdio::inherit())
206            .stdout(Stdio::inherit())
207            .stderr(Stdio::inherit())
208            .status()
209            .map_err(|e| Error::ToolExecutionFailed(format!("failed to run {}: {}", command, e)))?;
210
211        Ok(status)
212    }
213
214    /// Check if a tool is cached
215    pub fn is_cached(&self, package: &str) -> bool {
216        self.cache.get(package).is_some()
217    }
218
219    /// List cached tools
220    pub fn list_cached(&self) -> Result<Vec<CachedTool>> {
221        self.cache.list()
222    }
223
224    /// Clear a specific tool from the cache
225    pub fn clear(&self, package: &str) -> Result<bool> {
226        self.cache.clear(package)
227    }
228
229    /// Clear all cached tools
230    pub fn clear_all(&self) -> Result<usize> {
231        self.cache.clear_all()
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use tempfile::tempdir;
239
240    #[test]
241    fn test_tool_runner_new() {
242        // Should not fail on most systems
243        let runner = ToolRunner::new();
244        assert!(runner.is_ok());
245    }
246
247    #[test]
248    fn test_is_cached_false() {
249        let temp_dir = tempdir().unwrap();
250        let cache = ToolCache::with_dir(temp_dir.path().to_path_buf());
251        let runner = ToolRunner::with_cache(cache);
252
253        assert!(!runner.is_cached("nonexistent"));
254    }
255}