mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Resolves the Python interpreter used to run `mecha10_rl.mujoco.cli`, and a fast preflight
//! check for whether `mecha10_rl` is actually importable by it.

use std::path::Path;
use tokio::process::Command;

pub const DEFAULT_PYTHON_EXECUTABLE: &str = "python3";

/// Resolve the Python interpreter to invoke for the `mecha10_rl.mujoco.cli` subprocess.
///
/// Prefers a project-local virtualenv interpreter at `<project_root>/.venv` when it exists,
/// falling back to `configured` otherwise.
pub fn resolve_python_executable(configured: &str, project_root: &Path) -> String {
    if configured != DEFAULT_PYTHON_EXECUTABLE {
        return configured.to_string();
    }

    let venv_python = if cfg!(windows) {
        project_root.join(".venv").join("Scripts").join("python.exe")
    } else {
        project_root.join(".venv").join("bin").join("python")
    };

    if venv_python.exists() {
        venv_python.display().to_string()
    } else {
        configured.to_string()
    }
}

/// Run a quick `<python> -c "import mecha10_rl.mujoco.cli"` preflight check.
///
/// Returns `Ok(())` when it imports cleanly, or `Err` with an actionable message otherwise.
pub async fn check_mecha10_rl_importable(python_executable: &str) -> Result<(), String> {
    let output = Command::new(python_executable)
        .args(["-c", "import mecha10_rl.mujoco.cli"])
        .output()
        .await;

    match output {
        Ok(output) if output.status.success() => Ok(()),
        Ok(output) => {
            let stderr = String::from_utf8_lossy(&output.stderr);
            let detail = stderr.lines().last().unwrap_or("").trim();
            Err(format!(
                "'{python_executable} -c \"import mecha10_rl.mujoco.cli\"' failed ({}){}. \
                 `mecha10_rl` and/or one of its dependencies (mujoco, Pillow, redis, gymnasium) is \
                 not installed for this interpreter - run `pip install \
                 'mecha10-rl @ git+ssh://git@github.com/mecha-industries/mecha10-rl.git@main#subdirectory=rl-trainer'` \
                 (see mecha10-assets' rl/requirements-rl.txt.template for \
                 the same pattern used in scaffolded user projects), inside this resolved \
                 interpreter's environment - or create a project venv with `mecha10 setup` (which \
                 mecha10 then auto-detects and prefers) and re-run `mecha10 dev`.",
                output.status,
                if detail.is_empty() {
                    String::new()
                } else {
                    format!(": {detail}")
                }
            ))
        }
        Err(e) => Err(format!(
            "failed to run '{python_executable}': {e}. Is \"{python_executable}\" (the auto-resolved \
             interpreter) a valid interpreter on PATH? Install Python 3, or create a project venv \
             with `mecha10 setup`, which mecha10 auto-detects and prefers over the bare system \
             interpreter."
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    fn unique_temp_dir(label: &str) -> std::path::PathBuf {
        let dir = std::env::temp_dir().join(format!(
            "cli-sim-python-env-test-{label}-{:?}",
            std::thread::current().id()
        ));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    #[test]
    fn leaves_explicit_override_untouched_even_if_venv_exists() {
        let root = unique_temp_dir("explicit-override");
        let bin_dir = if cfg!(windows) {
            root.join(".venv").join("Scripts")
        } else {
            root.join(".venv").join("bin")
        };
        fs::create_dir_all(&bin_dir).unwrap();
        let venv_python = bin_dir.join(if cfg!(windows) { "python.exe" } else { "python" });
        fs::write(&venv_python, b"").unwrap();

        let resolved = resolve_python_executable("/usr/bin/python3.11", &root);
        assert_eq!(resolved, "/usr/bin/python3.11");

        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn falls_back_to_configured_default_when_no_venv_exists() {
        let root = unique_temp_dir("no-venv");
        let resolved = resolve_python_executable(DEFAULT_PYTHON_EXECUTABLE, &root);
        assert_eq!(resolved, DEFAULT_PYTHON_EXECUTABLE);
        fs::remove_dir_all(&root).unwrap();
    }

    #[test]
    fn prefers_project_venv_when_default_and_venv_exists() {
        let root = unique_temp_dir("has-venv");
        let bin_dir = if cfg!(windows) {
            root.join(".venv").join("Scripts")
        } else {
            root.join(".venv").join("bin")
        };
        fs::create_dir_all(&bin_dir).unwrap();
        let venv_python = bin_dir.join(if cfg!(windows) { "python.exe" } else { "python" });
        fs::write(&venv_python, b"").unwrap();

        let resolved = resolve_python_executable(DEFAULT_PYTHON_EXECUTABLE, &root);
        assert_eq!(resolved, venv_python.display().to_string());

        fs::remove_dir_all(&root).unwrap();
    }

    #[tokio::test]
    async fn check_mecha10_rl_importable_reports_actionable_error_for_missing_interpreter() {
        let err = check_mecha10_rl_importable("definitely-not-a-real-interpreter-xyz")
            .await
            .expect_err("nonexistent interpreter should fail");
        assert!(err.contains("definitely-not-a-real-interpreter-xyz"));
        assert!(err.contains("mecha10 setup"));
    }
}