pub mod python_env;
use crate::commands::{SimGenerateArgs, SimRunArgs};
use anyhow::{bail, Context, Result};
use std::collections::HashMap;
use std::process::Stdio;
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
}
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
}
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)")
}
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(())
}
pub async fn generate(args: &SimGenerateArgs, env: &HashMap<String, String>) -> Result<()> {
run_mujoco_cli(generate_argv(args), env).await
}
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}"
);
}
}