mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! Simulation command arguments

use clap::{Args, Subcommand, ValueEnum};
use std::path::PathBuf;

#[derive(Subcommand, Debug, Clone)]
pub enum SimCommand {
    Generate(SimGenerateArgs),

    Run(SimRunArgs),
}

#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SimBackend {
    #[default]
    Mujoco,
}

#[derive(Args, Debug, Clone)]
pub struct SimGenerateArgs {
    #[arg(long, value_enum, default_value_t = SimBackend::Mujoco)]
    pub backend: SimBackend,

    #[arg(long, default_value = "rover")]
    pub model: String,

    #[arg(long)]
    pub env: Option<String>,

    #[arg(long)]
    pub out: PathBuf,

    #[arg(long)]
    pub assets_path: Option<PathBuf>,
}

#[derive(Args, Debug, Clone)]
pub struct SimRunArgs {
    #[arg(long, value_enum, default_value_t = SimBackend::Mujoco)]
    pub backend: SimBackend,

    #[arg(long, default_value = "rover")]
    pub model: String,

    #[arg(long, default_value = "basic_arena")]
    pub env: String,

    #[arg(long, default_value_t = 100)]
    pub steps: u32,

    #[arg(long)]
    pub assets_path: Option<PathBuf>,
}

#[cfg(test)]
mod tests {
    use crate::types::{Cli, Commands};
    use clap::Parser;

    /// Parse a `mecha10 sim ...` argv into the `SimCommand` it resolves to, panicking (with a
    /// clap-formatted error) if parsing fails or the top-level command didn't route to `Sim`.
    fn parse_sim(args: &[&str]) -> super::SimCommand {
        let argv: Vec<&str> = std::iter::once("mecha10").chain(args.iter().copied()).collect();
        match Cli::try_parse_from(argv) {
            Ok(cli) => match cli.command {
                Commands::Sim { command } => command,
                _ => panic!("expected Commands::Sim"),
            },
            Err(e) => panic!("expected successful parse, got: {e}"),
        }
    }

    #[test]
    fn generate_requires_out() {
        let err = Cli::try_parse_from(["mecha10", "sim", "generate"])
            .map(|_| ())
            .unwrap_err();
        assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
    }

    #[test]
    fn generate_defaults_backend_mujoco_and_model_rover_with_no_env() {
        let super::SimCommand::Generate(args) = parse_sim(&["sim", "generate", "--out", "/tmp/rover.xml"]) else {
            panic!("expected SimCommand::Generate");
        };

        assert_eq!(args.backend, super::SimBackend::Mujoco);
        assert_eq!(args.model, "rover");
        assert_eq!(args.env, None);
        assert_eq!(args.out, std::path::PathBuf::from("/tmp/rover.xml"));
        assert_eq!(args.assets_path, None);
    }

    #[test]
    fn generate_parses_all_optional_flags() {
        let super::SimCommand::Generate(args) = parse_sim(&[
            "sim",
            "generate",
            "--model",
            "arm",
            "--env",
            "basic_arena",
            "--out",
            "/tmp/arm.xml",
            "--assets-path",
            "/tmp/assets",
        ]) else {
            panic!("expected SimCommand::Generate");
        };

        assert_eq!(args.model, "arm");
        assert_eq!(args.env, Some("basic_arena".to_string()));
        assert_eq!(args.out, std::path::PathBuf::from("/tmp/arm.xml"));
        assert_eq!(args.assets_path, Some(std::path::PathBuf::from("/tmp/assets")));
    }

    #[test]
    fn generate_rejects_unknown_backend() {
        let err = Cli::try_parse_from([
            "mecha10",
            "sim",
            "generate",
            "--out",
            "/tmp/rover.xml",
            "--backend",
            "godot",
        ])
        .map(|_| ())
        .unwrap_err();
        assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
    }

    #[test]
    fn run_defaults_env_basic_arena_and_steps_100_with_no_out_required() {
        let super::SimCommand::Run(args) = parse_sim(&["sim", "run"]) else {
            panic!("expected SimCommand::Run");
        };

        assert_eq!(args.backend, super::SimBackend::Mujoco);
        assert_eq!(args.model, "rover");
        assert_eq!(args.env, "basic_arena");
        assert_eq!(args.steps, 100);
        assert_eq!(args.assets_path, None);
    }

    #[test]
    fn run_parses_all_optional_flags() {
        let super::SimCommand::Run(args) = parse_sim(&[
            "sim",
            "run",
            "--model",
            "arm",
            "--env",
            "warehouse",
            "--steps",
            "250",
            "--assets-path",
            "/tmp/assets",
        ]) else {
            panic!("expected SimCommand::Run");
        };

        assert_eq!(args.model, "arm");
        assert_eq!(args.env, "warehouse");
        assert_eq!(args.steps, 250);
        assert_eq!(args.assets_path, Some(std::path::PathBuf::from("/tmp/assets")));
    }

    #[test]
    fn run_rejects_non_numeric_steps() {
        let err = Cli::try_parse_from(["mecha10", "sim", "run", "--steps", "not-a-number"])
            .map(|_| ())
            .unwrap_err();
        assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
    }
}