mecha10_cli/services/
init_service.rs

1//! Init service for project initialization
2//!
3//! Handles business logic for creating new Mecha10 projects including:
4//! - Directory structure creation
5//! - Component addition
6//! - Framework path detection
7
8use crate::paths;
9use anyhow::Result;
10use std::path::Path;
11
12/// Service for initializing new Mecha10 projects
13///
14/// # Examples
15///
16/// ```rust,ignore
17/// use mecha10_cli::services::InitService;
18///
19/// let service = InitService::new();
20///
21/// // Create project structure
22/// service.create_project_directories(&project_path).await?;
23///
24/// // Add example nodes
25/// service.add_example_node(&project_path, "speaker").await?;
26/// ```
27pub struct InitService;
28
29impl InitService {
30    /// Create a new InitService
31    pub fn new() -> Self {
32        Self
33    }
34
35    /// Create project directory structure
36    ///
37    /// Creates all the necessary directories for a new Mecha10 project:
38    /// - nodes/ - Custom node implementations
39    /// - drivers/ - Hardware driver implementations
40    /// - types/ - Shared type definitions
41    /// - behaviors/ - Behavior tree definitions
42    /// - config/ - Configuration files
43    /// - logs/ - Runtime logs
44    /// - simulation/ - Simulation environments
45    /// - simulation/models/ - Robot physical models (for Godot)
46    /// - assets/ - Static assets for simulation and visualization
47    /// - assets/images/ - Image assets for simulation textures, UI, etc.
48    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
49        let dirs = vec![
50            paths::project::NODES_DIR,
51            paths::project::DRIVERS_DIR,
52            paths::project::TYPES_DIR,
53            paths::project::BEHAVIORS_DIR,
54            paths::project::LOGS_DIR,
55            paths::project::SIMULATION_DIR,
56            paths::project::SIMULATION_MODELS_DIR,
57            paths::project::ASSETS_DIR,
58            paths::project::ASSETS_IMAGES_DIR,
59            // Config directories (nodes grouped by scope: @mecha10/, @local/)
60            paths::config::NODES_DIR,
61            paths::config::SIMULATION_DIR,
62        ];
63
64        for dir in dirs {
65            let dir_path = path.join(dir);
66            tokio::fs::create_dir_all(&dir_path).await?;
67
68            // Create .gitkeep to ensure empty directories are tracked
69            tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
70        }
71
72        Ok(())
73    }
74
75    /// Add an example node from the catalog to the project
76    ///
77    /// This method:
78    /// 1. Searches the component catalog for the component
79    /// 2. Generates component files from templates (if any)
80    /// 3. Updates Cargo.toml (workspace members or dependencies)
81    /// 4. Updates mecha10.json configuration
82    ///
83    /// # Arguments
84    ///
85    /// * `project_root` - Root path of the project
86    /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
87    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
88        use crate::component_catalog::ComponentCatalog;
89
90        // Load catalog and find component
91        let catalog = ComponentCatalog::new();
92        let components = catalog.search(component_name);
93
94        if components.is_empty() {
95            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
96        }
97
98        let component = &components[0];
99
100        // Generate component files from catalog templates (if any)
101        // For monorepo nodes like speaker/listener, files vec is empty
102        for file_template in &component.files {
103            let file_path = project_root.join(&file_template.path);
104
105            // Create parent directories
106            if let Some(parent) = file_path.parent() {
107                tokio::fs::create_dir_all(parent).await?;
108            }
109
110            // Write file content
111            tokio::fs::write(&file_path, &file_template.content).await?;
112        }
113
114        // Only update workspace Cargo.toml if files were generated
115        // For monorepo nodes, skip workspace member updates since there's no wrapper directory
116        if !component.files.is_empty() {
117            self.add_workspace_member(project_root, component_name).await?;
118        }
119
120        // For monorepo nodes, add as dependency to Cargo.toml
121        let is_monorepo_node = component.files.is_empty();
122        if is_monorepo_node {
123            self.add_dependency(project_root, component).await?;
124        }
125
126        // Update mecha10.json configuration
127        self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
128            .await?;
129
130        // Copy node configs from framework package to project
131        self.copy_node_configs(project_root, component_name).await?;
132
133        Ok(())
134    }
135
136    /// Add a workspace member to Cargo.toml
137    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
138        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
139        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
140
141        let new_member = format!("nodes/{}", component_name);
142        let updated_content = if content.contains("members = []") {
143            // Replace empty array
144            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
145        } else if let Some(start) = content.find("members = [") {
146            // Find the closing bracket
147            if let Some(end) = content[start..].find(']') {
148                let insert_pos = start + end;
149                // Check if there are existing members
150                let members_section = &content[start..insert_pos];
151                if members_section.contains('\"') {
152                    // Has existing members, add comma and new member
153                    format!(
154                        "{}\n    \"{}\",{}",
155                        &content[..insert_pos],
156                        new_member,
157                        &content[insert_pos..]
158                    )
159                } else {
160                    // Empty but not [], add member
161                    format!(
162                        "{}\n    \"{}\",\n{}",
163                        &content[..insert_pos],
164                        new_member,
165                        &content[insert_pos..]
166                    )
167                }
168            } else {
169                content
170            }
171        } else {
172            content
173        };
174
175        tokio::fs::write(&cargo_toml_path, updated_content).await?;
176        Ok(())
177    }
178
179    /// Add a dependency to Cargo.toml
180    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
181        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
182        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
183
184        // Get the actual package name from the component's cargo dependencies
185        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
186            dep.name.clone()
187        } else {
188            // Fallback to old behavior if no cargo dependencies specified
189            format!("mecha10-nodes-{}", component.id)
190        };
191
192        // Add dependency after mecha10-core if not already present
193        if !content.contains(&package_name) {
194            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
195                // Find end of line
196                if let Some(newline_pos) = content[pos..].find('\n') {
197                    let insert_pos = pos + newline_pos + 1;
198                    format!(
199                        "{}{} = \"0.1\"\n{}",
200                        &content[..insert_pos],
201                        package_name,
202                        &content[insert_pos..]
203                    )
204                } else {
205                    content
206                }
207            } else {
208                content
209            };
210
211            tokio::fs::write(&cargo_toml_path, updated_content).await?;
212        }
213
214        Ok(())
215    }
216
217    /// Add node entry to mecha10.json
218    async fn add_node_to_config(
219        &self,
220        project_root: &Path,
221        component_name: &str,
222        _component: &crate::component_catalog::Component,
223        is_monorepo_node: bool,
224    ) -> Result<()> {
225        use crate::services::ConfigService;
226
227        let config_path = project_root.join(paths::PROJECT_CONFIG);
228        let mut config = ConfigService::load_from(&config_path).await?;
229
230        // Create node identifier based on source
231        let node_identifier = if is_monorepo_node {
232            // Framework node: @mecha10/node-name
233            format!("@mecha10/{}", component_name)
234        } else {
235            // Project node: @local/node-name
236            format!("@local/{}", component_name)
237        };
238
239        // Add node using the new format
240        config.nodes.add_node(&node_identifier);
241
242        // Save updated config
243        let config_json = serde_json::to_string_pretty(&config)?;
244        tokio::fs::write(&config_path, config_json).await?;
245
246        Ok(())
247    }
248
249    /// Copy behavior tree templates from framework to project
250    ///
251    /// Copies seed templates from `packages/behavior-runtime/seeds/` to
252    /// `{project}/behaviors/` for use with the behavior tree system.
253    ///
254    /// # Arguments
255    ///
256    /// * `project_root` - Root path of the project
257    pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
258        // Detect framework path to find source templates
259        // Skip silently if framework path not available (standalone CLI mode)
260        let framework_path = match self.detect_framework_path() {
261            Ok(path) => path,
262            Err(_) => return Ok(()), // Not in dev mode, skip copying
263        };
264        let source_templates_path = std::path::PathBuf::from(&framework_path)
265            .join("packages")
266            .join("behavior-runtime")
267            .join("seeds");
268
269        // Check if source templates directory exists
270        if !source_templates_path.exists() {
271            // No templates to copy - this is okay
272            return Ok(());
273        }
274
275        // Create destination directory
276        let dest_templates_path = project_root.join("behaviors");
277        tokio::fs::create_dir_all(&dest_templates_path).await?;
278
279        // Copy all .json templates
280        let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
281        while let Some(entry) = entries.next_entry().await? {
282            let path = entry.path();
283            if path.extension().and_then(|s| s.to_str()) == Some("json") {
284                if let Some(filename) = path.file_name() {
285                    let dest_file = dest_templates_path.join(filename);
286                    tokio::fs::copy(&path, &dest_file).await?;
287                }
288            }
289        }
290
291        Ok(())
292    }
293
294    /// Copy simulation configuration file from framework to project
295    ///
296    /// Copies `packages/simulation/configs/config.json` to
297    /// `{project}/configs/simulation/config.json`
298    ///
299    /// The config file contains both dev and production sections:
300    /// ```json
301    /// {
302    ///   "dev": { ... },
303    ///   "production": { ... }
304    /// }
305    /// ```
306    ///
307    /// # Arguments
308    ///
309    /// * `project_root` - Root path of the project
310    pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
311        // Detect framework path to find source config
312        // Skip silently if framework path not available (standalone CLI mode)
313        let framework_path = match self.detect_framework_path() {
314            Ok(path) => path,
315            Err(_) => return Ok(()), // Not in dev mode, skip copying
316        };
317        let source_config = std::path::PathBuf::from(&framework_path)
318            .join("packages")
319            .join("simulation")
320            .join("configs")
321            .join("config.json");
322
323        // Check if source config exists
324        if !source_config.exists() {
325            return Ok(());
326        }
327
328        // Create destination directory and copy file
329        let dest_dir = project_root.join("configs").join("simulation");
330        tokio::fs::create_dir_all(&dest_dir).await?;
331
332        let dest_file = dest_dir.join("config.json");
333        tokio::fs::copy(&source_config, &dest_file).await?;
334
335        Ok(())
336    }
337
338    /// Copy simulation asset files from framework to project
339    ///
340    /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
341    /// to `{project}/assets/images/`
342    ///
343    /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
344    ///
345    /// # Arguments
346    ///
347    /// * `project_root` - Root path of the project
348    pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
349        // Detect framework path to find source assets
350        // Skip silently if framework path not available (standalone CLI mode)
351        let framework_path = match self.detect_framework_path() {
352            Ok(path) => path,
353            Err(_) => return Ok(()), // Not in dev mode, skip copying
354        };
355        let source_assets_path = std::path::PathBuf::from(&framework_path)
356            .join("packages")
357            .join("simulation")
358            .join("environments")
359            .join("basic_arena")
360            .join("assets")
361            .join("images");
362
363        // Check if source assets directory exists
364        if !source_assets_path.exists() {
365            // No assets to copy - this is okay
366            return Ok(());
367        }
368
369        // Create destination directory
370        let dest_assets_path = project_root.join("assets").join("images");
371        tokio::fs::create_dir_all(&dest_assets_path).await?;
372
373        // Copy image files
374        let images = vec!["aiko.jpg", "phoebe.jpg"];
375        for image in images {
376            let source_file = source_assets_path.join(image);
377            if source_file.exists() {
378                let dest_file = dest_assets_path.join(image);
379                tokio::fs::copy(&source_file, &dest_file).await?;
380            }
381        }
382
383        Ok(())
384    }
385
386    /// Copy node configuration file from framework package to project
387    ///
388    /// Copies `packages/nodes/{node}/configs/config.json` to
389    /// `{project}/configs/nodes/@mecha10/{node}/config.json`
390    ///
391    /// The config file contains both dev and production sections:
392    /// ```json
393    /// {
394    ///   "dev": { ... },
395    ///   "production": { ... }
396    /// }
397    /// ```
398    ///
399    /// # Arguments
400    ///
401    /// * `project_root` - Root path of the project
402    /// * `component_name` - Name of the node (e.g., "speaker", "listener")
403    async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
404        // Detect framework path to find source config
405        // Skip silently if framework path not available (standalone CLI mode)
406        let framework_path = match self.detect_framework_path() {
407            Ok(path) => path,
408            Err(_) => return Ok(()), // Not in dev mode, skip copying
409        };
410        let source_config = std::path::PathBuf::from(&framework_path)
411            .join("packages")
412            .join("nodes")
413            .join(component_name)
414            .join("configs")
415            .join("config.json");
416
417        // Check if source config exists
418        if !source_config.exists() {
419            return Ok(());
420        }
421
422        // Config path: configs/nodes/@mecha10/{node}/config.json
423        let dest_dir = project_root
424            .join("configs")
425            .join("nodes")
426            .join("@mecha10")
427            .join(component_name);
428
429        // Create destination directory and copy file
430        tokio::fs::create_dir_all(&dest_dir).await?;
431
432        let dest_file = dest_dir.join("config.json");
433        tokio::fs::copy(&source_config, &dest_file).await?;
434
435        Ok(())
436    }
437
438    /// Copy all default node configuration files from framework to project
439    ///
440    /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
441    /// to `{project}/configs/nodes/@mecha10/{node}/config.json`
442    ///
443    /// This is called during `mecha10 init` to set up node configs.
444    /// Skips silently if framework path is not available (standalone CLI mode).
445    ///
446    /// # Arguments
447    ///
448    /// * `project_root` - Root path of the project
449    pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
450        // Default nodes that should have configs copied
451        let default_nodes = [
452            "behavior-executor",
453            "image-classifier",
454            "listener",
455            "llm-command",
456            "object-detector",
457            "simulation-bridge",
458            "speaker",
459            "teleop",
460            "websocket-bridge",
461        ];
462
463        for node in default_nodes {
464            self.copy_node_configs(project_root, node).await?;
465        }
466
467        Ok(())
468    }
469
470    /// Detect the framework path for development mode
471    ///
472    /// Searches for the mecha10-monorepo in the following order:
473    /// 1. MECHA10_FRAMEWORK_PATH environment variable
474    /// 2. Walking up the directory tree from current directory
475    ///
476    /// This is used when creating projects in development mode to link
477    /// to the local framework instead of published crates.
478    ///
479    /// # Returns
480    ///
481    /// The absolute path to the framework root, or an error if not found.
482    pub fn detect_framework_path(&self) -> Result<String> {
483        // First check if MECHA10_FRAMEWORK_PATH is set
484        if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
485            let path_buf = std::path::PathBuf::from(&path);
486
487            // Expand ~ to home directory if present
488            let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
489                if let Ok(home) = std::env::var("HOME") {
490                    std::path::PathBuf::from(home).join(stripped)
491                } else {
492                    path_buf.clone()
493                }
494            } else {
495                path_buf.clone()
496            };
497
498            // Validate that this is actually the framework directory
499            let core_path = expanded_path.join("packages").join("core");
500            if core_path.exists() {
501                return Ok(expanded_path.to_string_lossy().to_string());
502            } else {
503                return Err(anyhow::anyhow!(
504                    "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
505                     Expected to find packages/core directory at that location.",
506                    path
507                ));
508            }
509        }
510
511        // Fall back to walking up from current directory
512        let mut current = std::env::current_dir()?;
513
514        loop {
515            // Check if this directory contains packages/core (framework marker)
516            let core_path = current.join("packages").join("core");
517            if core_path.exists() {
518                // Found the framework root
519                return Ok(current.to_string_lossy().to_string());
520            }
521
522            // Check if we reached the filesystem root
523            if !current.pop() {
524                return Err(anyhow::anyhow!(
525                    "Could not detect framework path. Either:\n\
526                     1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
527                     2. Run from within the mecha10-monorepo directory\n\n\
528                     Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
529                ));
530            }
531        }
532    }
533}
534
535impl Default for InitService {
536    fn default() -> Self {
537        Self::new()
538    }
539}