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    /// - configs/simulation/ - Simulation environments
46    /// - configs/simulation/models/ - Robot physical models
47    /// - configs/rl/tasks/ - RL task-curriculum configs (LAB-2429 - not under configs/simulation/,
48    ///   see `paths::project::RL_TASKS_DIR`'s doc comment)
49    /// - configs/simulation/images/ - Billboard image assets for simulation environments/tasks
50    pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
51        let dirs = vec![
52            paths::project::NODES_DIR,
53            paths::project::DRIVERS_DIR,
54            paths::project::TYPES_DIR,
55            paths::project::BEHAVIORS_DIR,
56            paths::project::LOGS_DIR,
57            paths::project::MODELS_DIR,
58            paths::project::SIMULATION_DIR,
59            paths::project::SIMULATION_MODELS_DIR,
60            paths::project::SIMULATION_ENVIRONMENTS_DIR,
61            paths::project::SIMULATION_IMAGES_DIR,
62            paths::project::RL_TASKS_DIR,
63            paths::config::NODES_DIR,
64        ];
65
66        for dir in dirs {
67            let dir_path = path.join(dir);
68            tokio::fs::create_dir_all(&dir_path).await?;
69
70            tokio::fs::write(dir_path.join(".gitkeep"), "").await?;
71        }
72
73        Ok(())
74    }
75
76    /// Add an example node from the catalog to the project
77    ///
78    /// This method:
79    /// 1. Searches the component catalog for the component
80    /// 2. Generates component files from templates (if any)
81    /// 3. Updates Cargo.toml (workspace members or dependencies)
82    /// 4. Updates mecha10.json configuration
83    ///
84    /// # Arguments
85    ///
86    /// * `project_root` - Root path of the project
87    /// * `component_name` - Name of the component to add (e.g., "speaker", "listener")
88    pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
89        use crate::component_catalog::ComponentCatalog;
90
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        for file_template in &component.files {
101            let file_path = project_root.join(&file_template.path);
102
103            if let Some(parent) = file_path.parent() {
104                tokio::fs::create_dir_all(parent).await?;
105            }
106
107            tokio::fs::write(&file_path, &file_template.content).await?;
108        }
109
110        if !component.files.is_empty() {
111            self.add_workspace_member(project_root, component_name).await?;
112        }
113
114        let is_monorepo_node = component.files.is_empty();
115        if is_monorepo_node {
116            self.add_dependency(project_root, component).await?;
117        }
118
119        self.add_node_to_config(project_root, component_name, component, is_monorepo_node)
120            .await?;
121
122        Ok(())
123    }
124
125    /// Add a workspace member to Cargo.toml
126    async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
127        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
128        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
129
130        let new_member = format!("nodes/{}", component_name);
131        let updated_content = if content.contains("members = []") {
132            content.replace("members = []", &format!("members = [\n    \"{}\",\n]", new_member))
133        } else if let Some(start) = content.find("members = [") {
134            if let Some(end) = content[start..].find(']') {
135                let insert_pos = start + end;
136                let members_section = &content[start..insert_pos];
137                if members_section.contains('\"') {
138                    format!(
139                        "{}\n    \"{}\",{}",
140                        &content[..insert_pos],
141                        new_member,
142                        &content[insert_pos..]
143                    )
144                } else {
145                    format!(
146                        "{}\n    \"{}\",\n{}",
147                        &content[..insert_pos],
148                        new_member,
149                        &content[insert_pos..]
150                    )
151                }
152            } else {
153                content
154            }
155        } else {
156            content
157        };
158
159        tokio::fs::write(&cargo_toml_path, updated_content).await?;
160        Ok(())
161    }
162
163    /// Add a dependency to Cargo.toml
164    async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
165        let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
166        let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
167
168        let package_name = if let Some(dep) = component.cargo_dependencies.first() {
169            dep.name.clone()
170        } else {
171            format!("mecha10-nodes-{}", component.id)
172        };
173
174        if !content.contains(&package_name) {
175            let updated_content = if let Some(pos) = content.find("mecha10-core = \"0.1\"") {
176                if let Some(newline_pos) = content[pos..].find('\n') {
177                    let insert_pos = pos + newline_pos + 1;
178                    format!(
179                        "{}{} = \"0.1\"\n{}",
180                        &content[..insert_pos],
181                        package_name,
182                        &content[insert_pos..]
183                    )
184                } else {
185                    content
186                }
187            } else {
188                content
189            };
190
191            tokio::fs::write(&cargo_toml_path, updated_content).await?;
192        }
193
194        Ok(())
195    }
196
197    /// Add node entry to mecha10.json
198    async fn add_node_to_config(
199        &self,
200        project_root: &Path,
201        component_name: &str,
202        component: &crate::component_catalog::Component,
203        is_monorepo_node: bool,
204    ) -> Result<()> {
205        use crate::services::ConfigService;
206
207        let config_path = project_root.join(paths::PROJECT_CONFIG);
208        let mut config = ConfigService::load_from(&config_path).await?;
209
210        let (node_identifier, version) = if is_monorepo_node {
211            (
212                format!("@mecha10/{}", component_name),
213                env!("CARGO_PKG_VERSION").to_string(),
214            )
215        } else {
216            (component_name.to_string(), "0.1.0".to_string())
217        };
218
219        config.nodes.add_node(&node_identifier, &version);
220
221        for key in component.relevant_target_keys() {
222            let entry = target_entry_mut(&mut config.targets, key);
223            if !entry.nodes.contains(&node_identifier) {
224                entry.nodes.push(node_identifier.clone());
225            }
226        }
227
228        let config_json = serde_json::to_string_pretty(&config)?;
229        tokio::fs::write(&config_path, config_json).await?;
230
231        Ok(())
232    }
233}
234
235/// Mutable access to the `targets.<key>` entry, creating it with an empty node list if absent.
236fn target_entry_mut<'a>(
237    targets: &'a mut mecha10_cli_core::types::project::TargetsConfig,
238    key: &str,
239) -> &'a mut mecha10_cli_core::types::project::TargetEntry {
240    match key {
241        "dev" => targets.dev.get_or_insert_with(Default::default),
242        "simulation" => targets.simulation.get_or_insert_with(Default::default),
243        "robot" => targets.robot.get_or_insert_with(Default::default),
244        "remote" => targets.remote.get_or_insert_with(Default::default),
245        _ => unreachable!("target_entry_mut only called with \"dev\"/\"simulation\"/\"robot\"/\"remote\""),
246    }
247}
248
249impl Default for InitService {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255#[cfg(test)]
256mod add_node_to_config_tests {
257    use super::*;
258    use crate::component_catalog::{Component, ComponentType, Mecha10Config};
259    use mecha10_cli_core::services::ConfigService;
260
261    /// Minimal valid mecha10.json content for a freshly-initialized project.
262    fn minimal_project_config_json() -> &'static str {
263        r#"{
264            "name": "test-project",
265            "version": "0.1.0",
266            "robot_id": "test-robot"
267        }"#
268    }
269
270    /// Build a placeholder `Component` sufficient for `add_node_to_config`. Plain (untagged)
271    /// components have no `relevant_lifecycle_modes()`, so they exercise only the
272    /// `nodes` bookkeeping, not the `targets.simulation.nodes` sync.
273    fn placeholder_component(name: &str) -> Component {
274        Component {
275            id: name.to_string(),
276            name: name.to_string(),
277            description: String::new(),
278            component_type: ComponentType::Package,
279            version: "0.1.0".to_string(),
280            category: "custom".to_string(),
281            tags: Vec::new(),
282            cargo_dependencies: Vec::new(),
283            files: Vec::new(),
284            mecha10_config: Mecha10Config {
285                driver: None,
286                custom_node: None,
287                behavior: None,
288            },
289        }
290    }
291
292    /// Same as [`placeholder_component`] but tagged as an AI/perception node (e.g.
293    /// `image-classifier`), so `relevant_lifecycle_modes()` returns `simulation` (see
294    /// `Component::relevant_lifecycle_modes` in `mecha10-cli-core/src/component_catalog/types.rs`).
295    fn ai_perception_component(name: &str) -> Component {
296        Component {
297            tags: vec!["vision".to_string()],
298            ..placeholder_component(name)
299        }
300    }
301
302    /// Same as [`placeholder_component`] but hardware-backed with `run_target: "robot"`, so
303    /// `relevant_target()` returns `Some("robot")` (see `Component::relevant_target`).
304    fn robot_hardware_component(name: &str) -> Component {
305        use crate::component_catalog::{CustomNodeConfig, Mecha10Config};
306
307        Component {
308            mecha10_config: Mecha10Config {
309                driver: None,
310                custom_node: Some(CustomNodeConfig {
311                    name: name.to_string(),
312                    path: format!("mecha10-nodes-{}", name),
313                    config: None,
314                    description: String::new(),
315                    run_target: "robot".to_string(),
316                    enabled: true,
317                }),
318                behavior: None,
319            },
320            ..placeholder_component(name)
321        }
322    }
323
324    #[tokio::test]
325    async fn mints_project_nodes_with_plain_identifier() {
326        let temp_dir = tempfile::tempdir().expect("create temp dir");
327        let project_root = temp_dir.path();
328        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
329            .await
330            .expect("write mecha10.json");
331
332        let service = InitService::new();
333        let component = placeholder_component("my-custom");
334
335        service
336            .add_node_to_config(project_root, "my-custom", &component, false)
337            .await
338            .expect("add_node_to_config should succeed");
339
340        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
341            .await
342            .expect("reload config");
343
344        assert!(
345            config.nodes.identifiers().contains(&"my-custom"),
346            "expected plain identifier 'my-custom' in nodes, got: {:?}",
347            config.nodes.identifiers()
348        );
349        assert!(
350            !config.nodes.identifiers().contains(&"@local/my-custom"),
351            "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
352            config.nodes.identifiers()
353        );
354    }
355
356    #[tokio::test]
357    async fn mints_framework_nodes_with_mecha10_scope() {
358        let temp_dir = tempfile::tempdir().expect("create temp dir");
359        let project_root = temp_dir.path();
360        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
361            .await
362            .expect("write mecha10.json");
363
364        let service = InitService::new();
365        let component = placeholder_component("speaker");
366
367        service
368            .add_node_to_config(project_root, "speaker", &component, true)
369            .await
370            .expect("add_node_to_config should succeed");
371
372        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
373            .await
374            .expect("reload config");
375
376        assert!(
377            config.nodes.identifiers().contains(&"@mecha10/speaker"),
378            "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
379            config.nodes.identifiers()
380        );
381    }
382
383    #[tokio::test]
384    async fn appends_ai_perception_node_to_simulation_target_when_targets_absent() {
385        let temp_dir = tempfile::tempdir().expect("create temp dir");
386        let project_root = temp_dir.path();
387        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
388            .await
389            .expect("write mecha10.json");
390
391        let service = InitService::new();
392        let component = ai_perception_component("image-classifier");
393
394        service
395            .add_node_to_config(project_root, "image-classifier", &component, true)
396            .await
397            .expect("add_node_to_config should succeed");
398
399        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
400            .await
401            .expect("reload config");
402
403        let simulation = config
404            .targets
405            .simulation
406            .expect("targets.simulation should be created when a relevant node is added");
407        assert!(
408            simulation.nodes.contains(&"@mecha10/image-classifier".to_string()),
409            "expected @mecha10/image-classifier in targets.simulation.nodes, got {:?}",
410            simulation.nodes
411        );
412    }
413
414    #[tokio::test]
415    async fn appends_ai_perception_node_to_existing_target_lists_without_duplicating() {
416        let temp_dir = tempfile::tempdir().expect("create temp dir");
417        let project_root = temp_dir.path();
418        let config_with_targets = r#"{
419            "name": "test-project",
420            "version": "0.1.0",
421            "robot_id": "test-robot",
422            "targets": {
423                "simulation": { "nodes": ["@mecha10/simulator"] }
424            }
425        }"#;
426        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
427            .await
428            .expect("write mecha10.json");
429
430        let service = InitService::new();
431        let component = ai_perception_component("image-classifier");
432
433        service
434            .add_node_to_config(project_root, "image-classifier", &component, true)
435            .await
436            .expect("add_node_to_config should succeed");
437
438        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
439            .await
440            .expect("reload config");
441        let simulation = config
442            .targets
443            .simulation
444            .expect("targets.simulation should be preserved");
445
446        assert_eq!(
447            simulation.nodes,
448            vec![
449                "@mecha10/simulator".to_string(),
450                "@mecha10/image-classifier".to_string()
451            ],
452            "expected image-classifier appended to the existing simulation target nodes"
453        );
454    }
455
456    #[tokio::test]
457    async fn plain_component_does_not_create_simulation_target() {
458        let temp_dir = tempfile::tempdir().expect("create temp dir");
459        let project_root = temp_dir.path();
460        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
461            .await
462            .expect("write mecha10.json");
463
464        let service = InitService::new();
465        let component = placeholder_component("my-custom");
466
467        service
468            .add_node_to_config(project_root, "my-custom", &component, false)
469            .await
470            .expect("add_node_to_config should succeed");
471
472        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
473            .await
474            .expect("reload config");
475
476        assert!(
477            config.targets.simulation.is_none(),
478            "a component with no relevant lifecycle modes must not synthesize a targets.simulation entry"
479        );
480    }
481
482    #[tokio::test]
483    async fn syncs_hardware_backed_node_into_targets_robot() {
484        let temp_dir = tempfile::tempdir().expect("create temp dir");
485        let project_root = temp_dir.path();
486        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
487            .await
488            .expect("write mecha10.json");
489
490        let service = InitService::new();
491        let component = robot_hardware_component("motor");
492
493        service
494            .add_node_to_config(project_root, "motor", &component, true)
495            .await
496            .expect("add_node_to_config should succeed");
497
498        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
499            .await
500            .expect("reload config");
501
502        let robot = config
503            .targets
504            .robot
505            .expect("targets.robot should be created for a robot-targeted node");
506        assert!(
507            robot.nodes.contains(&"@mecha10/motor".to_string()),
508            "expected @mecha10/motor in targets.robot.nodes, got {:?}",
509            robot.nodes
510        );
511        assert!(
512            config.targets.remote.is_none() || config.targets.remote.unwrap().nodes.is_empty(),
513            "a robot-targeted node must not also be added to targets.remote"
514        );
515    }
516
517    #[tokio::test]
518    async fn does_not_duplicate_hardware_node_already_present_in_targets_robot() {
519        let temp_dir = tempfile::tempdir().expect("create temp dir");
520        let project_root = temp_dir.path();
521        let config_with_targets = r#"{
522            "name": "test-project",
523            "version": "0.1.0",
524            "robot_id": "test-robot",
525            "targets": {
526                "robot": { "nodes": ["@mecha10/motor"] }
527            }
528        }"#;
529        tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
530            .await
531            .expect("write mecha10.json");
532
533        let service = InitService::new();
534        let component = robot_hardware_component("motor");
535
536        service
537            .add_node_to_config(project_root, "motor", &component, true)
538            .await
539            .expect("add_node_to_config should succeed");
540
541        let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
542            .await
543            .expect("reload config");
544        let robot = config.targets.robot.expect("targets.robot should be preserved");
545        let occurrences = robot.nodes.iter().filter(|n| n.as_str() == "@mecha10/motor").count();
546        assert_eq!(
547            occurrences, 1,
548            "expected exactly one @mecha10/motor entry in targets.robot.nodes, got {:?}",
549            robot.nodes
550        );
551    }
552}