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            // V2 config directories (simulation is dev-only)
59            "configs/dev/nodes",
60            "configs/dev/simulation",
61            "configs/production/nodes",
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("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("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        use crate::types::NodeEntry;
227
228        let config_path = project_root.join("mecha10.json");
229        let mut config = ConfigService::load_from(&config_path).await?;
230
231        // For monorepo nodes, use the monorepo package name instead of local path
232        let node_path = if is_monorepo_node {
233            // Use the path from component catalog (e.g., "mecha10-nodes-diagnostics")
234            component
235                .mecha10_config
236                .custom_node
237                .as_ref()
238                .map(|n| n.path.clone())
239                .unwrap_or_else(|| format!("mecha10-nodes-{}", component_name))
240        } else {
241            // Use local path for wrapper nodes
242            format!("nodes/{}", component_name)
243        };
244
245        let node_entry = NodeEntry {
246            name: component_name.to_string(),
247            path: node_path,
248            config: None,
249            description: Some(component.description.clone()),
250            run_target: None,
251            enabled: true,
252        };
253        config.nodes.custom.push(node_entry);
254
255        // Save updated config
256        let config_json = serde_json::to_string_pretty(&config)?;
257        tokio::fs::write(&config_path, config_json).await?;
258
259        Ok(())
260    }
261
262    /// Copy behavior tree templates from framework to project
263    ///
264    /// Copies seed templates from `packages/behavior-runtime/seeds/` to
265    /// `{project}/behaviors/` for use with the behavior tree system.
266    ///
267    /// # Arguments
268    ///
269    /// * `project_root` - Root path of the project
270    pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
271        // Detect framework path to find source templates
272        // Skip silently if framework path not available (standalone CLI mode)
273        let framework_path = match self.detect_framework_path() {
274            Ok(path) => path,
275            Err(_) => return Ok(()), // Not in dev mode, skip copying
276        };
277        let source_templates_path = std::path::PathBuf::from(&framework_path)
278            .join("packages")
279            .join("behavior-runtime")
280            .join("seeds");
281
282        // Check if source templates directory exists
283        if !source_templates_path.exists() {
284            // No templates to copy - this is okay
285            return Ok(());
286        }
287
288        // Create destination directory
289        let dest_templates_path = project_root.join("behaviors");
290        tokio::fs::create_dir_all(&dest_templates_path).await?;
291
292        // Copy all .json templates
293        let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
294        while let Some(entry) = entries.next_entry().await? {
295            let path = entry.path();
296            if path.extension().and_then(|s| s.to_str()) == Some("json") {
297                if let Some(filename) = path.file_name() {
298                    let dest_file = dest_templates_path.join(filename);
299                    tokio::fs::copy(&path, &dest_file).await?;
300                }
301            }
302        }
303
304        Ok(())
305    }
306
307    /// Copy simulation configuration files from framework to project
308    ///
309    /// Copies config from `packages/simulation/configs/config.json` to
310    /// `{project}/configs/dev/simulation/config.json`.
311    /// Simulation is dev-only, so no production config is needed.
312    ///
313    /// # Arguments
314    ///
315    /// * `project_root` - Root path of the project
316    pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
317        // Detect framework path to find source configs
318        // Skip silently if framework path not available (standalone CLI mode)
319        let framework_path = match self.detect_framework_path() {
320            Ok(path) => path,
321            Err(_) => return Ok(()), // Not in dev mode, skip copying
322        };
323        let source_configs_path = std::path::PathBuf::from(&framework_path)
324            .join("packages")
325            .join("simulation")
326            .join("configs");
327
328        // Check if source configs directory exists
329        if !source_configs_path.exists() {
330            // No configs to copy - this is okay, might be using old version
331            return Ok(());
332        }
333
334        // Copy simulation config (simulation is dev-only)
335        let src = source_configs_path.join("config.json");
336        if src.exists() {
337            let dest = project_root.join("configs/dev/simulation/config.json");
338            if let Some(parent) = dest.parent() {
339                tokio::fs::create_dir_all(parent).await?;
340            }
341            tokio::fs::copy(&src, &dest).await?;
342        }
343
344        Ok(())
345    }
346
347    /// Copy simulation asset files from framework to project
348    ///
349    /// Copies image assets from `packages/simulation/environments/basic_arena/assets/images/`
350    /// to `{project}/assets/images/`
351    ///
352    /// This includes cat images (aiko.jpg, phoebe.jpg) used in the basic arena environment
353    ///
354    /// # Arguments
355    ///
356    /// * `project_root` - Root path of the project
357    pub async fn copy_simulation_assets(&self, project_root: &Path) -> Result<()> {
358        // Detect framework path to find source assets
359        // Skip silently if framework path not available (standalone CLI mode)
360        let framework_path = match self.detect_framework_path() {
361            Ok(path) => path,
362            Err(_) => return Ok(()), // Not in dev mode, skip copying
363        };
364        let source_assets_path = std::path::PathBuf::from(&framework_path)
365            .join("packages")
366            .join("simulation")
367            .join("environments")
368            .join("basic_arena")
369            .join("assets")
370            .join("images");
371
372        // Check if source assets directory exists
373        if !source_assets_path.exists() {
374            // No assets to copy - this is okay
375            return Ok(());
376        }
377
378        // Create destination directory
379        let dest_assets_path = project_root.join("assets").join("images");
380        tokio::fs::create_dir_all(&dest_assets_path).await?;
381
382        // Copy image files
383        let images = vec!["aiko.jpg", "phoebe.jpg"];
384        for image in images {
385            let source_file = source_assets_path.join(image);
386            if source_file.exists() {
387                let dest_file = dest_assets_path.join(image);
388                tokio::fs::copy(&source_file, &dest_file).await?;
389            }
390        }
391
392        Ok(())
393    }
394
395    /// Copy node configuration files from framework package to project
396    ///
397    /// Copies configs from `packages/nodes/{node}/configs/` to
398    /// `{project}/configs/{env}/nodes/{node}/config.json`
399    ///
400    /// # Arguments
401    ///
402    /// * `project_root` - Root path of the project
403    /// * `component_name` - Name of the node (e.g., "speaker", "listener")
404    async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
405        // Detect framework path to find source configs
406        // Skip silently if framework path not available (standalone CLI mode)
407        let framework_path = match self.detect_framework_path() {
408            Ok(path) => path,
409            Err(_) => return Ok(()), // Not in dev mode, skip copying
410        };
411        let source_configs_path = std::path::PathBuf::from(&framework_path)
412            .join("packages")
413            .join("nodes")
414            .join(component_name)
415            .join("configs");
416
417        // Check if source configs directory exists
418        if !source_configs_path.exists() {
419            // No configs to copy - this is okay, node might not have configs yet
420            return Ok(());
421        }
422
423        // Copy configs for each environment
424        for env in &["dev", "production", "common"] {
425            let source_env_path = source_configs_path.join(env).join("config.json");
426
427            if source_env_path.exists() {
428                let dest_env_path = project_root
429                    .join("configs")
430                    .join(env)
431                    .join("nodes")
432                    .join(component_name);
433
434                // Create destination directory
435                tokio::fs::create_dir_all(&dest_env_path).await?;
436
437                // Copy config file
438                let dest_file = dest_env_path.join("config.json");
439                tokio::fs::copy(&source_env_path, &dest_file).await?;
440            }
441        }
442
443        Ok(())
444    }
445
446    /// Copy all default node configuration files from framework to project
447    ///
448    /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
449    /// to `{project}/configs/{env}/nodes/{node}/config.json`
450    ///
451    /// This is called during `mecha10 init` to set up node configs.
452    /// Skips silently if framework path is not available (standalone CLI mode).
453    ///
454    /// # Arguments
455    ///
456    /// * `project_root` - Root path of the project
457    pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
458        // Default nodes that should have configs copied
459        let default_nodes = [
460            "behavior-executor",
461            "image-classifier",
462            "llm-command",
463            "object-detector",
464            "simulation-bridge",
465            "websocket-bridge",
466        ];
467
468        for node in default_nodes {
469            self.copy_node_configs(project_root, node).await?;
470        }
471
472        Ok(())
473    }
474
475    /// Detect the framework path for development mode
476    ///
477    /// Searches for the mecha10-monorepo in the following order:
478    /// 1. MECHA10_FRAMEWORK_PATH environment variable
479    /// 2. Walking up the directory tree from current directory
480    ///
481    /// This is used when creating projects in development mode to link
482    /// to the local framework instead of published crates.
483    ///
484    /// # Returns
485    ///
486    /// The absolute path to the framework root, or an error if not found.
487    pub fn detect_framework_path(&self) -> Result<String> {
488        // First check if MECHA10_FRAMEWORK_PATH is set
489        if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
490            let path_buf = std::path::PathBuf::from(&path);
491
492            // Expand ~ to home directory if present
493            let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
494                if let Ok(home) = std::env::var("HOME") {
495                    std::path::PathBuf::from(home).join(stripped)
496                } else {
497                    path_buf.clone()
498                }
499            } else {
500                path_buf.clone()
501            };
502
503            // Validate that this is actually the framework directory
504            let core_path = expanded_path.join("packages").join("core");
505            if core_path.exists() {
506                return Ok(expanded_path.to_string_lossy().to_string());
507            } else {
508                return Err(anyhow::anyhow!(
509                    "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
510                     Expected to find packages/core directory at that location.",
511                    path
512                ));
513            }
514        }
515
516        // Fall back to walking up from current directory
517        let mut current = std::env::current_dir()?;
518
519        loop {
520            // Check if this directory contains packages/core (framework marker)
521            let core_path = current.join("packages").join("core");
522            if core_path.exists() {
523                // Found the framework root
524                return Ok(current.to_string_lossy().to_string());
525            }
526
527            // Check if we reached the filesystem root
528            if !current.pop() {
529                return Err(anyhow::anyhow!(
530                    "Could not detect framework path. Either:\n\
531                     1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
532                     2. Run from within the mecha10-monorepo directory\n\n\
533                     Example: export MECHA10_FRAMEWORK_PATH=~/src/laboratory-one/mecha10"
534                ));
535            }
536        }
537    }
538}
539
540impl Default for InitService {
541    fn default() -> Self {
542        Self::new()
543    }
544}