omne-cli 0.2.1

CLI for managing omne volumes: init, upgrade, and validate kernel and distro releases
Documentation
//! Python interpreter detection and gate runner invocation.
//!
//! `find_interpreter` searches for a working Python on `PATH` using a
//! platform-specific candidate list. `run_gate_runner` invokes a gate
//! runner script with the discovered interpreter, capturing bounded output.
//!
//! The gate runner is a soft dependency — when Python is absent, `validate`
//! warns and skips the gate runner but runs every other check (R15).

#![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;

/// Maximum bytes captured per stream (stdout/stderr) from the gate runner.
/// Prevents a compromised gate runner from OOMing the process.
const MAX_OUTPUT_BYTES: u64 = 65_536; // 64 KB

/// Default gate runner timeout in seconds.
const GATE_RUNNER_TIMEOUT_SECS: u64 = 60;

/// Errors returned by Python/gate runner operations.
#[derive(Debug, Error)]
pub enum Error {
    /// The gate runner script exited with a non-zero status.
    #[error("gate runner failed (exit {exit_code}):\n{stdout}\n{stderr}")]
    GateRunnerFailed {
        exit_code: i32,
        stdout: String,
        stderr: String,
    },

    /// The gate runner exceeded the timeout and was killed.
    #[error("gate runner timed out after {elapsed_seconds} seconds")]
    GateRunnerTimedOut { elapsed_seconds: u64 },

    /// Failed to spawn or wait on the interpreter process.
    #[error("failed to invoke interpreter: {0}")]
    InterpreterInvocation(#[from] std::io::Error),
}

/// Platform-specific Python candidate list.
///
/// Windows: `py.exe` first (PEP 397 launcher), then `python.exe`, `python3.exe`.
/// Unix: `python3` first (PEP 394), then `python`.
#[cfg(windows)]
const CANDIDATES: &[&str] = &["py.exe", "python.exe", "python3.exe"];

#[cfg(not(windows))]
const CANDIDATES: &[&str] = &["python3", "python"];

/// Find a working Python interpreter on `PATH`.
///
/// Returns the first candidate that resolves via `which` and passes a
/// `--version` health check. Returns `None` if no candidate works.
pub fn find_interpreter() -> Option<PathBuf> {
    find_interpreter_from(CANDIDATES)
}

/// Testable inner function: find an interpreter from the given candidate list.
pub(crate) fn find_interpreter_from(candidates: &[&str]) -> Option<PathBuf> {
    for &name in candidates {
        if let Ok(path) = which::which(name) {
            // Health-check: run `<path> --version` with a short timeout.
            if health_check(&path) {
                return Some(path);
            }
        }
    }
    None
}

/// Run `<interpreter> --version` and return true if it exits 0 within 5 seconds.
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) => {
                    // Timed out — kill and return false.
                    let _ = child.kill();
                    let _ = child.wait();
                    false
                }
                Err(_) => false,
            }
        }
        Err(_) => false,
    }
}

/// Warning message when no Python interpreter is found.
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.)"
}

/// Invoke a gate runner script with the given interpreter.
///
/// Runs `<interpreter> <script> <image_dir>` with bounded output capture
/// and a 60-second timeout. On exit 0: returns `Ok(())`. On non-zero exit:
/// returns `Error::GateRunnerFailed` with bounded stdout/stderr. On timeout:
/// kills the child and returns `Error::GateRunnerTimedOut`.
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) => {
            // Process exited within timeout. Read bounded output.
            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 => {
            // Timed out. Kill the child and collect partial output.
            let _ = child.kill();
            let _ = child.wait();
            Err(Error::GateRunnerTimedOut {
                elapsed_seconds: GATE_RUNNER_TIMEOUT_SECS,
            })
        }
    }
}

/// Read up to `MAX_OUTPUT_BYTES` from an optional stream.
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;

    // ── Error Display tests ──────────────────────────────────────────

    #[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}"
        );
    }

    // ── missing_python_warning ───────────────────────────────────────

    #[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");
    }

    // ── find_interpreter_from (testable seam) ────────────────────────

    #[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());
    }

    // ── run_gate_runner with synthetic scripts ───────────────────────

    /// Create a synthetic script that exits with the given code.
    /// Returns the path to the script file.
    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
        }
    }

    /// Create a synthetic script that writes to stdout and exits with given code.
    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
        }
    }

    /// Create a wrapper script that acts as an "interpreter" for test scripts.
    ///
    /// On Unix: returns `/bin/sh` (shell natively runs scripts given as args).
    /// On Windows: creates a `run.cmd` wrapper in `dir` that executes its
    /// first argument via `cmd /c`, because bare `cmd.exe script.cmd` opens
    /// an interactive session instead of running and exiting.
    fn test_interpreter(dir: &Path) -> PathBuf {
        #[cfg(unix)]
        {
            let _ = dir; // unused on Unix
            PathBuf::from("/bin/sh")
        }
        #[cfg(windows)]
        {
            let wrapper = dir.join("run.cmd");
            // %~1 strips quotes; %2 %3 forward remaining args.
            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:?}"
        );
    }

    // ── Platform-specific find_interpreter ────────────────────────────

    #[test]
    fn candidate_list_is_nonempty() {
        // CANDIDATES is a const array — assert length rather than is_empty()
        // to avoid clippy::const_is_empty.
        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");
    }
}