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};
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,
}
}
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(())
}
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;
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
}
#[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}"
);
}
#[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,
};
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,
};
handle_sim_push(&mut ctx, &args).await.unwrap();
}
#[tokio::test]
async fn sim_push_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 = 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"));
}
}