Skip to main content

mecha10_templates/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
7use crate::paths;
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    /// - configs/behaviors/ - Behavior tree definitions (LAB-2072: nested under configs/ so
41    ///   `mecha10 config push` syncs them)
42    /// - config/ - Configuration files
43    /// - logs/ - Runtime logs
44    /// - models/ - ML/AI models (ONNX, etc.) for remote nodes
45    /// - simulation/ - Simulation environments
46    /// - simulation/models/ - Robot physical models
47    /// - assets/ - Static assets for simulation and visualization
48    /// - assets/images/ - Image assets for simulation textures, UI, etc.
49    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
50        let dirs = vec![
51            paths::project::NODES_DIR,
52            paths::project::DRIVERS_DIR,
53            paths::project::TYPES_DIR,
54            paths::project::BEHAVIORS_DIR,
55            paths::project::LOGS_DIR,
56            paths::project::MODELS_DIR,
57            paths::project::SIMULATION_DIR,
58            paths::project::SIMULATION_MODELS_DIR,
59            paths::project::SIMULATION_ENVIRONMENTS_DIR,
60            paths::project::SIMULATION_TASKS_DIR,
61            paths::project::ASSETS_DIR,
62            paths::project::ASSETS_IMAGES_DIR,
63            // Config directories (nodes grouped by scope: @mecha10/, plain project override)
64            paths::config::NODES_DIR,
65            paths::config::SIMULATION_DIR,
66        ];
67
68        for dir in dirs {
69            let dir_path = path.join(dir);
70            tokio::fs::create_dir_all(&dir_path).await?;
71
72            // Create .gitkeep to ensure empty directories are tracked
73            tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
74        }
75
76        Ok(())
77    }
78
79    /// Add an example node from the catalog to the project
80    ///
81    /// This method:
82    /// 1. Searches the component catalog for the component
83    /// 2. Generates component files from templates (if any)
84    /// 3. Updates Cargo.toml (workspace members or dependencies)
85    /// 4. Updates mecha10.json configuration
86    ///
87    /// # Arguments
88    ///
89    /// * `project_root` - Root path of the project
90    /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
91    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
92        use crate::component_catalog::ComponentCatalog;
93
94        // Load catalog and find component
95        let catalog = ComponentCatalog::new();
96        let components = catalog.search(component_name);
97
98        if components.is_empty() {
99            return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
100        }
101
102        let component = &components[0];
103
104        // Generate component files from catalog templates (if any)
105        // For monorepo nodes like speaker/listener, files vec is empty
106        for file_template in &component.files {
107            let file_path = project_root.join(&file_template.path);
108
109            // Create parent directories
110            if let Some(parent) = file_path.parent() {
111                tokio::fs::create_dir_all(parent).await?;
112            }
113
114            // Write file content
115            tokio::fs::write(&file_path, &file_template.content).await?;
116        }
117
118        // Only update workspace Cargo.toml if files were generated
119        // For monorepo nodes, skip workspace member updates since there's no wrapper directory
120        if !component.files.is_empty() {
121            self.add_workspace_member(project_root, component_name).await?;
122        }
123
124        // For monorepo nodes, add as dependency to Cargo.toml
125        let is_monorepo_node = component.files.is_empty();
126        if is_monorepo_node {
127            self.add_dependency(project_root, component).await?;
128        }
129
130        // Update mecha10.json configuration
131        self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
132            .await?;
133
134        Ok(())
135    }
136
137    /// Add a workspace member to Cargo.toml
138    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
139        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
140        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
141
142        let new_member = format!("nodes/{}", component_name);
143        let updated_content = if content.contains("members = []") {
144            // Replace empty array
145            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
146        } else if let Some(start) = content.find("members = [") {
147            // Find the closing bracket
148            if let Some(end) = content[start..].find(']') {
149                let insert_pos = start + end;
150                // Check if there are existing members
151                let members_section = &content[start..insert_pos];
152                if members_section.contains('\"') {
153                    // Has existing members, add comma and new member
154                    format!(
155                        "{}\n    \"{}\",{}",
156                        &content[..insert_pos],
157                        new_member,
158                        &content[insert_pos..]
159                    )
160                } else {
161                    // Empty but not [], add member
162                    format!(
163                        "{}\n    \"{}\",\n{}",
164                        &content[..insert_pos],
165                        new_member,
166                        &content[insert_pos..]
167                    )
168                }
169            } else {
170                content
171            }
172        } else {
173            content
174        };
175
176        tokio::fs::write(&cargo_toml_path, updated_content).await?;
177        Ok(())
178    }
179
180    /// Add a dependency to Cargo.toml
181    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
182        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
183        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
184
185        // Get the actual package name from the component's cargo dependencies
186        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
187            dep.name.clone()
188        } else {
189            // Fallback to old behavior if no cargo dependencies specified
190            format!("mecha10-nodes-{}", component.id)
191        };
192
193        // Add dependency after mecha10-core if not already present
194        if !content.contains(&package_name) {
195            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
196                // Find end of line
197                if let Some(newline_pos) = content[pos..].find('\n') {
198                    let insert_pos = pos + newline_pos + 1;
199                    format!(
200                        "{}{} = \"0.1\"\n{}",
201                        &content[..insert_pos],
202                        package_name,
203                        &content[insert_pos..]
204                    )
205                } else {
206                    content
207                }
208            } else {
209                content
210            };
211
212            tokio::fs::write(&cargo_toml_path, updated_content).await?;
213        }
214
215        Ok(())
216    }
217
218    /// Add node entry to mecha10.json
219    async fn add_node_to_config(
220        &self,
221        project_root: &Path,
222        component_name: &str,
223        component: &crate::component_catalog::Component,
224        is_monorepo_node: bool,
225    ) -> Result<()> {
226        use crate::services::ConfigService;
227        use mecha10_cli_core::types::project::{LifecycleConfig, ModeConfig};
228        use std::collections::HashMap;
229
230        let config_path = project_root.join(paths::PROJECT_CONFIG);
231        let mut config = ConfigService::load_from(&config_path).await?;
232
233        // Create node identifier and version based on source
234        let (node_identifier, version) = if is_monorepo_node {
235            // Framework node: @mecha10/node-name with framework version
236            (
237                format!("@mecha10/{}", component_name),
238                env!("CARGO_PKG_VERSION").to_string(),
239            )
240        } else {
241            // Project node: plain node-name (no @local/ prefix) with default version.
242            // Plain, unscoped identifiers are the preferred project-node form; @local/*
243            // remains supported for parsing existing configs (NodeSpec::parse) but is no
244            // longer minted for newly scaffolded nodes.
245            (component_name.to_string(), "0.1.0".to_string())
246        };
247
248        // Add node using the new format with version
249        config.nodes.add_node(&node_identifier, &version);
250
251        // Keep `lifecycle.modes.*.nodes` in sync with newly added/selected nodes. Those lists
252        // are seeded once from the static `mecha10.json.template` and otherwise never grow, so
253        // without this a node selected via `mecha10 init` (or added via `mecha10 add`) is
254        // registered in `nodes`/`targets` but never actually gets spawned by
255        // `CliLifecycleManager::nodes_for_current_mode`, which only reads
256        // `lifecycle.modes.<mode>.nodes`.
257        let relevant_modes = component.relevant_lifecycle_modes();
258        if !relevant_modes.is_empty() {
259            let lifecycle = config
260                .lifecycle
261                .get_or_insert_with(|| LifecycleConfig { modes: HashMap::new() });
262
263            for mode in relevant_modes {
264                let mode_config = lifecycle
265                    .modes
266                    .entry(mode.to_string())
267                    .or_insert_with(|| ModeConfig { nodes: Vec::new() });
268
269                if !mode_config.nodes.contains(&node_identifier) {
270                    mode_config.nodes.push(node_identifier.clone());
271                }
272            }
273        }
274
275        // Save updated config
276        let config_json = serde_json::to_string_pretty(&config)?;
277        tokio::fs::write(&config_path, config_json).await?;
278
279        Ok(())
280    }
281}
282
283impl Default for InitService {
284    fn default() -> Self {
285        Self::new()
286    }
287}
288
289#[cfg(test)]
290mod add_node_to_config_tests {
291    use super::*;
292    use crate::component_catalog::{Component, ComponentType, Mecha10Config};
293    use mecha10_cli_core::services::ConfigService;
294
295    /// Minimal valid mecha10.json content for a freshly-initialized project.
296    fn minimal_project_config_json() -> &'static str {
297        r#"{
298            "name": "test-project",
299            "version": "0.1.0",
300            "robot": { "id": "test-robot" }
301        }"#
302    }
303
304    /// Build a placeholder `Component` sufficient for `add_node_to_config`. Plain (untagged)
305    /// components have no `relevant_lifecycle_modes()`, so they exercise only the
306    /// `nodes`/`targets` bookkeeping, not the `lifecycle.modes.*.nodes` sync.
307    fn placeholder_component(name: &str) -> Component {
308        Component {
309            id: name.to_string(),
310            name: name.to_string(),
311            description: String::new(),
312            component_type: ComponentType::Package,
313            version: "0.1.0".to_string(),
314            category: "custom".to_string(),
315            tags: Vec::new(),
316            cargo_dependencies: Vec::new(),
317            files: Vec::new(),
318            mecha10_config: Mecha10Config {
319                driver: None,
320                custom_node: None,
321                behavior: None,
322            },
323        }
324    }
325
326    /// Same as [`placeholder_component`] but tagged as an AI/perception node (e.g.
327    /// `image-classifier`), so `relevant_lifecycle_modes()` returns both `simulation` and
328    /// `hardware` (see `Component::relevant_lifecycle_modes` in
329    /// `mecha10-cli-core/src/component_catalog/types.rs`).
330    fn ai_perception_component(name: &str) -> Component {
331        Component {
332            tags: vec!["vision".to_string()],
333            ..placeholder_component(name)
334        }
335    }
336
337    #[tokio::test]
338    async fn mints_project_nodes_with_plain_identifier() {
339        let temp_dir = tempfile::tempdir().expect("create temp dir");
340        let project_root = temp_dir.path();
341        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
342            .await
343            .expect("write mecha10.json");
344
345        let service = InitService::new();
346        let component = placeholder_component("my-custom");
347
348        service
349            .add_node_to_config(project_root, "my-custom", &component, false)
350            .await
351            .expect("add_node_to_config should succeed");
352
353        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
354            .await
355            .expect("reload config");
356
357        assert!(
358            config.nodes.identifiers().contains(&"my-custom"),
359            "expected plain identifier 'my-custom' in nodes, got: {:?}",
360            config.nodes.identifiers()
361        );
362        assert!(
363            !config.nodes.identifiers().contains(&"@local/my-custom"),
364            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
365            config.nodes.identifiers()
366        );
367    }
368
369    #[tokio::test]
370    async fn mints_framework_nodes_with_mecha10_scope() {
371        let temp_dir = tempfile::tempdir().expect("create temp dir");
372        let project_root = temp_dir.path();
373        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
374            .await
375            .expect("write mecha10.json");
376
377        let service = InitService::new();
378        let component = placeholder_component("speaker");
379
380        service
381            .add_node_to_config(project_root, "speaker", &component, true)
382            .await
383            .expect("add_node_to_config should succeed");
384
385        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
386            .await
387            .expect("reload config");
388
389        assert!(
390            config.nodes.identifiers().contains(&"@mecha10/speaker"),
391            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
392            config.nodes.identifiers()
393        );
394    }
395
396    #[tokio::test]
397    async fn appends_ai_perception_node_to_simulation_and_hardware_modes_when_lifecycle_absent() {
398        let temp_dir = tempfile::tempdir().expect("create temp dir");
399        let project_root = temp_dir.path();
400        // No `lifecycle` key at all - mirrors a freshly-initialized project before any mode
401        // config exists.
402        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
403            .await
404            .expect("write mecha10.json");
405
406        let service = InitService::new();
407        let component = ai_perception_component("image-classifier");
408
409        service
410            .add_node_to_config(project_root, "image-classifier", &component, true)
411            .await
412            .expect("add_node_to_config should succeed");
413
414        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
415            .await
416            .expect("reload config");
417
418        let lifecycle = config
419            .lifecycle
420            .expect("lifecycle should be created when a relevant node is added");
421        for mode in ["simulation", "hardware"] {
422            let mode_nodes = &lifecycle
423                .modes
424                .get(mode)
425                .unwrap_or_else(|| panic!("expected '{mode}' mode to be created"))
426                .nodes;
427            assert!(
428                mode_nodes.contains(&"@mecha10/image-classifier".to_string()),
429                "expected @mecha10/image-classifier in lifecycle.modes.{mode}.nodes, got {mode_nodes:?}"
430            );
431        }
432    }
433
434    #[tokio::test]
435    async fn appends_ai_perception_node_to_existing_mode_lists_without_duplicating() {
436        let temp_dir = tempfile::tempdir().expect("create temp dir");
437        let project_root = temp_dir.path();
438        let config_with_lifecycle = r#"{
439            "name": "test-project",
440            "version": "0.1.0",
441            "robot": { "id": "test-robot" },
442            "lifecycle": {
443                "modes": {
444                    "simulation": { "nodes": ["@mecha10/simulator"] },
445                    "hardware": { "nodes": ["@mecha10/websocket-bridge", "@mecha10/image-classifier"] }
446                }
447            }
448        }"#;
449        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_lifecycle)
450            .await
451            .expect("write mecha10.json");
452
453        let service = InitService::new();
454        let component = ai_perception_component("image-classifier");
455
456        service
457            .add_node_to_config(project_root, "image-classifier", &component, true)
458            .await
459            .expect("add_node_to_config should succeed");
460
461        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
462            .await
463            .expect("reload config");
464        let lifecycle = config.lifecycle.expect("lifecycle should be preserved");
465
466        let simulation_nodes = &lifecycle.modes.get("simulation").unwrap().nodes;
467        assert_eq!(
468            simulation_nodes,
469            &vec![
470                "@mecha10/simulator".to_string(),
471                "@mecha10/image-classifier".to_string()
472            ],
473            "expected image-classifier appended to the existing simulation mode nodes"
474        );
475
476        let hardware_nodes = &lifecycle.modes.get("hardware").unwrap().nodes;
477        assert_eq!(
478            hardware_nodes,
479            &vec![
480                "@mecha10/websocket-bridge".to_string(),
481                "@mecha10/image-classifier".to_string()
482            ],
483            "expected no duplicate @mecha10/image-classifier entry in hardware mode nodes, got {hardware_nodes:?}"
484        );
485    }
486
487    #[tokio::test]
488    async fn plain_component_does_not_create_lifecycle_config() {
489        let temp_dir = tempfile::tempdir().expect("create temp dir");
490        let project_root = temp_dir.path();
491        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
492            .await
493            .expect("write mecha10.json");
494
495        let service = InitService::new();
496        let component = placeholder_component("my-custom");
497
498        service
499            .add_node_to_config(project_root, "my-custom", &component, false)
500            .await
501            .expect("add_node_to_config should succeed");
502
503        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
504            .await
505            .expect("reload config");
506
507        assert!(
508            config.lifecycle.is_none(),
509            "a component with no relevant lifecycle modes must not synthesize a lifecycle config"
510        );
511    }
512}