mecha10_templates/services/
init_service.rs1use crate::paths;
6use anyhow::{Context, Result};
7use mecha10_cli_core::utils::build_client;
8use std::path::Path;
9
10const CRATES_IO_USER_AGENT: &str = "mecha10-cli (+https://github.com/mecha10/mecha10; contact: releases@mecha10.dev)";
14
15#[derive(serde::Deserialize)]
18struct CratesIoCrateResponse {
19 #[serde(rename = "crate")]
20 krate: CratesIoCrateMeta,
21}
22
23#[derive(serde::Deserialize)]
24struct CratesIoCrateMeta {
25 max_version: String,
26}
27
28async fn latest_published_crate_version(package_name: &str) -> Result<String> {
35 let url = format!("https://crates.io/api/v1/crates/{}", package_name);
36
37 let client = build_client()?;
38 let response = client
39 .get(&url)
40 .header("User-Agent", CRATES_IO_USER_AGENT)
41 .send()
42 .await
43 .with_context(|| format!("Failed to query crates.io for latest version of {}", package_name))?;
44
45 if !response.status().is_success() {
46 anyhow::bail!(
47 "Failed to look up latest published version of {} on crates.io: HTTP {}\nURL: {}",
48 package_name,
49 response.status(),
50 url
51 );
52 }
53
54 let body = response
55 .text()
56 .await
57 .with_context(|| format!("Failed to read crates.io response body for {}", package_name))?;
58
59 parse_max_version(&body, package_name)
60}
61
62fn parse_max_version(body: &str, package_name: &str) -> Result<String> {
66 let parsed: CratesIoCrateResponse = serde_json::from_str(body)
67 .with_context(|| format!("Failed to parse crates.io response for {}", package_name))?;
68
69 Ok(parsed.krate.max_version)
70}
71
72pub struct InitService;
73
74impl InitService {
75 pub fn new() -> Self {
77 Self
78 }
79
80 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
82 let dirs = vec![
83 paths::project::NODES_DIR,
84 paths::project::DRIVERS_DIR,
85 paths::project::TYPES_DIR,
86 paths::project::BEHAVIORS_DIR,
87 paths::project::LOGS_DIR,
88 paths::project::MODELS_DIR,
89 paths::project::MODELS_CATALOG_DIR,
90 paths::project::SIMULATION_DIR,
91 paths::project::SIMULATION_MODELS_DIR,
92 paths::project::SIMULATION_ENVIRONMENTS_DIR,
93 paths::project::SIMULATION_IMAGES_DIR,
94 paths::project::RL_TASKS_DIR,
95 paths::config::NODES_DIR,
96 ];
97
98 for dir in dirs {
99 let dir_path = path.join(dir);
100 tokio::fs::create_dir_all(&dir_path).await?;
101 }
102
103 Ok(())
104 }
105
106 pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
109 use crate::component_catalog::ComponentCatalog;
110
111 let catalog = ComponentCatalog::new();
112 let components = catalog.search(component_name);
113
114 if components.is_empty() {
115 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
116 }
117
118 let component = &components[0];
119
120 for file_template in &component.files {
121 let file_path = project_root.join(&file_template.path);
122
123 if let Some(parent) = file_path.parent() {
124 tokio::fs::create_dir_all(parent).await?;
125 }
126
127 tokio::fs::write(&file_path, &file_template.content).await?;
128 }
129
130 if !component.files.is_empty() {
131 self.add_workspace_member(project_root, component_name).await?;
132 }
133
134 let is_monorepo_node = component.files.is_empty();
135 if is_monorepo_node {
136 self.add_dependency(project_root, component).await?;
137 }
138
139 self.add_node_to_config(project_root, component_name, is_monorepo_node)
140 .await?;
141
142 Ok(())
143 }
144
145 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 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
153 } else if let Some(start) = content.find("members = [") {
154 if let Some(end) = content[start..].find(']') {
155 let insert_pos = start + end;
156 let members_section = &content[start..insert_pos];
157 if members_section.contains('\"') {
158 format!(
159 "{}\n \"{}\",{}",
160 &content[..insert_pos],
161 new_member,
162 &content[insert_pos..]
163 )
164 } else {
165 format!(
166 "{}\n \"{}\",\n{}",
167 &content[..insert_pos],
168 new_member,
169 &content[insert_pos..]
170 )
171 }
172 } else {
173 content
174 }
175 } else {
176 content
177 };
178
179 tokio::fs::write(&cargo_toml_path, updated_content).await?;
180 Ok(())
181 }
182
183 fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
194 if component.cargo_dependencies.is_empty() {
195 return vec![format!("mecha10-nodes-{}", component.id)];
196 }
197
198 component
199 .cargo_dependencies
200 .iter()
201 .filter(|dep| dep.path.is_none())
202 .map(|dep| dep.name.clone())
203 .collect()
204 }
205
206 fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
210 let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
211 return content.to_string();
212 };
213 let Some(newline_pos) = content[pos..].find('\n') else {
214 return content.to_string();
215 };
216 let insert_pos = pos + newline_pos + 1;
217 format!(
218 "{}{} = \"{}\"\n{}",
219 &content[..insert_pos],
220 package_name,
221 version,
222 &content[insert_pos..]
223 )
224 }
225
226 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
228 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
229 let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
230
231 let mut changed = false;
232
233 for package_name in Self::external_dependency_names(component) {
234 if content.contains(&package_name) {
235 continue;
236 }
237
238 let version = latest_published_crate_version(&package_name).await?;
239 content = Self::insert_dependency_version(&content, &package_name, &version);
240 changed = true;
241 }
242
243 if changed {
244 tokio::fs::write(&cargo_toml_path, content).await?;
245 }
246
247 Ok(())
248 }
249
250 async fn add_node_to_config(
253 &self,
254 project_root: &Path,
255 component_name: &str,
256 is_monorepo_node: bool,
257 ) -> Result<()> {
258 use crate::services::ConfigService;
259
260 let config_path = project_root.join(paths::PROJECT_CONFIG);
261 let mut config = ConfigService::load_from(&config_path).await?;
262
263 let (node_identifier, version) = if is_monorepo_node {
264 (
265 format!("@mecha10/{}", component_name),
266 env!("CARGO_PKG_VERSION").to_string(),
267 )
268 } else {
269 (component_name.to_string(), "0.1.0".to_string())
270 };
271
272 if config.nodes.get_version(&node_identifier).is_none() {
273 config.nodes.add_node(&node_identifier, &version);
274
275 let config_json = serde_json::to_string_pretty(&config)?;
276 tokio::fs::write(&config_path, config_json).await?;
277 }
278
279 Ok(())
280 }
281}
282
283impl Default for InitService {
284 fn default() -> Self {
285 Self::new()
286 }
287}
288
289#[cfg(test)]
290mod parse_max_version_tests {
291 use super::*;
292
293 #[test]
294 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
295 let body = r#"{
296 "crate": {
297 "id": "mecha10-nodes-behavior-executor",
298 "max_version": "0.4.2",
299 "max_stable_version": "0.4.2"
300 },
301 "versions": []
302 }"#;
303
304 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
305 assert_eq!(version, "0.4.2");
306 }
307
308 #[test]
309 fn errors_instead_of_silently_falling_back_on_malformed_body() {
310 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
311 assert!(result.is_err());
312 }
313}
314
315#[cfg(test)]
316mod add_dependency_tests {
317 use super::*;
318
319 fn minimal_cargo_toml() -> &'static str {
322 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
323 }
324
325 #[tokio::test]
326 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
327 let temp_dir = tempfile::tempdir().expect("create temp dir");
330 let project_root = temp_dir.path();
331 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
332
333 let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
334 tokio::fs::write(&cargo_toml_path, &existing)
335 .await
336 .expect("write Cargo.toml");
337
338 let catalog = crate::component_catalog::ComponentCatalog::new();
339 let component = catalog
340 .get("behavior-executor")
341 .expect("behavior-executor is in the catalog")
342 .clone();
343
344 let service = InitService::new();
345 service
346 .add_dependency(project_root, &component)
347 .await
348 .expect("add_dependency should no-op without touching the network");
349
350 let updated = tokio::fs::read_to_string(&cargo_toml_path)
351 .await
352 .expect("read Cargo.toml");
353 assert_eq!(
354 updated, existing,
355 "an already-present dependency must be left untouched"
356 );
357 }
358
359 #[tokio::test]
360 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
361 let temp_dir = tempfile::tempdir().expect("create temp dir");
366 let project_root = temp_dir.path();
367 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
368
369 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
370 tokio::fs::write(&cargo_toml_path, &existing)
371 .await
372 .expect("write Cargo.toml");
373
374 let catalog = crate::component_catalog::ComponentCatalog::new();
375 let component = catalog
376 .get("realsense-camera")
377 .expect("realsense-camera is in the catalog")
378 .clone();
379
380 let service = InitService::new();
381 service
382 .add_dependency(project_root, &component)
383 .await
384 .expect("add_dependency should no-op without touching the network");
385
386 let updated = tokio::fs::read_to_string(&cargo_toml_path)
387 .await
388 .expect("read Cargo.toml");
389 assert_eq!(
390 updated, existing,
391 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
392 );
393 }
394
395 mod external_dependency_names_tests {
399 use super::*;
400
401 #[test]
402 fn resolves_only_the_single_external_crate_for_a_package_component() {
403 let catalog = crate::component_catalog::ComponentCatalog::new();
407 let component = catalog
408 .get("behavior-executor")
409 .expect("behavior-executor is in the catalog");
410
411 let names = InitService::external_dependency_names(component);
412
413 assert_eq!(
414 names,
415 vec!["mecha10-nodes-behavior-executor".to_string()],
416 "a Package component must resolve exactly its own node crate"
417 );
418 }
419
420 #[test]
421 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
422 let catalog = crate::component_catalog::ComponentCatalog::new();
427 let component = catalog
428 .get("realsense-camera")
429 .expect("realsense-camera is in the catalog");
430
431 let names = InitService::external_dependency_names(component);
432
433 assert_eq!(
434 names,
435 vec!["realsense-rust".to_string()],
436 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
437 );
438 }
439 }
440
441 mod insert_dependency_version_tests {
442 use super::*;
443
444 #[test]
445 fn inserts_right_after_the_mecha10_core_anchor_line() {
446 let content = minimal_cargo_toml();
447
448 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
449
450 assert_eq!(
451 updated,
452 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
453 );
454 }
455 }
456
457 #[tokio::test]
458 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
459 let temp_dir = tempfile::tempdir().expect("create temp dir");
463 let project_root = temp_dir.path();
464 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
465 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
466 .await
467 .expect("write Cargo.toml");
468
469 let catalog = crate::component_catalog::ComponentCatalog::new();
470 let component = catalog
471 .get("realsense-camera")
472 .expect("realsense-camera is in the catalog")
473 .clone();
474
475 let names = InitService::external_dependency_names(&component);
479 assert_eq!(names, vec!["realsense-rust".to_string()]);
480
481 let content = tokio::fs::read_to_string(&cargo_toml_path)
482 .await
483 .expect("read Cargo.toml");
484 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
485 tokio::fs::write(&cargo_toml_path, &updated)
486 .await
487 .expect("write Cargo.toml");
488
489 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
490 .await
491 .expect("read Cargo.toml");
492 assert!(
493 final_content.contains("realsense-rust = \"0.6.1\""),
494 "the driver's real third-party crate must be added, got: {}",
495 final_content
496 );
497 assert_eq!(
498 final_content.matches("mecha10-core").count(),
499 1,
500 "mecha10-core must not be duplicated, got: {}",
501 final_content
502 );
503 }
504}
505
506#[cfg(test)]
507mod add_node_to_config_tests {
508 use super::*;
509 use mecha10_cli_core::services::ConfigService;
510
511 fn minimal_project_config_json() -> &'static str {
513 r#"{
514 "name": "test-project",
515 "version": "0.1.0",
516 "robot_id": "test-robot"
517 }"#
518 }
519
520 #[tokio::test]
521 async fn mints_project_nodes_with_plain_identifier() {
522 let temp_dir = tempfile::tempdir().expect("create temp dir");
523 let project_root = temp_dir.path();
524 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
525 .await
526 .expect("write mecha10.json");
527
528 let service = InitService::new();
529
530 service
531 .add_node_to_config(project_root, "my-custom", false)
532 .await
533 .expect("add_node_to_config should succeed");
534
535 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
536 .await
537 .expect("reload config");
538
539 assert!(
540 config.nodes.identifiers().contains(&"my-custom"),
541 "expected plain identifier 'my-custom' in nodes, got: {:?}",
542 config.nodes.identifiers()
543 );
544 assert!(
545 !config.nodes.identifiers().contains(&"@local/my-custom"),
546 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
547 config.nodes.identifiers()
548 );
549 }
550
551 #[tokio::test]
554 async fn preserves_template_version_written_by_mecha10_init() {
555 let temp_dir = tempfile::tempdir().expect("create temp dir");
556 let project_root = temp_dir.path();
557 tokio::fs::write(
558 project_root.join(paths::PROJECT_CONFIG),
559 r#"{
560 "name": "test-project",
561 "version": "0.6.5",
562 "template_version": "0.6.5",
563 "robot_id": "test-robot"
564 }"#,
565 )
566 .await
567 .expect("write mecha10.json");
568
569 let service = InitService::new();
570
571 service
572 .add_node_to_config(project_root, "speaker", true)
573 .await
574 .expect("add_node_to_config should succeed");
575
576 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
577 .await
578 .expect("reload config");
579
580 assert_eq!(
581 config.template_version,
582 Some("0.6.5".to_string()),
583 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
584 );
585 }
586
587 #[tokio::test]
588 async fn mints_framework_nodes_with_mecha10_scope() {
589 let temp_dir = tempfile::tempdir().expect("create temp dir");
590 let project_root = temp_dir.path();
591 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
592 .await
593 .expect("write mecha10.json");
594
595 let service = InitService::new();
596
597 service
598 .add_node_to_config(project_root, "speaker", true)
599 .await
600 .expect("add_node_to_config should succeed");
601
602 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
603 .await
604 .expect("reload config");
605
606 assert!(
607 config.nodes.identifiers().contains(&"@mecha10/speaker"),
608 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
609 config.nodes.identifiers()
610 );
611 }
612
613 #[tokio::test]
617 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
618 let temp_dir = tempfile::tempdir().expect("create temp dir");
619 let project_root = temp_dir.path();
620 let config_with_pinned_node = r#"{
621 "name": "test-project",
622 "version": "0.1.0",
623 "robot_id": "test-robot",
624 "nodes": { "@mecha10/speaker": "0.7.0" }
625 }"#;
626 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
627 .await
628 .expect("write mecha10.json");
629
630 let service = InitService::new();
631
632 service
633 .add_node_to_config(project_root, "speaker", true)
634 .await
635 .expect("add_node_to_config should succeed");
636
637 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
638 .await
639 .expect("reload config");
640
641 assert_eq!(
642 config.nodes.get_version("@mecha10/speaker"),
643 Some("0.7.0"),
644 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
645 reset to this crate's own CARGO_PKG_VERSION"
646 );
647 }
648
649 #[tokio::test]
652 async fn does_not_touch_targets_at_all() {
653 let temp_dir = tempfile::tempdir().expect("create temp dir");
654 let project_root = temp_dir.path();
655 let config_with_targets = r#"{
656 "name": "test-project",
657 "version": "0.1.0",
658 "robot_id": "test-robot",
659 "targets": {
660 "robot": { "nodes": ["@mecha10/camera"] }
661 }
662 }"#;
663 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
664 .await
665 .expect("write mecha10.json");
666
667 let service = InitService::new();
668
669 service
670 .add_node_to_config(project_root, "image-classifier", true)
671 .await
672 .expect("add_node_to_config should succeed");
673
674 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
675 .await
676 .expect("reload config");
677
678 assert!(
679 config.targets.simulation.is_none(),
680 "add_node_to_config must not synthesize any targets.* entry anymore"
681 );
682 let robot = config
683 .targets
684 .robot
685 .expect("pre-existing targets.robot must be preserved");
686 assert_eq!(
687 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
688 vec!["@mecha10/camera".to_string()],
689 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
690 robot.nodes
691 );
692 }
693}