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    /// Detect the framework path for development mode
518    ///
519    /// Searches for the mecha10-monorepo in the following order:
520    /// 1. MECHA10_FRAMEWORK_PATH environment variable
521    /// 2. Walking up the directory tree from current directory
522    ///
523    /// This is used when creating projects in development mode to link
524    /// to the local framework instead of published crates.
525    ///
526    /// # Returns
527    ///
528    /// The absolute path to the framework root, or an error if not found.
529    pub fn detect_framework_path(&self) -> Result<String> {
530        // First check if MECHA10_FRAMEWORK_PATH is set
531        if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
532            let path_buf = std::path::PathBuf::from(&path);
533
534            // Expand ~ to home directory if present
535            let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
536                if let Ok(home) = std::env::var("HOME") {
537                    std::path::PathBuf::from(home).join(stripped)
538                } else {
539                    path_buf.clone()
540                }
541            } else {
542                path_buf.clone()
543            };
544
545            // Validate that this is actually the framework directory
546            let core_path = expanded_path.join("packages").join("core");
547            if core_path.exists() {
548                return Ok(expanded_path.to_string_lossy().to_string());
549            } else {
550                return Err(anyhow::anyhow!(
551                    "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
552                     Expected to find packages/core directory at that location.",
553                    path
554                ));
555            }
556        }
557
558        // Fall back to walking up from current directory
559        let mut current = std::env::current_dir()?;
560
561        loop {
562            // Check if this directory contains packages/core (framework marker)
563            let core_path = current.join("packages").join("core");
564            if core_path.exists() {
565                // Found the framework root
566                return Ok(current.to_string_lossy().to_string());
567            }
568
569            // Check if we reached the filesystem root
570            if !current.pop() {
571                return Err(anyhow::anyhow!(
572                    "Could not detect framework path. Either:\n\
573                     1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
574                     2. Run from within the mecha10-monorepo directory\n\n\
575                     Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
576                ));
577            }
578        }
579    }
580}
581
582impl Default for InitService {
583    fn default() -> Self {
584        Self::new()
585    }
586}