1use std::path::{Path, PathBuf};
17use std::process::Stdio;
18
19use anyhow::{Context, Result, anyhow, bail};
20use tokio::process::Command;
21
22#[derive(Debug)]
24pub struct Output {
25 pub stdout: String,
26 pub stderr: String,
27}
28
29pub async fn run(program: &Path, args: &[&str], cwd: Option<&Path>) -> Result<Output> {
33 let mut cmd = Command::new(program);
34 cmd.args(args)
35 .stdin(Stdio::null())
36 .stdout(Stdio::piped())
37 .stderr(Stdio::piped());
38 if let Some(dir) = cwd {
39 cmd.current_dir(dir);
40 }
41 let out = cmd
42 .output()
43 .await
44 .with_context(|| format!("spawning {}", program.display()))?;
45 let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
46 let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
47 if !out.status.success() {
48 let tail: Vec<&str> = stderr.lines().rev().take(12).collect();
49 let tail: Vec<&str> = tail.into_iter().rev().collect();
50 bail!(
51 "{} {} failed ({}):\n{}",
52 program.display(),
53 args.join(" "),
54 out.status,
55 tail.join("\n")
56 );
57 }
58 Ok(Output { stdout, stderr })
59}
60
61pub async fn find_python(min_major: u32, min_minor: u32) -> Result<PathBuf> {
67 let candidates: Vec<PathBuf> = match std::env::var_os("METRALE_PYTHON") {
68 Some(p) => vec![PathBuf::from(p)],
69 None => vec![PathBuf::from("python3"), PathBuf::from("python")],
70 };
71 let mut tried = Vec::new();
72 for cand in candidates {
73 match run(&cand, &["--version"], None).await {
74 Ok(out) => {
75 let text = if out.stdout.trim().is_empty() {
76 out.stderr
77 } else {
78 out.stdout
79 };
80 let (major, minor) = parse_version(&text)
81 .ok_or_else(|| anyhow!("could not parse {:?} from {}", text, cand.display()))?;
82 if (major, minor) >= (min_major, min_minor) {
83 return Ok(cand);
84 }
85 tried.push(format!(
86 "{} is {major}.{minor}, need >= {min_major}.{min_minor}",
87 cand.display()
88 ));
89 }
90 Err(e) => tried.push(format!("{}: {e}", cand.display())),
91 }
92 }
93 bail!(
94 "no usable python found (need >= {min_major}.{min_minor}). Tried:\n {}\n\
95 Install python3 or point METRALE_PYTHON at an interpreter.",
96 tried.join("\n ")
97 )
98}
99
100fn parse_version(text: &str) -> Option<(u32, u32)> {
101 let token = text.split_whitespace().find(|t| {
104 t.split('.')
105 .next()
106 .is_some_and(|h| !h.is_empty() && h.chars().all(|c| c.is_ascii_digit()))
107 })?;
108 let mut parts = token.split('.');
109 let major = parts.next()?.parse().ok()?;
110 let minor = parts.next().unwrap_or("0").parse().ok()?;
111 Some((major, minor))
112}
113
114pub fn venv_python(venv: &Path) -> PathBuf {
116 if cfg!(windows) {
117 venv.join("Scripts").join("python.exe")
118 } else {
119 venv.join("bin").join("python")
120 }
121}
122
123pub async fn ensure_venv(python: &Path, venv: &Path) -> Result<PathBuf> {
126 let interpreter = venv_python(venv);
127 if interpreter.is_file() {
128 return Ok(interpreter);
129 }
130 run(python, &["-c", "import venv"], None)
133 .await
134 .context("python is present but the `venv` module is not — install python3-venv")?;
135 let venv_str = venv
136 .to_str()
137 .ok_or_else(|| anyhow!("venv path is not valid UTF-8: {}", venv.display()))?;
138 run(python, &["-m", "venv", venv_str], None)
139 .await
140 .with_context(|| format!("creating venv at {}", venv.display()))?;
141 if !interpreter.is_file() {
142 bail!(
143 "venv created at {} but {} is missing",
144 venv.display(),
145 interpreter.display()
146 );
147 }
148 Ok(interpreter)
149}
150
151pub async fn pip_install(interpreter: &Path, requirements: &Path) -> Result<()> {
153 let req = requirements
154 .to_str()
155 .ok_or_else(|| anyhow!("requirements path is not valid UTF-8"))?;
156 run(
157 interpreter,
158 &[
159 "-m",
160 "pip",
161 "install",
162 "--disable-pip-version-check",
163 "-r",
164 req,
165 ],
166 None,
167 )
168 .await
169 .context(
170 "pip install failed — this step needs network access to PyPI \
171 (air-gapped boxes must pre-populate ~/.metrale/artifacts)",
172 )?;
173 Ok(())
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[test]
181 fn version_parses_from_either_stream_format() {
182 assert_eq!(parse_version("Python 3.12.3\n"), Some((3, 12)));
183 assert_eq!(parse_version("Python 3.9"), Some((3, 9)));
184 assert_eq!(parse_version("no version here"), None);
185 }
186
187 #[test]
188 fn venv_interpreter_path_matches_the_platform_layout() {
189 let p = venv_python(Path::new("/tmp/v"));
190 if cfg!(windows) {
191 assert!(p.ends_with("Scripts/python.exe"));
192 } else {
193 assert_eq!(p, Path::new("/tmp/v/bin/python"));
194 }
195 }
196
197 #[tokio::test]
198 async fn a_failing_command_reports_stderr_not_just_the_code() {
199 let script =
200 "i=1; while [ $i -le 14 ]; do printf 'line-%02d\\n' $i >&2; i=$((i + 1)); done; exit 3";
201 let err = run(Path::new("sh"), &["-c", script], None)
202 .await
203 .unwrap_err()
204 .to_string();
205 assert!(err.contains("failed (exit status: 3)"), "{err}");
206 assert!(!err.contains("line-01\n"), "tail excludes line 1: {err}");
207 assert!(!err.contains("line-02\n"), "tail excludes line 2: {err}");
208 assert!(err.contains("line-03\n"), "tail begins at line 3: {err}");
209 assert!(err.ends_with("line-14"), "tail preserves order: {err}");
210 }
211
212 #[tokio::test]
213 async fn a_successful_command_captures_both_streams_and_honors_cwd() {
214 let dir = std::env::temp_dir().join(format!("metrale-python-run-{}", std::process::id()));
215 std::fs::create_dir_all(&dir).expect("scratch");
216 let out = run(
217 Path::new("sh"),
218 &["-c", "pwd; printf warning >&2"],
219 Some(&dir),
220 )
221 .await
222 .expect("runs");
223 assert_eq!(
224 PathBuf::from(out.stdout.trim()),
225 dir.canonicalize().expect("canonical scratch")
226 );
227 assert_eq!(out.stderr, "warning");
228 let _ = std::fs::remove_dir_all(dir);
229 }
230}