mecha10-cli 0.6.3

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`), plus `sim push`/`sim
//! pull` (LAB-1984, LAB-2021), which publish/fetch project-authored
//! `simulation/{models,environments,tasks}` assets to/from the robot-config service — see
//! `mecha10_robot_config::sim_assets` for why that service (rather than a new endpoint) is the
//! sync target, and why `pull` always lands assets at
//! `<project>/simulation/{models,environments,tasks}` - the same directory `mecha10 init`
//! scaffolds the bundled catalog into (LAB-2019) and `packages/nodes/simulator`'s
//! `SimulatorNode::run` always resolves assets from.

use crate::commands::{SimCommand, SimPullArgs, SimPushArgs};
use crate::context::CliContext;
use crate::handlers::config::{create_config_client, get_robot_id};
use crate::sim;
use anyhow::Result;
use mecha10_robot_config::{collect_sim_assets_from_dir, is_sim_asset_key, write_sim_assets_to_dir};

/// Handle the `sim` command family.
pub async fn handle_sim(ctx: &mut CliContext, command: &SimCommand) -> Result<()> {
    match command {
        SimCommand::Generate(args) => {
            println!();
            println!("🤖 Generating MuJoCo MJCF");
            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            println!();

            sim::generate(args).await?;

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

            sim::run(args).await?;

            println!();
            println!("✅ Ran {} steps", args.steps);
            Ok(())
        }
        SimCommand::Push(args) => handle_sim_push(ctx, args).await,
        SimCommand::Pull(args) => handle_sim_pull(ctx, args).await,
    }
}

/// Handle the `sim push` command
///
/// Publishes local `<project>/simulation/{models,environments,tasks}` asset files to the
/// robot-config service, base64-encoded under `simulation-assets/<relative-path>` config keys
/// (see `mecha10_robot_config::sim_assets` docs for why).
async fn handle_sim_push(ctx: &mut CliContext, args: &SimPushArgs) -> Result<()> {
    println!();
    println!("Pushing simulation assets...");
    println!();

    let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;

    let simulation_dir = match &args.path {
        Some(path) => path.clone(),
        None => ctx.simulation_dir(),
    };

    let assets = collect_sim_assets_from_dir(&simulation_dir)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to collect simulation assets: {}", e))?;

    if assets.is_empty() {
        println!("No simulation assets found");
        println!();
        println!("Expected:");
        println!("  - {}/models/", simulation_dir.display());
        println!("  - {}/environments/", simulation_dir.display());
        println!("  - {}/tasks/", simulation_dir.display());
        return Ok(());
    }

    println!("Robot ID:        {}", robot_id);
    println!("Simulation dir:  {}", simulation_dir.display());
    println!();
    println!("Found {} simulation asset(s):", assets.len());
    for (key, _) in &assets {
        println!("  - {}", key);
    }
    println!();

    if args.dry_run {
        println!("DRY RUN - No changes will be made");
        return Ok(());
    }

    let client = create_config_client(ctx).await?;

    match client.bulk_upsert_configs(&robot_id, assets).await {
        Ok(result) => {
            println!(
                "Pushed {} simulation asset(s) ({} created, {} updated)",
                result.configs.len(),
                result.created,
                result.updated
            );
        }
        Err(e) => {
            return Err(anyhow::anyhow!("Failed to push simulation assets: {}", e));
        }
    }

    println!();
    Ok(())
}

/// Handle the `sim pull` command
///
/// Fetches simulation assets from the robot-config service and writes them to
/// `<project>/simulation/{models,environments,tasks}` — the same directory `mecha10 init`
/// scaffolds the bundled catalog into (LAB-2019) and `mecha10_rl`'s `AssetResolver` already
/// checks first (LAB-1980), so a pulled asset is picked up with no extra configuration.
async fn handle_sim_pull(ctx: &mut CliContext, args: &SimPullArgs) -> Result<()> {
    println!();
    println!("Pulling simulation assets...");
    println!();

    let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;

    let output_dir = args.output.clone().unwrap_or_else(|| ctx.simulation_dir());

    println!("Robot ID:        {}", robot_id);
    println!("Output dir:      {}", output_dir.display());
    println!();

    let client = create_config_client(ctx).await?;

    let configs = match client.get_configs(&robot_id).await {
        Ok(configs) => configs,
        Err(e) => {
            return Err(anyhow::anyhow!("Failed to fetch simulation assets: {}", e));
        }
    };

    let assets: Vec<_> = configs
        .into_iter()
        .filter(|c| is_sim_asset_key(&c.config_key))
        .collect();

    if assets.is_empty() {
        println!("No simulation assets found for robot {}", robot_id);
        return Ok(());
    }

    println!("Found {} simulation asset(s):", assets.len());
    for asset in &assets {
        println!("  - {}", asset.config_key);
    }
    println!();

    let result = write_sim_assets_to_dir(&assets, &output_dir, args.force)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to write simulation assets: {}", e))?;

    for key in &result.written_keys {
        println!("  Wrote {}", key);
    }
    for key in &result.skipped_keys {
        println!("  Skipped {} (exists, use --force to overwrite)", key);
    }

    println!();
    println!(
        "Pulled {} simulation asset(s), skipped {}",
        result.written, result.skipped
    );
    println!();

    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 - `handle_sim`'s `Generate`/`Run`
    /// arms don't touch it, and the `Push`/`Pull` tests below build their own project-initialized
    /// context, so this only needs to satisfy the type signature.
    fn test_ctx() -> (tempfile::TempDir, CliContext) {
        let dir = tempdir().unwrap();
        let ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .build()
            .unwrap();
        (dir, ctx)
    }

    fn write_project_config(dir: &std::path::Path, robot_id: &str) -> PathBuf {
        let config_path = dir.join("mecha10.json");
        std::fs::write(
            &config_path,
            serde_json::json!({
                "name": "proj",
                "version": "0.1.0",
                "robot": {"id": robot_id},
            })
            .to_string(),
        )
        .unwrap();
        config_path
    }

    // `handle_sim` is a thin orchestration wrapper: print a banner, delegate to
    // `sim::generate`/`sim::run`, print a success banner. There's no way to inject a fake
    // backend (no DI/mocking exists in this crate — see `crate::sim`'s own tests, which take the
    // same approach), so these tests exercise the real error path: on a fresh clone / CI runner
    // (real `python3`, `mecha10_rl` NOT installed), both variants must propagate the backend's
    // error through `handle_sim` unchanged (not swallow it, not report success) rather than
    // hanging or panicking.

    #[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}"
        );
    }

    // `sim push`/`sim pull` (LAB-1984) — exercise the parts that don't require a live
    // robot-config service: empty-directory short circuits, dry-run, and the robot-id
    // resolution error path (mirrors `handlers::config`'s equivalent tests). The actual
    // HTTP push/pull round trip is covered by `mecha10_robot_config::sim_assets`'s tests
    // (collect/write) plus `RobotConfigClient`'s own tests — there's no mock HTTP server in
    // this crate to exercise `bulk_upsert_configs`/`get_configs` against, same precedent as
    // `handlers::config`'s tests.

    #[tokio::test]
    async fn sim_push_reports_no_assets_found_for_empty_simulation_dir() {
        let dir = tempdir().unwrap();
        let config_path = write_project_config(dir.path(), "robot-1");
        let mut ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .config_path(Some(config_path))
            .build()
            .unwrap();

        let args = SimPushArgs {
            path: None,
            robot_id: None,
            dry_run: false,
        };

        // No `simulation/` directory exists under `dir` at all — should short-circuit with Ok(())
        // rather than erroring, same as `handle_config_push` with an empty `configs/`.
        handle_sim_push(&mut ctx, &args).await.unwrap();
    }

    #[tokio::test]
    async fn sim_push_dry_run_does_not_push() {
        let dir = tempdir().unwrap();
        let config_path = write_project_config(dir.path(), "robot-1");
        std::fs::create_dir_all(dir.path().join("simulation/models/rover")).unwrap();
        std::fs::write(
            dir.path().join("simulation/models/rover/model.json"),
            r#"{"name":"rover"}"#,
        )
        .unwrap();

        let mut ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .config_path(Some(config_path))
            .build()
            .unwrap();

        let args = SimPushArgs {
            path: None,
            robot_id: None,
            dry_run: true,
        };

        // Dry run must return Ok(()) without attempting a network call (no credentials are set
        // up in this test, so a real push attempt would fail with "Not logged in").
        handle_sim_push(&mut ctx, &args).await.unwrap();
    }

    #[tokio::test]
    async fn sim_push_errors_without_robot_id_or_project() {
        let dir = tempdir().unwrap();
        // No mecha10.json written, so the project is not initialized and no --robot-id given.
        let mut ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .build()
            .unwrap();

        let args = SimPushArgs {
            path: None,
            robot_id: None,
            dry_run: false,
        };

        let err = handle_sim_push(&mut ctx, &args).await.unwrap_err();
        assert!(err.to_string().contains("No robot ID specified"));
    }

    #[tokio::test]
    async fn sim_pull_errors_without_robot_id_or_project() {
        let dir = tempdir().unwrap();
        let mut ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .build()
            .unwrap();

        let args = SimPullArgs {
            output: None,
            robot_id: None,
            force: false,
        };

        let err = handle_sim_pull(&mut ctx, &args).await.unwrap_err();
        assert!(err.to_string().contains("No robot ID specified"));
    }
}