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