mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Simulation command handler
//!
//! Orchestrates `mecha10 sim generate`/`sim run`, delegating the actual MJCF generation and
//! Gymnasium env stepping to the Python MuJoCo backend (see `crate::sim`).

use crate::commands::SimCommand;
use crate::context::CliContext;
use crate::handlers::dev::ops::inject_locomotion_limits_env;
use crate::sim;
use crate::ui::style;
use anyhow::Result;
use std::collections::HashMap;

/// Handle the `sim` command family.
///
/// Loads the project's locomotion limits and injects them as env vars for the Python MuJoCo
/// backend (`mecha10_rl.mujoco.cli`).
pub async fn handle_sim(ctx: &mut CliContext, command: &SimCommand) -> Result<()> {
    let project_config = ctx.load_project_config().await?;
    let mut env: HashMap<String, String> = HashMap::new();
    inject_locomotion_limits_env(&project_config, &mut env);

    match command {
        SimCommand::Generate(args) => {
            println!();
            println!("{}", style::heading("🤖 Generating MuJoCo MJCF"));
            println!("{}", style::separator(47));
            println!();

            sim::generate(args, &env).await?;

            println!();
            println!("{}", style::success(format!("✅ Wrote MJCF to {}", args.out.display())));
            Ok(())
        }
        SimCommand::Run(args) => {
            println!();
            println!("{}", style::heading("🤖 Running MuJoCo rollout"));
            println!("{}", style::separator(47));
            println!();

            sim::run(args, &env).await?;

            println!();
            println!("{}", style::success(format!("✅ Ran {} steps", args.steps)));
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::commands::{SimBackend, SimGenerateArgs, SimRunArgs};
    use crate::context::CliContextBuilder;
    use std::path::PathBuf;
    use tempfile::tempdir;

    /// Build a throwaway `CliContext` rooted at a fresh temp dir, with a minimal valid
    /// `mecha10.json` written since `handle_sim` loads the project config unconditionally.
    fn test_ctx() -> (tempfile::TempDir, CliContext) {
        let dir = tempdir().unwrap();
        std::fs::write(
            dir.path().join("mecha10.json"),
            r#"{
                "name": "test-project",
                "version": "0.1.0",
                "robot_id": "00000000-0000-0000-0000-000000000000",
                "robot_name": "test-project",
                "robot_platform": "rover",
                "actuator": {
                    "locomotion": {
                        "max_linear_vel": 10.0,
                        "max_angular_vel": 10.0,
                        "max_acceleration": 10.0
                    }
                }
            }"#,
        )
        .unwrap();
        let ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .build()
            .unwrap();
        (dir, ctx)
    }

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

        let (_dir, mut ctx) = test_ctx();
        let command = SimCommand::Generate(SimGenerateArgs {
            backend: SimBackend::Mujoco,
            model: "rover".to_string(),
            env: None,
            out: PathBuf::from("/tmp/mecha10-handle-sim-test-generate.xml"),
            assets_path: None,
        });

        let Err(err) = handle_sim(&mut ctx, &command).await else {
            panic!(
                "expected handle_sim to propagate a backend error in this environment \
                 (mecha10_rl not installed) rather than report success"
            );
        };

        let message = err.chain().map(ToString::to_string).collect::<Vec<_>>().join(": ");
        assert!(
            message.contains("MuJoCo backend not available") || message.contains("mecha10_rl.mujoco.cli exited with"),
            "expected the underlying backend error to survive handle_sim unchanged, got: {message}"
        );
    }

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

        let (_dir, mut ctx) = test_ctx();
        let command = SimCommand::Run(SimRunArgs {
            backend: SimBackend::Mujoco,
            model: "rover".to_string(),
            env: "basic_arena".to_string(),
            steps: 10,
            assets_path: None,
        });

        let Err(err) = handle_sim(&mut ctx, &command).await else {
            panic!(
                "expected handle_sim to propagate a backend error in this environment \
                 (mecha10_rl not installed) rather than report success"
            );
        };

        let message = err.chain().map(ToString::to_string).collect::<Vec<_>>().join(": ");
        assert!(
            message.contains("MuJoCo backend not available") || message.contains("mecha10_rl.mujoco.cli exited with"),
            "expected the underlying backend error to survive handle_sim unchanged, got: {message}"
        );
    }
}