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        let framework_path = self.detect_framework_path()?;
276        let source_templates_path = std::path::PathBuf::from(&framework_path)
277            .join("packages")
278            .join("behavior-runtime")
279            .join("seeds");
280
281        // Check if source templates directory exists
282        if !source_templates_path.exists() {
283            // No templates to copy - this is okay
284            return Ok(());
285        }
286
287        // Create destination directory
288        let dest_templates_path = project_root.join("behaviors");
289        tokio::fs::create_dir_all(&dest_templates_path).await?;
290
291        // Copy all .json templates
292        let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
293        while let Some(entry) = entries.next_entry().await? {
294            let path = entry.path();
295            if path.extension().and_then(|s| s.to_str()) == Some("json") {
296                if let Some(filename) = path.file_name() {
297                    let dest_file = dest_templates_path.join(filename);
298                    tokio::fs::copy(&path, &dest_file).await?;
299                }
300            }
301        }
302
303        Ok(())
304    }
305
306    /// Copy simulation configuration files from framework to project
307    ///
308    /// Copies configs from `packages/simulation/configs/` to
309    /// `{project}/configs/{env}/simulation/config.json`
310    ///
311    /// # Arguments
312    ///
313    /// * `project_root` - Root path of the project
314    pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
315        // Detect framework path to find source configs
316        let framework_path = self.detect_framework_path()?;
317        let source_configs_path = std::path::PathBuf::from(&framework_path)
318            .join("packages")
319            .join("simulation")
320            .join("configs");
321
322        // Check if source configs directory exists
323        if !source_configs_path.exists() {
324            // No configs to copy - this is okay, might be using old version
325            return Ok(());
326        }
327
328        // Copy dev config
329        let dev_src = source_configs_path.join("dev/config.json");
330        if dev_src.exists() {
331            let dev_dest = project_root.join("configs/dev/simulation/config.json");
332            if let Some(parent) = dev_dest.parent() {
333                tokio::fs::create_dir_all(parent).await?;
334            }
335            tokio::fs::copy(&dev_src, &dev_dest).await?;
336        }
337
338        // Copy production config
339        let prod_src = source_configs_path.join("production/config.json");
340        if prod_src.exists() {
341            let prod_dest = project_root.join("configs/production/simulation/config.json");
342            if let Some(parent) = prod_dest.parent() {
343                tokio::fs::create_dir_all(parent).await?;
344            }
345            tokio::fs::copy(&prod_src, &prod_dest).await?;
346        }
347
348        Ok(())
349    }
350
351    /// Copy simulation Docker files from framework to project
352    ///
353    /// Copies Docker files from `packages/simulation/docker/` to
354    /// `{project}/simulation/`
355    ///
356    /// # Arguments
357    ///
358    /// * `project_root` - Root path of the project
359    pub async fn copy_simulation_docker_files(&self, project_root: &Path) -> Result<()> {
360        // Detect framework path to find source files
361        let framework_path = self.detect_framework_path()?;
362        let source_docker_path = std::path::PathBuf::from(&framework_path)
363            .join("packages")
364            .join("simulation")
365            .join("docker");
366
367        // Check if source docker directory exists
368        if !source_docker_path.exists() {
369            // No docker files to copy - this is okay, might be using old version
370            return Ok(());
371        }
372
373        let dest_dir = project_root.join("simulation");
374        tokio::fs::create_dir_all(&dest_dir).await?;
375
376        // Copy Dockerfile
377        let dockerfile_src = source_docker_path.join("Dockerfile");
378        if dockerfile_src.exists() {
379            tokio::fs::copy(&dockerfile_src, dest_dir.join("Dockerfile")).await?;
380        }
381
382        // Copy entrypoint.sh
383        let entrypoint_src = source_docker_path.join("entrypoint.sh");
384        if entrypoint_src.exists() {
385            tokio::fs::copy(&entrypoint_src, dest_dir.join("entrypoint.sh")).await?;
386
387            // Make entrypoint.sh executable on Unix systems
388            #[cfg(unix)]
389            {
390                use std::os::unix::fs::PermissionsExt;
391                let mut perms = tokio::fs::metadata(dest_dir.join("entrypoint.sh")).await?.permissions();
392                perms.set_mode(0o755);
393                tokio::fs::set_permissions(dest_dir.join("entrypoint.sh"), perms).await?;
394            }
395        }
396
397        // Copy .dockerignore
398        let dockerignore_src = source_docker_path.join(".dockerignore");
399        if dockerignore_src.exists() {
400            tokio::fs::copy(&dockerignore_src, dest_dir.join(".dockerignore")).await?;
401        }
402
403        Ok(())
404    }
405
406    /// Copy simulation asset files from framework to project
407    ///
408    /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
409    /// to `{project}/assets/images/`
410    ///
411    /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
412    ///
413    /// # Arguments
414    ///
415    /// * `project_root` - Root path of the project
416    pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
417        // Detect framework path to find source assets
418        let framework_path = self.detect_framework_path()?;
419        let source_assets_path = std::path::PathBuf::from(&framework_path)
420            .join("packages")
421            .join("simulation")
422            .join("environments")
423            .join("basic_arena")
424            .join("assets")
425            .join("images");
426
427        // Check if source assets directory exists
428        if !source_assets_path.exists() {
429            // No assets to copy - this is okay
430            return Ok(());
431        }
432
433        // Create destination directory
434        let dest_assets_path = project_root.join("assets").join("images");
435        tokio::fs::create_dir_all(&dest_assets_path).await?;
436
437        // Copy image files
438        let images = vec!["aiko.jpg", "phoebe.jpg"];
439        for image in images {
440            let source_file = source_assets_path.join(image);
441            if source_file.exists() {
442                let dest_file = dest_assets_path.join(image);
443                tokio::fs::copy(&source_file, &dest_file).await?;
444            }
445        }
446
447        Ok(())
448    }
449
450    /// Copy node configuration files from framework package to project
451    ///
452    /// Copies configs from `packages/nodes/{node}/configs/` to
453    /// `{project}/configs/{env}/nodes/{node}/config.json`
454    ///
455    /// # Arguments
456    ///
457    /// * `project_root` - Root path of the project
458    /// * `component_name` - Name of the node (e.g., "speaker", "listener")
459    async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
460        // Detect framework path to find source configs
461        let framework_path = self.detect_framework_path()?;
462        let source_configs_path = std::path::PathBuf::from(&framework_path)
463            .join("packages")
464            .join("nodes")
465            .join(component_name)
466            .join("configs");
467
468        // Check if source configs directory exists
469        if !source_configs_path.exists() {
470            // No configs to copy - this is okay, node might not have configs yet
471            return Ok(());
472        }
473
474        // Copy configs for each environment
475        for env in &["dev", "production", "common"] {
476            let source_env_path = source_configs_path.join(env).join("config.json");
477
478            if source_env_path.exists() {
479                let dest_env_path = project_root
480                    .join("configs")
481                    .join(env)
482                    .join("nodes")
483                    .join(component_name);
484
485                // Create destination directory
486                tokio::fs::create_dir_all(&dest_env_path).await?;
487
488                // Copy config file
489                let dest_file = dest_env_path.join("config.json");
490                tokio::fs::copy(&source_env_path, &dest_file).await?;
491            }
492        }
493
494        Ok(())
495    }
496
497    /// Detect the framework path for development mode
498    ///
499    /// Searches for the mecha10-monorepo in the following order:
500    /// 1. MECHA10_FRAMEWORK_PATH environment variable
501    /// 2. Walking up the directory tree from current directory
502    ///
503    /// This is used when creating projects in development mode to link
504    /// to the local framework instead of published crates.
505    ///
506    /// # Returns
507    ///
508    /// The absolute path to the framework root, or an error if not found.
509    pub fn detect_framework_path(&self) -> Result<String> {
510        // First check if MECHA10_FRAMEWORK_PATH is set
511        if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
512            let path_buf = std::path::PathBuf::from(&path);
513
514            // Expand ~ to home directory if present
515            let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
516                if let Ok(home) = std::env::var("HOME") {
517                    std::path::PathBuf::from(home).join(stripped)
518                } else {
519                    path_buf.clone()
520                }
521            } else {
522                path_buf.clone()
523            };
524
525            // Validate that this is actually the framework directory
526            let core_path = expanded_path.join("packages").join("core");
527            if core_path.exists() {
528                return Ok(expanded_path.to_string_lossy().to_string());
529            } else {
530                return Err(anyhow::anyhow!(
531                    "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
532                     Expected to find packages/core directory at that location.",
533                    path
534                ));
535            }
536        }
537
538        // Fall back to walking up from current directory
539        let mut current = std::env::current_dir()?;
540
541        loop {
542            // Check if this directory contains packages/core (framework marker)
543            let core_path = current.join("packages").join("core");
544            if core_path.exists() {
545                // Found the framework root
546                return Ok(current.to_string_lossy().to_string());
547            }
548
549            // Check if we reached the filesystem root
550            if !current.pop() {
551                return Err(anyhow::anyhow!(
552                    "Could not detect framework path. Either:\n\
553                     1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
554                     2. Run from within the mecha10-monorepo directory\n\n\
555                     Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
556                ));
557            }
558        }
559    }
560}
561
562impl Default for InitService {
563    fn default() -> Self {
564        Self::new()
565    }
566}