1use crate::paths;
6use anyhow::{Context, Result};
7use mecha10_cli_core::types::project::NodeEntry;
8use mecha10_cli_core::utils::build_client;
9use mecha10_core::node::Capability;
10use std::path::Path;
11
12const CRATES_IO_USER_AGENT: &str = "mecha10-cli (+https://github.com/mecha10/mecha10; contact: releases@mecha10.dev)";
16
17#[derive(serde::Deserialize)]
20struct CratesIoCrateResponse {
21 #[serde(rename = "crate")]
22 krate: CratesIoCrateMeta,
23}
24
25#[derive(serde::Deserialize)]
26struct CratesIoCrateMeta {
27 max_version: String,
28}
29
30async fn latest_published_crate_version(package_name: &str) -> Result<String> {
37 let url = format!("https://crates.io/api/v1/crates/{}", package_name);
38
39 let client = build_client()?;
40 let response = client
41 .get(&url)
42 .header("User-Agent", CRATES_IO_USER_AGENT)
43 .send()
44 .await
45 .with_context(|| format!("Failed to query crates.io for latest version of {}", package_name))?;
46
47 if !response.status().is_success() {
48 anyhow::bail!(
49 "Failed to look up latest published version of {} on crates.io: HTTP {}\nURL: {}",
50 package_name,
51 response.status(),
52 url
53 );
54 }
55
56 let body = response
57 .text()
58 .await
59 .with_context(|| format!("Failed to read crates.io response body for {}", package_name))?;
60
61 parse_max_version(&body, package_name)
62}
63
64fn parse_max_version(body: &str, package_name: &str) -> Result<String> {
68 let parsed: CratesIoCrateResponse = serde_json::from_str(body)
69 .with_context(|| format!("Failed to parse crates.io response for {}", package_name))?;
70
71 Ok(parsed.krate.max_version)
72}
73
74pub struct InitService;
75
76impl InitService {
77 pub fn new() -> Self {
79 Self
80 }
81
82 pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
84 let dirs = vec![
85 paths::project::NODES_DIR,
86 paths::project::DRIVERS_DIR,
87 paths::project::TYPES_DIR,
88 paths::project::BEHAVIORS_DIR,
89 paths::project::LOGS_DIR,
90 paths::project::MODELS_DIR,
91 paths::project::MODELS_CATALOG_DIR,
92 paths::project::SIMULATION_DIR,
93 paths::project::SIMULATION_MODELS_DIR,
94 paths::project::SIMULATION_ENVIRONMENTS_DIR,
95 paths::project::SIMULATION_IMAGES_DIR,
96 paths::project::RL_TASKS_DIR,
97 paths::config::NODES_DIR,
98 ];
99
100 for dir in dirs {
101 let dir_path = path.join(dir);
102 tokio::fs::create_dir_all(&dir_path).await?;
103 }
104
105 Ok(())
106 }
107
108 pub async fn add_example_node(
120 &self,
121 project_root: &Path,
122 component_name: &str,
123 capabilities: Vec<Capability>,
124 ) -> Result<()> {
125 use crate::component_catalog::ComponentCatalog;
126
127 let catalog = ComponentCatalog::new();
128 let components = catalog.search(component_name);
129
130 if components.is_empty() {
131 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
132 }
133
134 let component = &components[0];
135
136 for file_template in &component.files {
137 let file_path = project_root.join(&file_template.path);
138
139 if let Some(parent) = file_path.parent() {
140 tokio::fs::create_dir_all(parent).await?;
141 }
142
143 tokio::fs::write(&file_path, &file_template.content).await?;
144 }
145
146 if !component.files.is_empty() {
147 self.add_workspace_member(project_root, component_name).await?;
148 }
149
150 let is_monorepo_node = component.files.is_empty();
151 if is_monorepo_node {
152 self.add_dependency(project_root, component).await?;
153 }
154
155 self.add_node_to_config(project_root, component_name, is_monorepo_node, capabilities)
156 .await?;
157
158 Ok(())
159 }
160
161 pub async fn sync_node_capabilities(
171 &self,
172 project_root: &Path,
173 component_name: &str,
174 is_monorepo_node: bool,
175 capabilities: Vec<Capability>,
176 ) -> Result<()> {
177 self.add_node_to_config(project_root, component_name, is_monorepo_node, capabilities)
178 .await
179 }
180
181 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
183 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
184 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
185
186 let new_member = format!("nodes/{}", component_name);
187 let updated_content = if content.contains("members = []") {
188 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
189 } else if let Some(start) = content.find("members = [") {
190 if let Some(end) = content[start..].find(']') {
191 let insert_pos = start + end;
192 let members_section = &content[start..insert_pos];
193 if members_section.contains('\"') {
194 format!(
195 "{}\n \"{}\",{}",
196 &content[..insert_pos],
197 new_member,
198 &content[insert_pos..]
199 )
200 } else {
201 format!(
202 "{}\n \"{}\",\n{}",
203 &content[..insert_pos],
204 new_member,
205 &content[insert_pos..]
206 )
207 }
208 } else {
209 content
210 }
211 } else {
212 content
213 };
214
215 tokio::fs::write(&cargo_toml_path, updated_content).await?;
216 Ok(())
217 }
218
219 fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
244 if component.cargo_dependencies.is_empty() {
245 return vec![];
246 }
247
248 let own_node_crate = format!("mecha10-nodes-{}", component.id);
249
250 component
251 .cargo_dependencies
252 .iter()
253 .filter(|dep| dep.path.is_none())
254 .map(|dep| dep.name.clone())
255 .filter(|name| *name != own_node_crate)
256 .collect()
257 }
258
259 fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
263 let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
264 return content.to_string();
265 };
266 let Some(newline_pos) = content[pos..].find('\n') else {
267 return content.to_string();
268 };
269 let insert_pos = pos + newline_pos + 1;
270 format!(
271 "{}{} = \"{}\"\n{}",
272 &content[..insert_pos],
273 package_name,
274 version,
275 &content[insert_pos..]
276 )
277 }
278
279 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
287 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
288 let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
289
290 let mut changed = false;
291
292 for package_name in Self::external_dependency_names(component) {
293 if content.contains(&package_name) {
294 continue;
295 }
296
297 let version = latest_published_crate_version(&package_name).await?;
298 content = Self::insert_dependency_version(&content, &package_name, &version);
299 changed = true;
300 }
301
302 if changed {
303 tokio::fs::write(&cargo_toml_path, content).await?;
304 }
305
306 Ok(())
307 }
308
309 async fn add_node_to_config(
330 &self,
331 project_root: &Path,
332 component_name: &str,
333 is_monorepo_node: bool,
334 capabilities: Vec<Capability>,
335 ) -> Result<()> {
336 use crate::services::ConfigService;
337
338 let config_path = project_root.join(paths::PROJECT_CONFIG);
339 let mut config = ConfigService::load_from(&config_path).await?;
340
341 let (node_identifier, version) = if is_monorepo_node {
342 (
343 format!("@mecha10/{}", component_name),
344 env!("CARGO_PKG_VERSION").to_string(),
345 )
346 } else {
347 (component_name.to_string(), "0.1.0".to_string())
348 };
349
350 let new_entry = match config.nodes.0.get(&node_identifier) {
351 None => Some(NodeEntry::Detailed {
352 version,
353 enabled: true,
354 capabilities,
355 }),
356 Some(NodeEntry::Version(existing_version)) if !capabilities.is_empty() => Some(NodeEntry::Detailed {
357 version: existing_version.clone(),
358 enabled: true,
359 capabilities,
360 }),
361 Some(NodeEntry::Detailed {
362 version: existing_version,
363 enabled: existing_enabled,
364 capabilities: existing_capabilities,
365 }) if existing_capabilities.is_empty() && !capabilities.is_empty() => Some(NodeEntry::Detailed {
366 version: existing_version.clone(),
367 enabled: *existing_enabled,
368 capabilities,
369 }),
370 _ => None,
371 };
372
373 if let Some(entry) = new_entry {
374 config.nodes.0.insert(node_identifier, entry);
375
376 let config_json = serde_json::to_string_pretty(&config)?;
377 tokio::fs::write(&config_path, config_json).await?;
378 }
379
380 Ok(())
381 }
382}
383
384impl Default for InitService {
385 fn default() -> Self {
386 Self::new()
387 }
388}
389
390#[cfg(test)]
391mod parse_max_version_tests {
392 use super::*;
393
394 #[test]
395 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
396 let body = r#"{
397 "crate": {
398 "id": "mecha10-nodes-behavior-executor",
399 "max_version": "0.4.2",
400 "max_stable_version": "0.4.2"
401 },
402 "versions": []
403 }"#;
404
405 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
406 assert_eq!(version, "0.4.2");
407 }
408
409 #[test]
410 fn errors_instead_of_silently_falling_back_on_malformed_body() {
411 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
412 assert!(result.is_err());
413 }
414}
415
416#[cfg(test)]
417mod add_dependency_tests {
418 use super::*;
419
420 fn minimal_cargo_toml() -> &'static str {
423 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
424 }
425
426 #[tokio::test]
427 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
428 let temp_dir = tempfile::tempdir().expect("create temp dir");
431 let project_root = temp_dir.path();
432 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
433
434 let existing = format!("{}realsense-rust = \"0.3.0\"\n", minimal_cargo_toml());
435 tokio::fs::write(&cargo_toml_path, &existing)
436 .await
437 .expect("write Cargo.toml");
438
439 let catalog = crate::component_catalog::ComponentCatalog::new();
440 let component = catalog
441 .get("realsense-camera")
442 .expect("realsense-camera is in the catalog")
443 .clone();
444
445 let service = InitService::new();
446 service
447 .add_dependency(project_root, &component)
448 .await
449 .expect("add_dependency should no-op without touching the network");
450
451 let updated = tokio::fs::read_to_string(&cargo_toml_path)
452 .await
453 .expect("read Cargo.toml");
454 assert_eq!(
455 updated, existing,
456 "an already-present dependency must be left untouched"
457 );
458 }
459
460 #[tokio::test]
461 async fn is_a_complete_no_op_for_a_package_node_regardless_of_cargo_toml_content() {
462 let temp_dir = tempfile::tempdir().expect("create temp dir");
468 let project_root = temp_dir.path();
469 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
470
471 let existing = minimal_cargo_toml().to_string();
472 tokio::fs::write(&cargo_toml_path, &existing)
473 .await
474 .expect("write Cargo.toml");
475
476 let catalog = crate::component_catalog::ComponentCatalog::new();
477 let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog").clone();
478
479 let service = InitService::new();
480 service
481 .add_dependency(project_root, &component)
482 .await
483 .expect("add_dependency should no-op without touching the network");
484
485 let updated = tokio::fs::read_to_string(&cargo_toml_path)
486 .await
487 .expect("read Cargo.toml");
488 assert_eq!(
489 updated, existing,
490 "a Package node's own mecha10-nodes-<id> crate must never be added to Cargo.toml"
491 );
492 }
493
494 #[tokio::test]
495 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
496 let temp_dir = tempfile::tempdir().expect("create temp dir");
501 let project_root = temp_dir.path();
502 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
503
504 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
505 tokio::fs::write(&cargo_toml_path, &existing)
506 .await
507 .expect("write Cargo.toml");
508
509 let catalog = crate::component_catalog::ComponentCatalog::new();
510 let component = catalog
511 .get("realsense-camera")
512 .expect("realsense-camera is in the catalog")
513 .clone();
514
515 let service = InitService::new();
516 service
517 .add_dependency(project_root, &component)
518 .await
519 .expect("add_dependency should no-op without touching the network");
520
521 let updated = tokio::fs::read_to_string(&cargo_toml_path)
522 .await
523 .expect("read Cargo.toml");
524 assert_eq!(
525 updated, existing,
526 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
527 );
528 }
529
530 mod external_dependency_names_tests {
534 use super::*;
535
536 #[test]
537 fn excludes_a_package_components_own_node_crate() {
538 let catalog = crate::component_catalog::ComponentCatalog::new();
546 let component = catalog.get("pan-tilt").expect("pan-tilt is in the catalog");
547
548 let names = InitService::external_dependency_names(component);
549
550 assert_eq!(
551 names,
552 Vec::<String>::new(),
553 "a Package component must never resolve its own mecha10-nodes-<id> crate"
554 );
555 }
556
557 #[test]
558 fn excludes_every_package_components_own_node_crate() {
559 let catalog = crate::component_catalog::ComponentCatalog::new();
563
564 for component in catalog.get_all() {
565 let own_node_crate = format!("mecha10-nodes-{}", component.id);
566 let names = InitService::external_dependency_names(component);
567 assert!(
568 !names.contains(&own_node_crate),
569 "{} must never resolve its own node crate {}, got {:?}",
570 component.id,
571 own_node_crate,
572 names
573 );
574 }
575 }
576
577 #[test]
578 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
579 let catalog = crate::component_catalog::ComponentCatalog::new();
584 let component = catalog
585 .get("realsense-camera")
586 .expect("realsense-camera is in the catalog");
587
588 let names = InitService::external_dependency_names(component);
589
590 assert_eq!(
591 names,
592 vec!["realsense-rust".to_string()],
593 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
594 );
595 }
596 }
597
598 mod insert_dependency_version_tests {
599 use super::*;
600
601 #[test]
602 fn inserts_right_after_the_mecha10_core_anchor_line() {
603 let content = minimal_cargo_toml();
604
605 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
606
607 assert_eq!(
608 updated,
609 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
610 );
611 }
612 }
613
614 #[tokio::test]
615 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
616 let temp_dir = tempfile::tempdir().expect("create temp dir");
620 let project_root = temp_dir.path();
621 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
622 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
623 .await
624 .expect("write Cargo.toml");
625
626 let catalog = crate::component_catalog::ComponentCatalog::new();
627 let component = catalog
628 .get("realsense-camera")
629 .expect("realsense-camera is in the catalog")
630 .clone();
631
632 let names = InitService::external_dependency_names(&component);
636 assert_eq!(names, vec!["realsense-rust".to_string()]);
637
638 let content = tokio::fs::read_to_string(&cargo_toml_path)
639 .await
640 .expect("read Cargo.toml");
641 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
642 tokio::fs::write(&cargo_toml_path, &updated)
643 .await
644 .expect("write Cargo.toml");
645
646 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
647 .await
648 .expect("read Cargo.toml");
649 assert!(
650 final_content.contains("realsense-rust = \"0.6.1\""),
651 "the driver's real third-party crate must be added, got: {}",
652 final_content
653 );
654 assert_eq!(
655 final_content.matches("mecha10-core").count(),
656 1,
657 "mecha10-core must not be duplicated, got: {}",
658 final_content
659 );
660 }
661}
662
663#[cfg(test)]
664mod add_node_to_config_tests {
665 use super::*;
666 use mecha10_cli_core::services::ConfigService;
667
668 fn minimal_project_config_json() -> &'static str {
670 r#"{
671 "name": "test-project",
672 "version": "0.1.0",
673 "robot_id": "test-robot"
674 }"#
675 }
676
677 #[tokio::test]
678 async fn mints_project_nodes_with_plain_identifier() {
679 let temp_dir = tempfile::tempdir().expect("create temp dir");
680 let project_root = temp_dir.path();
681 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
682 .await
683 .expect("write mecha10.json");
684
685 let service = InitService::new();
686
687 service
688 .add_node_to_config(project_root, "my-custom", false, Vec::new())
689 .await
690 .expect("add_node_to_config should succeed");
691
692 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
693 .await
694 .expect("reload config");
695
696 assert!(
697 config.nodes.identifiers().contains(&"my-custom"),
698 "expected plain identifier 'my-custom' in nodes, got: {:?}",
699 config.nodes.identifiers()
700 );
701 assert!(
702 !config.nodes.identifiers().contains(&"@local/my-custom"),
703 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
704 config.nodes.identifiers()
705 );
706 }
707
708 #[tokio::test]
711 async fn preserves_template_version_written_by_mecha10_init() {
712 let temp_dir = tempfile::tempdir().expect("create temp dir");
713 let project_root = temp_dir.path();
714 tokio::fs::write(
715 project_root.join(paths::PROJECT_CONFIG),
716 r#"{
717 "name": "test-project",
718 "version": "0.6.5",
719 "template_version": "0.6.5",
720 "robot_id": "test-robot"
721 }"#,
722 )
723 .await
724 .expect("write mecha10.json");
725
726 let service = InitService::new();
727
728 service
729 .add_node_to_config(project_root, "speaker", true, Vec::new())
730 .await
731 .expect("add_node_to_config should succeed");
732
733 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
734 .await
735 .expect("reload config");
736
737 assert_eq!(
738 config.template_version,
739 Some("0.6.5".to_string()),
740 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
741 );
742 }
743
744 #[tokio::test]
745 async fn mints_framework_nodes_with_mecha10_scope() {
746 let temp_dir = tempfile::tempdir().expect("create temp dir");
747 let project_root = temp_dir.path();
748 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
749 .await
750 .expect("write mecha10.json");
751
752 let service = InitService::new();
753
754 service
755 .add_node_to_config(project_root, "speaker", true, Vec::new())
756 .await
757 .expect("add_node_to_config should succeed");
758
759 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
760 .await
761 .expect("reload config");
762
763 assert!(
764 config.nodes.identifiers().contains(&"@mecha10/speaker"),
765 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
766 config.nodes.identifiers()
767 );
768 }
769
770 #[tokio::test]
775 async fn mints_detailed_entry_with_capabilities_given_by_the_caller() {
776 let temp_dir = tempfile::tempdir().expect("create temp dir");
777 let project_root = temp_dir.path();
778 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
779 .await
780 .expect("write mecha10.json");
781
782 let service = InitService::new();
783
784 service
785 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
786 .await
787 .expect("add_node_to_config should succeed");
788
789 let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
790 .await
791 .expect("read mecha10.json");
792 let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
793
794 assert_eq!(
795 raw["nodes"]["@mecha10/speaker"],
796 serde_json::json!({
797 "version": env!("CARGO_PKG_VERSION"),
798 "enabled": true,
799 "capabilities": ["audio"],
800 })
801 );
802 }
803
804 #[tokio::test]
808 async fn mints_detailed_entry_with_capabilities_even_with_no_local_config_json_on_disk() {
809 let temp_dir = tempfile::tempdir().expect("create temp dir");
810 let project_root = temp_dir.path();
811 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
812 .await
813 .expect("write mecha10.json");
814
815 assert!(
816 !project_root.join("configs/nodes/@mecha10/speaker/config.json").exists(),
817 "precondition: no local config.json for this node"
818 );
819
820 let service = InitService::new();
821
822 service
823 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
824 .await
825 .expect("add_node_to_config should succeed");
826
827 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
828 .await
829 .expect("reload config");
830
831 assert_eq!(
832 config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
833 Some([Capability::Audio].as_slice()),
834 "capabilities must come from the caller-given value, not a local file read-back"
835 );
836 }
837
838 #[tokio::test]
842 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
843 let temp_dir = tempfile::tempdir().expect("create temp dir");
844 let project_root = temp_dir.path();
845 let config_with_pinned_node = r#"{
846 "name": "test-project",
847 "version": "0.1.0",
848 "robot_id": "test-robot",
849 "nodes": { "@mecha10/speaker": "0.7.0" }
850 }"#;
851 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
852 .await
853 .expect("write mecha10.json");
854
855 let service = InitService::new();
856
857 service
858 .add_node_to_config(project_root, "speaker", true, Vec::new())
859 .await
860 .expect("add_node_to_config should succeed");
861
862 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
863 .await
864 .expect("reload config");
865
866 assert_eq!(
867 config.nodes.get_version("@mecha10/speaker"),
868 Some("0.7.0"),
869 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
870 reset to this crate's own CARGO_PKG_VERSION"
871 );
872 }
873
874 #[tokio::test]
879 async fn upgrades_a_bare_version_entry_to_detailed_when_backfilling_capabilities() {
880 let temp_dir = tempfile::tempdir().expect("create temp dir");
881 let project_root = temp_dir.path();
882 let config_with_pinned_node = r#"{
883 "name": "test-project",
884 "version": "0.1.0",
885 "robot_id": "test-robot",
886 "nodes": { "@mecha10/speaker": "0.7.0" }
887 }"#;
888 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
889 .await
890 .expect("write mecha10.json");
891
892 let service = InitService::new();
893
894 service
895 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
896 .await
897 .expect("add_node_to_config should succeed");
898
899 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
900 .await
901 .expect("reload config");
902
903 assert_eq!(
904 config.nodes.get_version("@mecha10/speaker"),
905 Some("0.7.0"),
906 "existing pinned version must be preserved when upgrading to the Detailed form"
907 );
908 assert_eq!(
909 config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
910 Some([Capability::Audio].as_slice()),
911 "capabilities must be backfilled onto the upgraded entry"
912 );
913 }
914
915 #[tokio::test]
920 async fn backfills_capabilities_onto_a_detailed_entry_with_empty_capabilities() {
921 let temp_dir = tempfile::tempdir().expect("create temp dir");
922 let project_root = temp_dir.path();
923 let config_with_empty_capabilities = r#"{
924 "name": "test-project",
925 "version": "0.1.0",
926 "robot_id": "test-robot",
927 "nodes": { "@mecha10/speaker": { "version": "0.7.0", "enabled": false, "capabilities": [] } }
928 }"#;
929 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_empty_capabilities)
930 .await
931 .expect("write mecha10.json");
932
933 let service = InitService::new();
934
935 service
936 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
937 .await
938 .expect("add_node_to_config should succeed");
939
940 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
941 .await
942 .expect("reload config");
943
944 let entry = config.nodes.0.get("@mecha10/speaker").expect("entry must still exist");
945 assert_eq!(entry.version(), "0.7.0", "version must be preserved");
946 assert!(!entry.enabled(), "enabled: false must be preserved, not clobbered");
947 assert_eq!(
948 entry.capabilities(),
949 &[Capability::Audio],
950 "capabilities must be backfilled"
951 );
952 }
953
954 #[tokio::test]
958 async fn does_not_clobber_an_existing_non_empty_capabilities_list() {
959 let temp_dir = tempfile::tempdir().expect("create temp dir");
960 let project_root = temp_dir.path();
961 let config_with_existing_capabilities = r#"{
962 "name": "test-project",
963 "version": "0.1.0",
964 "robot_id": "test-robot",
965 "nodes": { "@mecha10/speaker": { "version": "0.7.0", "capabilities": ["vision"] } }
966 }"#;
967 tokio::fs::write(
968 project_root.join(paths::PROJECT_CONFIG),
969 config_with_existing_capabilities,
970 )
971 .await
972 .expect("write mecha10.json");
973
974 let service = InitService::new();
975
976 service
977 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
978 .await
979 .expect("add_node_to_config should succeed");
980
981 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
982 .await
983 .expect("reload config");
984
985 assert_eq!(
986 config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
987 Some([Capability::Vision].as_slice()),
988 "an already-populated capabilities list must not be overwritten by a later call"
989 );
990 }
991
992 #[tokio::test]
995 async fn does_not_touch_targets_at_all() {
996 let temp_dir = tempfile::tempdir().expect("create temp dir");
997 let project_root = temp_dir.path();
998 let config_with_targets = r#"{
999 "name": "test-project",
1000 "version": "0.1.0",
1001 "robot_id": "test-robot",
1002 "targets": {
1003 "robot": { "nodes": ["@mecha10/camera"] }
1004 }
1005 }"#;
1006 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
1007 .await
1008 .expect("write mecha10.json");
1009
1010 let service = InitService::new();
1011
1012 service
1013 .add_node_to_config(project_root, "image-classifier", true, Vec::new())
1014 .await
1015 .expect("add_node_to_config should succeed");
1016
1017 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
1018 .await
1019 .expect("reload config");
1020
1021 assert!(
1022 config.targets.simulation.is_none(),
1023 "add_node_to_config must not synthesize any targets.* entry anymore"
1024 );
1025 let robot = config
1026 .targets
1027 .robot
1028 .expect("pre-existing targets.robot must be preserved");
1029 assert_eq!(
1030 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
1031 vec!["@mecha10/camera".to_string()],
1032 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
1033 robot.nodes
1034 );
1035 }
1036}