mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Simulation configuration types
//!
//! Provides environment-aware configuration for simulation with CLI override support.
//! These types represent runtime simulation configuration loaded from configs/{profile}/simulation/.

use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

/// Godot engine configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(dead_code)] // Part of public API, may be used by library consumers
pub struct GodotConfig {
    /// Path to Godot executable
    #[serde(default = "default_godot_path")]
    pub executable_path: String,

    /// Run in headless mode (no GUI)
    #[serde(default)]
    pub headless: bool,

    /// Viewport width
    #[serde(default = "default_viewport_width")]
    pub viewport_width: u32,

    /// Viewport height
    #[serde(default = "default_viewport_height")]
    pub viewport_height: u32,
}

fn default_godot_path() -> String {
    "godot".to_string()
}

fn default_viewport_width() -> u32 {
    1280
}

fn default_viewport_height() -> u32 {
    720
}

impl Default for GodotConfig {
    fn default() -> Self {
        Self {
            executable_path: default_godot_path(),
            headless: false,
            viewport_width: default_viewport_width(),
            viewport_height: default_viewport_height(),
        }
    }
}

/// Camera stream configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(dead_code)] // Part of public API, may be used by library consumers
pub struct CameraConfig {
    /// Enable camera streaming
    #[serde(default = "default_camera_enabled")]
    pub enabled: bool,

    /// Camera resolution width
    #[serde(default = "default_camera_width")]
    pub width: u32,

    /// Camera resolution height
    #[serde(default = "default_camera_height")]
    pub height: u32,

    /// Frames per second
    #[serde(default = "default_camera_fps")]
    pub fps: u32,
}

fn default_camera_enabled() -> bool {
    true
}

fn default_camera_width() -> u32 {
    320
}

fn default_camera_height() -> u32 {
    240
}

fn default_camera_fps() -> u32 {
    20
}

impl Default for CameraConfig {
    fn default() -> Self {
        Self {
            enabled: default_camera_enabled(),
            width: default_camera_width(),
            height: default_camera_height(),
            fps: default_camera_fps(),
        }
    }
}

/// Network ports configuration
#[derive(Debug, Clone, Deserialize, Serialize)]
#[allow(dead_code)] // Part of public API, may be used by library consumers
pub struct NetworkingConfig {
    /// Godot protocol server port
    #[serde(default = "default_protocol_port")]
    pub protocol_port: u16,

    /// Godot protocol server bind address
    #[serde(default = "default_bind_address")]
    pub protocol_bind: String,

    /// Camera stream port
    #[serde(default = "default_camera_port")]
    pub camera_port: u16,

    /// Camera stream bind address
    #[serde(default = "default_bind_address")]
    pub camera_bind: String,

    /// WebRTC signaling port
    #[serde(default = "default_signaling_port")]
    pub signaling_port: u16,

    /// WebRTC signaling bind address
    #[serde(default = "default_bind_address")]
    pub signaling_bind: String,
}

fn default_protocol_port() -> u16 {
    11008
}

fn default_camera_port() -> u16 {
    11009
}

fn default_signaling_port() -> u16 {
    11010
}

fn default_bind_address() -> String {
    "0.0.0.0".to_string()
}

impl Default for NetworkingConfig {
    fn default() -> Self {
        Self {
            protocol_port: default_protocol_port(),
            protocol_bind: default_bind_address(),
            camera_port: default_camera_port(),
            camera_bind: default_bind_address(),
            signaling_port: default_signaling_port(),
            signaling_bind: default_bind_address(),
        }
    }
}

/// Runtime simulation configuration
///
/// Loaded from configs/simulation/config.json with environment sections.
/// The config file format is:
/// ```json
/// {
///   "dev": { ... config ... },
///   "production": { ... config ... }
/// }
/// ```
/// This is distinct from ProjectSimulationConfig which is stored in mecha10.json.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct SimulationConfig {
    /// Robot model identifier (e.g., "@mecha10/simulation-models/rover")
    pub model: String,

    /// Path to model configuration file
    pub model_config: Option<String>,

    /// Environment identifier (e.g., "@mecha10/simulation-environments/basic_arena")
    pub environment: String,

    /// Path to environment configuration file
    pub environment_config: Option<String>,

    /// Godot engine configuration
    #[serde(default)]
    pub godot: GodotConfig,

    /// Camera configuration
    #[serde(default)]
    pub camera: CameraConfig,

    /// Networking configuration
    #[serde(default)]
    pub networking: NetworkingConfig,
}

/// Environment-aware simulation configuration wrapper
///
/// Used for deserializing configs/simulation/config.json which contains
/// both dev and production sections.
#[derive(Debug, Clone, Deserialize, Serialize)]
struct EnvironmentSimulationConfig {
    dev: Option<SimulationConfig>,
    production: Option<SimulationConfig>,
}

impl SimulationConfig {
    /// Load simulation config with environment and scenario support
    ///
    /// Priority (first found wins):
    /// 1. configs/simulation/{scenario}.json (if scenario specified)
    /// 2. configs/simulation/config.json (with dev/production sections)
    /// 3. Error
    #[allow(dead_code)] // Part of public API, may be used by library consumers
    pub fn load() -> anyhow::Result<Self> {
        Self::load_with_profile_and_scenario(None, None)
    }

    /// Load simulation config with specific profile and/or scenario
    ///
    /// Config files are now environment-aware with this format:
    /// ```json
    /// {
    ///   "dev": { ... config ... },
    ///   "production": { ... config ... }
    /// }
    /// ```
    ///
    /// # Arguments
    /// * `profile` - Config profile (dev, production, etc.). If None, uses MECHA10_ENVIRONMENT or "dev"
    /// * `scenario` - Scenario name (navigation, obstacle_avoidance, etc.). If None, uses MECHA10_SIMULATION_SCENARIO or loads config.json
    pub fn load_with_profile_and_scenario(profile: Option<&str>, scenario: Option<&str>) -> anyhow::Result<Self> {
        use std::env;

        // Get profile from parameter, env var, or default
        let profile = profile
            .map(String::from)
            .or_else(|| env::var("MECHA10_CONFIG_PROFILE").ok())
            .or_else(|| env::var("MECHA10_ENVIRONMENT").ok())
            .unwrap_or_else(|| "dev".to_string());

        // Get scenario from parameter or env var
        let scenario = scenario
            .map(String::from)
            .or_else(|| env::var("MECHA10_SIMULATION_SCENARIO").ok());

        // Try multiple base paths (for running from different locations)
        let base_paths = vec![
            PathBuf::from("."),           // Current directory
            PathBuf::from("../.."),       // From target/debug/deps
            PathBuf::from("../../../.."), // From packages/*/target/debug/deps
        ];

        // Build fallback chain for each base path
        for base_path in &base_paths {
            let configs_dir = base_path.join("configs");

            // 1. Try scenario-specific config (if scenario specified)
            if let Some(scenario_name) = &scenario {
                let scenario_path = configs_dir.join("simulation").join(format!("{}.json", scenario_name));

                if scenario_path.exists() {
                    let content = std::fs::read_to_string(&scenario_path)?;
                    // Scenario configs also use environment-aware format
                    if let Ok(config) = Self::parse_environment_config(&content, &profile) {
                        return Ok(config);
                    }
                }
            }

            // 2. Try configs/simulation/config.json (new format with dev/production sections)
            let config_path = configs_dir.join("simulation").join("config.json");

            if config_path.exists() {
                let content = std::fs::read_to_string(&config_path)?;
                if let Ok(config) = Self::parse_environment_config(&content, &profile) {
                    return Ok(config);
                }
            }
        }

        if let Some(scenario_name) = &scenario {
            Err(anyhow::anyhow!(
                "No simulation config found.\nTried:\n  - configs/simulation/{}.json\n  - configs/simulation/config.json\n\nExpected format:\n{{\n  \"dev\": {{ ... }},\n  \"production\": {{ ... }}\n}}",
                scenario_name
            ))
        } else {
            Err(anyhow::anyhow!(
                "No simulation config found.\nTried:\n  - configs/simulation/config.json\n\nExpected format:\n{{\n  \"dev\": {{ ... }},\n  \"production\": {{ ... }}\n}}"
            ))
        }
    }

    /// Parse environment-aware config and extract the appropriate environment section
    fn parse_environment_config(content: &str, profile: &str) -> anyhow::Result<Self> {
        // Try to parse as environment-aware config first
        if let Ok(env_config) = serde_json::from_str::<EnvironmentSimulationConfig>(content) {
            // Select the appropriate environment
            let config = match profile {
                "production" | "prod" => env_config.production,
                _ => env_config.dev, // Default to dev for any other profile
            };

            return config.ok_or_else(|| {
                anyhow::anyhow!(
                    "No '{}' section found in simulation config. Expected format:\n{{\n  \"dev\": {{ ... }},\n  \"production\": {{ ... }}\n}}",
                    profile
                )
            });
        }

        // Fall back to direct parsing for backwards compatibility
        serde_json::from_str(content).map_err(|e| anyhow::anyhow!("Failed to parse simulation config: {}", e))
    }

    /// Apply CLI overrides to config
    ///
    /// Allows command-line arguments to override config file values.
    #[allow(dead_code)] // Part of public API, may be used by library consumers
    pub fn with_overrides(mut self, overrides: SimulationOverrides) -> Self {
        if let Some(model) = overrides.model {
            self.model = model;
        }
        if let Some(model_config) = overrides.model_config {
            self.model_config = Some(model_config);
        }
        if let Some(environment) = overrides.environment {
            self.environment = environment;
        }
        if let Some(environment_config) = overrides.environment_config {
            self.environment_config = Some(environment_config);
        }
        if let Some(headless) = overrides.headless {
            self.godot.headless = headless;
        }
        self
    }

    /// Get the model path for Godot
    ///
    /// Resolves @mecha10 references to actual filesystem paths.
    #[allow(dead_code)] // Part of public API, may be used by library consumers
    pub fn resolve_model_path(&self, framework_path: &Path) -> PathBuf {
        if self.model.starts_with("@mecha10/") {
            let relative = self.model.strip_prefix("@mecha10/").unwrap();
            framework_path.join("packages").join(relative)
        } else {
            PathBuf::from(&self.model)
        }
    }

    /// Get the environment path for Godot
    ///
    /// Resolves @mecha10 references to actual filesystem paths.
    #[allow(dead_code)] // Part of public API, may be used by library consumers
    pub fn resolve_environment_path(&self, framework_path: &Path) -> PathBuf {
        if self.environment.starts_with("@mecha10/") {
            let relative = self.environment.strip_prefix("@mecha10/").unwrap();
            framework_path.join("packages").join(relative)
        } else {
            PathBuf::from(&self.environment)
        }
    }
}

/// CLI overrides for simulation config
#[derive(Debug, Default, Clone)]
#[allow(dead_code)] // Part of public API, may be used by library consumers
pub struct SimulationOverrides {
    pub model: Option<String>,
    pub model_config: Option<String>,
    pub environment: Option<String>,
    pub environment_config: Option<String>,
    pub headless: Option<bool>,
}