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 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
163 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
164 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
165
166 let new_member = format!("nodes/{}", component_name);
167 let updated_content = if content.contains("members = []") {
168 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
169 } else if let Some(start) = content.find("members = [") {
170 if let Some(end) = content[start..].find(']') {
171 let insert_pos = start + end;
172 let members_section = &content[start..insert_pos];
173 if members_section.contains('\"') {
174 format!(
175 "{}\n \"{}\",{}",
176 &content[..insert_pos],
177 new_member,
178 &content[insert_pos..]
179 )
180 } else {
181 format!(
182 "{}\n \"{}\",\n{}",
183 &content[..insert_pos],
184 new_member,
185 &content[insert_pos..]
186 )
187 }
188 } else {
189 content
190 }
191 } else {
192 content
193 };
194
195 tokio::fs::write(&cargo_toml_path, updated_content).await?;
196 Ok(())
197 }
198
199 fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
210 if component.cargo_dependencies.is_empty() {
211 return vec![format!("mecha10-nodes-{}", component.id)];
212 }
213
214 component
215 .cargo_dependencies
216 .iter()
217 .filter(|dep| dep.path.is_none())
218 .map(|dep| dep.name.clone())
219 .collect()
220 }
221
222 fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
226 let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
227 return content.to_string();
228 };
229 let Some(newline_pos) = content[pos..].find('\n') else {
230 return content.to_string();
231 };
232 let insert_pos = pos + newline_pos + 1;
233 format!(
234 "{}{} = \"{}\"\n{}",
235 &content[..insert_pos],
236 package_name,
237 version,
238 &content[insert_pos..]
239 )
240 }
241
242 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
244 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
245 let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
246
247 let mut changed = false;
248
249 for package_name in Self::external_dependency_names(component) {
250 if content.contains(&package_name) {
251 continue;
252 }
253
254 let version = latest_published_crate_version(&package_name).await?;
255 content = Self::insert_dependency_version(&content, &package_name, &version);
256 changed = true;
257 }
258
259 if changed {
260 tokio::fs::write(&cargo_toml_path, content).await?;
261 }
262
263 Ok(())
264 }
265
266 async fn add_node_to_config(
275 &self,
276 project_root: &Path,
277 component_name: &str,
278 is_monorepo_node: bool,
279 capabilities: Vec<Capability>,
280 ) -> Result<()> {
281 use crate::services::ConfigService;
282
283 let config_path = project_root.join(paths::PROJECT_CONFIG);
284 let mut config = ConfigService::load_from(&config_path).await?;
285
286 let (node_identifier, version) = if is_monorepo_node {
287 (
288 format!("@mecha10/{}", component_name),
289 env!("CARGO_PKG_VERSION").to_string(),
290 )
291 } else {
292 (component_name.to_string(), "0.1.0".to_string())
293 };
294
295 if config.nodes.get_version(&node_identifier).is_none() {
296 config.nodes.0.insert(
297 node_identifier,
298 NodeEntry::Detailed {
299 version,
300 enabled: true,
301 capabilities,
302 },
303 );
304
305 let config_json = serde_json::to_string_pretty(&config)?;
306 tokio::fs::write(&config_path, config_json).await?;
307 }
308
309 Ok(())
310 }
311}
312
313impl Default for InitService {
314 fn default() -> Self {
315 Self::new()
316 }
317}
318
319#[cfg(test)]
320mod parse_max_version_tests {
321 use super::*;
322
323 #[test]
324 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
325 let body = r#"{
326 "crate": {
327 "id": "mecha10-nodes-behavior-executor",
328 "max_version": "0.4.2",
329 "max_stable_version": "0.4.2"
330 },
331 "versions": []
332 }"#;
333
334 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
335 assert_eq!(version, "0.4.2");
336 }
337
338 #[test]
339 fn errors_instead_of_silently_falling_back_on_malformed_body() {
340 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
341 assert!(result.is_err());
342 }
343}
344
345#[cfg(test)]
346mod add_dependency_tests {
347 use super::*;
348
349 fn minimal_cargo_toml() -> &'static str {
352 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
353 }
354
355 #[tokio::test]
356 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
357 let temp_dir = tempfile::tempdir().expect("create temp dir");
360 let project_root = temp_dir.path();
361 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
362
363 let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
364 tokio::fs::write(&cargo_toml_path, &existing)
365 .await
366 .expect("write Cargo.toml");
367
368 let catalog = crate::component_catalog::ComponentCatalog::new();
369 let component = catalog
370 .get("behavior-executor")
371 .expect("behavior-executor is in the catalog")
372 .clone();
373
374 let service = InitService::new();
375 service
376 .add_dependency(project_root, &component)
377 .await
378 .expect("add_dependency should no-op without touching the network");
379
380 let updated = tokio::fs::read_to_string(&cargo_toml_path)
381 .await
382 .expect("read Cargo.toml");
383 assert_eq!(
384 updated, existing,
385 "an already-present dependency must be left untouched"
386 );
387 }
388
389 #[tokio::test]
390 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
391 let temp_dir = tempfile::tempdir().expect("create temp dir");
396 let project_root = temp_dir.path();
397 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
398
399 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
400 tokio::fs::write(&cargo_toml_path, &existing)
401 .await
402 .expect("write Cargo.toml");
403
404 let catalog = crate::component_catalog::ComponentCatalog::new();
405 let component = catalog
406 .get("realsense-camera")
407 .expect("realsense-camera is in the catalog")
408 .clone();
409
410 let service = InitService::new();
411 service
412 .add_dependency(project_root, &component)
413 .await
414 .expect("add_dependency should no-op without touching the network");
415
416 let updated = tokio::fs::read_to_string(&cargo_toml_path)
417 .await
418 .expect("read Cargo.toml");
419 assert_eq!(
420 updated, existing,
421 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
422 );
423 }
424
425 mod external_dependency_names_tests {
429 use super::*;
430
431 #[test]
432 fn resolves_only_the_single_external_crate_for_a_package_component() {
433 let catalog = crate::component_catalog::ComponentCatalog::new();
437 let component = catalog
438 .get("behavior-executor")
439 .expect("behavior-executor is in the catalog");
440
441 let names = InitService::external_dependency_names(component);
442
443 assert_eq!(
444 names,
445 vec!["mecha10-nodes-behavior-executor".to_string()],
446 "a Package component must resolve exactly its own node crate"
447 );
448 }
449
450 #[test]
451 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
452 let catalog = crate::component_catalog::ComponentCatalog::new();
457 let component = catalog
458 .get("realsense-camera")
459 .expect("realsense-camera is in the catalog");
460
461 let names = InitService::external_dependency_names(component);
462
463 assert_eq!(
464 names,
465 vec!["realsense-rust".to_string()],
466 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
467 );
468 }
469 }
470
471 mod insert_dependency_version_tests {
472 use super::*;
473
474 #[test]
475 fn inserts_right_after_the_mecha10_core_anchor_line() {
476 let content = minimal_cargo_toml();
477
478 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
479
480 assert_eq!(
481 updated,
482 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
483 );
484 }
485 }
486
487 #[tokio::test]
488 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
489 let temp_dir = tempfile::tempdir().expect("create temp dir");
493 let project_root = temp_dir.path();
494 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
495 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
496 .await
497 .expect("write Cargo.toml");
498
499 let catalog = crate::component_catalog::ComponentCatalog::new();
500 let component = catalog
501 .get("realsense-camera")
502 .expect("realsense-camera is in the catalog")
503 .clone();
504
505 let names = InitService::external_dependency_names(&component);
509 assert_eq!(names, vec!["realsense-rust".to_string()]);
510
511 let content = tokio::fs::read_to_string(&cargo_toml_path)
512 .await
513 .expect("read Cargo.toml");
514 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
515 tokio::fs::write(&cargo_toml_path, &updated)
516 .await
517 .expect("write Cargo.toml");
518
519 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
520 .await
521 .expect("read Cargo.toml");
522 assert!(
523 final_content.contains("realsense-rust = \"0.6.1\""),
524 "the driver's real third-party crate must be added, got: {}",
525 final_content
526 );
527 assert_eq!(
528 final_content.matches("mecha10-core").count(),
529 1,
530 "mecha10-core must not be duplicated, got: {}",
531 final_content
532 );
533 }
534}
535
536#[cfg(test)]
537mod add_node_to_config_tests {
538 use super::*;
539 use mecha10_cli_core::services::ConfigService;
540
541 fn minimal_project_config_json() -> &'static str {
543 r#"{
544 "name": "test-project",
545 "version": "0.1.0",
546 "robot_id": "test-robot"
547 }"#
548 }
549
550 #[tokio::test]
551 async fn mints_project_nodes_with_plain_identifier() {
552 let temp_dir = tempfile::tempdir().expect("create temp dir");
553 let project_root = temp_dir.path();
554 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
555 .await
556 .expect("write mecha10.json");
557
558 let service = InitService::new();
559
560 service
561 .add_node_to_config(project_root, "my-custom", false, Vec::new())
562 .await
563 .expect("add_node_to_config should succeed");
564
565 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
566 .await
567 .expect("reload config");
568
569 assert!(
570 config.nodes.identifiers().contains(&"my-custom"),
571 "expected plain identifier 'my-custom' in nodes, got: {:?}",
572 config.nodes.identifiers()
573 );
574 assert!(
575 !config.nodes.identifiers().contains(&"@local/my-custom"),
576 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
577 config.nodes.identifiers()
578 );
579 }
580
581 #[tokio::test]
584 async fn preserves_template_version_written_by_mecha10_init() {
585 let temp_dir = tempfile::tempdir().expect("create temp dir");
586 let project_root = temp_dir.path();
587 tokio::fs::write(
588 project_root.join(paths::PROJECT_CONFIG),
589 r#"{
590 "name": "test-project",
591 "version": "0.6.5",
592 "template_version": "0.6.5",
593 "robot_id": "test-robot"
594 }"#,
595 )
596 .await
597 .expect("write mecha10.json");
598
599 let service = InitService::new();
600
601 service
602 .add_node_to_config(project_root, "speaker", true, Vec::new())
603 .await
604 .expect("add_node_to_config should succeed");
605
606 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
607 .await
608 .expect("reload config");
609
610 assert_eq!(
611 config.template_version,
612 Some("0.6.5".to_string()),
613 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
614 );
615 }
616
617 #[tokio::test]
618 async fn mints_framework_nodes_with_mecha10_scope() {
619 let temp_dir = tempfile::tempdir().expect("create temp dir");
620 let project_root = temp_dir.path();
621 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
622 .await
623 .expect("write mecha10.json");
624
625 let service = InitService::new();
626
627 service
628 .add_node_to_config(project_root, "speaker", true, Vec::new())
629 .await
630 .expect("add_node_to_config should succeed");
631
632 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
633 .await
634 .expect("reload config");
635
636 assert!(
637 config.nodes.identifiers().contains(&"@mecha10/speaker"),
638 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
639 config.nodes.identifiers()
640 );
641 }
642
643 #[tokio::test]
648 async fn mints_detailed_entry_with_capabilities_given_by_the_caller() {
649 let temp_dir = tempfile::tempdir().expect("create temp dir");
650 let project_root = temp_dir.path();
651 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
652 .await
653 .expect("write mecha10.json");
654
655 let service = InitService::new();
656
657 service
658 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
659 .await
660 .expect("add_node_to_config should succeed");
661
662 let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
663 .await
664 .expect("read mecha10.json");
665 let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
666
667 assert_eq!(
668 raw["nodes"]["@mecha10/speaker"],
669 serde_json::json!({
670 "version": env!("CARGO_PKG_VERSION"),
671 "enabled": true,
672 "capabilities": ["audio"],
673 })
674 );
675 }
676
677 #[tokio::test]
681 async fn mints_detailed_entry_with_capabilities_even_with_no_local_config_json_on_disk() {
682 let temp_dir = tempfile::tempdir().expect("create temp dir");
683 let project_root = temp_dir.path();
684 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
685 .await
686 .expect("write mecha10.json");
687
688 assert!(
689 !project_root.join("configs/nodes/@mecha10/speaker/config.json").exists(),
690 "precondition: no local config.json for this node"
691 );
692
693 let service = InitService::new();
694
695 service
696 .add_node_to_config(project_root, "speaker", true, vec![Capability::Audio])
697 .await
698 .expect("add_node_to_config should succeed");
699
700 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
701 .await
702 .expect("reload config");
703
704 assert_eq!(
705 config.nodes.0.get("@mecha10/speaker").map(|n| n.capabilities()),
706 Some([Capability::Audio].as_slice()),
707 "capabilities must come from the caller-given value, not a local file read-back"
708 );
709 }
710
711 #[tokio::test]
715 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
716 let temp_dir = tempfile::tempdir().expect("create temp dir");
717 let project_root = temp_dir.path();
718 let config_with_pinned_node = r#"{
719 "name": "test-project",
720 "version": "0.1.0",
721 "robot_id": "test-robot",
722 "nodes": { "@mecha10/speaker": "0.7.0" }
723 }"#;
724 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
725 .await
726 .expect("write mecha10.json");
727
728 let service = InitService::new();
729
730 service
731 .add_node_to_config(project_root, "speaker", true, Vec::new())
732 .await
733 .expect("add_node_to_config should succeed");
734
735 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
736 .await
737 .expect("reload config");
738
739 assert_eq!(
740 config.nodes.get_version("@mecha10/speaker"),
741 Some("0.7.0"),
742 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
743 reset to this crate's own CARGO_PKG_VERSION"
744 );
745 }
746
747 #[tokio::test]
750 async fn does_not_touch_targets_at_all() {
751 let temp_dir = tempfile::tempdir().expect("create temp dir");
752 let project_root = temp_dir.path();
753 let config_with_targets = r#"{
754 "name": "test-project",
755 "version": "0.1.0",
756 "robot_id": "test-robot",
757 "targets": {
758 "robot": { "nodes": ["@mecha10/camera"] }
759 }
760 }"#;
761 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
762 .await
763 .expect("write mecha10.json");
764
765 let service = InitService::new();
766
767 service
768 .add_node_to_config(project_root, "image-classifier", true, Vec::new())
769 .await
770 .expect("add_node_to_config should succeed");
771
772 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
773 .await
774 .expect("reload config");
775
776 assert!(
777 config.targets.simulation.is_none(),
778 "add_node_to_config must not synthesize any targets.* entry anymore"
779 );
780 let robot = config
781 .targets
782 .robot
783 .expect("pre-existing targets.robot must be preserved");
784 assert_eq!(
785 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
786 vec!["@mecha10/camera".to_string()],
787 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
788 robot.nodes
789 );
790 }
791}