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