mecha10_cli/services/dev/
mod.rs

1//! Development mode service
2//!
3//! Service for managing development mode operations including Redis flushing,
4//! port checking, and simulation launching.
5
6use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::net::TcpListener;
9use std::path::{Path, PathBuf};
10
11use crate::types::SimulationConfig;
12
13/// Resolved simulation paths from configuration
14#[derive(Debug, Clone)]
15pub struct SimulationPaths {
16    /// Path to the robot model directory
17    pub model_path: PathBuf,
18    /// Path to the environment directory
19    pub environment_path: PathBuf,
20    /// Optional path to model configuration override
21    pub model_config_path: Option<PathBuf>,
22    /// Optional path to environment configuration override
23    pub environment_config_path: Option<PathBuf>,
24    /// Run Godot in headless mode
25    pub headless: bool,
26    /// Networking configuration
27    pub networking: crate::types::simulation::NetworkingConfig,
28}
29
30/// Development mode service
31///
32/// Provides business logic for development mode operations:
33/// - Port availability checking
34/// - Redis state management
35/// - Simulation path resolution
36/// - Environment variable setup
37///
38/// # Examples
39///
40/// ```rust,ignore
41/// use mecha10_cli::services::DevService;
42///
43/// # async fn example() -> anyhow::Result<()> {
44/// let service = DevService::new();
45///
46/// // Check if port is available
47/// if service.is_port_available(11008)? {
48///     println!("Port 11008 is available");
49/// }
50///
51/// // Flush Redis for clean dev session
52/// service.flush_redis(&mut redis_conn).await?;
53/// # Ok(())
54/// # }
55/// ```
56pub struct DevService {
57    #[allow(dead_code)]
58    redis_url: String,
59}
60
61impl DevService {
62    /// Create a new dev service
63    ///
64    /// # Arguments
65    ///
66    /// * `redis_url` - Redis connection URL
67    pub fn new(redis_url: String) -> Self {
68        Self { redis_url }
69    }
70
71    /// Load project information from context
72    ///
73    /// # Returns
74    ///
75    /// Tuple of (project_name, project_version, project_config)
76    pub async fn load_project_info(
77        ctx: &mut crate::context::CliContext,
78    ) -> Result<(String, String, crate::types::ProjectConfig)> {
79        let project = ctx.project()?;
80        let name = project.name()?.to_string();
81        let version = project.version()?.to_string();
82        let config = ctx.load_project_config().await?;
83        Ok((name, version, config))
84    }
85
86    /// Resolve simulation paths from configuration
87    ///
88    /// Loads simulation config from configs/{profile}/simulation/{scenario}.json
89    /// based on mecha10.json simulation settings.
90    ///
91    /// # Arguments
92    ///
93    /// * `ctx` - CLI context
94    /// * `config` - Project configuration
95    ///
96    /// # Returns
97    ///
98    /// Optional SimulationPaths struct with model and environment paths
99    pub fn resolve_simulation_paths(
100        ctx: &mut crate::context::CliContext,
101        config: &crate::types::ProjectConfig,
102    ) -> Result<Option<SimulationPaths>> {
103        if let Some(sim_config) = &config.simulation {
104            // Check if simulation is enabled
105            if !sim_config.enabled {
106                return Ok(None);
107            }
108
109            let sim = ctx.simulation();
110
111            // Try new schema first (config_profile + scenario)
112            if let Ok(runtime_config) = SimulationConfig::load_with_profile_and_scenario(
113                Some(&sim_config.config_profile),
114                sim_config.scenario.as_deref(),
115            ) {
116                // Load model and environment from runtime config
117                let model_path = sim.resolve_model_path(&runtime_config.model)?;
118                let env_path = sim.resolve_environment_path(&runtime_config.environment)?;
119
120                // Get config paths if specified and convert to absolute paths
121                let model_config_path = sim_config.model_config.as_ref().map(|p| {
122                    let path = PathBuf::from(p);
123                    if path.is_absolute() {
124                        path
125                    } else {
126                        std::env::current_dir().unwrap_or_default().join(path)
127                    }
128                });
129                let env_config_path = sim_config.environment_config.as_ref().map(|p| {
130                    let path = PathBuf::from(p);
131                    if path.is_absolute() {
132                        path
133                    } else {
134                        std::env::current_dir().unwrap_or_default().join(path)
135                    }
136                });
137
138                return Ok(Some(SimulationPaths {
139                    model_path,
140                    environment_path: env_path,
141                    model_config_path,
142                    environment_config_path: env_config_path,
143                    headless: runtime_config.godot.headless,
144                    networking: runtime_config.networking.clone(),
145                }));
146            }
147
148            // Fall back to legacy schema for backwards compatibility
149            if let Some(model) = &sim_config.model {
150                let model_path = sim.resolve_model_path(model)?;
151                let env_name = sim_config
152                    .environment
153                    .as_deref()
154                    .unwrap_or("@mecha10/simulation-environments/basic_arena");
155                let env_path = sim.resolve_environment_path(env_name)?;
156
157                // Get config paths if specified and convert to absolute paths
158                let model_config_path = sim_config.model_config.as_ref().map(|p| {
159                    let path = PathBuf::from(p);
160                    if path.is_absolute() {
161                        path
162                    } else {
163                        std::env::current_dir().unwrap_or_default().join(path)
164                    }
165                });
166                let env_config_path = sim_config.environment_config.as_ref().map(|p| {
167                    let path = PathBuf::from(p);
168                    if path.is_absolute() {
169                        path
170                    } else {
171                        std::env::current_dir().unwrap_or_default().join(path)
172                    }
173                });
174
175                return Ok(Some(SimulationPaths {
176                    model_path,
177                    environment_path: env_path,
178                    model_config_path,
179                    environment_config_path: env_config_path,
180                    headless: false, // Legacy mode defaults to non-headless
181                    networking: crate::types::simulation::NetworkingConfig::default(),
182                }));
183            }
184
185            // No valid config found
186            Err(anyhow::anyhow!(
187                "Simulation enabled but no valid configuration found.\n\
188                 Either:\n\
189                 1. Set config_profile in mecha10.json and create configs/{{profile}}/simulation/config.json\n\
190                 2. Use legacy format with model/environment fields in mecha10.json"
191            ))
192        } else {
193            Ok(None)
194        }
195    }
196
197    /// Check if a port is available
198    ///
199    /// # Arguments
200    ///
201    /// * `port` - Port number to check
202    ///
203    /// # Returns
204    ///
205    /// `Ok(true)` if port is available, `Ok(false)` if in use
206    pub fn is_port_available(&self, port: u16) -> Result<bool> {
207        Ok(TcpListener::bind(("127.0.0.1", port)).is_ok())
208    }
209
210    /// Check if a port is in use
211    ///
212    /// # Arguments
213    ///
214    /// * `port` - Port number to check
215    ///
216    /// # Returns
217    ///
218    /// `Ok(true)` if port is in use, `Ok(false)` if available
219    pub fn is_port_in_use(&self, port: u16) -> Result<bool> {
220        Ok(!self.is_port_available(port)?)
221    }
222
223    /// Flush Redis database for clean dev session
224    ///
225    /// # Arguments
226    ///
227    /// * `conn` - Mutable reference to Redis connection (generic type)
228    ///
229    /// # Errors
230    ///
231    /// Returns error if Redis FLUSHALL command fails
232    pub async fn flush_redis<C>(&self, conn: &mut C) -> Result<()>
233    where
234        C: redis::aio::ConnectionLike,
235    {
236        redis::cmd("FLUSHALL")
237            .query_async::<()>(conn)
238            .await
239            .context("Failed to flush Redis")?;
240        Ok(())
241    }
242
243    /// Get default environment variables for dev mode
244    ///
245    /// # Returns
246    ///
247    /// HashMap of environment variable key-value pairs
248    ///
249    /// Note: This method is no longer used by CLI (replaced by node-runner in Phase 2).
250    /// Kept for testing purposes.
251    #[allow(dead_code)]
252    pub fn get_default_env_vars(&self) -> HashMap<String, String> {
253        let mut env = HashMap::new();
254        env.insert("REDIS_URL".to_string(), self.redis_url.clone());
255        env.insert("RUST_LOG".to_string(), "info".to_string());
256        env
257    }
258
259    /// Build Godot launch command arguments
260    ///
261    /// # Arguments
262    ///
263    /// * `godot_project_path` - Path to Godot project directory
264    /// * `model_path` - Path to robot model directory
265    /// * `env_path` - Path to environment directory
266    /// * `headless` - Run Godot in headless mode (no GUI)
267    ///
268    /// # Returns
269    ///
270    /// Vector of command-line arguments for Godot
271    #[allow(clippy::too_many_arguments)]
272    pub fn build_godot_args(
273        &self,
274        godot_project_path: &Path,
275        model_path: &Path,
276        env_path: &Path,
277        model_config_path: Option<&PathBuf>,
278        env_config_path: Option<&PathBuf>,
279        headless: bool,
280        networking: Option<&crate::types::simulation::NetworkingConfig>,
281    ) -> Vec<String> {
282        // Extract environment name from path (e.g., "basic_arena")
283        let env_name = env_path.file_name().and_then(|n| n.to_str()).unwrap_or("environment");
284
285        // Extract model name from path (e.g., "rover")
286        let model_name = model_path.file_name().and_then(|n| n.to_str()).unwrap_or("robot");
287
288        // Build res:// paths for resource packs
289        // Resource packs export with full paths: res://packages/simulation/
290        let env_scene_path = format!("res://packages/simulation/environments/{}/{}.tscn", env_name, env_name);
291        let model_scene_path = format!("res://packages/simulation/models/{}/robot.tscn", model_name);
292
293        // Don't pass scene path - let Godot use run/main_scene from project.godot
294        // This ensures consistent scene loading behavior
295        let mut args = vec!["--path".to_string(), godot_project_path.to_string_lossy().to_string()];
296
297        // Add headless flag before -- separator if enabled
298        if headless {
299            args.push("--headless".to_string());
300        }
301
302        args.push("--".to_string());
303        args.push(format!("--env-path={}", env_scene_path));
304        args.push(format!("--model-path={}", model_scene_path));
305
306        // Use provided config paths if available
307        if let Some(model_config) = model_config_path {
308            args.push(format!("--model-config={}", model_config.display()));
309        }
310
311        if let Some(env_config) = env_config_path {
312            args.push(format!("--env-config={}", env_config.display()));
313        }
314
315        // Add networking config if provided
316        if let Some(net) = networking {
317            args.push(format!("--protocol-port={}", net.protocol_port));
318            args.push(format!("--protocol-bind={}", net.protocol_bind));
319            args.push(format!("--camera-port={}", net.camera_port));
320            args.push(format!("--camera-bind={}", net.camera_bind));
321            args.push(format!("--signaling-port={}", net.signaling_port));
322            args.push(format!("--signaling-bind={}", net.signaling_bind));
323        }
324
325        args
326    }
327
328    /// Determine Godot project path based on environment
329    ///
330    /// Resolution order:
331    /// 1. MECHA10_FRAMEWORK_PATH environment variable (if set)
332    /// 2. Compile-time path from CLI package location (../simulation/godot-project)
333    /// 3. Downloaded assets cache (~/.mecha10/simulation/current/godot-project)
334    /// 4. Project-local path (simulation/godot) as fallback
335    ///
336    /// # Returns
337    ///
338    /// PathBuf to Godot project directory
339    pub fn get_godot_project_path(&self) -> PathBuf {
340        // 1. Check MECHA10_FRAMEWORK_PATH environment variable first
341        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
342            return PathBuf::from(framework_path).join("packages/simulation/godot-project");
343        }
344
345        // 2. Use compile-time path from CLI package (packages/cli -> packages/simulation)
346        // This works for installed CLI from the monorepo
347        let cli_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
348        let godot_project_path = cli_manifest_dir
349            .parent() // packages/
350            .map(|p| p.join("simulation/godot-project"))
351            .unwrap_or_else(|| PathBuf::from("simulation/godot"));
352
353        if godot_project_path.exists() {
354            return godot_project_path;
355        }
356
357        // 3. Check downloaded assets cache
358        let assets_service = crate::services::SimulationAssetsService::new();
359        if let Some(godot_path) = assets_service.godot_project_path() {
360            return godot_path;
361        }
362
363        // 4. Fall back to project-local path
364        PathBuf::from("simulation/godot")
365    }
366}
367
368impl Default for DevService {
369    fn default() -> Self {
370        Self::new("redis://localhost:6379".to_string())
371    }
372}