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