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