pro_core/script/
runner.rs1use std::fs;
7use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus, Stdio};
9
10use crate::installer::{default_cache_dir, Installer};
11use crate::lockfile::LockedPackage;
12use crate::pep::Requirement;
13use crate::resolver::Resolver;
14use crate::venv::VenvManager;
15use crate::{Error, Result};
16
17use super::parser::{parse_script_metadata, ScriptMetadata};
18
19pub struct ScriptRunner {
21 cache_dir: PathBuf,
23 python: Option<PathBuf>,
25}
26
27impl ScriptRunner {
28 pub fn new() -> Result<Self> {
30 let cache_dir = dirs::cache_dir()
31 .ok_or_else(|| Error::Config("cannot determine cache directory".into()))?;
32
33 Ok(Self {
34 cache_dir: cache_dir.join("rx").join("scripts"),
35 python: None,
36 })
37 }
38
39 pub fn with_python(python: PathBuf) -> Result<Self> {
41 let cache_dir = dirs::cache_dir()
42 .ok_or_else(|| Error::Config("cannot determine cache directory".into()))?;
43
44 Ok(Self {
45 cache_dir: cache_dir.join("rx").join("scripts"),
46 python: Some(python),
47 })
48 }
49
50 pub fn with_cache_dir(cache_dir: PathBuf) -> Self {
52 Self {
53 cache_dir,
54 python: None,
55 }
56 }
57
58 pub async fn run(&self, script_path: &Path, args: &[String]) -> Result<ExitStatus> {
60 let content = fs::read_to_string(script_path).map_err(|e| {
62 Error::ScriptExecutionFailed(format!(
63 "failed to read script {}: {}",
64 script_path.display(),
65 e
66 ))
67 })?;
68
69 let metadata = parse_script_metadata(&content)?;
71
72 if metadata.is_empty() {
73 return self.run_simple(script_path, args);
75 }
76
77 self.run_with_deps(script_path, args, &metadata).await
79 }
80
81 fn run_simple(&self, script_path: &Path, args: &[String]) -> Result<ExitStatus> {
83 let python = self
84 .python
85 .clone()
86 .unwrap_or_else(|| PathBuf::from("python3"));
87
88 let status = Command::new(&python)
89 .arg(script_path)
90 .args(args)
91 .stdin(Stdio::inherit())
92 .stdout(Stdio::inherit())
93 .stderr(Stdio::inherit())
94 .status()
95 .map_err(|e| {
96 Error::ScriptExecutionFailed(format!("failed to execute script: {}", e))
97 })?;
98
99 Ok(status)
100 }
101
102 async fn run_with_deps(
104 &self,
105 script_path: &Path,
106 args: &[String],
107 metadata: &ScriptMetadata,
108 ) -> Result<ExitStatus> {
109 let venv_path = self.get_or_create_venv(metadata).await?;
111
112 let python = {
114 #[cfg(unix)]
115 {
116 venv_path.join("bin").join("python")
117 }
118 #[cfg(windows)]
119 {
120 venv_path.join("Scripts").join("python.exe")
121 }
122 };
123
124 let bin_dir = {
126 #[cfg(unix)]
127 {
128 venv_path.join("bin")
129 }
130 #[cfg(windows)]
131 {
132 venv_path.join("Scripts")
133 }
134 };
135
136 let current_path = std::env::var("PATH").unwrap_or_default();
137 let new_path = format!(
138 "{}{}{}",
139 bin_dir.display(),
140 if cfg!(windows) { ";" } else { ":" },
141 current_path
142 );
143
144 let status = Command::new(&python)
146 .arg(script_path)
147 .args(args)
148 .env("PATH", &new_path)
149 .env("VIRTUAL_ENV", &venv_path)
150 .env_remove("PYTHONHOME")
151 .stdin(Stdio::inherit())
152 .stdout(Stdio::inherit())
153 .stderr(Stdio::inherit())
154 .status()
155 .map_err(|e| {
156 Error::ScriptExecutionFailed(format!("failed to execute script: {}", e))
157 })?;
158
159 Ok(status)
160 }
161
162 async fn get_or_create_venv(&self, metadata: &ScriptMetadata) -> Result<PathBuf> {
164 let hash = metadata.dependency_hash();
165 let venv_path = self.cache_dir.join(&hash);
166
167 if self.is_venv_valid(&venv_path) {
169 tracing::debug!("Using cached script environment: {}", hash);
170 return Ok(venv_path);
171 }
172
173 tracing::info!(
174 "Creating script environment for {} dependencies...",
175 metadata.dependencies.len()
176 );
177
178 fs::create_dir_all(&self.cache_dir).map_err(Error::Io)?;
180
181 if venv_path.exists() {
183 fs::remove_dir_all(&venv_path).map_err(Error::Io)?;
184 }
185
186 let venv = VenvManager::new(&venv_path);
188 venv.create(self.python.as_deref())?;
189
190 let requirements: Vec<Requirement> = metadata
192 .dependencies
193 .iter()
194 .filter_map(|dep| Requirement::parse(dep).ok())
195 .collect();
196
197 if requirements.is_empty() && !metadata.dependencies.is_empty() {
198 return Err(Error::ScriptMetadataError(
199 "failed to parse script dependencies".into(),
200 ));
201 }
202
203 let resolver = Resolver::new();
205 let resolution = resolver.resolve(&requirements).await?;
206
207 let mut packages = std::collections::HashMap::new();
209 for pkg in resolution.packages {
210 packages.insert(
211 pkg.name.clone(),
212 LockedPackage {
213 version: pkg.version.clone(),
214 url: if pkg.url.is_empty() {
215 None
216 } else {
217 Some(pkg.url.clone())
218 },
219 hash: if pkg.hash.is_empty() {
220 None
221 } else {
222 Some(pkg.hash.clone())
223 },
224 dependencies: pkg.dependencies.clone(),
225 markers: pkg.markers.clone(),
226 files: vec![],
227 },
228 );
229 }
230
231 let site_packages = venv.site_packages()?;
233 let installer = Installer::new(default_cache_dir());
234 let results = installer.install(&packages, &site_packages).await?;
235 tracing::debug!("Installed {} packages", results.len());
236
237 Ok(venv_path)
238 }
239
240 fn is_venv_valid(&self, venv_path: &Path) -> bool {
242 if !venv_path.exists() {
243 return false;
244 }
245
246 if !venv_path.join("pyvenv.cfg").exists() {
248 return false;
249 }
250
251 #[cfg(unix)]
253 let python = venv_path.join("bin").join("python");
254 #[cfg(windows)]
255 let python = venv_path.join("Scripts").join("python.exe");
256
257 python.exists()
258 }
259
260 pub fn clear_cache(&self) -> Result<usize> {
262 if !self.cache_dir.exists() {
263 return Ok(0);
264 }
265
266 let mut count = 0;
267 for entry in fs::read_dir(&self.cache_dir).map_err(Error::Io)? {
268 let entry = entry.map_err(Error::Io)?;
269 if entry.path().is_dir() {
270 fs::remove_dir_all(entry.path()).map_err(Error::Io)?;
271 count += 1;
272 }
273 }
274
275 Ok(count)
276 }
277
278 pub fn list_cached(&self) -> Result<Vec<PathBuf>> {
280 if !self.cache_dir.exists() {
281 return Ok(Vec::new());
282 }
283
284 let mut cached = Vec::new();
285 for entry in fs::read_dir(&self.cache_dir).map_err(Error::Io)? {
286 let entry = entry.map_err(Error::Io)?;
287 if entry.path().is_dir() {
288 cached.push(entry.path());
289 }
290 }
291
292 Ok(cached)
293 }
294}
295
296pub fn is_pep723_script(path: &Path) -> Result<bool> {
298 if path.extension().map_or(true, |e| e != "py") {
300 return Ok(false);
301 }
302
303 let content = fs::read_to_string(path).map_err(Error::Io)?;
305
306 Ok(super::parser::might_have_metadata(&content))
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use tempfile::tempdir;
313
314 #[test]
315 fn test_script_runner_new() {
316 let runner = ScriptRunner::new();
317 assert!(runner.is_ok());
318 }
319
320 #[test]
321 fn test_is_venv_valid_nonexistent() {
322 let temp_dir = tempdir().unwrap();
323 let runner = ScriptRunner::with_cache_dir(temp_dir.path().to_path_buf());
324
325 assert!(!runner.is_venv_valid(&temp_dir.path().join("nonexistent")));
326 }
327
328 #[test]
329 fn test_list_cached_empty() {
330 let temp_dir = tempdir().unwrap();
331 let runner = ScriptRunner::with_cache_dir(temp_dir.path().to_path_buf());
332
333 let cached = runner.list_cached().unwrap();
334 assert!(cached.is_empty());
335 }
336}