mecha10_templates/services/
init_service.rs1use crate::paths;
8use anyhow::Result;
9use std::path::Path;
10
11pub struct InitService;
27
28impl InitService {
29 pub fn new() -> Self {
31 Self
32 }
33
34 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 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 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 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 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
235fn 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 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 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 fn ai_perception_component(name: &str) -> Component {
296 Component {
297 tags: vec!["vision".to_string()],
298 ..placeholder_component(name)
299 }
300 }
301
302 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}