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    /// - embedded/models/ - ML model configurations and weights
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            "nodes",
51            "drivers",
52            "types",
53            "behaviors",
54            "logs",
55            "simulation",
56            "simulation/models",
57            "embedded/models",
58            "assets",
59            "assets/images",
60            // V2 config directories
61            "configs/dev/nodes",
62            "configs/dev/simulation",
63            "configs/production/nodes",
64            "configs/production/simulation",
65        ];
66
67        for dir in dirs {
68            let dir_path = path.join(dir);
69            tokio::fs::create_dir_all(&dir_path).await?;
70
71            // Create .gitkeep to ensure empty directories are tracked
72            tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
73        }
74
75        Ok(())
76    }
77
78    /// Add an example node from the catalog to the project
79    ///
80    /// This method:
81    /// 1. Searches the component catalog for the component
82    /// 2. Generates component files from templates (if any)
83    /// 3. Updates Cargo.toml (workspace members or dependencies)
84    /// 4. Updates mecha10.json configuration
85    ///
86    /// # Arguments
87    ///
88    /// * `project_root` - Root path of the project
89    /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
90    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
91        use crate::component_catalog::ComponentCatalog;
92
93        // Load catalog and find component
94        let catalog = ComponentCatalog::new();
95        let components = catalog.search(component_name);
96
97        if components.is_empty() {
98            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
99        }
100
101        let component = &components[0];
102
103        // Generate component files from catalog templates (if any)
104        // For monorepo nodes like speaker/listener, files vec is empty
105        for file_template in &component.files {
106            let file_path = project_root.join(&file_template.path);
107
108            // Create parent directories
109            if let Some(parent) = file_path.parent() {
110                tokio::fs::create_dir_all(parent).await?;
111            }
112
113            // Write file content
114            tokio::fs::write(&file_path, &file_template.content).await?;
115        }
116
117        // Only update workspace Cargo.toml if files were generated
118        // For monorepo nodes, skip workspace member updates since there's no wrapper directory
119        if !component.files.is_empty() {
120            self.add_workspace_member(project_root, component_name).await?;
121        }
122
123        // For monorepo nodes, add as dependency to Cargo.toml
124        let is_monorepo_node = component.files.is_empty();
125        if is_monorepo_node {
126            self.add_dependency(project_root, component).await?;
127        }
128
129        // Update mecha10.json configuration
130        self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
131            .await?;
132
133        // Copy node configs from framework package to project
134        self.copy_node_configs(project_root, component_name).await?;
135
136        Ok(())
137    }
138
139    /// Add a workspace member to Cargo.toml
140    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
141        let cargo_toml_path = project_root.join("Cargo.toml");
142        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
143
144        let new_member = format!("nodes/{}", component_name);
145        let updated_content = if content.contains("members = []") {
146            // Replace empty array
147            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
148        } else if let Some(start) = content.find("members = [") {
149            // Find the closing bracket
150            if let Some(end) = content[start..].find(']') {
151                let insert_pos = start + end;
152                // Check if there are existing members
153                let members_section = &content[start..insert_pos];
154                if members_section.contains('\"') {
155                    // Has existing members, add comma and new member
156                    format!(
157                        "{}\n    \"{}\",{}",
158                        &content[..insert_pos],
159                        new_member,
160                        &content[insert_pos..]
161                    )
162                } else {
163                    // Empty but not [], add member
164                    format!(
165                        "{}\n    \"{}\",\n{}",
166                        &content[..insert_pos],
167                        new_member,
168                        &content[insert_pos..]
169                    )
170                }
171            } else {
172                content
173            }
174        } else {
175            content
176        };
177
178        tokio::fs::write(&cargo_toml_path, updated_content).await?;
179        Ok(())
180    }
181
182    /// Add a dependency to Cargo.toml
183    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
184        let cargo_toml_path = project_root.join("Cargo.toml");
185        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
186
187        // Get the actual package name from the component's cargo dependencies
188        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
189            dep.name.clone()
190        } else {
191            // Fallback to old behavior if no cargo dependencies specified
192            format!("mecha10-nodes-{}", component.id)
193        };
194
195        // Add dependency after mecha10-core if not already present
196        if !content.contains(&package_name) {
197            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
198                // Find end of line
199                if let Some(newline_pos) = content[pos..].find('\n') {
200                    let insert_pos = pos + newline_pos + 1;
201                    format!(
202                        "{}{} = \"0.1\"\n{}",
203                        &content[..insert_pos],
204                        package_name,
205                        &content[insert_pos..]
206                    )
207                } else {
208                    content
209                }
210            } else {
211                content
212            };
213
214            tokio::fs::write(&cargo_toml_path, updated_content).await?;
215        }
216
217        Ok(())
218    }
219
220    /// Add node entry to mecha10.json
221    async fn add_node_to_config(
222        &self,
223        project_root: &Path,
224        component_name: &str,
225        component: &crate::component_catalog::Component,
226        is_monorepo_node: bool,
227    ) -> Result<()> {
228        use crate::services::ConfigService;
229        use crate::types::NodeEntry;
230
231        let config_path = project_root.join("mecha10.json");
232        let mut config = ConfigService::load_from(&config_path).await?;
233
234        // For monorepo nodes, use the monorepo package name instead of local path
235        let node_path = if is_monorepo_node {
236            // Use the path from component catalog (e.g., "mecha10-nodes-diagnostics")
237            component
238                .mecha10_config
239                .custom_node
240                .as_ref()
241                .map(|n| n.path.clone())
242                .unwrap_or_else(|| format!("mecha10-nodes-{}", component_name))
243        } else {
244            // Use local path for wrapper nodes
245            format!("nodes/{}", component_name)
246        };
247
248        let node_entry = NodeEntry {
249            name: component_name.to_string(),
250            path: node_path,
251            config: None,
252            description: Some(component.description.clone()),
253            run_target: None,
254            enabled: true,
255        };
256        config.nodes.custom.push(node_entry);
257
258        // Save updated config
259        let config_json = serde_json::to_string_pretty(&config)?;
260        tokio::fs::write(&config_path, config_json).await?;
261
262        Ok(())
263    }
264
265    /// Copy behavior tree templates from framework to project
266    ///
267    /// Copies seed templates from `packages/behavior-runtime/seeds/` to
268    /// `{project}/behaviors/` for use with the behavior tree system.
269    ///
270    /// # Arguments
271    ///
272    /// * `project_root` - Root path of the project
273    pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
274        // Detect framework path to find source templates
275        // Skip silently if framework path not available (standalone CLI mode)
276        let framework_path = match self.detect_framework_path() {
277            Ok(path) => path,
278            Err(_) => return Ok(()), // Not in dev mode, skip copying
279        };
280        let source_templates_path = std::path::PathBuf::from(&framework_path)
281            .join("packages")
282            .join("behavior-runtime")
283            .join("seeds");
284
285        // Check if source templates directory exists
286        if !source_templates_path.exists() {
287            // No templates to copy - this is okay
288            return Ok(());
289        }
290
291        // Create destination directory
292        let dest_templates_path = project_root.join("behaviors");
293        tokio::fs::create_dir_all(&dest_templates_path).await?;
294
295        // Copy all .json templates
296        let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
297        while let Some(entry) = entries.next_entry().await? {
298            let path = entry.path();
299            if path.extension().and_then(|s| s.to_str()) == Some("json") {
300                if let Some(filename) = path.file_name() {
301                    let dest_file = dest_templates_path.join(filename);
302                    tokio::fs::copy(&path, &dest_file).await?;
303                }
304            }
305        }
306
307        Ok(())
308    }
309
310    /// Copy simulation configuration files from framework to project
311    ///
312    /// Copies configs from `packages/simulation/configs/` to
313    /// `{project}/configs/{env}/simulation/config.json`
314    ///
315    /// # Arguments
316    ///
317    /// * `project_root` - Root path of the project
318    pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
319        // Detect framework path to find source configs
320        // Skip silently if framework path not available (standalone CLI mode)
321        let framework_path = match self.detect_framework_path() {
322            Ok(path) => path,
323            Err(_) => return Ok(()), // Not in dev mode, skip copying
324        };
325        let source_configs_path = std::path::PathBuf::from(&framework_path)
326            .join("packages")
327            .join("simulation")
328            .join("configs");
329
330        // Check if source configs directory exists
331        if !source_configs_path.exists() {
332            // No configs to copy - this is okay, might be using old version
333            return Ok(());
334        }
335
336        // Copy dev config
337        let dev_src = source_configs_path.join("dev/config.json");
338        if dev_src.exists() {
339            let dev_dest = project_root.join("configs/dev/simulation/config.json");
340            if let Some(parent) = dev_dest.parent() {
341                tokio::fs::create_dir_all(parent).await?;
342            }
343            tokio::fs::copy(&dev_src, &dev_dest).await?;
344        }
345
346        // Copy production config
347        let prod_src = source_configs_path.join("production/config.json");
348        if prod_src.exists() {
349            let prod_dest = project_root.join("configs/production/simulation/config.json");
350            if let Some(parent) = prod_dest.parent() {
351                tokio::fs::create_dir_all(parent).await?;
352            }
353            tokio::fs::copy(&prod_src, &prod_dest).await?;
354        }
355
356        Ok(())
357    }
358
359    /// Copy simulation Docker files from framework to project
360    ///
361    /// Copies Docker files from `packages/simulation/docker/` to
362    /// `{project}/simulation/`
363    ///
364    /// # Arguments
365    ///
366    /// * `project_root` - Root path of the project
367    pub async fn copy_simulation_docker_files(&self, project_root: &Path) -> Result<()> {
368        // Detect framework path to find source files
369        // Skip silently if framework path not available (standalone CLI mode)
370        let framework_path = match self.detect_framework_path() {
371            Ok(path) => path,
372            Err(_) => return Ok(()), // Not in dev mode, skip copying
373        };
374        let source_docker_path = std::path::PathBuf::from(&framework_path)
375            .join("packages")
376            .join("simulation")
377            .join("docker");
378
379        // Check if source docker directory exists
380        if !source_docker_path.exists() {
381            // No docker files to copy - this is okay, might be using old version
382            return Ok(());
383        }
384
385        let dest_dir = project_root.join("simulation");
386        tokio::fs::create_dir_all(&dest_dir).await?;
387
388        // Copy Dockerfile
389        let dockerfile_src = source_docker_path.join("Dockerfile");
390        if dockerfile_src.exists() {
391            tokio::fs::copy(&dockerfile_src, dest_dir.join("Dockerfile")).await?;
392        }
393
394        // Copy entrypoint.sh
395        let entrypoint_src = source_docker_path.join("entrypoint.sh");
396        if entrypoint_src.exists() {
397            tokio::fs::copy(&entrypoint_src, dest_dir.join("entrypoint.sh")).await?;
398
399            // Make entrypoint.sh executable on Unix systems
400            #[cfg(unix)]
401            {
402                use std::os::unix::fs::PermissionsExt;
403                let mut perms = tokio::fs::metadata(dest_dir.join("entrypoint.sh")).await?.permissions();
404                perms.set_mode(0o755);
405                tokio::fs::set_permissions(dest_dir.join("entrypoint.sh"), perms).await?;
406            }
407        }
408
409        // Copy .dockerignore
410        let dockerignore_src = source_docker_path.join(".dockerignore");
411        if dockerignore_src.exists() {
412            tokio::fs::copy(&dockerignore_src, dest_dir.join(".dockerignore")).await?;
413        }
414
415        Ok(())
416    }
417
418    /// Copy simulation asset files from framework to project
419    ///
420    /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
421    /// to `{project}/assets/images/`
422    ///
423    /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
424    ///
425    /// # Arguments
426    ///
427    /// * `project_root` - Root path of the project
428    pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
429        // Detect framework path to find source assets
430        // Skip silently if framework path not available (standalone CLI mode)
431        let framework_path = match self.detect_framework_path() {
432            Ok(path) => path,
433            Err(_) => return Ok(()), // Not in dev mode, skip copying
434        };
435        let source_assets_path = std::path::PathBuf::from(&framework_path)
436            .join("packages")
437            .join("simulation")
438            .join("environments")
439            .join("basic_arena")
440            .join("assets")
441            .join("images");
442
443        // Check if source assets directory exists
444        if !source_assets_path.exists() {
445            // No assets to copy - this is okay
446            return Ok(());
447        }
448
449        // Create destination directory
450        let dest_assets_path = project_root.join("assets").join("images");
451        tokio::fs::create_dir_all(&dest_assets_path).await?;
452
453        // Copy image files
454        let images = vec!["aiko.jpg", "phoebe.jpg"];
455        for image in images {
456            let source_file = source_assets_path.join(image);
457            if source_file.exists() {
458                let dest_file = dest_assets_path.join(image);
459                tokio::fs::copy(&source_file, &dest_file).await?;
460            }
461        }
462
463        Ok(())
464    }
465
466    /// Copy node configuration files from framework package to project
467    ///
468    /// Copies configs from `packages/nodes/{node}/configs/` to
469    /// `{project}/configs/{env}/nodes/{node}/config.json`
470    ///
471    /// # Arguments
472    ///
473    /// * `project_root` - Root path of the project
474    /// * `component_name` - Name of the node (e.g., "speaker", "listener")
475    async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
476        // Detect framework path to find source configs
477        // Skip silently if framework path not available (standalone CLI mode)
478        let framework_path = match self.detect_framework_path() {
479            Ok(path) => path,
480            Err(_) => return Ok(()), // Not in dev mode, skip copying
481        };
482        let source_configs_path = std::path::PathBuf::from(&framework_path)
483            .join("packages")
484            .join("nodes")
485            .join(component_name)
486            .join("configs");
487
488        // Check if source configs directory exists
489        if !source_configs_path.exists() {
490            // No configs to copy - this is okay, node might not have configs yet
491            return Ok(());
492        }
493
494        // Copy configs for each environment
495        for env in &["dev", "production", "common"] {
496            let source_env_path = source_configs_path.join(env).join("config.json");
497
498            if source_env_path.exists() {
499                let dest_env_path = project_root
500                    .join("configs")
501                    .join(env)
502                    .join("nodes")
503                    .join(component_name);
504
505                // Create destination directory
506                tokio::fs::create_dir_all(&dest_env_path).await?;
507
508                // Copy config file
509                let dest_file = dest_env_path.join("config.json");
510                tokio::fs::copy(&source_env_path, &dest_file).await?;
511            }
512        }
513
514        Ok(())
515    }
516
517    /// Copy all default node configuration files from framework to project
518    ///
519    /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
520    /// to `{project}/configs/{env}/nodes/{node}/config.json`
521    ///
522    /// This is called during `mecha10 init` to set up node configs.
523    /// Skips silently if framework path is not available (standalone CLI mode).
524    ///
525    /// # Arguments
526    ///
527    /// * `project_root` - Root path of the project
528    pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
529        // Default nodes that should have configs copied
530        let default_nodes = [
531            "behavior-executor",
532            "image-classifier",
533            "llm-command",
534            "object-detector",
535            "simulation-bridge",
536            "websocket-bridge",
537        ];
538
539        for node in default_nodes {
540            self.copy_node_configs(project_root, node).await?;
541        }
542
543        Ok(())
544    }
545
546    /// Detect the framework path for development mode
547    ///
548    /// Searches for the mecha10-monorepo in the following order:
549    /// 1. MECHA10_FRAMEWORK_PATH environment variable
550    /// 2. Walking up the directory tree from current directory
551    ///
552    /// This is used when creating projects in development mode to link
553    /// to the local framework instead of published crates.
554    ///
555    /// # Returns
556    ///
557    /// The absolute path to the framework root, or an error if not found.
558    pub fn detect_framework_path(&self) -> Result<String> {
559        // First check if MECHA10_FRAMEWORK_PATH is set
560        if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
561            let path_buf = std::path::PathBuf::from(&path);
562
563            // Expand ~ to home directory if present
564            let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
565                if let Ok(home) = std::env::var("HOME") {
566                    std::path::PathBuf::from(home).join(stripped)
567                } else {
568                    path_buf.clone()
569                }
570            } else {
571                path_buf.clone()
572            };
573
574            // Validate that this is actually the framework directory
575            let core_path = expanded_path.join("packages").join("core");
576            if core_path.exists() {
577                return Ok(expanded_path.to_string_lossy().to_string());
578            } else {
579                return Err(anyhow::anyhow!(
580                    "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
581                     Expected to find packages/core directory at that location.",
582                    path
583                ));
584            }
585        }
586
587        // Fall back to walking up from current directory
588        let mut current = std::env::current_dir()?;
589
590        loop {
591            // Check if this directory contains packages/core (framework marker)
592            let core_path = current.join("packages").join("core");
593            if core_path.exists() {
594                // Found the framework root
595                return Ok(current.to_string_lossy().to_string());
596            }
597
598            // Check if we reached the filesystem root
599            if !current.pop() {
600                return Err(anyhow::anyhow!(
601                    "Could not detect framework path. Either:\n\
602                     1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
603                     2. Run from within the mecha10-monorepo directory\n\n\
604                     Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
605                ));
606            }
607        }
608    }
609}
610
611impl Default for InitService {
612    fn default() -> Self {
613        Self::new()
614    }
615}