mecha10_cli/services/
simulation.rs

1#![allow(dead_code)]
2
3//! Simulation service for Godot scene generation and management
4//!
5//! This service provides operations for generating and managing Godot simulations,
6//! including robot scene generation, environment selection, and validation.
7
8use crate::paths;
9use crate::sim::{EnvironmentSelector, RobotGenerator, RobotProfile};
10use anyhow::{Context, Result};
11use std::path::{Path, PathBuf};
12use std::process::Command;
13
14/// Simulation service for managing Godot simulations
15///
16/// # Examples
17///
18/// ```rust,ignore
19/// use mecha10_cli::services::SimulationService;
20/// use std::path::Path;
21///
22/// # async fn example() -> anyhow::Result<()> {
23/// let service = SimulationService::new();
24///
25/// // Validate Godot installation
26/// service.validate_godot()?;
27///
28/// // Generate simulation
29/// service.generate(
30///     Path::new("mecha10.json"),
31///     Path::new("simulation/godot"),
32///     5, // max environments
33///     0  // min score
34/// )?;
35///
36/// // Run simulation
37/// service.run(
38///     "rover-robot",
39///     "warehouse",
40///     false // not headless
41/// )?;
42/// # Ok(())
43/// # }
44/// ```
45pub struct SimulationService {
46    /// Base path for simulation output
47    base_path: PathBuf,
48}
49
50impl SimulationService {
51    /// Create a new simulation service with default paths
52    pub fn new() -> Self {
53        Self {
54            base_path: PathBuf::from(paths::project::SIMULATION_GODOT_DIR),
55        }
56    }
57
58    /// Create a simulation service with custom base path
59    ///
60    /// # Arguments
61    ///
62    /// * `base_path` - Base directory for simulation output
63    pub fn with_base_path(base_path: impl Into<PathBuf>) -> Self {
64        Self {
65            base_path: base_path.into(),
66        }
67    }
68
69    /// Validate Godot installation
70    ///
71    /// Checks if Godot 4.x is installed and accessible.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if Godot is not found or version is incorrect.
76    pub fn validate_godot(&self) -> Result<GodotInfo> {
77        // Try common Godot executable locations
78        let godot_paths = if cfg!(target_os = "macos") {
79            vec![
80                "/Applications/Godot.app/Contents/MacOS/Godot",
81                "/usr/local/bin/godot",
82                "/opt/homebrew/bin/godot",
83            ]
84        } else if cfg!(target_os = "linux") {
85            vec!["/usr/bin/godot", "/usr/local/bin/godot", "/snap/bin/godot"]
86        } else {
87            vec!["godot.exe", "C:\\Program Files\\Godot\\godot.exe"]
88        };
89
90        // First try 'godot' command from PATH
91        if let Ok(output) = Command::new("godot").arg("--version").output() {
92            if output.status.success() {
93                let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
94                return Ok(GodotInfo {
95                    path: "godot".to_string(),
96                    version,
97                    in_path: true,
98                });
99            }
100        }
101
102        // If not found in PATH, try specific locations
103        for path in &godot_paths {
104            if std::path::Path::new(path).exists() {
105                if let Ok(output) = Command::new(path).arg("--version").output() {
106                    if output.status.success() {
107                        let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
108                        return Ok(GodotInfo {
109                            path: path.to_string(),
110                            version,
111                            in_path: false,
112                        });
113                    }
114                }
115            }
116        }
117
118        Err(anyhow::anyhow!(
119            "Godot not found\n\n\
120            Godot 4.x is required to generate and run simulations.\n\n\
121            Installation instructions:\n\
122            • macOS: brew install godot or download from https://godotengine.org/download/macos\n\
123            • Linux: sudo apt install godot or download from https://godotengine.org/download/linux\n\
124            • Windows: Download from https://godotengine.org/download/windows\n\n\
125            After installation, ensure 'godot' is in your PATH or installed to a standard location."
126        ))
127    }
128
129    /// Generate simulation scenes from project configuration
130    ///
131    /// # Arguments
132    ///
133    /// * `config_path` - Path to mecha10.json
134    /// * `output_path` - Output directory for simulation files
135    /// * `max_envs` - Maximum number of environments to generate
136    /// * `min_score` - Minimum compatibility score (0-100)
137    pub fn generate(
138        &self,
139        config_path: &Path,
140        output_path: &Path,
141        max_envs: usize,
142        min_score: i32,
143    ) -> Result<GenerationResult> {
144        // Validate Godot first
145        let godot_info = self.validate_godot()?;
146
147        // Load robot profile
148        let profile = RobotProfile::from_config_file(config_path)?;
149
150        // Generate robot scene
151        let robot_output = output_path.join("robot.tscn");
152        let robot_generator = RobotGenerator::from_config_file(config_path)?;
153        robot_generator.generate(&robot_output)?;
154
155        // Select environments
156        let selector = EnvironmentSelector::new()?;
157        let matches = selector.select_environments(&profile, max_envs)?;
158
159        let filtered_matches: Vec<_> = matches.into_iter().filter(|m| m.score >= min_score).collect();
160
161        if filtered_matches.is_empty() {
162            return Err(anyhow::anyhow!(
163                "No matching environments found (min score: {})",
164                min_score
165            ));
166        }
167
168        // Count available environments
169        let _catalog_path = PathBuf::from(paths::framework::ROBOT_TASKS_CATALOG);
170        let base_path = PathBuf::from(paths::framework::ROBOT_TASKS_DIR);
171        let mut available_count = 0;
172
173        for env_match in &filtered_matches {
174            let env_path = base_path.join(&env_match.environment.path);
175            if env_path.exists() {
176                available_count += 1;
177            }
178        }
179
180        Ok(GenerationResult {
181            robot_scene: robot_output,
182            environments: filtered_matches.iter().map(|m| m.environment.id.clone()).collect(),
183            available_count,
184            godot_info,
185        })
186    }
187
188    /// Generate only the robot scene
189    ///
190    /// # Arguments
191    ///
192    /// * `config_path` - Path to mecha10.json
193    /// * `output_path` - Output path for robot.tscn
194    pub fn generate_robot(&self, config_path: &Path, output_path: &Path) -> Result<()> {
195        let robot_generator = RobotGenerator::from_config_file(config_path)?;
196        robot_generator.generate(output_path)
197    }
198
199    /// List available environments
200    ///
201    /// # Arguments
202    ///
203    /// * `config_path` - Path to mecha10.json
204    /// * `verbose` - Show detailed information
205    pub fn list_environments(&self, config_path: &Path, verbose: bool) -> Result<Vec<EnvironmentInfo>> {
206        let profile = RobotProfile::from_config_file(config_path)?;
207        let selector = EnvironmentSelector::new()?;
208        let matches = selector.select_environments(&profile, 100)?; // Get all
209
210        let base_path = PathBuf::from(paths::framework::ROBOT_TASKS_DIR);
211        let mut environments = Vec::new();
212
213        for env_match in matches {
214            let env_path = base_path.join(&env_match.environment.path);
215            let available = env_path.exists();
216
217            if verbose || available {
218                environments.push(EnvironmentInfo {
219                    id: env_match.environment.id.clone(),
220                    name: env_match.environment.name.clone(),
221                    description: env_match.environment.description.clone(),
222                    score: env_match.score,
223                    available,
224                });
225            }
226        }
227
228        Ok(environments)
229    }
230
231    /// Validate project configuration for simulation
232    ///
233    /// # Arguments
234    ///
235    /// * `config_path` - Path to mecha10.json
236    pub fn validate_config(&self, config_path: &Path) -> Result<ValidationResult> {
237        let profile = RobotProfile::from_config_file(config_path)?;
238
239        let mut errors = Vec::new();
240        let mut warnings = Vec::new();
241
242        // Validate platform
243        if profile.platform.is_empty() {
244            errors.push("Platform not specified".to_string());
245        }
246
247        // Validate sensors
248        if profile.sensors.is_empty() {
249            warnings.push("No sensors configured".to_string());
250        }
251
252        // Validate task nodes
253        if profile.task_nodes.is_empty() {
254            warnings.push("No task nodes configured".to_string());
255        }
256
257        let is_valid = errors.is_empty();
258
259        Ok(ValidationResult {
260            is_valid,
261            errors,
262            warnings,
263            platform: profile.platform,
264            sensor_count: profile.sensors.len(),
265            node_count: profile.task_nodes.len(),
266        })
267    }
268
269    /// Run a simulation in Godot
270    ///
271    /// # Arguments
272    ///
273    /// * `robot_name` - Name of the robot
274    /// * `env_id` - Environment ID
275    /// * `headless` - Run without UI
276    pub fn run(&self, robot_name: &str, env_id: &str, headless: bool) -> Result<()> {
277        // Validate Godot is available
278        let godot_info = self.validate_godot()?;
279
280        // Determine Godot project path
281        let godot_project_path = if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
282            // Framework dev mode - use monorepo path
283            PathBuf::from(framework_path).join(paths::framework::SIMULATION_GODOT_DIR)
284        } else {
285            // Generated project mode - use relative path
286            PathBuf::from(paths::project::SIMULATION_GODOT_DIR)
287        };
288
289        let mut cmd = Command::new(&godot_info.path);
290        cmd.arg("--path").arg(godot_project_path);
291
292        if headless {
293            cmd.arg("--headless");
294        }
295
296        cmd.arg("--")
297            .arg(format!("--env={}", env_id))
298            .arg(format!("--robot={}", robot_name));
299
300        // Read config paths from mecha10.json if it exists
301        let mecha10_config_path = PathBuf::from(paths::PROJECT_CONFIG);
302        if mecha10_config_path.exists() {
303            if let Ok(config_content) = std::fs::read_to_string(&mecha10_config_path) {
304                if let Ok(config) = serde_json::from_str::<crate::types::ProjectConfig>(&config_content) {
305                    if let Some(sim_config) = config.simulation {
306                        // Pass model config path if specified
307                        if let Some(model_config) = sim_config.model_config {
308                            let model_config_path = PathBuf::from(&model_config);
309                            if model_config_path.exists() {
310                                cmd.arg(format!("--model-config={}", model_config_path.display()));
311                            }
312                        }
313
314                        // Pass environment config path if specified
315                        if let Some(env_config) = sim_config.environment_config {
316                            let env_config_path = PathBuf::from(&env_config);
317                            if env_config_path.exists() {
318                                cmd.arg(format!("--env-config={}", env_config_path.display()));
319                            }
320                        }
321                    }
322                }
323            }
324        } else {
325            // Framework dev mode or no mecha10.json - use path resolution
326            // Use the resolve functions to ensure proper priority (project > framework)
327            if let Ok(env_path) = self.resolve_environment_path(env_id) {
328                let env_config_path = env_path.join("environment.json");
329                if env_config_path.exists() {
330                    cmd.arg(format!("--env-config={}", env_config_path.display()));
331                }
332            }
333
334            if let Ok(model_path) = self.resolve_model_path(robot_name) {
335                let model_config_path = model_path.join("model.json");
336                if model_config_path.exists() {
337                    cmd.arg(format!("--model-config={}", model_config_path.display()));
338                }
339            }
340        }
341
342        let status = cmd.status().context("Failed to launch Godot")?;
343
344        if !status.success() {
345            return Err(anyhow::anyhow!("Godot exited with error code: {:?}", status.code()));
346        }
347
348        Ok(())
349    }
350
351    /// Check if simulation is properly set up
352    pub fn is_setup(&self) -> bool {
353        self.base_path.exists() && self.base_path.join("robot.tscn").exists()
354    }
355
356    /// Get the base path for simulations
357    pub fn base_path(&self) -> &Path {
358        &self.base_path
359    }
360
361    /// Resolve a model path from package namespace or plain name
362    ///
363    /// Supports multiple formats:
364    /// - Package namespace: "@mecha10/simulation-models/rover"
365    /// - Plain name: "rover"
366    ///
367    /// Resolution order:
368    /// 1. Framework (monorepo): packages/simulation/models/{name}/
369    /// 2. Project-local: simulation/models/{name}/
370    ///
371    /// # Arguments
372    ///
373    /// * `model_ref` - Model reference (package namespace or plain name)
374    ///
375    /// # Returns
376    ///
377    /// Absolute path to the model directory, or error if not found
378    pub fn resolve_model_path(&self, model_ref: &str) -> Result<PathBuf> {
379        // Strip package namespace if present
380        let model_name = model_ref
381            .strip_prefix("@mecha10/simulation-models/")
382            .unwrap_or(model_ref);
383
384        // Try project-local path FIRST (project customizations take precedence)
385        let project_model_path = PathBuf::from(paths::project::SIMULATION_MODELS_DIR).join(model_name);
386        if project_model_path.exists() {
387            return Ok(project_model_path);
388        }
389
390        // Try MECHA10_FRAMEWORK_PATH environment variable
391        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
392            let framework_model_path = PathBuf::from(framework_path)
393                .join(paths::framework::SIMULATION_MODELS_DIR)
394                .join(model_name);
395            if framework_model_path.exists() {
396                return Ok(framework_model_path);
397            }
398        }
399
400        // Try compile-time path from CLI package (packages/cli -> packages/simulation)
401        let cli_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
402        if let Some(packages_dir) = cli_manifest_dir.parent() {
403            let bundled_model_path = packages_dir.join("simulation/models").join(model_name);
404            if bundled_model_path.exists() {
405                return Ok(bundled_model_path);
406            }
407        }
408
409        // Try cached assets from GitHub releases (~/.mecha10/simulation/current/models/)
410        let assets_service = crate::services::SimulationAssetsService::new();
411        if let Some(models_path) = assets_service.models_path() {
412            let cached_model_path = models_path.join(model_name);
413            if cached_model_path.exists() {
414                return Ok(cached_model_path);
415            }
416        }
417
418        Err(anyhow::anyhow!(
419            "Model not found: {}\n\nChecked:\n  • Project: simulation/models/{}\n  • Framework: packages/simulation/models/{}\n  • Cached: ~/.mecha10/simulation/current/models/{}",
420            model_ref,
421            model_name,
422            model_name,
423            model_name
424        ))
425    }
426
427    /// Resolve an environment path from package namespace or plain name
428    ///
429    /// Supports multiple formats:
430    /// - Package namespace: "@mecha10/simulation-environments/basic_arena"
431    /// - Plain name: "basic_arena"
432    ///
433    /// Resolution order:
434    /// 1. Project-local: simulation/environments/{name}/ (project customizations take precedence)
435    /// 2. Framework (monorepo): packages/simulation/environments/{name}/
436    ///
437    /// # Arguments
438    ///
439    /// * `env_ref` - Environment reference (package namespace or plain name)
440    ///
441    /// # Returns
442    ///
443    /// Absolute path to the environment directory, or error if not found
444    pub fn resolve_environment_path(&self, env_ref: &str) -> Result<PathBuf> {
445        // Strip package namespace if present
446        let env_name = env_ref
447            .strip_prefix("@mecha10/simulation-environments/")
448            .unwrap_or(env_ref);
449
450        // Try project-local path FIRST (project customizations take precedence)
451        let project_env_path = PathBuf::from(paths::project::SIMULATION_ENVIRONMENTS_DIR).join(env_name);
452        if project_env_path.exists() {
453            return Ok(project_env_path);
454        }
455
456        // Try MECHA10_FRAMEWORK_PATH environment variable
457        if let Ok(framework_path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
458            let framework_env_path = PathBuf::from(framework_path)
459                .join(paths::framework::SIMULATION_ENVIRONMENTS_DIR)
460                .join(env_name);
461            if framework_env_path.exists() {
462                return Ok(framework_env_path);
463            }
464        }
465
466        // Try compile-time path from CLI package (packages/cli -> packages/simulation)
467        let cli_manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
468        if let Some(packages_dir) = cli_manifest_dir.parent() {
469            let bundled_env_path = packages_dir.join("simulation/environments").join(env_name);
470            if bundled_env_path.exists() {
471                return Ok(bundled_env_path);
472            }
473        }
474
475        // Try cached assets from GitHub releases (~/.mecha10/simulation/current/environments/)
476        let assets_service = crate::services::SimulationAssetsService::new();
477        if let Some(envs_path) = assets_service.environments_path() {
478            let cached_env_path = envs_path.join(env_name);
479            if cached_env_path.exists() {
480                return Ok(cached_env_path);
481            }
482        }
483
484        Err(anyhow::anyhow!(
485            "Environment not found: {}\n\nChecked:\n  • Project: simulation/environments/{}\n  • Framework: packages/simulation/environments/{}\n  • Cached: ~/.mecha10/simulation/current/environments/{}",
486            env_ref,
487            env_name,
488            env_name,
489            env_name
490        ))
491    }
492}
493
494impl Default for SimulationService {
495    fn default() -> Self {
496        Self::new()
497    }
498}
499
500/// Information about Godot installation
501#[derive(Debug, Clone)]
502pub struct GodotInfo {
503    pub path: String,
504    pub version: String,
505    pub in_path: bool,
506}
507
508/// Result of simulation generation
509#[derive(Debug)]
510pub struct GenerationResult {
511    pub robot_scene: PathBuf,
512    pub environments: Vec<String>,
513    pub available_count: usize,
514    pub godot_info: GodotInfo,
515}
516
517/// Information about an environment
518#[derive(Debug, Clone)]
519pub struct EnvironmentInfo {
520    pub id: String,
521    pub name: String,
522    pub description: String,
523    pub score: i32,
524    pub available: bool,
525}
526
527/// Result of configuration validation
528#[derive(Debug)]
529pub struct ValidationResult {
530    pub is_valid: bool,
531    pub errors: Vec<String>,
532    pub warnings: Vec<String>,
533    pub platform: String,
534    pub sensor_count: usize,
535    pub node_count: usize,
536}