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 crate::paths;
9use anyhow::Result;
10use std::path::Path;
11
12// Embedded node configs for standalone mode (V2 structure: dev/production)
13mod embedded_configs {
14    // Motor node (V2 structure)
15    pub const MOTOR_DEV: &str = include_str!("../../templates/configs/nodes/motor/dev/config.json");
16    pub const MOTOR_PRODUCTION: &str = include_str!("../../templates/configs/nodes/motor/production/config.json");
17}
18
19/// Service for initializing new Mecha10 projects
20///
21/// # Examples
22///
23/// ```rust,ignore
24/// use mecha10_cli::services::InitService;
25///
26/// let service = InitService::new();
27///
28/// // Create project structure
29/// service.create_project_directories(&project_path).await?;
30///
31/// // Add example nodes
32/// service.add_example_node(&project_path, "speaker").await?;
33/// ```
34pub struct InitService;
35
36impl InitService {
37    /// Create a new InitService
38    pub fn new() -> Self {
39        Self
40    }
41
42    /// Create project directory structure
43    ///
44    /// Creates all the necessary directories for a new Mecha10 project:
45    /// - nodes/ - Custom node implementations
46    /// - drivers/ - Hardware driver implementations
47    /// - types/ - Shared type definitions
48    /// - behaviors/ - Behavior tree definitions
49    /// - config/ - Configuration files
50    /// - logs/ - Runtime logs
51    /// - models/ - ML/AI models (ONNX, etc.) for remote nodes
52    /// - simulation/ - Simulation environments
53    /// - simulation/models/ - Robot physical models (for Godot)
54    /// - assets/ - Static assets for simulation and visualization
55    /// - assets/images/ - Image assets for simulation textures, UI, etc.
56    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
57        let dirs = vec![
58            paths::project::NODES_DIR,
59            paths::project::DRIVERS_DIR,
60            paths::project::TYPES_DIR,
61            paths::project::BEHAVIORS_DIR,
62            paths::project::LOGS_DIR,
63            paths::project::MODELS_DIR,
64            paths::project::SIMULATION_DIR,
65            paths::project::SIMULATION_MODELS_DIR,
66            paths::project::ASSETS_DIR,
67            paths::project::ASSETS_IMAGES_DIR,
68            // Config directories (nodes grouped by scope: @mecha10/, @local/)
69            paths::config::NODES_DIR,
70            paths::config::SIMULATION_DIR,
71        ];
72
73        for dir in dirs {
74            let dir_path = path.join(dir);
75            tokio::fs::create_dir_all(&dir_path).await?;
76
77            // Create .gitkeep to ensure empty directories are tracked
78            tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
79        }
80
81        Ok(())
82    }
83
84    /// Add an example node from the catalog to the project
85    ///
86    /// This method:
87    /// 1. Searches the component catalog for the component
88    /// 2. Generates component files from templates (if any)
89    /// 3. Updates Cargo.toml (workspace members or dependencies)
90    /// 4. Updates mecha10.json configuration
91    ///
92    /// # Arguments
93    ///
94    /// * `project_root` - Root path of the project
95    /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
96    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
97        use crate::component_catalog::ComponentCatalog;
98
99        // Load catalog and find component
100        let catalog = ComponentCatalog::new();
101        let components = catalog.search(component_name);
102
103        if components.is_empty() {
104            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
105        }
106
107        let component = &components[0];
108
109        // Generate component files from catalog templates (if any)
110        // For monorepo nodes like speaker/listener, files vec is empty
111        for file_template in &component.files {
112            let file_path = project_root.join(&file_template.path);
113
114            // Create parent directories
115            if let Some(parent) = file_path.parent() {
116                tokio::fs::create_dir_all(parent).await?;
117            }
118
119            // Write file content
120            tokio::fs::write(&file_path, &file_template.content).await?;
121        }
122
123        // Only update workspace Cargo.toml if files were generated
124        // For monorepo nodes, skip workspace member updates since there's no wrapper directory
125        if !component.files.is_empty() {
126            self.add_workspace_member(project_root, component_name).await?;
127        }
128
129        // For monorepo nodes, add as dependency to Cargo.toml
130        let is_monorepo_node = component.files.is_empty();
131        if is_monorepo_node {
132            self.add_dependency(project_root, component).await?;
133        }
134
135        // Update mecha10.json configuration
136        self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
137            .await?;
138
139        // Copy node configs from framework package to project
140        self.copy_node_configs(project_root, component_name).await?;
141
142        Ok(())
143    }
144
145    /// Add a workspace member to Cargo.toml
146    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
147        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
148        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
149
150        let new_member = format!("nodes/{}", component_name);
151        let updated_content = if content.contains("members = []") {
152            // Replace empty array
153            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
154        } else if let Some(start) = content.find("members = [") {
155            // Find the closing bracket
156            if let Some(end) = content[start..].find(']') {
157                let insert_pos = start + end;
158                // Check if there are existing members
159                let members_section = &content[start..insert_pos];
160                if members_section.contains('\"') {
161                    // Has existing members, add comma and new member
162                    format!(
163                        "{}\n    \"{}\",{}",
164                        &content[..insert_pos],
165                        new_member,
166                        &content[insert_pos..]
167                    )
168                } else {
169                    // Empty but not [], add member
170                    format!(
171                        "{}\n    \"{}\",\n{}",
172                        &content[..insert_pos],
173                        new_member,
174                        &content[insert_pos..]
175                    )
176                }
177            } else {
178                content
179            }
180        } else {
181            content
182        };
183
184        tokio::fs::write(&cargo_toml_path, updated_content).await?;
185        Ok(())
186    }
187
188    /// Add a dependency to Cargo.toml
189    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
190        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
191        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
192
193        // Get the actual package name from the component's cargo dependencies
194        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
195            dep.name.clone()
196        } else {
197            // Fallback to old behavior if no cargo dependencies specified
198            format!("mecha10-nodes-{}", component.id)
199        };
200
201        // Add dependency after mecha10-core if not already present
202        if !content.contains(&package_name) {
203            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
204                // Find end of line
205                if let Some(newline_pos) = content[pos..].find('\n') {
206                    let insert_pos = pos + newline_pos + 1;
207                    format!(
208                        "{}{} = \"0.1\"\n{}",
209                        &content[..insert_pos],
210                        package_name,
211                        &content[insert_pos..]
212                    )
213                } else {
214                    content
215                }
216            } else {
217                content
218            };
219
220            tokio::fs::write(&cargo_toml_path, updated_content).await?;
221        }
222
223        Ok(())
224    }
225
226    /// Add node entry to mecha10.json
227    async fn add_node_to_config(
228        &self,
229        project_root: &Path,
230        component_name: &str,
231        _component: &crate::component_catalog::Component,
232        is_monorepo_node: bool,
233    ) -> Result<()> {
234        use crate::services::ConfigService;
235
236        let config_path = project_root.join(paths::PROJECT_CONFIG);
237        let mut config = ConfigService::load_from(&config_path).await?;
238
239        // Create node identifier based on source
240        let node_identifier = if is_monorepo_node {
241            // Framework node: @mecha10/node-name
242            format!("@mecha10/{}", component_name)
243        } else {
244            // Project node: @local/node-name
245            format!("@local/{}", component_name)
246        };
247
248        // Add node using the new format
249        config.nodes.add_node(&node_identifier);
250
251        // Save updated config
252        let config_json = serde_json::to_string_pretty(&config)?;
253        tokio::fs::write(&config_path, config_json).await?;
254
255        Ok(())
256    }
257
258    /// Copy behavior tree templates from framework to project
259    ///
260    /// Copies seed templates from `packages/behavior-runtime/seeds/` to
261    /// `{project}/behaviors/` for use with the behavior tree system.
262    ///
263    /// # Arguments
264    ///
265    /// * `project_root` - Root path of the project
266    pub async fn copy_behavior_templates(&self, project_root: &Path) -> Result<()> {
267        // Detect framework path to find source templates
268        // Skip silently if framework path not available (standalone CLI mode)
269        let framework_path = match self.detect_framework_path() {
270            Ok(path) => path,
271            Err(_) => return Ok(()), // Not in dev mode, skip copying
272        };
273        let source_templates_path = std::path::PathBuf::from(&framework_path)
274            .join("packages")
275            .join("behavior-runtime")
276            .join("seeds");
277
278        // Check if source templates directory exists
279        if !source_templates_path.exists() {
280            // No templates to copy - this is okay
281            return Ok(());
282        }
283
284        // Create destination directory
285        let dest_templates_path = project_root.join("behaviors");
286        tokio::fs::create_dir_all(&dest_templates_path).await?;
287
288        // Copy all .json templates
289        let mut entries = tokio::fs::read_dir(&source_templates_path).await?;
290        while let Some(entry) = entries.next_entry().await? {
291            let path = entry.path();
292            if path.extension().and_then(|s| s.to_str()) == Some("json") {
293                if let Some(filename) = path.file_name() {
294                    let dest_file = dest_templates_path.join(filename);
295                    tokio::fs::copy(&path, &dest_file).await?;
296                }
297            }
298        }
299
300        Ok(())
301    }
302
303    /// Copy simulation configuration file from framework to project
304    ///
305    /// Copies `packages/simulation/configs/config.json` to
306    /// `{project}/configs/simulation/config.json`
307    ///
308    /// The config file contains both dev and production sections:
309    /// ```json
310    /// {
311    ///   "dev": { ... },
312    ///   "production": { ... }
313    /// }
314    /// ```
315    ///
316    /// # Arguments
317    ///
318    /// * `project_root` - Root path of the project
319    pub async fn copy_simulation_configs(&self, project_root: &Path) -> Result<()> {
320        // Detect framework path to find source config
321        // Skip silently if framework path not available (standalone CLI mode)
322        let framework_path = match self.detect_framework_path() {
323            Ok(path) => path,
324            Err(_) => return Ok(()), // Not in dev mode, skip copying
325        };
326        let source_config = std::path::PathBuf::from(&framework_path)
327            .join("packages")
328            .join("simulation")
329            .join("configs")
330            .join("config.json");
331
332        // Check if source config exists
333        if !source_config.exists() {
334            return Ok(());
335        }
336
337        // Create destination directory and copy file
338        let dest_dir = project_root.join("configs").join("simulation");
339        tokio::fs::create_dir_all(&dest_dir).await?;
340
341        let dest_file = dest_dir.join("config.json");
342        tokio::fs::copy(&source_config, &dest_file).await?;
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 file from framework package to project
396    ///
397    /// Supports two config formats:
398    /// - V2 (preferred): `packages/nodes/{node}/configs/dev/config.json` and `production/config.json`
399    ///   → `{project}/configs/dev/nodes/{node}/config.json` and `production/nodes/{node}/config.json`
400    /// - Legacy: `packages/nodes/{node}/configs/config.json`
401    ///   → `{project}/configs/nodes/@mecha10/{node}/config.json`
402    ///
403    /// Falls back to embedded configs for standalone CLI mode when framework path unavailable.
404    ///
405    /// # Arguments
406    ///
407    /// * `project_root` - Root path of the project
408    /// * `component_name` - Name of the node (e.g., "speaker", "motor")
409    async fn copy_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
410        // Try framework path first, fall back to embedded configs
411        match self.detect_framework_path() {
412            Ok(framework_path) => {
413                self.copy_node_configs_from_framework(project_root, component_name, &framework_path)
414                    .await
415            }
416            Err(_) => {
417                // Standalone mode: use embedded configs
418                self.write_embedded_node_configs(project_root, component_name).await
419            }
420        }
421    }
422
423    /// Copy node configs from framework path
424    ///
425    /// Creates environment-aware config: configs/nodes/@mecha10/{node}/config.json
426    /// with `dev` and `production` keys at the top level.
427    ///
428    /// Source can be either:
429    /// - V2 structure: `packages/nodes/{node}/configs/dev/config.json` and `production/config.json`
430    /// - Legacy: `packages/nodes/{node}/configs/config.json` (used for both environments)
431    async fn copy_node_configs_from_framework(
432        &self,
433        project_root: &Path,
434        component_name: &str,
435        framework_path: &str,
436    ) -> Result<()> {
437        let source_base = std::path::PathBuf::from(framework_path)
438            .join("packages")
439            .join("nodes")
440            .join(component_name)
441            .join("configs");
442
443        // Destination path for framework nodes
444        let dest_dir = project_root
445            .join("configs")
446            .join("nodes")
447            .join("@mecha10")
448            .join(component_name);
449
450        // Check for V2 config structure (dev/ and production/ directories)
451        let v2_dev_config = source_base.join("dev").join("config.json");
452        let v2_prod_config = source_base.join("production").join("config.json");
453
454        if v2_dev_config.exists() {
455            // Read dev config
456            let dev_content = tokio::fs::read_to_string(&v2_dev_config).await?;
457            let dev_json: serde_json::Value = serde_json::from_str(&dev_content)?;
458
459            // Read production config (fall back to dev if not present)
460            let prod_json: serde_json::Value = if v2_prod_config.exists() {
461                let prod_content = tokio::fs::read_to_string(&v2_prod_config).await?;
462                serde_json::from_str(&prod_content)?
463            } else {
464                dev_json.clone()
465            };
466
467            // Create merged config with dev/production keys
468            let merged = serde_json::json!({
469                "dev": dev_json,
470                "production": prod_json
471            });
472
473            tokio::fs::create_dir_all(&dest_dir).await?;
474            let dest_file = dest_dir.join("config.json");
475            let merged_content = serde_json::to_string_pretty(&merged)?;
476            tokio::fs::write(&dest_file, merged_content).await?;
477            return Ok(());
478        }
479
480        // Legacy single config.json format
481        let legacy_config = source_base.join("config.json");
482        if legacy_config.exists() {
483            let content = tokio::fs::read_to_string(&legacy_config).await?;
484            let config_json: serde_json::Value = serde_json::from_str(&content)?;
485
486            // Check if config already has environment-aware structure (dev/production keys)
487            let final_config = if let Some(obj) = config_json.as_object() {
488                if obj.contains_key("dev") || obj.contains_key("production") {
489                    // Already environment-aware, use as-is
490                    config_json
491                } else {
492                    // Not environment-aware, wrap with dev/production keys
493                    serde_json::json!({
494                        "dev": config_json.clone(),
495                        "production": config_json
496                    })
497                }
498            } else {
499                // Not an object, wrap it
500                serde_json::json!({
501                    "dev": config_json.clone(),
502                    "production": config_json
503                })
504            };
505
506            tokio::fs::create_dir_all(&dest_dir).await?;
507            let dest_file = dest_dir.join("config.json");
508            let merged_content = serde_json::to_string_pretty(&final_config)?;
509            tokio::fs::write(&dest_file, merged_content).await?;
510        }
511
512        Ok(())
513    }
514
515    /// Write embedded node configs for standalone mode
516    ///
517    /// Creates environment-aware config: configs/nodes/@mecha10/{node}/config.json
518    /// with `dev` and `production` keys at the top level.
519    async fn write_embedded_node_configs(&self, project_root: &Path, component_name: &str) -> Result<()> {
520        // Get embedded configs for this node (both dev and production)
521        let configs: Option<(&str, &str)> = match component_name {
522            "motor" => Some((embedded_configs::MOTOR_DEV, embedded_configs::MOTOR_PRODUCTION)),
523            // Add more nodes here as needed
524            _ => None,
525        };
526
527        if let Some((dev_content, prod_content)) = configs {
528            // Parse both configs
529            let dev_json: serde_json::Value = serde_json::from_str(dev_content)?;
530            let prod_json: serde_json::Value = serde_json::from_str(prod_content)?;
531
532            // Create merged config with dev/production keys
533            let merged = serde_json::json!({
534                "dev": dev_json,
535                "production": prod_json
536            });
537
538            // Write to: configs/nodes/@mecha10/{node}/config.json
539            let dest_dir = project_root
540                .join("configs")
541                .join("nodes")
542                .join("@mecha10")
543                .join(component_name);
544            tokio::fs::create_dir_all(&dest_dir).await?;
545            let dest_file = dest_dir.join("config.json");
546            let merged_content = serde_json::to_string_pretty(&merged)?;
547            tokio::fs::write(&dest_file, merged_content).await?;
548        }
549
550        Ok(())
551    }
552
553    /// Copy all default node configuration files from framework to project
554    ///
555    /// Copies configs for all default nodes from `packages/nodes/{node}/configs/`
556    /// to `{project}/configs/nodes/@mecha10/{node}/config.json`
557    ///
558    /// This is called during `mecha10 init` to set up node configs.
559    /// Skips silently if framework path is not available (standalone CLI mode).
560    ///
561    /// # Arguments
562    ///
563    /// * `project_root` - Root path of the project
564    pub async fn copy_all_node_configs(&self, project_root: &Path) -> Result<()> {
565        // Default nodes that should have configs copied
566        let default_nodes = [
567            "behavior-executor",
568            "image-classifier",
569            "imu",
570            "listener",
571            "llm-command",
572            "motor",
573            "object-detector",
574            "simulation-bridge",
575            "speaker",
576            "teleop",
577            "websocket-bridge",
578        ];
579
580        for node in default_nodes {
581            self.copy_node_configs(project_root, node).await?;
582        }
583
584        Ok(())
585    }
586
587    /// Detect the framework path for development mode
588    ///
589    /// Searches for the mecha10-monorepo in the following order:
590    /// 1. MECHA10_FRAMEWORK_PATH environment variable
591    /// 2. Walking up the directory tree from current directory
592    ///
593    /// This is used when creating projects in development mode to link
594    /// to the local framework instead of published crates.
595    ///
596    /// # Returns
597    ///
598    /// The absolute path to the framework root, or an error if not found.
599    pub fn detect_framework_path(&self) -> Result<String> {
600        // First check if MECHA10_FRAMEWORK_PATH is set
601        if let Ok(path) = std::env::var("MECHA10_FRAMEWORK_PATH") {
602            let path_buf = std::path::PathBuf::from(&path);
603
604            // Expand ~ to home directory if present
605            let expanded_path = if let Some(stripped) = path.strip_prefix("~/") {
606                if let Ok(home) = std::env::var("HOME") {
607                    std::path::PathBuf::from(home).join(stripped)
608                } else {
609                    path_buf.clone()
610                }
611            } else {
612                path_buf.clone()
613            };
614
615            // Validate that this is actually the framework directory
616            let core_path = expanded_path.join("packages").join("core");
617            if core_path.exists() {
618                return Ok(expanded_path.to_string_lossy().to_string());
619            } else {
620                return Err(anyhow::anyhow!(
621                    "MECHA10_FRAMEWORK_PATH is set to '{}' but this doesn't appear to be the framework root.\n\
622                     Expected to find packages/core directory at that location.",
623                    path
624                ));
625            }
626        }
627
628        // Fall back to walking up from current directory
629        let mut current = std::env::current_dir()?;
630
631        loop {
632            // Check if this directory contains packages/core (framework marker)
633            let core_path = current.join("packages").join("core");
634            if core_path.exists() {
635                // Found the framework root
636                return Ok(current.to_string_lossy().to_string());
637            }
638
639            // Check if we reached the filesystem root
640            if !current.pop() {
641                return Err(anyhow::anyhow!(
642                    "Could not detect framework path. Either:\n\
643                     1. Set MECHA10_FRAMEWORK_PATH environment variable, or\n\
644                     2. Run from within the mecha10-monorepo directory\n\n\
645                     Example: export MECHA10_FRAMEWORK_PATH=~/src/mecha-industries/mecha10"
646                ));
647            }
648        }
649    }
650}
651
652impl Default for InitService {
653    fn default() -> Self {
654        Self::new()
655    }
656}