Skip to main content

omne_cli/
python.rs

1//! Python interpreter detection and gate runner invocation.
2//!
3//! `find_interpreter` searches for a working Python on `PATH` using a
4//! platform-specific candidate list. `run_gate_runner` invokes a gate
5//! runner script with the discovered interpreter, capturing bounded output.
6//!
7//! The gate runner is a soft dependency — when Python is absent, `validate`
8//! warns and skips the gate runner but runs every other check (R15).
9
10#![allow(dead_code)]
11
12use std::io::Read as _;
13use std::path::{Path, PathBuf};
14use std::process::{Command, Stdio};
15use std::time::Duration;
16
17use thiserror::Error;
18use wait_timeout::ChildExt;
19
20/// Maximum bytes captured per stream (stdout/stderr) from the gate runner.
21/// Prevents a compromised gate runner from OOMing the process.
22const MAX_OUTPUT_BYTES: u64 = 65_536; // 64 KB
23
24/// Default gate runner timeout in seconds.
25const GATE_RUNNER_TIMEOUT_SECS: u64 = 60;
26
27/// Errors returned by Python/gate runner operations.
28#[derive(Debug, Error)]
29pub enum Error {
30    /// The gate runner script exited with a non-zero status.
31    #[error("gate runner failed (exit {exit_code}):\n{stdout}\n{stderr}")]
32    GateRunnerFailed {
33        exit_code: i32,
34        stdout: String,
35        stderr: String,
36    },
37
38    /// The gate runner exceeded the timeout and was killed.
39    #[error("gate runner timed out after {elapsed_seconds} seconds")]
40    GateRunnerTimedOut { elapsed_seconds: u64 },
41
42    /// Failed to spawn or wait on the interpreter process.
43    #[error("failed to invoke interpreter: {0}")]
44    InterpreterInvocation(#[from] std::io::Error),
45}
46
47/// Platform-specific Python candidate list.
48///
49/// Windows: `py.exe` first (PEP 397 launcher), then `python.exe`, `python3.exe`.
50/// Unix: `python3` first (PEP 394), then `python`.
51#[cfg(windows)]
52const CANDIDATES: &[&str] = &["py.exe", "python.exe", "python3.exe"];
53
54#[cfg(not(windows))]
55const CANDIDATES: &[&str] = &["python3", "python"];
56
57/// Find a working Python interpreter on `PATH`.
58///
59/// Returns the first candidate that resolves via `which` and passes a
60/// `--version` health check. Returns `None` if no candidate works.
61pub fn find_interpreter() -> Option<PathBuf> {
62    find_interpreter_from(CANDIDATES)
63}
64
65/// Testable inner function: find an interpreter from the given candidate list.
66pub(crate) fn find_interpreter_from(candidates: &[&str]) -> Option<PathBuf> {
67    for &name in candidates {
68        if let Ok(path) = which::which(name) {
69            // Health-check: run `<path> --version` with a short timeout.
70            if health_check(&path) {
71                return Some(path);
72            }
73        }
74    }
75    None
76}
77
78/// Run `<interpreter> --version` and return true if it exits 0 within 5 seconds.
79fn health_check(interpreter: &Path) -> bool {
80    let child = Command::new(interpreter)
81        .arg("--version")
82        .stdout(Stdio::null())
83        .stderr(Stdio::null())
84        .spawn();
85
86    match child {
87        Ok(mut child) => {
88            let timeout = Duration::from_secs(5);
89            match child.wait_timeout(timeout) {
90                Ok(Some(status)) => status.success(),
91                Ok(None) => {
92                    // Timed out — kill and return false.
93                    let _ = child.kill();
94                    let _ = child.wait();
95                    false
96                }
97                Err(_) => false,
98            }
99        }
100        Err(_) => false,
101    }
102}
103
104/// Warning message when no Python interpreter is found.
105pub fn missing_python_warning() -> &'static str {
106    "Warning: no Python interpreter found on PATH.\n\
107     The gate runner check has been skipped.\n\
108     To install Python, use one of:\n\
109     - uv: https://docs.astral.sh/uv/\n\
110     - System package manager (apt install python3, winget install Python.Python.3, etc.)"
111}
112
113/// Invoke a gate runner script with the given interpreter.
114///
115/// Runs `<interpreter> <script> <image_dir>` with bounded output capture
116/// and a 60-second timeout. On exit 0: returns `Ok(())`. On non-zero exit:
117/// returns `Error::GateRunnerFailed` with bounded stdout/stderr. On timeout:
118/// kills the child and returns `Error::GateRunnerTimedOut`.
119pub fn run_gate_runner(interpreter: &Path, script: &Path, image_dir: &Path) -> Result<(), Error> {
120    let mut child = Command::new(interpreter)
121        .arg(script)
122        .arg(image_dir)
123        .stdout(Stdio::piped())
124        .stderr(Stdio::piped())
125        .spawn()?;
126
127    let timeout = Duration::from_secs(GATE_RUNNER_TIMEOUT_SECS);
128    match child.wait_timeout(timeout)? {
129        Some(status) => {
130            // Process exited within timeout. Read bounded output.
131            let stdout = read_bounded(child.stdout.take());
132            let stderr = read_bounded(child.stderr.take());
133
134            if status.success() {
135                Ok(())
136            } else {
137                Err(Error::GateRunnerFailed {
138                    exit_code: status.code().unwrap_or(-1),
139                    stdout,
140                    stderr,
141                })
142            }
143        }
144        None => {
145            // Timed out. Kill the child and collect partial output.
146            let _ = child.kill();
147            let _ = child.wait();
148            Err(Error::GateRunnerTimedOut {
149                elapsed_seconds: GATE_RUNNER_TIMEOUT_SECS,
150            })
151        }
152    }
153}
154
155/// Read up to `MAX_OUTPUT_BYTES` from an optional stream.
156fn read_bounded<R: std::io::Read>(stream: Option<R>) -> String {
157    let Some(stream) = stream else {
158        return String::new();
159    };
160    let mut buf = Vec::new();
161    let _ = stream.take(MAX_OUTPUT_BYTES).read_to_end(&mut buf);
162    String::from_utf8_lossy(&buf).into_owned()
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use tempfile::TempDir;
169
170    // ── Error Display tests ──────────────────────────────────────────
171
172    #[test]
173    fn gate_runner_failed_error_contains_exit_code() {
174        let err = Error::GateRunnerFailed {
175            exit_code: 1,
176            stdout: "fail: bad config".to_string(),
177            stderr: String::new(),
178        };
179        let display = format!("{err}");
180        assert!(
181            display.contains("exit 1"),
182            "Should contain exit code: {display}"
183        );
184        assert!(
185            display.contains("fail: bad config"),
186            "Should contain stdout: {display}"
187        );
188    }
189
190    #[test]
191    fn gate_runner_timed_out_error_contains_seconds() {
192        let err = Error::GateRunnerTimedOut {
193            elapsed_seconds: 60,
194        };
195        let display = format!("{err}");
196        assert!(
197            display.contains("60"),
198            "Should contain timeout seconds: {display}"
199        );
200    }
201
202    #[test]
203    fn interpreter_invocation_error_is_transparent() {
204        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
205        let err = Error::InterpreterInvocation(io_err);
206        let display = format!("{err}");
207        assert!(
208            display.contains("not found"),
209            "Should pass through IO error: {display}"
210        );
211    }
212
213    // ── missing_python_warning ───────────────────────────────────────
214
215    #[test]
216    fn missing_python_warning_mentions_uv() {
217        let warning = missing_python_warning();
218        assert!(!warning.is_empty());
219        assert!(warning.contains("uv"), "Should mention uv");
220        assert!(warning.contains("install"), "Should mention install");
221    }
222
223    // ── find_interpreter_from (testable seam) ────────────────────────
224
225    #[test]
226    fn find_interpreter_from_empty_candidates_returns_none() {
227        let result = find_interpreter_from(&[]);
228        assert!(result.is_none());
229    }
230
231    #[test]
232    fn find_interpreter_from_nonexistent_candidates_returns_none() {
233        let result = find_interpreter_from(&[
234            "definitely_not_a_real_interpreter_abc123",
235            "also_not_real_xyz789",
236        ]);
237        assert!(result.is_none());
238    }
239
240    // ── run_gate_runner with synthetic scripts ───────────────────────
241
242    /// Create a synthetic script that exits with the given code.
243    /// Returns the path to the script file.
244    fn create_synthetic_script(dir: &Path, name: &str, exit_code: i32) -> PathBuf {
245        #[cfg(unix)]
246        {
247            use std::os::unix::fs::PermissionsExt;
248            let script_path = dir.join(name);
249            let content = format!("#!/bin/sh\nexit {exit_code}\n");
250            std::fs::write(&script_path, content).unwrap();
251            std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
252            script_path
253        }
254        #[cfg(windows)]
255        {
256            let script_path = dir.join(format!("{name}.cmd"));
257            let content = format!("@echo off\r\nexit /b {exit_code}\r\n");
258            std::fs::write(&script_path, content).unwrap();
259            script_path
260        }
261    }
262
263    /// Create a synthetic script that writes to stdout and exits with given code.
264    fn create_script_with_output(
265        dir: &Path,
266        name: &str,
267        stdout_text: &str,
268        exit_code: i32,
269    ) -> PathBuf {
270        #[cfg(unix)]
271        {
272            use std::os::unix::fs::PermissionsExt;
273            let script_path = dir.join(name);
274            let content = format!("#!/bin/sh\necho '{}'\nexit {}\n", stdout_text, exit_code);
275            std::fs::write(&script_path, content).unwrap();
276            std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
277            script_path
278        }
279        #[cfg(windows)]
280        {
281            let script_path = dir.join(format!("{name}.cmd"));
282            let content = format!(
283                "@echo off\r\necho {}\r\nexit /b {}\r\n",
284                stdout_text, exit_code
285            );
286            std::fs::write(&script_path, content).unwrap();
287            script_path
288        }
289    }
290
291    /// Create a wrapper script that acts as an "interpreter" for test scripts.
292    ///
293    /// On Unix: returns `/bin/sh` (shell natively runs scripts given as args).
294    /// On Windows: creates a `run.cmd` wrapper in `dir` that executes its
295    /// first argument via `cmd /c`, because bare `cmd.exe script.cmd` opens
296    /// an interactive session instead of running and exiting.
297    fn test_interpreter(dir: &Path) -> PathBuf {
298        #[cfg(unix)]
299        {
300            let _ = dir; // unused on Unix
301            PathBuf::from("/bin/sh")
302        }
303        #[cfg(windows)]
304        {
305            let wrapper = dir.join("run.cmd");
306            // %~1 strips quotes; %2 %3 forward remaining args.
307            std::fs::write(&wrapper, "@echo off\r\ncmd /c %~1 %2 %3 %4\r\n").unwrap();
308            wrapper
309        }
310    }
311
312    #[test]
313    fn run_gate_runner_success_with_exit_zero() {
314        let tmp = TempDir::new().unwrap();
315        let script = create_synthetic_script(tmp.path(), "gate", 0);
316        let image_dir = tmp.path();
317
318        let interpreter = test_interpreter(tmp.path());
319        let result = run_gate_runner(&interpreter, &script, image_dir);
320        assert!(result.is_ok(), "Expected Ok, got: {result:?}");
321    }
322
323    #[test]
324    fn run_gate_runner_failure_with_nonzero_exit() {
325        let tmp = TempDir::new().unwrap();
326        let script = create_script_with_output(tmp.path(), "gate_fail", "fail: reason", 1);
327        let image_dir = tmp.path();
328
329        let interpreter = test_interpreter(tmp.path());
330        let err = run_gate_runner(&interpreter, &script, image_dir).unwrap_err();
331        match err {
332            Error::GateRunnerFailed {
333                exit_code, stdout, ..
334            } => {
335                assert_eq!(exit_code, 1);
336                assert!(
337                    stdout.contains("fail: reason"),
338                    "stdout should contain output: {stdout}"
339                );
340            }
341            other => panic!("Expected GateRunnerFailed, got: {other:?}"),
342        }
343    }
344
345    #[test]
346    fn run_gate_runner_nonexistent_interpreter_returns_error() {
347        let tmp = TempDir::new().unwrap();
348        let script = create_synthetic_script(tmp.path(), "gate", 0);
349        let image_dir = tmp.path();
350
351        let fake_interp = PathBuf::from("definitely_not_a_real_interpreter_abc123");
352        let err = run_gate_runner(&fake_interp, &script, image_dir).unwrap_err();
353        assert!(
354            matches!(err, Error::InterpreterInvocation(_)),
355            "Expected InterpreterInvocation, got: {err:?}"
356        );
357    }
358
359    // ── Platform-specific find_interpreter ────────────────────────────
360
361    #[test]
362    fn candidate_list_is_nonempty() {
363        // CANDIDATES is a const array — assert length rather than is_empty()
364        // to avoid clippy::const_is_empty.
365        assert!(CANDIDATES.len() >= 2);
366    }
367
368    #[cfg(windows)]
369    #[test]
370    fn windows_candidates_start_with_py_exe() {
371        assert_eq!(CANDIDATES[0], "py.exe");
372    }
373
374    #[cfg(unix)]
375    #[test]
376    fn unix_candidates_start_with_python3() {
377        assert_eq!(CANDIDATES[0], "python3");
378    }
379}