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(
307 &self,
308 project_root: &Path,
309 component_name: &str,
310 is_monorepo_node: bool,
311 ) -> Result<()> {
312 use crate::services::ConfigService;
313
314 let config_path = project_root.join(paths::PROJECT_CONFIG);
315 let mut config = ConfigService::load_from(&config_path).await?;
316
317 let (node_identifier, version) = if is_monorepo_node {
318 (
319 format!("@mecha10/{}", component_name),
320 env!("CARGO_PKG_VERSION").to_string(),
321 )
322 } else {
323 (component_name.to_string(), "0.1.0".to_string())
324 };
325
326 if config.nodes.0.contains_key(&node_identifier) {
327 return Ok(());
328 }
329
330 config
331 .nodes
332 .0
333 .insert(node_identifier, NodeEntry::Detailed { version, enabled: true });
334
335 let config_json = serde_json::to_string_pretty(&config)?;
336 tokio::fs::write(&config_path, config_json).await?;
337
338 Ok(())
339 }
340}
341
342impl Default for InitService {
343 fn default() -> Self {
344 Self::new()
345 }
346}
347
348#[cfg(test)]
349mod parse_max_version_tests {
350 use super::*;
351
352 #[test]
353 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
354 let body = r#"{
355 "crate": {
356 "id": "mecha10-nodes-behavior-executor",
357 "max_version": "0.4.2",
358 "max_stable_version": "0.4.2"
359 },
360 "versions": []
361 }"#;
362
363 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
364 assert_eq!(version, "0.4.2");
365 }
366
367 #[test]
368 fn errors_instead_of_silently_falling_back_on_malformed_body() {
369 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
370 assert!(result.is_err());
371 }
372}
373
374#[cfg(test)]
375mod add_dependency_tests {
376 use super::*;
377
378 fn minimal_cargo_toml() -> &'static str {
381 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
382 }
383
384 #[tokio::test]
385 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
386 let temp_dir = tempfile::tempdir().expect("create temp dir");
389 let project_root = temp_dir.path();
390 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
391
392 let existing = format!("{}realsense-rust = \"0.3.0\"\n", minimal_cargo_toml());
393 tokio::fs::write(&cargo_toml_path, &existing)
394 .await
395 .expect("write Cargo.toml");
396
397 let catalog = crate::component_catalog::ComponentCatalog::new();
398 let component = catalog
399 .get("realsense-camera")
400 .expect("realsense-camera is in the catalog")
401 .clone();
402
403 let service = InitService::new();
404 service
405 .add_dependency(project_root, &component)
406 .await
407 .expect("add_dependency should no-op without touching the network");
408
409 let updated = tokio::fs::read_to_string(&cargo_toml_path)
410 .await
411 .expect("read Cargo.toml");
412 assert_eq!(
413 updated, existing,
414 "an already-present dependency must be left untouched"
415 );
416 }
417
418 #[tokio::test]
419 async fn is_a_complete_no_op_for_a_package_node_regardless_of_cargo_toml_content() {
420 let temp_dir = tempfile::tempdir().expect("create temp dir");
426 let project_root = temp_dir.path();
427 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
428
429 let existing = minimal_cargo_toml().to_string();
430 tokio::fs::write(&cargo_toml_path, &existing)
431 .await
432 .expect("write Cargo.toml");
433
434 let catalog = crate::component_catalog::ComponentCatalog::new();
435 let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog").clone();
436
437 let service = InitService::new();
438 service
439 .add_dependency(project_root, &component)
440 .await
441 .expect("add_dependency should no-op without touching the network");
442
443 let updated = tokio::fs::read_to_string(&cargo_toml_path)
444 .await
445 .expect("read Cargo.toml");
446 assert_eq!(
447 updated, existing,
448 "a Package node's own mecha10-nodes-<id> crate must never be added to Cargo.toml"
449 );
450 }
451
452 #[tokio::test]
453 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
454 let temp_dir = tempfile::tempdir().expect("create temp dir");
459 let project_root = temp_dir.path();
460 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
461
462 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
463 tokio::fs::write(&cargo_toml_path, &existing)
464 .await
465 .expect("write Cargo.toml");
466
467 let catalog = crate::component_catalog::ComponentCatalog::new();
468 let component = catalog
469 .get("realsense-camera")
470 .expect("realsense-camera is in the catalog")
471 .clone();
472
473 let service = InitService::new();
474 service
475 .add_dependency(project_root, &component)
476 .await
477 .expect("add_dependency should no-op without touching the network");
478
479 let updated = tokio::fs::read_to_string(&cargo_toml_path)
480 .await
481 .expect("read Cargo.toml");
482 assert_eq!(
483 updated, existing,
484 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
485 );
486 }
487
488 mod external_dependency_names_tests {
492 use super::*;
493
494 #[test]
495 fn excludes_a_package_components_own_node_crate() {
496 let catalog = crate::component_catalog::ComponentCatalog::new();
504 let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog");
505
506 let names = InitService::external_dependency_names(component);
507
508 assert_eq!(
509 names,
510 Vec::<String>::new(),
511 "a Package component must never resolve its own mecha10-nodes-<id> crate"
512 );
513 }
514
515 #[test]
516 fn excludes_every_package_components_own_node_crate() {
517 let catalog = crate::component_catalog::ComponentCatalog::new();
521
522 for component in catalog.get_all() {
523 let own_node_crate = format!("mecha10-nodes-{}", component.id);
524 let names = InitService::external_dependency_names(component);
525 assert!(
526 !names.contains(&own_node_crate),
527 "{} must never resolve its own node crate {}, got {:?}",
528 component.id,
529 own_node_crate,
530 names
531 );
532 }
533 }
534
535 #[test]
536 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
537 let catalog = crate::component_catalog::ComponentCatalog::new();
542 let component = catalog
543 .get("realsense-camera")
544 .expect("realsense-camera is in the catalog");
545
546 let names = InitService::external_dependency_names(component);
547
548 assert_eq!(
549 names,
550 vec!["realsense-rust".to_string()],
551 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
552 );
553 }
554 }
555
556 mod insert_dependency_version_tests {
557 use super::*;
558
559 #[test]
560 fn inserts_right_after_the_mecha10_core_anchor_line() {
561 let content = minimal_cargo_toml();
562
563 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
564
565 assert_eq!(
566 updated,
567 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
568 );
569 }
570 }
571
572 #[tokio::test]
573 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
574 let temp_dir = tempfile::tempdir().expect("create temp dir");
578 let project_root = temp_dir.path();
579 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
580 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
581 .await
582 .expect("write Cargo.toml");
583
584 let catalog = crate::component_catalog::ComponentCatalog::new();
585 let component = catalog
586 .get("realsense-camera")
587 .expect("realsense-camera is in the catalog")
588 .clone();
589
590 let names = InitService::external_dependency_names(&component);
594 assert_eq!(names, vec!["realsense-rust".to_string()]);
595
596 let content = tokio::fs::read_to_string(&cargo_toml_path)
597 .await
598 .expect("read Cargo.toml");
599 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
600 tokio::fs::write(&cargo_toml_path, &updated)
601 .await
602 .expect("write Cargo.toml");
603
604 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
605 .await
606 .expect("read Cargo.toml");
607 assert!(
608 final_content.contains("realsense-rust = \"0.6.1\""),
609 "the driver's real third-party crate must be added, got: {}",
610 final_content
611 );
612 assert_eq!(
613 final_content.matches("mecha10-core").count(),
614 1,
615 "mecha10-core must not be duplicated, got: {}",
616 final_content
617 );
618 }
619}
620
621#[cfg(test)]
622mod add_node_to_config_tests {
623 use super::*;
624 use mecha10_cli_core::services::ConfigService;
625
626 fn minimal_project_config_json() -> &'static str {
628 r#"{
629 "name": "test-project",
630 "version": "0.1.0",
631 "robot_id": "test-robot"
632 }"#
633 }
634
635 #[tokio::test]
636 async fn mints_project_nodes_with_plain_identifier() {
637 let temp_dir = tempfile::tempdir().expect("create temp dir");
638 let project_root = temp_dir.path();
639 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
640 .await
641 .expect("write mecha10.json");
642
643 let service = InitService::new();
644
645 service
646 .add_node_to_config(project_root, "my-custom", false)
647 .await
648 .expect("add_node_to_config should succeed");
649
650 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
651 .await
652 .expect("reload config");
653
654 assert!(
655 config.nodes.identifiers().contains(&"my-custom"),
656 "expected plain identifier 'my-custom' in nodes, got: {:?}",
657 config.nodes.identifiers()
658 );
659 assert!(
660 !config.nodes.identifiers().contains(&"@local/my-custom"),
661 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
662 config.nodes.identifiers()
663 );
664 }
665
666 #[tokio::test]
669 async fn preserves_template_version_written_by_mecha10_init() {
670 let temp_dir = tempfile::tempdir().expect("create temp dir");
671 let project_root = temp_dir.path();
672 tokio::fs::write(
673 project_root.join(paths::PROJECT_CONFIG),
674 r#"{
675 "name": "test-project",
676 "version": "0.6.5",
677 "template_version": "0.6.5",
678 "robot_id": "test-robot"
679 }"#,
680 )
681 .await
682 .expect("write mecha10.json");
683
684 let service = InitService::new();
685
686 service
687 .add_node_to_config(project_root, "speaker", true)
688 .await
689 .expect("add_node_to_config should succeed");
690
691 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
692 .await
693 .expect("reload config");
694
695 assert_eq!(
696 config.template_version,
697 Some("0.6.5".to_string()),
698 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
699 );
700 }
701
702 #[tokio::test]
703 async fn mints_framework_nodes_with_mecha10_scope() {
704 let temp_dir = tempfile::tempdir().expect("create temp dir");
705 let project_root = temp_dir.path();
706 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
707 .await
708 .expect("write mecha10.json");
709
710 let service = InitService::new();
711
712 service
713 .add_node_to_config(project_root, "speaker", true)
714 .await
715 .expect("add_node_to_config should succeed");
716
717 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
718 .await
719 .expect("reload config");
720
721 assert!(
722 config.nodes.identifiers().contains(&"@mecha10/speaker"),
723 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
724 config.nodes.identifiers()
725 );
726 }
727
728 #[tokio::test]
731 async fn mints_detailed_entry_with_just_version_and_enabled() {
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!({
753 "version": env!("CARGO_PKG_VERSION"),
754 "enabled": true,
755 })
756 );
757 }
758
759 #[tokio::test]
763 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
764 let temp_dir = tempfile::tempdir().expect("create temp dir");
765 let project_root = temp_dir.path();
766 let config_with_pinned_node = r#"{
767 "name": "test-project",
768 "version": "0.1.0",
769 "robot_id": "test-robot",
770 "nodes": { "@mecha10/speaker": "0.7.0" }
771 }"#;
772 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
773 .await
774 .expect("write mecha10.json");
775
776 let service = InitService::new();
777
778 service
779 .add_node_to_config(project_root, "speaker", true)
780 .await
781 .expect("add_node_to_config should succeed");
782
783 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
784 .await
785 .expect("reload config");
786
787 assert_eq!(
788 config.nodes.get_version("@mecha10/speaker"),
789 Some("0.7.0"),
790 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
791 reset to this crate's own CARGO_PKG_VERSION"
792 );
793 }
794
795 #[tokio::test]
798 async fn does_not_touch_an_already_present_entry() {
799 let temp_dir = tempfile::tempdir().expect("create temp dir");
800 let project_root = temp_dir.path();
801 let config_with_existing_entry = r#"{
802 "name": "test-project",
803 "version": "0.1.0",
804 "robot_id": "test-robot",
805 "nodes": { "@mecha10/speaker": { "version": "0.7.0", "enabled": false } }
806 }"#;
807 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_existing_entry)
808 .await
809 .expect("write mecha10.json");
810
811 let service = InitService::new();
812
813 service
814 .add_node_to_config(project_root, "speaker", true)
815 .await
816 .expect("add_node_to_config should succeed");
817
818 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
819 .await
820 .expect("reload config");
821
822 let entry = config.nodes.0.get("@mecha10/speaker").expect("entry must still exist");
823 assert_eq!(entry.version(), "0.7.0", "version must be preserved");
824 assert!(!entry.enabled(), "enabled: false must be preserved, not clobbered");
825 }
826
827 #[tokio::test]
830 async fn does_not_touch_targets_at_all() {
831 let temp_dir = tempfile::tempdir().expect("create temp dir");
832 let project_root = temp_dir.path();
833 let config_with_targets = r#"{
834 "name": "test-project",
835 "version": "0.1.0",
836 "robot_id": "test-robot",
837 "targets": {
838 "robot": { "nodes": ["@mecha10/camera"] }
839 }
840 }"#;
841 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
842 .await
843 .expect("write mecha10.json");
844
845 let service = InitService::new();
846
847 service
848 .add_node_to_config(project_root, "image-classifier", true)
849 .await
850 .expect("add_node_to_config should succeed");
851
852 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
853 .await
854 .expect("reload config");
855
856 assert!(
857 config.targets.simulation.is_none(),
858 "add_node_to_config must not synthesize any targets.* entry anymore"
859 );
860 let robot = config
861 .targets
862 .robot
863 .expect("pre-existing targets.robot must be preserved");
864 assert_eq!(
865 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
866 vec!["@mecha10/camera".to_string()],
867 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
868 robot.nodes
869 );
870 }
871}