mecha10_cli/types/
simulation.rs

1//! Simulation configuration types
2//!
3//! Provides environment-aware configuration for simulation with CLI override support.
4//! These types represent runtime simulation configuration loaded from configs/{profile}/simulation/.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Godot engine configuration
10#[derive(Debug, Clone, Deserialize, Serialize)]
11#[allow(dead_code)] // Part of public API, may be used by library consumers
12pub struct GodotConfig {
13    /// Path to Godot executable
14    #[serde(default = "default_godot_path")]
15    pub executable_path: String,
16
17    /// Run in headless mode (no GUI)
18    #[serde(default)]
19    pub headless: bool,
20
21    /// Viewport width
22    #[serde(default = "default_viewport_width")]
23    pub viewport_width: u32,
24
25    /// Viewport height
26    #[serde(default = "default_viewport_height")]
27    pub viewport_height: u32,
28}
29
30fn default_godot_path() -> String {
31    "godot".to_string()
32}
33
34fn default_viewport_width() -> u32 {
35    1280
36}
37
38fn default_viewport_height() -> u32 {
39    720
40}
41
42impl Default for GodotConfig {
43    fn default() -> Self {
44        Self {
45            executable_path: default_godot_path(),
46            headless: false,
47            viewport_width: default_viewport_width(),
48            viewport_height: default_viewport_height(),
49        }
50    }
51}
52
53/// Camera stream configuration
54#[derive(Debug, Clone, Deserialize, Serialize)]
55#[allow(dead_code)] // Part of public API, may be used by library consumers
56pub struct CameraConfig {
57    /// Enable camera streaming
58    #[serde(default = "default_camera_enabled")]
59    pub enabled: bool,
60
61    /// Camera resolution width
62    #[serde(default = "default_camera_width")]
63    pub width: u32,
64
65    /// Camera resolution height
66    #[serde(default = "default_camera_height")]
67    pub height: u32,
68
69    /// Frames per second
70    #[serde(default = "default_camera_fps")]
71    pub fps: u32,
72}
73
74fn default_camera_enabled() -> bool {
75    true
76}
77
78fn default_camera_width() -> u32 {
79    320
80}
81
82fn default_camera_height() -> u32 {
83    240
84}
85
86fn default_camera_fps() -> u32 {
87    20
88}
89
90impl Default for CameraConfig {
91    fn default() -> Self {
92        Self {
93            enabled: default_camera_enabled(),
94            width: default_camera_width(),
95            height: default_camera_height(),
96            fps: default_camera_fps(),
97        }
98    }
99}
100
101/// Network ports configuration
102#[derive(Debug, Clone, Deserialize, Serialize)]
103#[allow(dead_code)] // Part of public API, may be used by library consumers
104pub struct NetworkingConfig {
105    /// Godot protocol server port
106    #[serde(default = "default_protocol_port")]
107    pub protocol_port: u16,
108
109    /// Godot protocol server bind address
110    #[serde(default = "default_bind_address")]
111    pub protocol_bind: String,
112
113    /// Camera stream port
114    #[serde(default = "default_camera_port")]
115    pub camera_port: u16,
116
117    /// Camera stream bind address
118    #[serde(default = "default_bind_address")]
119    pub camera_bind: String,
120
121    /// WebRTC signaling port
122    #[serde(default = "default_signaling_port")]
123    pub signaling_port: u16,
124
125    /// WebRTC signaling bind address
126    #[serde(default = "default_bind_address")]
127    pub signaling_bind: String,
128}
129
130fn default_protocol_port() -> u16 {
131    11008
132}
133
134fn default_camera_port() -> u16 {
135    11009
136}
137
138fn default_signaling_port() -> u16 {
139    11010
140}
141
142fn default_bind_address() -> String {
143    "0.0.0.0".to_string()
144}
145
146impl Default for NetworkingConfig {
147    fn default() -> Self {
148        Self {
149            protocol_port: default_protocol_port(),
150            protocol_bind: default_bind_address(),
151            camera_port: default_camera_port(),
152            camera_bind: default_bind_address(),
153            signaling_port: default_signaling_port(),
154            signaling_bind: default_bind_address(),
155        }
156    }
157}
158
159/// Runtime simulation configuration
160///
161/// Loaded from configs/simulation/config.json with environment sections.
162/// The config file format is:
163/// ```json
164/// {
165///   "dev": { ... config ... },
166///   "production": { ... config ... }
167/// }
168/// ```
169/// This is distinct from ProjectSimulationConfig which is stored in mecha10.json.
170#[derive(Debug, Clone, Deserialize, Serialize)]
171pub struct SimulationConfig {
172    /// Robot model identifier (e.g., "@mecha10/simulation-models/rover")
173    pub model: String,
174
175    /// Path to model configuration file
176    pub model_config: Option<String>,
177
178    /// Environment identifier (e.g., "@mecha10/simulation-environments/basic_arena")
179    pub environment: String,
180
181    /// Path to environment configuration file
182    pub environment_config: Option<String>,
183
184    /// Godot engine configuration
185    #[serde(default)]
186    pub godot: GodotConfig,
187
188    /// Camera configuration
189    #[serde(default)]
190    pub camera: CameraConfig,
191
192    /// Networking configuration
193    #[serde(default)]
194    pub networking: NetworkingConfig,
195}
196
197/// Environment-aware simulation configuration wrapper
198///
199/// Used for deserializing configs/simulation/config.json which contains
200/// both dev and production sections.
201#[derive(Debug, Clone, Deserialize, Serialize)]
202struct EnvironmentSimulationConfig {
203    dev: Option<SimulationConfig>,
204    production: Option<SimulationConfig>,
205}
206
207impl SimulationConfig {
208    /// Load simulation config with environment and scenario support
209    ///
210    /// Priority (first found wins):
211    /// 1. configs/simulation/{scenario}.json (if scenario specified)
212    /// 2. configs/simulation/config.json (with dev/production sections)
213    /// 3. Error
214    #[allow(dead_code)] // Part of public API, may be used by library consumers
215    pub fn load() -> anyhow::Result<Self> {
216        Self::load_with_profile_and_scenario(None, None)
217    }
218
219    /// Load simulation config with specific profile and/or scenario
220    ///
221    /// Config files are now environment-aware with this format:
222    /// ```json
223    /// {
224    ///   "dev": { ... config ... },
225    ///   "production": { ... config ... }
226    /// }
227    /// ```
228    ///
229    /// # Arguments
230    /// * `profile` - Config profile (dev, production, etc.). If None, uses MECHA10_ENVIRONMENT or "dev"
231    /// * `scenario` - Scenario name (navigation, obstacle_avoidance, etc.). If None, uses MECHA10_SIMULATION_SCENARIO or loads config.json
232    pub fn load_with_profile_and_scenario(profile: Option<&str>, scenario: Option<&str>) -> anyhow::Result<Self> {
233        use std::env;
234
235        // Get profile from parameter, env var, or default
236        let profile = profile
237            .map(String::from)
238            .or_else(|| env::var("MECHA10_CONFIG_PROFILE").ok())
239            .or_else(|| env::var("MECHA10_ENVIRONMENT").ok())
240            .unwrap_or_else(|| "dev".to_string());
241
242        // Get scenario from parameter or env var
243        let scenario = scenario
244            .map(String::from)
245            .or_else(|| env::var("MECHA10_SIMULATION_SCENARIO").ok());
246
247        // Try multiple base paths (for running from different locations)
248        let base_paths = vec![
249            PathBuf::from("."),           // Current directory
250            PathBuf::from("../.."),       // From target/debug/deps
251            PathBuf::from("../../../.."), // From packages/*/target/debug/deps
252        ];
253
254        // Build fallback chain for each base path
255        for base_path in &base_paths {
256            let configs_dir = base_path.join("configs");
257
258            // 1. Try scenario-specific config (if scenario specified)
259            if let Some(scenario_name) = &scenario {
260                let scenario_path = configs_dir.join("simulation").join(format!("{}.json", scenario_name));
261
262                if scenario_path.exists() {
263                    let content = std::fs::read_to_string(&scenario_path)?;
264                    // Scenario configs also use environment-aware format
265                    if let Ok(config) = Self::parse_environment_config(&content, &profile) {
266                        return Ok(config);
267                    }
268                }
269            }
270
271            // 2. Try configs/simulation/config.json (new format with dev/production sections)
272            let config_path = configs_dir.join("simulation").join("config.json");
273
274            if config_path.exists() {
275                let content = std::fs::read_to_string(&config_path)?;
276                if let Ok(config) = Self::parse_environment_config(&content, &profile) {
277                    return Ok(config);
278                }
279            }
280        }
281
282        if let Some(scenario_name) = &scenario {
283            Err(anyhow::anyhow!(
284                "No simulation config found.\nTried:\n  - configs/simulation/{}.json\n  - configs/simulation/config.json\n\nExpected format:\n{{\n  \"dev\": {{ ... }},\n  \"production\": {{ ... }}\n}}",
285                scenario_name
286            ))
287        } else {
288            Err(anyhow::anyhow!(
289                "No simulation config found.\nTried:\n  - configs/simulation/config.json\n\nExpected format:\n{{\n  \"dev\": {{ ... }},\n  \"production\": {{ ... }}\n}}"
290            ))
291        }
292    }
293
294    /// Parse environment-aware config and extract the appropriate environment section
295    fn parse_environment_config(content: &str, profile: &str) -> anyhow::Result<Self> {
296        // Try to parse as environment-aware config first
297        if let Ok(env_config) = serde_json::from_str::<EnvironmentSimulationConfig>(content) {
298            // Select the appropriate environment
299            let config = match profile {
300                "production" | "prod" => env_config.production,
301                _ => env_config.dev, // Default to dev for any other profile
302            };
303
304            return config.ok_or_else(|| {
305                anyhow::anyhow!(
306                    "No '{}' section found in simulation config. Expected format:\n{{\n  \"dev\": {{ ... }},\n  \"production\": {{ ... }}\n}}",
307                    profile
308                )
309            });
310        }
311
312        // Fall back to direct parsing for backwards compatibility
313        serde_json::from_str(content).map_err(|e| anyhow::anyhow!("Failed to parse simulation config: {}", e))
314    }
315
316    /// Apply CLI overrides to config
317    ///
318    /// Allows command-line arguments to override config file values.
319    #[allow(dead_code)] // Part of public API, may be used by library consumers
320    pub fn with_overrides(mut self, overrides: SimulationOverrides) -> Self {
321        if let Some(model) = overrides.model {
322            self.model = model;
323        }
324        if let Some(model_config) = overrides.model_config {
325            self.model_config = Some(model_config);
326        }
327        if let Some(environment) = overrides.environment {
328            self.environment = environment;
329        }
330        if let Some(environment_config) = overrides.environment_config {
331            self.environment_config = Some(environment_config);
332        }
333        if let Some(headless) = overrides.headless {
334            self.godot.headless = headless;
335        }
336        self
337    }
338
339    /// Get the model path for Godot
340    ///
341    /// Resolves @mecha10 references to actual filesystem paths.
342    #[allow(dead_code)] // Part of public API, may be used by library consumers
343    pub fn resolve_model_path(&self, framework_path: &Path) -> PathBuf {
344        if self.model.starts_with("@mecha10/") {
345            let relative = self.model.strip_prefix("@mecha10/").unwrap();
346            framework_path.join("packages").join(relative)
347        } else {
348            PathBuf::from(&self.model)
349        }
350    }
351
352    /// Get the environment path for Godot
353    ///
354    /// Resolves @mecha10 references to actual filesystem paths.
355    #[allow(dead_code)] // Part of public API, may be used by library consumers
356    pub fn resolve_environment_path(&self, framework_path: &Path) -> PathBuf {
357        if self.environment.starts_with("@mecha10/") {
358            let relative = self.environment.strip_prefix("@mecha10/").unwrap();
359            framework_path.join("packages").join(relative)
360        } else {
361            PathBuf::from(&self.environment)
362        }
363    }
364}
365
366/// CLI overrides for simulation config
367#[derive(Debug, Default, Clone)]
368#[allow(dead_code)] // Part of public API, may be used by library consumers
369pub struct SimulationOverrides {
370    pub model: Option<String>,
371    pub model_config: Option<String>,
372    pub environment: Option<String>,
373    pub environment_config: Option<String>,
374    pub headless: Option<bool>,
375}