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}