mecha10-cli 0.10.0

Mecha10 CLI tool
Documentation
//! Simulation command support.
//!
//! `mecha10 sim generate`/`sim run` delegate to the Python MuJoCo backend
//! (`mecha10_rl.mujoco.cli`) rather than reimplementing simulation logic in Rust.

pub mod python_env;

use crate::commands::{SimGenerateArgs, SimRunArgs};
use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::process::Stdio;

/// Build the `mecha10_rl.mujoco.cli generate` argv (excluding the interpreter/module prefix).
fn generate_argv(args: &SimGenerateArgs) -> Vec<String> {
    let mut argv = vec!["generate".to_string(), "--model".to_string(), args.model.clone()];

    if let Some(env) = &args.env {
        argv.push("--env".to_string());
        argv.push(env.clone());
    }

    argv.push("--out".to_string());
    argv.push(args.out.display().to_string());

    if let Some(assets_path) = &args.assets_path {
        argv.push("--assets-path".to_string());
        argv.push(assets_path.display().to_string());
    }

    argv
}

/// Build the `mecha10_rl.mujoco.cli run` argv (excluding the interpreter/module prefix).
fn run_argv(args: &SimRunArgs) -> Vec<String> {
    let mut argv = vec![
        "run".to_string(),
        "--model".to_string(),
        args.model.clone(),
        "--env".to_string(),
        args.env.clone(),
        "--steps".to_string(),
        args.steps.to_string(),
    ];

    if let Some(assets_path) = &args.assets_path {
        argv.push("--assets-path".to_string());
        argv.push(assets_path.display().to_string());
    }

    argv
}

/// Find a Python 3 interpreter on `PATH`.
fn find_python() -> Result<String> {
    for candidate in ["python3", "python"] {
        if let Ok(output) = std::process::Command::new(candidate).arg("--version").output() {
            let version = format!(
                "{}{}",
                String::from_utf8_lossy(&output.stdout),
                String::from_utf8_lossy(&output.stderr)
            );
            if output.status.success() && version.contains("Python 3") {
                return Ok(candidate.to_string());
            }
        }
    }
    bail!("Python 3 not found. Install with: brew install python3 (macOS) or apt install python3 (Linux)")
}

/// Run `python -m mecha10_rl.mujoco.cli <argv>`, inheriting stdio so the Python side's own
/// logging streams straight through to the user.
async fn run_mujoco_cli(argv: Vec<String>, env: &HashMap<String, String>) -> Result<()> {
    let python = find_python()?;

    python_env::check_mecha10_rl_importable(&python)
        .await
        .map_err(anyhow::Error::msg)
        .context("MuJoCo backend not available")?;

    let status = tokio::process::Command::new(&python)
        .args(["-m", "mecha10_rl.mujoco.cli"])
        .args(&argv)
        .envs(env)
        .stdin(Stdio::inherit())
        .stdout(Stdio::inherit())
        .stderr(Stdio::inherit())
        .status()
        .await
        .with_context(|| format!("failed to spawn '{python} -m mecha10_rl.mujoco.cli'"))?;

    if !status.success() {
        bail!(
            "mecha10_rl.mujoco.cli exited with {}",
            status
                .code()
                .map_or_else(|| "a signal".to_string(), |code| code.to_string())
        );
    }

    Ok(())
}

/// Generate MJCF via the Python MuJoCo backend (`mecha10_rl.mujoco.cli generate`).
pub async fn generate(args: &SimGenerateArgs, env: &HashMap<String, String>) -> Result<()> {
    run_mujoco_cli(generate_argv(args), env).await
}

/// Run a headless smoke-test rollout via the Python MuJoCo backend (`mecha10_rl.mujoco.cli run`).
pub async fn run(args: &SimRunArgs, env: &HashMap<String, String>) -> Result<()> {
    run_mujoco_cli(run_argv(args), env).await
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::SimBackend;
    use std::path::PathBuf;

    fn generate_args(model: &str, env: Option<&str>, out: &str, assets_path: Option<&str>) -> SimGenerateArgs {
        SimGenerateArgs {
            backend: SimBackend::Mujoco,
            model: model.to_string(),
            env: env.map(str::to_string),
            out: PathBuf::from(out),
            assets_path: assets_path.map(PathBuf::from),
        }
    }

    fn run_args(model: &str, env: &str, steps: u32, assets_path: Option<&str>) -> SimRunArgs {
        SimRunArgs {
            backend: SimBackend::Mujoco,
            model: model.to_string(),
            env: env.to_string(),
            steps,
            assets_path: assets_path.map(PathBuf::from),
        }
    }

    #[test]
    fn generate_argv_includes_required_flags() {
        let args = generate_args("rover", None, "/tmp/rover.xml", None);
        assert_eq!(
            generate_argv(&args),
            vec!["generate", "--model", "rover", "--out", "/tmp/rover.xml"]
        );
    }

    #[test]
    fn generate_argv_includes_optional_env_and_assets_path() {
        let args = generate_args("rover", Some("basic_arena"), "/tmp/rover.xml", Some("/tmp/assets"));
        assert_eq!(
            generate_argv(&args),
            vec![
                "generate",
                "--model",
                "rover",
                "--env",
                "basic_arena",
                "--out",
                "/tmp/rover.xml",
                "--assets-path",
                "/tmp/assets",
            ]
        );
    }

    #[test]
    fn run_argv_includes_required_flags() {
        let args = run_args("rover", "basic_arena", 100, None);
        assert_eq!(
            run_argv(&args),
            vec!["run", "--model", "rover", "--env", "basic_arena", "--steps", "100"]
        );
    }

    #[test]
    fn run_argv_includes_optional_assets_path() {
        let args = run_args("rover", "basic_arena", 250, Some("/tmp/assets"));
        assert_eq!(
            run_argv(&args),
            vec![
                "run",
                "--model",
                "rover",
                "--env",
                "basic_arena",
                "--steps",
                "250",
                "--assets-path",
                "/tmp/assets",
            ]
        );
    }

    #[tokio::test]
    async fn run_mujoco_cli_reports_actionable_error_or_actually_invokes_the_backend() {
        let Ok(_) = std::process::Command::new("python3").arg("--version").output() else {
            eprintln!("skipping: no python3 on PATH in this environment");
            return;
        };

        let result = run_mujoco_cli(vec!["--this-flag-does-not-exist".to_string()], &HashMap::new()).await;

        let Err(err) = result else {
            panic!("expected run_mujoco_cli to fail (either mecha10_rl isn't installed, or the bogus argv above should error)");
        };
        let message = err.chain().map(ToString::to_string).collect::<Vec<_>>().join(": ");
        let mecha10_rl_missing = message.contains("MuJoCo backend not available") && message.contains("mecha10_rl");
        let backend_invoked_and_rejected_bad_argv = message.contains("mecha10_rl.mujoco.cli exited with");
        assert!(
            mecha10_rl_missing || backend_invoked_and_rejected_bad_argv,
            "expected either a clear 'mecha10_rl not installed' error or evidence the backend \
             was actually invoked, got: {message}"
        );
    }
}