mecha10-cli 0.7.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`).
//!
//! `sim push`/`sim pull` (LAB-1984, LAB-2021) were removed in LAB-2936: simulation assets
//! (models, environments) now live under `<project>/configs/simulation`, so `mecha10 config
//! push`/`pull` already syncs them generically - the dedicated sim-asset sync path they used
//! (`mecha10_robot_config::sim_assets`, since removed) is no longer needed.

use crate::commands::SimCommand;
use crate::context::CliContext;
use crate::handlers::dev::ops::inject_locomotion_limits_env;
use crate::sim;
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`), the same way `handlers::run::run_robot_target` already
/// does for the robot target - `mecha10.json`'s `actuator.locomotion` is the sole source since
/// LAB-1978 removed `model.json`'s implicit default, and the backend hard-errors without it.
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!("🤖 Generating MuJoCo MJCF");
            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            println!();

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

            println!();
            println!("✅ Wrote MJCF to {}", args.out.display());
            Ok(())
        }
        SimCommand::Run(args) => {
            println!();
            println!("🤖 Running MuJoCo rollout");
            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            println!();

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

            println!();
            println!("✅ 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 - `handle_sim` now loads the project config unconditionally (to
    /// inject locomotion-limit env vars the MuJoCo backend requires), so a bare tempdir with no
    /// config file would fail at that step before ever reaching the backend-error path these
    /// tests actually exercise.
    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}"
        );
    }
}