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}