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> {
230 if component.cargo_dependencies.is_empty() {
231 return vec![format!("mecha10-nodes-{}", component.id)];
232 }
233
234 component
235 .cargo_dependencies
236 .iter()
237 .filter(|dep| dep.path.is_none())
238 .map(|dep| dep.name.clone())
239 .collect()
240 }
241
242 fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
246 let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
247 return content.to_string();
248 };
249 let Some(newline_pos) = content[pos..].find('\n') else {
250 return content.to_string();
251 };
252 let insert_pos = pos + newline_pos + 1;
253 format!(
254 "{}{} = \"{}\"\n{}",
255 &content[..insert_pos],
256 package_name,
257 version,
258 &content[insert_pos..]
259 )
260 }
261
262 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
264 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
265 let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
266
267 let mut changed = false;
268
269 for package_name in Self::external_dependency_names(component) {
270 if content.contains(&package_name) {
271 continue;
272 }
273
274 let version = latest_published_crate_version(&package_name).await?;
275 content = Self::insert_dependency_version(&content, &package_name, &version);
276 changed = true;
277 }
278
279 if changed {
280 tokio::fs::write(&cargo_toml_path, content).await?;
281 }
282
283 Ok(())
284 }
285
286 async fn add_node_to_config(
307 &self,
308 project_root: &Path,
309 component_name: &str,
310 is_monorepo_node: bool,
311 capabilities: Vec<Capability>,
312 ) -> Result<()> {
313 use crate::services::ConfigService;
314
315 let config_path = project_root.join(paths::PROJECT_CONFIG);
316 let mut config = ConfigService::load_from(&config_path).await?;
317
318 let (node_identifier, version) = if is_monorepo_node {
319 (
320 format!("@mecha10/{}", component_name),
321 env!("CARGO_PKG_VERSION").to_string(),
322 )
323 } else {
324 (component_name.to_string(), "0.1.0".to_string())
325 };
326
327 let new_entry = match config.nodes.0.get(&node_identifier) {
328 None => Some(NodeEntry::Detailed {
329 version,
330 enabled: true,
331 capabilities,
332 }),
333 Some(NodeEntry::Version(existing_version)) if !capabilities.is_empty() => Some(NodeEntry::Detailed {
334 version: existing_version.clone(),
335 enabled: true,
336 capabilities,
337 }),
338 Some(NodeEntry::Detailed {
339 version: existing_version,
340 enabled: existing_enabled,
341 capabilities: existing_capabilities,
342 }) if existing_capabilities.is_empty() && !capabilities.is_empty() => Some(NodeEntry::Detailed {
343 version: existing_version.clone(),
344 enabled: *existing_enabled,
345 capabilities,
346 }),
347 _ => None,
348 };
349
350 if let Some(entry) = new_entry {
351 config.nodes.0.insert(node_identifier, entry);
352
353 let config_json = serde_json::to_string_pretty(&config)?;
354 tokio::fs::write(&config_path, config_json).await?;
355 }
356
357 Ok(())
358 }
359}
360
361impl Default for InitService {
362 fn default() -> Self {
363 Self::new()
364 }
365}
366
367#[cfg(test)]
368mod parse_max_version_tests {
369 use super::*;
370
371 #[test]
372 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
373 let body = r#"{
374 "crate": {
375 "id": "mecha10-nodes-behavior-executor",
376 "max_version": "0.4.2",
377 "max_stable_version": "0.4.2"
378 },
379 "versions": []
380 }"#;
381
382 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
383 assert_eq!(version, "0.4.2");
384 }
385
386 #[test]
387 fn errors_instead_of_silently_falling_back_on_malformed_body() {
388 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
389 assert!(result.is_err());
390 }
391}
392
393#[cfg(test)]
394mod add_dependency_tests {
395 use super::*;
396
397 fn minimal_cargo_toml() -> &'static str {
400 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
401 }
402
403 #[tokio::test]
404 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
405 let temp_dir = tempfile::tempdir().expect("create temp dir");
408 let project_root = temp_dir.path();
409 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
410
411 let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
412 tokio::fs::write(&cargo_toml_path, &existing)
413 .await
414 .expect("write Cargo.toml");
415
416 let catalog = crate::component_catalog::ComponentCatalog::new();
417 let component = catalog
418 .get("behavior-executor")
419 .expect("behavior-executor is in the catalog")
420 .clone();
421
422 let service = InitService::new();
423 service
424 .add_dependency(project_root, &component)
425 .await
426 .expect("add_dependency should no-op without touching the network");
427
428 let updated = tokio::fs::read_to_string(&cargo_toml_path)
429 .await
430 .expect("read Cargo.toml");
431 assert_eq!(
432 updated, existing,
433 "an already-present dependency must be left untouched"
434 );
435 }
436
437 #[tokio::test]
438 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
439 let temp_dir = tempfile::tempdir().expect("create temp dir");
444 let project_root = temp_dir.path();
445 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
446
447 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
448 tokio::fs::write(&cargo_toml_path, &existing)
449 .await
450 .expect("write Cargo.toml");
451
452 let catalog = crate::component_catalog::ComponentCatalog::new();
453 let component = catalog
454 .get("realsense-camera")
455 .expect("realsense-camera is in the catalog")
456 .clone();
457
458 let service = InitService::new();
459 service
460 .add_dependency(project_root, &component)
461 .await
462 .expect("add_dependency should no-op without touching the network");
463
464 let updated = tokio::fs::read_to_string(&cargo_toml_path)
465 .await
466 .expect("read Cargo.toml");
467 assert_eq!(
468 updated, existing,
469 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
470 );
471 }
472
473 mod external_dependency_names_tests {
477 use super::*;
478
479 #[test]
480 fn resolves_only_the_single_external_crate_for_a_package_component() {
481 let catalog = crate::component_catalog::ComponentCatalog::new();
485 let component = catalog
486 .get("behavior-executor")
487 .expect("behavior-executor is in the catalog");
488
489 let names = InitService::external_dependency_names(component);
490
491 assert_eq!(
492 names,
493 vec!["mecha10-nodes-behavior-executor".to_string()],
494 "a Package component must resolve exactly its own node crate"
495 );
496 }
497
498 #[test]
499 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
500 let catalog = crate::component_catalog::ComponentCatalog::new();
505 let component = catalog
506 .get("realsense-camera")
507 .expect("realsense-camera is in the catalog");
508
509 let names = InitService::external_dependency_names(component);
510
511 assert_eq!(
512 names,
513 vec!["realsense-rust".to_string()],
514 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
515 );
516 }
517 }
518
519 mod insert_dependency_version_tests {
520 use super::*;
521
522 #[test]
523 fn inserts_right_after_the_mecha10_core_anchor_line() {
524 let content = minimal_cargo_toml();
525
526 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
527
528 assert_eq!(
529 updated,
530 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
531 );
532 }
533 }
534
535 #[tokio::test]
536 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
537 let temp_dir = tempfile::tempdir().expect("create temp dir");
541 let project_root = temp_dir.path();
542 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
543 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
544 .await
545 .expect("write Cargo.toml");
546
547 let catalog = crate::component_catalog::ComponentCatalog::new();
548 let component = catalog
549 .get("realsense-camera")
550 .expect("realsense-camera is in the catalog")
551 .clone();
552
553 let names = InitService::external_dependency_names(&component);
557 assert_eq!(names, vec!["realsense-rust".to_string()]);
558
559 let content = tokio::fs::read_to_string(&cargo_toml_path)
560 .await
561 .expect("read Cargo.toml");
562 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
563 tokio::fs::write(&cargo_toml_path, &updated)
564 .await
565 .expect("write Cargo.toml");
566
567 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
568 .await
569 .expect("read Cargo.toml");
570 assert!(
571 final_content.contains("realsense-rust = \"0.6.1\""),
572 "the driver's real third-party crate must be added, got: {}",
573 final_content
574 );
575 assert_eq!(
576 final_content.matches("mecha10-core").count(),
577 1,
578 "mecha10-core must not be duplicated, got: {}",
579 final_content
580 );
581 }
582}
583
584#[cfg(test)]
585mod add_node_to_config_tests {
586 use super::*;
587 use mecha10_cli_core::services::ConfigService;
588
589 fn minimal_project_config_json() -> &'static str {
591 r#"{
592 "name": "test-project",
593 "version": "0.1.0",
594 "robot_id": "test-robot"
595 }"#
596 }
597
598 #[tokio::test]
599 async fn mints_project_nodes_with_plain_identifier() {
600 let temp_dir = tempfile::tempdir().expect("create temp dir");
601 let project_root = temp_dir.path();
602 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
603 .await
604 .expect("write mecha10.json");
605
606 let service = InitService::new();
607
608 service
609 .add_node_to_config(project_root, "my-custom", false, Vec::new())
610 .await
611 .expect("add_node_to_config should succeed");
612
613 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
614 .await
615 .expect("reload config");
616
617 assert!(
618 config.nodes.identifiers().contains(&"my-custom"),
619 "expected plain identifier 'my-custom' in nodes, got: {:?}",
620 config.nodes.identifiers()
621 );
622 assert!(
623 !config.nodes.identifiers().contains(&"@local/my-custom"),
624 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
625 config.nodes.identifiers()
626 );
627 }
628
629 #[tokio::test]
632 async fn preserves_template_version_written_by_mecha10_init() {
633 let temp_dir = tempfile::tempdir().expect("create temp dir");
634 let project_root = temp_dir.path();
635 tokio::fs::write(
636 project_root.join(paths::PROJECT_CONFIG),
637 r#"{
638 "name": "test-project",
639 "version": "0.6.5",
640 "template_version": "0.6.5",
641 "robot_id": "test-robot"
642 }"#,
643 )
644 .await
645 .expect("write mecha10.json");
646
647 let service = InitService::new();
648
649 service
650 .add_node_to_config(project_root, "speaker", true, Vec::new())
651 .await
652 .expect("add_node_to_config should succeed");
653
654 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
655 .await
656 .expect("reload config");
657
658 assert_eq!(
659 config.template_version,
660 Some("0.6.5".to_string()),
661 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
662 );
663 }
664
665 #[tokio::test]
666 async fn mints_framework_nodes_with_mecha10_scope() {
667 let temp_dir = tempfile::tempdir().expect("create temp dir");
668 let project_root = temp_dir.path();
669 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
670 .await
671 .expect("write mecha10.json");
672
673 let service = InitService::new();
674
675 service
676 .add_node_to_config(project_root, "speaker", true, Vec::new())
677 .await
678 .expect("add_node_to_config should succeed");
679
680 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
681 .await
682 .expect("reload config");
683
684 assert!(
685 config.nodes.identifiers().contains(&"@mecha10/speaker"),
686 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
687 config.nodes.identifiers()
688 );
689 }
690
691 #[tokio::test]
696 async fn mints_detailed_entry_with_capabilities_given_by_the_caller() {
697 let temp_dir = tempfile::tempdir().expect("create temp dir");
698 let project_root = temp_dir.path();
699 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
700 .await
701 .expect("write mecha10.json");
702
703 let service = InitService::new();
704
705 service
706 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
707 .await
708 .expect("add_node_to_config should succeed");
709
710 let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
711 .await
712 .expect("read mecha10.json");
713 let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
714
715 assert_eq!(
716 raw["nodes"]["@mecha10/speaker"],
717 serde_json::json!({
718 "version": env!("CARGO_PKG_VERSION"),
719 "enabled": true,
720 "capabilities": ["audio"],
721 })
722 );
723 }
724
725 #[tokio::test]
729 async fn mints_detailed_entry_with_capabilities_even_with_no_local_config_json_on_disk() {
730 let temp_dir = tempfile::tempdir().expect("create temp dir");
731 let project_root = temp_dir.path();
732 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
733 .await
734 .expect("write mecha10.json");
735
736 assert!(
737 !project_root.join("configs/nodes/@mecha10/speaker/config.json").exists(),
738 "precondition: no local config.json for this node"
739 );
740
741 let service = InitService::new();
742
743 service
744 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
745 .await
746 .expect("add_node_to_config should succeed");
747
748 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
749 .await
750 .expect("reload config");
751
752 assert_eq!(
753 config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
754 Some([Capability::Audio].as_slice()),
755 "capabilities must come from the caller-given value, not a local file read-back"
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, Vec::new())
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]
800 async fn upgrades_a_bare_version_entry_to_detailed_when_backfilling_capabilities() {
801 let temp_dir = tempfile::tempdir().expect("create temp dir");
802 let project_root = temp_dir.path();
803 let config_with_pinned_node = r#"{
804 "name": "test-project",
805 "version": "0.1.0",
806 "robot_id": "test-robot",
807 "nodes": { "@mecha10/speaker": "0.7.0" }
808 }"#;
809 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
810 .await
811 .expect("write mecha10.json");
812
813 let service = InitService::new();
814
815 service
816 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
817 .await
818 .expect("add_node_to_config should succeed");
819
820 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
821 .await
822 .expect("reload config");
823
824 assert_eq!(
825 config.nodes.get_version("@mecha10/speaker"),
826 Some("0.7.0"),
827 "existing pinned version must be preserved when upgrading to the Detailed form"
828 );
829 assert_eq!(
830 config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
831 Some([Capability::Audio].as_slice()),
832 "capabilities must be backfilled onto the upgraded entry"
833 );
834 }
835
836 #[tokio::test]
841 async fn backfills_capabilities_onto_a_detailed_entry_with_empty_capabilities() {
842 let temp_dir = tempfile::tempdir().expect("create temp dir");
843 let project_root = temp_dir.path();
844 let config_with_empty_capabilities = r#"{
845 "name": "test-project",
846 "version": "0.1.0",
847 "robot_id": "test-robot",
848 "nodes": { "@mecha10/speaker": { "version": "0.7.0", "enabled": false, "capabilities": [] } }
849 }"#;
850 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_empty_capabilities)
851 .await
852 .expect("write mecha10.json");
853
854 let service = InitService::new();
855
856 service
857 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
858 .await
859 .expect("add_node_to_config should succeed");
860
861 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
862 .await
863 .expect("reload config");
864
865 let entry = config.nodes.0.get("@mecha10/speaker").expect("entry must still exist");
866 assert_eq!(entry.version(), "0.7.0", "version must be preserved");
867 assert!(!entry.enabled(), "enabled: false must be preserved, not clobbered");
868 assert_eq!(
869 entry.capabilities(),
870 &[Capability::Audio],
871 "capabilities must be backfilled"
872 );
873 }
874
875 #[tokio::test]
879 async fn does_not_clobber_an_existing_non_empty_capabilities_list() {
880 let temp_dir = tempfile::tempdir().expect("create temp dir");
881 let project_root = temp_dir.path();
882 let config_with_existing_capabilities = r#"{
883 "name": "test-project",
884 "version": "0.1.0",
885 "robot_id": "test-robot",
886 "nodes": { "@mecha10/speaker": { "version": "0.7.0", "capabilities": ["vision"] } }
887 }"#;
888 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_existing_capabilities)
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.0.get("@mecha10/speaker").map(|n| n.capabilities()),
905 Some([Capability::Vision].as_slice()),
906 "an already-populated capabilities list must not be overwritten by a later call"
907 );
908 }
909
910 #[tokio::test]
913 async fn does_not_touch_targets_at_all() {
914 let temp_dir = tempfile::tempdir().expect("create temp dir");
915 let project_root = temp_dir.path();
916 let config_with_targets = r#"{
917 "name": "test-project",
918 "version": "0.1.0",
919 "robot_id": "test-robot",
920 "targets": {
921 "robot": { "nodes": ["@mecha10/camera"] }
922 }
923 }"#;
924 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
925 .await
926 .expect("write mecha10.json");
927
928 let service = InitService::new();
929
930 service
931 .add_node_to_config(project_root, "image-classifier", true, Vec::new())
932 .await
933 .expect("add_node_to_config should succeed");
934
935 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
936 .await
937 .expect("reload config");
938
939 assert!(
940 config.targets.simulation.is_none(),
941 "add_node_to_config must not synthesize any targets.* entry anymore"
942 );
943 let robot = config
944 .targets
945 .robot
946 .expect("pre-existing targets.robot must be preserved");
947 assert_eq!(
948 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
949 vec!["@mecha10/camera".to_string()],
950 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
951 robot.nodes
952 );
953 }
954}