#![allow(dead_code)]
use std::io::Read as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Duration;
use thiserror::Error;
use wait_timeout::ChildExt;
const MAX_OUTPUT_BYTES: u64 = 65_536;
const GATE_RUNNER_TIMEOUT_SECS: u64 = 60;
#[derive(Debug, Error)]
pub enum Error {
#[error("gate runner failed (exit {exit_code}):\n{stdout}\n{stderr}")]
GateRunnerFailed {
exit_code: i32,
stdout: String,
stderr: String,
},
#[error("gate runner timed out after {elapsed_seconds} seconds")]
GateRunnerTimedOut { elapsed_seconds: u64 },
#[error("failed to invoke interpreter: {0}")]
InterpreterInvocation(#[from] std::io::Error),
}
#[cfg(windows)]
const CANDIDATES: &[&str] = &["py.exe", "python.exe", "python3.exe"];
#[cfg(not(windows))]
const CANDIDATES: &[&str] = &["python3", "python"];
pub fn find_interpreter() -> Option<PathBuf> {
find_interpreter_from(CANDIDATES)
}
pub(crate) fn find_interpreter_from(candidates: &[&str]) -> Option<PathBuf> {
for &name in candidates {
if let Ok(path) = which::which(name) {
if health_check(&path) {
return Some(path);
}
}
}
None
}
fn health_check(interpreter: &Path) -> bool {
let child = Command::new(interpreter)
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
match child {
Ok(mut child) => {
let timeout = Duration::from_secs(5);
match child.wait_timeout(timeout) {
Ok(Some(status)) => status.success(),
Ok(None) => {
let _ = child.kill();
let _ = child.wait();
false
}
Err(_) => false,
}
}
Err(_) => false,
}
}
pub fn missing_python_warning() -> &'static str {
"Warning: no Python interpreter found on PATH.\n\
The gate runner check has been skipped.\n\
To install Python, use one of:\n\
- uv: https://docs.astral.sh/uv/\n\
- System package manager (apt install python3, winget install Python.Python.3, etc.)"
}
pub fn run_gate_runner(interpreter: &Path, script: &Path, image_dir: &Path) -> Result<(), Error> {
let mut child = Command::new(interpreter)
.arg(script)
.arg(image_dir)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let timeout = Duration::from_secs(GATE_RUNNER_TIMEOUT_SECS);
match child.wait_timeout(timeout)? {
Some(status) => {
let stdout = read_bounded(child.stdout.take());
let stderr = read_bounded(child.stderr.take());
if status.success() {
Ok(())
} else {
Err(Error::GateRunnerFailed {
exit_code: status.code().unwrap_or(-1),
stdout,
stderr,
})
}
}
None => {
let _ = child.kill();
let _ = child.wait();
Err(Error::GateRunnerTimedOut {
elapsed_seconds: GATE_RUNNER_TIMEOUT_SECS,
})
}
}
}
fn read_bounded<R: std::io::Read>(stream: Option<R>) -> String {
let Some(stream) = stream else {
return String::new();
};
let mut buf = Vec::new();
let _ = stream.take(MAX_OUTPUT_BYTES).read_to_end(&mut buf);
String::from_utf8_lossy(&buf).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn gate_runner_failed_error_contains_exit_code() {
let err = Error::GateRunnerFailed {
exit_code: 1,
stdout: "fail: bad config".to_string(),
stderr: String::new(),
};
let display = format!("{err}");
assert!(
display.contains("exit 1"),
"Should contain exit code: {display}"
);
assert!(
display.contains("fail: bad config"),
"Should contain stdout: {display}"
);
}
#[test]
fn gate_runner_timed_out_error_contains_seconds() {
let err = Error::GateRunnerTimedOut {
elapsed_seconds: 60,
};
let display = format!("{err}");
assert!(
display.contains("60"),
"Should contain timeout seconds: {display}"
);
}
#[test]
fn interpreter_invocation_error_is_transparent() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
let err = Error::InterpreterInvocation(io_err);
let display = format!("{err}");
assert!(
display.contains("not found"),
"Should pass through IO error: {display}"
);
}
#[test]
fn missing_python_warning_mentions_uv() {
let warning = missing_python_warning();
assert!(!warning.is_empty());
assert!(warning.contains("uv"), "Should mention uv");
assert!(warning.contains("install"), "Should mention install");
}
#[test]
fn find_interpreter_from_empty_candidates_returns_none() {
let result = find_interpreter_from(&[]);
assert!(result.is_none());
}
#[test]
fn find_interpreter_from_nonexistent_candidates_returns_none() {
let result = find_interpreter_from(&[
"definitely_not_a_real_interpreter_abc123",
"also_not_real_xyz789",
]);
assert!(result.is_none());
}
fn create_synthetic_script(dir: &Path, name: &str, exit_code: i32) -> PathBuf {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let script_path = dir.join(name);
let content = format!("#!/bin/sh\nexit {exit_code}\n");
std::fs::write(&script_path, content).unwrap();
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
script_path
}
#[cfg(windows)]
{
let script_path = dir.join(format!("{name}.cmd"));
let content = format!("@echo off\r\nexit /b {exit_code}\r\n");
std::fs::write(&script_path, content).unwrap();
script_path
}
}
fn create_script_with_output(
dir: &Path,
name: &str,
stdout_text: &str,
exit_code: i32,
) -> PathBuf {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let script_path = dir.join(name);
let content = format!("#!/bin/sh\necho '{}'\nexit {}\n", stdout_text, exit_code);
std::fs::write(&script_path, content).unwrap();
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
script_path
}
#[cfg(windows)]
{
let script_path = dir.join(format!("{name}.cmd"));
let content = format!(
"@echo off\r\necho {}\r\nexit /b {}\r\n",
stdout_text, exit_code
);
std::fs::write(&script_path, content).unwrap();
script_path
}
}
fn test_interpreter(dir: &Path) -> PathBuf {
#[cfg(unix)]
{
let _ = dir; PathBuf::from("/bin/sh")
}
#[cfg(windows)]
{
let wrapper = dir.join("run.cmd");
std::fs::write(&wrapper, "@echo off\r\ncmd /c %~1 %2 %3 %4\r\n").unwrap();
wrapper
}
}
#[test]
fn run_gate_runner_success_with_exit_zero() {
let tmp = TempDir::new().unwrap();
let script = create_synthetic_script(tmp.path(), "gate", 0);
let image_dir = tmp.path();
let interpreter = test_interpreter(tmp.path());
let result = run_gate_runner(&interpreter, &script, image_dir);
assert!(result.is_ok(), "Expected Ok, got: {result:?}");
}
#[test]
fn run_gate_runner_failure_with_nonzero_exit() {
let tmp = TempDir::new().unwrap();
let script = create_script_with_output(tmp.path(), "gate_fail", "fail: reason", 1);
let image_dir = tmp.path();
let interpreter = test_interpreter(tmp.path());
let err = run_gate_runner(&interpreter, &script, image_dir).unwrap_err();
match err {
Error::GateRunnerFailed {
exit_code, stdout, ..
} => {
assert_eq!(exit_code, 1);
assert!(
stdout.contains("fail: reason"),
"stdout should contain output: {stdout}"
);
}
other => panic!("Expected GateRunnerFailed, got: {other:?}"),
}
}
#[test]
fn run_gate_runner_nonexistent_interpreter_returns_error() {
let tmp = TempDir::new().unwrap();
let script = create_synthetic_script(tmp.path(), "gate", 0);
let image_dir = tmp.path();
let fake_interp = PathBuf::from("definitely_not_a_real_interpreter_abc123");
let err = run_gate_runner(&fake_interp, &script, image_dir).unwrap_err();
assert!(
matches!(err, Error::InterpreterInvocation(_)),
"Expected InterpreterInvocation, got: {err:?}"
);
}
#[test]
fn candidate_list_is_nonempty() {
assert!(CANDIDATES.len() >= 2);
}
#[cfg(windows)]
#[test]
fn windows_candidates_start_with_py_exe() {
assert_eq!(CANDIDATES[0], "py.exe");
}
#[cfg(unix)]
#[test]
fn unix_candidates_start_with_python3() {
assert_eq!(CANDIDATES[0], "python3");
}
}