1use crate::paths;
6use anyhow::{Context, Result};
7use mecha10_cli_core::types::project::NodeEntry;
8use mecha10_cli_core::utils::build_client;
9use std::path::Path;
10
11const CRATES_IO_USER_AGENT: &str = "mecha10-cli (+https://github.com/mecha10/mecha10; contact: releases@mecha10.dev)";
15
16#[derive(serde::Deserialize)]
19struct CratesIoCrateResponse {
20 #[serde(rename = "crate")]
21 krate: CratesIoCrateMeta,
22}
23
24#[derive(serde::Deserialize)]
25struct CratesIoCrateMeta {
26 max_version: String,
27}
28
29async fn latest_published_crate_version(package_name: &str) -> Result<String> {
36 let url = format!("https://crates.io/api/v1/crates/{}", package_name);
37
38 let client = build_client()?;
39 let response = client
40 .get(&url)
41 .header("User-Agent", CRATES_IO_USER_AGENT)
42 .send()
43 .await
44 .with_context(|| format!("Failed to query crates.io for latest version of {}", package_name))?;
45
46 if !response.status().is_success() {
47 anyhow::bail!(
48 "Failed to look up latest published version of {} on crates.io: HTTP {}\nURL: {}",
49 package_name,
50 response.status(),
51 url
52 );
53 }
54
55 let body = response
56 .text()
57 .await
58 .with_context(|| format!("Failed to read crates.io response body for {}", package_name))?;
59
60 parse_max_version(&body, package_name)
61}
62
63fn parse_max_version(body: &str, package_name: &str) -> Result<String> {
67 let parsed: CratesIoCrateResponse = serde_json::from_str(body)
68 .with_context(|| format!("Failed to parse crates.io response for {}", package_name))?;
69
70 Ok(parsed.krate.max_version)
71}
72
73pub struct InitService;
74
75impl InitService {
76 pub fn new() -> Self {
78 Self
79 }
80
81 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
83 let dirs = vec![
84 paths::project::NODES_DIR,
85 paths::project::DRIVERS_DIR,
86 paths::project::TYPES_DIR,
87 paths::project::BEHAVIORS_DIR,
88 paths::project::LOGS_DIR,
89 paths::project::MODELS_DIR,
90 paths::project::MODELS_CATALOG_DIR,
91 paths::project::SIMULATION_DIR,
92 paths::project::SIMULATION_MODELS_DIR,
93 paths::project::SIMULATION_ENVIRONMENTS_DIR,
94 paths::project::SIMULATION_IMAGES_DIR,
95 paths::project::RL_TASKS_DIR,
96 paths::config::NODES_DIR,
97 ];
98
99 for dir in dirs {
100 let dir_path = path.join(dir);
101 tokio::fs::create_dir_all(&dir_path).await?;
102 }
103
104 Ok(())
105 }
106
107 pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
114 use crate::component_catalog::ComponentCatalog;
115
116 let catalog = ComponentCatalog::new();
117 let components = catalog.search(component_name);
118
119 if components.is_empty() {
120 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
121 }
122
123 let component = &components[0];
124
125 for file_template in &component.files {
126 let file_path = project_root.join(&file_template.path);
127
128 if let Some(parent) = file_path.parent() {
129 tokio::fs::create_dir_all(parent).await?;
130 }
131
132 tokio::fs::write(&file_path, &file_template.content).await?;
133 }
134
135 if !component.files.is_empty() {
136 self.add_workspace_member(project_root, component_name).await?;
137 }
138
139 let is_monorepo_node = component.files.is_empty();
140 if is_monorepo_node {
141 self.add_dependency(project_root, component).await?;
142 }
143
144 self.add_node_to_config(project_root, component_name, is_monorepo_node)
145 .await?;
146
147 Ok(())
148 }
149
150 pub async fn sync_node_capabilities(
161 &self,
162 project_root: &Path,
163 component_name: &str,
164 is_monorepo_node: bool,
165 ) -> Result<()> {
166 self.add_node_to_config(project_root, component_name, is_monorepo_node)
167 .await
168 }
169
170 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
172 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
173 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
174
175 let new_member = format!("nodes/{}", component_name);
176 let updated_content = if content.contains("members = []") {
177 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
178 } else if let Some(start) = content.find("members = [") {
179 if let Some(end) = content[start..].find(']') {
180 let insert_pos = start + end;
181 let members_section = &content[start..insert_pos];
182 if members_section.contains('\"') {
183 format!(
184 "{}\n \"{}\",{}",
185 &content[..insert_pos],
186 new_member,
187 &content[insert_pos..]
188 )
189 } else {
190 format!(
191 "{}\n \"{}\",\n{}",
192 &content[..insert_pos],
193 new_member,
194 &content[insert_pos..]
195 )
196 }
197 } else {
198 content
199 }
200 } else {
201 content
202 };
203
204 tokio::fs::write(&cargo_toml_path, updated_content).await?;
205 Ok(())
206 }
207
208 fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
233 if component.cargo_dependencies.is_empty() {
234 return vec![];
235 }
236
237 let own_node_crate = format!("mecha10-nodes-{}", component.id);
238
239 component
240 .cargo_dependencies
241 .iter()
242 .filter(|dep| dep.path.is_none())
243 .map(|dep| dep.name.clone())
244 .filter(|name| *name != own_node_crate)
245 .collect()
246 }
247
248 fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
252 let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
253 return content.to_string();
254 };
255 let Some(newline_pos) = content[pos..].find('\n') else {
256 return content.to_string();
257 };
258 let insert_pos = pos + newline_pos + 1;
259 format!(
260 "{}{} = \"{}\"\n{}",
261 &content[..insert_pos],
262 package_name,
263 version,
264 &content[insert_pos..]
265 )
266 }
267
268 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
276 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
277 let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
278
279 let mut changed = false;
280
281 for package_name in Self::external_dependency_names(component) {
282 if content.contains(&package_name) {
283 continue;
284 }
285
286 let version = latest_published_crate_version(&package_name).await?;
287 content = Self::insert_dependency_version(&content, &package_name, &version);
288 changed = true;
289 }
290
291 if changed {
292 tokio::fs::write(&cargo_toml_path, content).await?;
293 }
294
295 Ok(())
296 }
297
298 async fn add_node_to_config(
309 &self,
310 project_root: &Path,
311 component_name: &str,
312 is_monorepo_node: bool,
313 ) -> Result<()> {
314 use crate::services::ConfigService;
315
316 let config_path = project_root.join(paths::PROJECT_CONFIG);
317 let mut config = ConfigService::load_from(&config_path).await?;
318
319 let (node_identifier, version) = if is_monorepo_node {
320 (
321 format!("@mecha10/{}", component_name),
322 env!("CARGO_PKG_VERSION").to_string(),
323 )
324 } else {
325 (component_name.to_string(), "0.1.0".to_string())
326 };
327
328 if config.nodes.0.contains_key(&node_identifier) {
329 return Ok(());
330 }
331
332 config.nodes.0.insert(node_identifier, NodeEntry::Version(version));
333
334 let config_json = serde_json::to_string_pretty(&config)?;
335 tokio::fs::write(&config_path, config_json).await?;
336
337 Ok(())
338 }
339}
340
341impl Default for InitService {
342 fn default() -> Self {
343 Self::new()
344 }
345}
346
347#[cfg(test)]
348mod parse_max_version_tests {
349 use super::*;
350
351 #[test]
352 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
353 let body = r#"{
354 "crate": {
355 "id": "mecha10-nodes-behavior-executor",
356 "max_version": "0.4.2",
357 "max_stable_version": "0.4.2"
358 },
359 "versions": []
360 }"#;
361
362 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
363 assert_eq!(version, "0.4.2");
364 }
365
366 #[test]
367 fn errors_instead_of_silently_falling_back_on_malformed_body() {
368 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
369 assert!(result.is_err());
370 }
371}
372
373#[cfg(test)]
374mod add_dependency_tests {
375 use super::*;
376
377 fn minimal_cargo_toml() -> &'static str {
380 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
381 }
382
383 #[tokio::test]
384 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
385 let temp_dir = tempfile::tempdir().expect("create temp dir");
388 let project_root = temp_dir.path();
389 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
390
391 let existing = format!("{}realsense-rust = \"0.3.0\"\n", minimal_cargo_toml());
392 tokio::fs::write(&cargo_toml_path, &existing)
393 .await
394 .expect("write Cargo.toml");
395
396 let catalog = crate::component_catalog::ComponentCatalog::new();
397 let component = catalog
398 .get("realsense-camera")
399 .expect("realsense-camera is in the catalog")
400 .clone();
401
402 let service = InitService::new();
403 service
404 .add_dependency(project_root, &component)
405 .await
406 .expect("add_dependency should no-op without touching the network");
407
408 let updated = tokio::fs::read_to_string(&cargo_toml_path)
409 .await
410 .expect("read Cargo.toml");
411 assert_eq!(
412 updated, existing,
413 "an already-present dependency must be left untouched"
414 );
415 }
416
417 #[tokio::test]
418 async fn is_a_complete_no_op_for_a_package_node_regardless_of_cargo_toml_content() {
419 let temp_dir = tempfile::tempdir().expect("create temp dir");
425 let project_root = temp_dir.path();
426 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
427
428 let existing = minimal_cargo_toml().to_string();
429 tokio::fs::write(&cargo_toml_path, &existing)
430 .await
431 .expect("write Cargo.toml");
432
433 let catalog = crate::component_catalog::ComponentCatalog::new();
434 let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog").clone();
435
436 let service = InitService::new();
437 service
438 .add_dependency(project_root, &component)
439 .await
440 .expect("add_dependency should no-op without touching the network");
441
442 let updated = tokio::fs::read_to_string(&cargo_toml_path)
443 .await
444 .expect("read Cargo.toml");
445 assert_eq!(
446 updated, existing,
447 "a Package node's own mecha10-nodes-<id> crate must never be added to Cargo.toml"
448 );
449 }
450
451 #[tokio::test]
452 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
453 let temp_dir = tempfile::tempdir().expect("create temp dir");
458 let project_root = temp_dir.path();
459 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
460
461 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
462 tokio::fs::write(&cargo_toml_path, &existing)
463 .await
464 .expect("write Cargo.toml");
465
466 let catalog = crate::component_catalog::ComponentCatalog::new();
467 let component = catalog
468 .get("realsense-camera")
469 .expect("realsense-camera is in the catalog")
470 .clone();
471
472 let service = InitService::new();
473 service
474 .add_dependency(project_root, &component)
475 .await
476 .expect("add_dependency should no-op without touching the network");
477
478 let updated = tokio::fs::read_to_string(&cargo_toml_path)
479 .await
480 .expect("read Cargo.toml");
481 assert_eq!(
482 updated, existing,
483 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
484 );
485 }
486
487 mod external_dependency_names_tests {
491 use super::*;
492
493 #[test]
494 fn excludes_a_package_components_own_node_crate() {
495 let catalog = crate::component_catalog::ComponentCatalog::new();
503 let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog");
504
505 let names = InitService::external_dependency_names(component);
506
507 assert_eq!(
508 names,
509 Vec::<String>::new(),
510 "a Package component must never resolve its own mecha10-nodes-<id> crate"
511 );
512 }
513
514 #[test]
515 fn excludes_every_package_components_own_node_crate() {
516 let catalog = crate::component_catalog::ComponentCatalog::new();
520
521 for component in catalog.get_all() {
522 let own_node_crate = format!("mecha10-nodes-{}", component.id);
523 let names = InitService::external_dependency_names(component);
524 assert!(
525 !names.contains(&own_node_crate),
526 "{} must never resolve its own node crate {}, got {:?}",
527 component.id,
528 own_node_crate,
529 names
530 );
531 }
532 }
533
534 #[test]
535 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
536 let catalog = crate::component_catalog::ComponentCatalog::new();
541 let component = catalog
542 .get("realsense-camera")
543 .expect("realsense-camera is in the catalog");
544
545 let names = InitService::external_dependency_names(component);
546
547 assert_eq!(
548 names,
549 vec!["realsense-rust".to_string()],
550 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
551 );
552 }
553 }
554
555 mod insert_dependency_version_tests {
556 use super::*;
557
558 #[test]
559 fn inserts_right_after_the_mecha10_core_anchor_line() {
560 let content = minimal_cargo_toml();
561
562 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
563
564 assert_eq!(
565 updated,
566 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
567 );
568 }
569 }
570
571 #[tokio::test]
572 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
573 let temp_dir = tempfile::tempdir().expect("create temp dir");
577 let project_root = temp_dir.path();
578 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
579 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
580 .await
581 .expect("write Cargo.toml");
582
583 let catalog = crate::component_catalog::ComponentCatalog::new();
584 let component = catalog
585 .get("realsense-camera")
586 .expect("realsense-camera is in the catalog")
587 .clone();
588
589 let names = InitService::external_dependency_names(&component);
593 assert_eq!(names, vec!["realsense-rust".to_string()]);
594
595 let content = tokio::fs::read_to_string(&cargo_toml_path)
596 .await
597 .expect("read Cargo.toml");
598 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
599 tokio::fs::write(&cargo_toml_path, &updated)
600 .await
601 .expect("write Cargo.toml");
602
603 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
604 .await
605 .expect("read Cargo.toml");
606 assert!(
607 final_content.contains("realsense-rust = \"0.6.1\""),
608 "the driver's real third-party crate must be added, got: {}",
609 final_content
610 );
611 assert_eq!(
612 final_content.matches("mecha10-core").count(),
613 1,
614 "mecha10-core must not be duplicated, got: {}",
615 final_content
616 );
617 }
618}
619
620#[cfg(test)]
621mod add_node_to_config_tests {
622 use super::*;
623 use mecha10_cli_core::services::ConfigService;
624
625 fn minimal_project_config_json() -> &'static str {
627 r#"{
628 "name": "test-project",
629 "version": "0.1.0",
630 "robot_id": "test-robot"
631 }"#
632 }
633
634 #[tokio::test]
635 async fn mints_project_nodes_with_plain_identifier() {
636 let temp_dir = tempfile::tempdir().expect("create temp dir");
637 let project_root = temp_dir.path();
638 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
639 .await
640 .expect("write mecha10.json");
641
642 let service = InitService::new();
643
644 service
645 .add_node_to_config(project_root, "my-custom", false)
646 .await
647 .expect("add_node_to_config should succeed");
648
649 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
650 .await
651 .expect("reload config");
652
653 assert!(
654 config.nodes.identifiers().contains(&"my-custom"),
655 "expected plain identifier 'my-custom' in nodes, got: {:?}",
656 config.nodes.identifiers()
657 );
658 assert!(
659 !config.nodes.identifiers().contains(&"@local/my-custom"),
660 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
661 config.nodes.identifiers()
662 );
663 }
664
665 #[tokio::test]
668 async fn preserves_template_version_written_by_mecha10_init() {
669 let temp_dir = tempfile::tempdir().expect("create temp dir");
670 let project_root = temp_dir.path();
671 tokio::fs::write(
672 project_root.join(paths::PROJECT_CONFIG),
673 r#"{
674 "name": "test-project",
675 "version": "0.6.5",
676 "template_version": "0.6.5",
677 "robot_id": "test-robot"
678 }"#,
679 )
680 .await
681 .expect("write mecha10.json");
682
683 let service = InitService::new();
684
685 service
686 .add_node_to_config(project_root, "speaker", true)
687 .await
688 .expect("add_node_to_config should succeed");
689
690 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
691 .await
692 .expect("reload config");
693
694 assert_eq!(
695 config.template_version,
696 Some("0.6.5".to_string()),
697 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
698 );
699 }
700
701 #[tokio::test]
702 async fn mints_framework_nodes_with_mecha10_scope() {
703 let temp_dir = tempfile::tempdir().expect("create temp dir");
704 let project_root = temp_dir.path();
705 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
706 .await
707 .expect("write mecha10.json");
708
709 let service = InitService::new();
710
711 service
712 .add_node_to_config(project_root, "speaker", true)
713 .await
714 .expect("add_node_to_config should succeed");
715
716 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
717 .await
718 .expect("reload config");
719
720 assert!(
721 config.nodes.identifiers().contains(&"@mecha10/speaker"),
722 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
723 config.nodes.identifiers()
724 );
725 }
726
727 #[tokio::test]
731 async fn mints_plain_version_string() {
732 let temp_dir = tempfile::tempdir().expect("create temp dir");
733 let project_root = temp_dir.path();
734 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
735 .await
736 .expect("write mecha10.json");
737
738 let service = InitService::new();
739
740 service
741 .add_node_to_config(project_root, "speaker", true)
742 .await
743 .expect("add_node_to_config should succeed");
744
745 let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
746 .await
747 .expect("read mecha10.json");
748 let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
749
750 assert_eq!(
751 raw["nodes"]["@mecha10/speaker"],
752 serde_json::json!(env!("CARGO_PKG_VERSION"))
753 );
754 }
755
756 #[tokio::test]
760 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
761 let temp_dir = tempfile::tempdir().expect("create temp dir");
762 let project_root = temp_dir.path();
763 let config_with_pinned_node = r#"{
764 "name": "test-project",
765 "version": "0.1.0",
766 "robot_id": "test-robot",
767 "nodes": { "@mecha10/speaker": "0.7.0" }
768 }"#;
769 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
770 .await
771 .expect("write mecha10.json");
772
773 let service = InitService::new();
774
775 service
776 .add_node_to_config(project_root, "speaker", true)
777 .await
778 .expect("add_node_to_config should succeed");
779
780 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
781 .await
782 .expect("reload config");
783
784 assert_eq!(
785 config.nodes.get_version("@mecha10/speaker"),
786 Some("0.7.0"),
787 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
788 reset to this crate's own CARGO_PKG_VERSION"
789 );
790 }
791
792 #[tokio::test]
795 async fn does_not_touch_an_already_present_entry() {
796 let temp_dir = tempfile::tempdir().expect("create temp dir");
797 let project_root = temp_dir.path();
798 let config_with_existing_entry = r#"{
799 "name": "test-project",
800 "version": "0.1.0",
801 "robot_id": "test-robot",
802 "nodes": { "@mecha10/speaker": { "version": "0.7.0", "enabled": false } }
803 }"#;
804 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_existing_entry)
805 .await
806 .expect("write mecha10.json");
807
808 let service = InitService::new();
809
810 service
811 .add_node_to_config(project_root, "speaker", true)
812 .await
813 .expect("add_node_to_config should succeed");
814
815 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
816 .await
817 .expect("reload config");
818
819 let entry = config.nodes.0.get("@mecha10/speaker").expect("entry must still exist");
820 assert_eq!(entry.version(), "0.7.0", "version must be preserved");
821 assert!(!entry.enabled(), "enabled: false must be preserved, not clobbered");
822 }
823
824 #[tokio::test]
827 async fn does_not_touch_targets_at_all() {
828 let temp_dir = tempfile::tempdir().expect("create temp dir");
829 let project_root = temp_dir.path();
830 let config_with_targets = r#"{
831 "name": "test-project",
832 "version": "0.1.0",
833 "robot_id": "test-robot",
834 "targets": {
835 "robot": { "nodes": ["@mecha10/camera"] }
836 }
837 }"#;
838 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
839 .await
840 .expect("write mecha10.json");
841
842 let service = InitService::new();
843
844 service
845 .add_node_to_config(project_root, "image-classifier", true)
846 .await
847 .expect("add_node_to_config should succeed");
848
849 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
850 .await
851 .expect("reload config");
852
853 assert!(
854 config.targets.simulation.is_none(),
855 "add_node_to_config must not synthesize any targets.* entry anymore"
856 );
857 let robot = config
858 .targets
859 .robot
860 .expect("pre-existing targets.robot must be preserved");
861 assert_eq!(
862 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
863 vec!["@mecha10/camera".to_string()],
864 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
865 robot.nodes
866 );
867 }
868}