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(&self, project_root: &Path, component_name: &str) -> Result<()> {
111 use crate::component_catalog::ComponentCatalog;
112
113 let catalog = ComponentCatalog::new();
114 let components = catalog.search(component_name);
115
116 if components.is_empty() {
117 return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
118 }
119
120 let component = &components[0];
121
122 for file_template in &component.files {
123 let file_path = project_root.join(&file_template.path);
124
125 if let Some(parent) = file_path.parent() {
126 tokio::fs::create_dir_all(parent).await?;
127 }
128
129 tokio::fs::write(&file_path, &file_template.content).await?;
130 }
131
132 if !component.files.is_empty() {
133 self.add_workspace_member(project_root, component_name).await?;
134 }
135
136 let is_monorepo_node = component.files.is_empty();
137 if is_monorepo_node {
138 self.add_dependency(project_root, component).await?;
139 }
140
141 self.add_node_to_config(project_root, component_name, is_monorepo_node)
142 .await?;
143
144 Ok(())
145 }
146
147 async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
149 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
150 let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
151
152 let new_member = format!("nodes/{}", component_name);
153 let updated_content = if content.contains("members = []") {
154 content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
155 } else if let Some(start) = content.find("members = [") {
156 if let Some(end) = content[start..].find(']') {
157 let insert_pos = start + end;
158 let members_section = &content[start..insert_pos];
159 if members_section.contains('\"') {
160 format!(
161 "{}\n \"{}\",{}",
162 &content[..insert_pos],
163 new_member,
164 &content[insert_pos..]
165 )
166 } else {
167 format!(
168 "{}\n \"{}\",\n{}",
169 &content[..insert_pos],
170 new_member,
171 &content[insert_pos..]
172 )
173 }
174 } else {
175 content
176 }
177 } else {
178 content
179 };
180
181 tokio::fs::write(&cargo_toml_path, updated_content).await?;
182 Ok(())
183 }
184
185 fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
196 if component.cargo_dependencies.is_empty() {
197 return vec![format!("mecha10-nodes-{}", component.id)];
198 }
199
200 component
201 .cargo_dependencies
202 .iter()
203 .filter(|dep| dep.path.is_none())
204 .map(|dep| dep.name.clone())
205 .collect()
206 }
207
208 fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
212 let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
213 return content.to_string();
214 };
215 let Some(newline_pos) = content[pos..].find('\n') else {
216 return content.to_string();
217 };
218 let insert_pos = pos + newline_pos + 1;
219 format!(
220 "{}{} = \"{}\"\n{}",
221 &content[..insert_pos],
222 package_name,
223 version,
224 &content[insert_pos..]
225 )
226 }
227
228 async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
230 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
231 let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
232
233 let mut changed = false;
234
235 for package_name in Self::external_dependency_names(component) {
236 if content.contains(&package_name) {
237 continue;
238 }
239
240 let version = latest_published_crate_version(&package_name).await?;
241 content = Self::insert_dependency_version(&content, &package_name, &version);
242 changed = true;
243 }
244
245 if changed {
246 tokio::fs::write(&cargo_toml_path, content).await?;
247 }
248
249 Ok(())
250 }
251
252 async fn add_node_to_config(
261 &self,
262 project_root: &Path,
263 component_name: &str,
264 is_monorepo_node: bool,
265 ) -> Result<()> {
266 use crate::services::ConfigService;
267
268 let config_path = project_root.join(paths::PROJECT_CONFIG);
269 let mut config = ConfigService::load_from(&config_path).await?;
270
271 let (node_identifier, version) = if is_monorepo_node {
272 (
273 format!("@mecha10/{}", component_name),
274 env!("CARGO_PKG_VERSION").to_string(),
275 )
276 } else {
277 (component_name.to_string(), "0.1.0".to_string())
278 };
279
280 if config.nodes.get_version(&node_identifier).is_none() {
281 let capabilities = Self::read_local_node_capabilities(project_root, component_name, is_monorepo_node);
282 config.nodes.0.insert(
283 node_identifier,
284 NodeEntry::Detailed {
285 version,
286 enabled: true,
287 capabilities,
288 },
289 );
290
291 let config_json = serde_json::to_string_pretty(&config)?;
292 tokio::fs::write(&config_path, config_json).await?;
293 }
294
295 Ok(())
296 }
297
298 fn read_local_node_capabilities(
304 project_root: &Path,
305 component_name: &str,
306 is_monorepo_node: bool,
307 ) -> Vec<Capability> {
308 let config_path = project_root.join(if is_monorepo_node {
309 paths::config::framework_node(component_name)
310 } else {
311 paths::config::project_node(component_name)
312 });
313
314 let Ok(content) = std::fs::read_to_string(&config_path) else {
315 return Vec::new();
316 };
317
318 let Ok(value) = serde_json::from_str::<serde_json::Value>(&content) else {
319 return Vec::new();
320 };
321
322 value
323 .get("capabilities")
324 .cloned()
325 .and_then(|v| serde_json::from_value::<Vec<Capability>>(v).ok())
326 .unwrap_or_default()
327 }
328}
329
330impl Default for InitService {
331 fn default() -> Self {
332 Self::new()
333 }
334}
335
336#[cfg(test)]
337mod parse_max_version_tests {
338 use super::*;
339
340 #[test]
341 fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
342 let body = r#"{
343 "crate": {
344 "id": "mecha10-nodes-behavior-executor",
345 "max_version": "0.4.2",
346 "max_stable_version": "0.4.2"
347 },
348 "versions": []
349 }"#;
350
351 let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
352 assert_eq!(version, "0.4.2");
353 }
354
355 #[test]
356 fn errors_instead_of_silently_falling_back_on_malformed_body() {
357 let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
358 assert!(result.is_err());
359 }
360}
361
362#[cfg(test)]
363mod add_dependency_tests {
364 use super::*;
365
366 fn minimal_cargo_toml() -> &'static str {
369 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
370 }
371
372 #[tokio::test]
373 async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
374 let temp_dir = tempfile::tempdir().expect("create temp dir");
377 let project_root = temp_dir.path();
378 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
379
380 let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
381 tokio::fs::write(&cargo_toml_path, &existing)
382 .await
383 .expect("write Cargo.toml");
384
385 let catalog = crate::component_catalog::ComponentCatalog::new();
386 let component = catalog
387 .get("behavior-executor")
388 .expect("behavior-executor is in the catalog")
389 .clone();
390
391 let service = InitService::new();
392 service
393 .add_dependency(project_root, &component)
394 .await
395 .expect("add_dependency should no-op without touching the network");
396
397 let updated = tokio::fs::read_to_string(&cargo_toml_path)
398 .await
399 .expect("read Cargo.toml");
400 assert_eq!(
401 updated, existing,
402 "an already-present dependency must be left untouched"
403 );
404 }
405
406 #[tokio::test]
407 async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
408 let temp_dir = tempfile::tempdir().expect("create temp dir");
413 let project_root = temp_dir.path();
414 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
415
416 let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
417 tokio::fs::write(&cargo_toml_path, &existing)
418 .await
419 .expect("write Cargo.toml");
420
421 let catalog = crate::component_catalog::ComponentCatalog::new();
422 let component = catalog
423 .get("realsense-camera")
424 .expect("realsense-camera is in the catalog")
425 .clone();
426
427 let service = InitService::new();
428 service
429 .add_dependency(project_root, &component)
430 .await
431 .expect("add_dependency should no-op without touching the network");
432
433 let updated = tokio::fs::read_to_string(&cargo_toml_path)
434 .await
435 .expect("read Cargo.toml");
436 assert_eq!(
437 updated, existing,
438 "a driver whose real crate (and local mecha10-core) are already present must be left untouched"
439 );
440 }
441
442 mod external_dependency_names_tests {
446 use super::*;
447
448 #[test]
449 fn resolves_only_the_single_external_crate_for_a_package_component() {
450 let catalog = crate::component_catalog::ComponentCatalog::new();
454 let component = catalog
455 .get("behavior-executor")
456 .expect("behavior-executor is in the catalog");
457
458 let names = InitService::external_dependency_names(component);
459
460 assert_eq!(
461 names,
462 vec!["mecha10-nodes-behavior-executor".to_string()],
463 "a Package component must resolve exactly its own node crate"
464 );
465 }
466
467 #[test]
468 fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
469 let catalog = crate::component_catalog::ComponentCatalog::new();
474 let component = catalog
475 .get("realsense-camera")
476 .expect("realsense-camera is in the catalog");
477
478 let names = InitService::external_dependency_names(component);
479
480 assert_eq!(
481 names,
482 vec!["realsense-rust".to_string()],
483 "a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
484 );
485 }
486 }
487
488 mod insert_dependency_version_tests {
489 use super::*;
490
491 #[test]
492 fn inserts_right_after_the_mecha10_core_anchor_line() {
493 let content = minimal_cargo_toml();
494
495 let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
496
497 assert_eq!(
498 updated,
499 "[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
500 );
501 }
502 }
503
504 #[tokio::test]
505 async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
506 let temp_dir = tempfile::tempdir().expect("create temp dir");
510 let project_root = temp_dir.path();
511 let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
512 tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
513 .await
514 .expect("write Cargo.toml");
515
516 let catalog = crate::component_catalog::ComponentCatalog::new();
517 let component = catalog
518 .get("realsense-camera")
519 .expect("realsense-camera is in the catalog")
520 .clone();
521
522 let names = InitService::external_dependency_names(&component);
526 assert_eq!(names, vec!["realsense-rust".to_string()]);
527
528 let content = tokio::fs::read_to_string(&cargo_toml_path)
529 .await
530 .expect("read Cargo.toml");
531 let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
532 tokio::fs::write(&cargo_toml_path, &updated)
533 .await
534 .expect("write Cargo.toml");
535
536 let final_content = tokio::fs::read_to_string(&cargo_toml_path)
537 .await
538 .expect("read Cargo.toml");
539 assert!(
540 final_content.contains("realsense-rust = \"0.6.1\""),
541 "the driver's real third-party crate must be added, got: {}",
542 final_content
543 );
544 assert_eq!(
545 final_content.matches("mecha10-core").count(),
546 1,
547 "mecha10-core must not be duplicated, got: {}",
548 final_content
549 );
550 }
551}
552
553#[cfg(test)]
554mod add_node_to_config_tests {
555 use super::*;
556 use mecha10_cli_core::services::ConfigService;
557
558 fn minimal_project_config_json() -> &'static str {
560 r#"{
561 "name": "test-project",
562 "version": "0.1.0",
563 "robot_id": "test-robot"
564 }"#
565 }
566
567 #[tokio::test]
568 async fn mints_project_nodes_with_plain_identifier() {
569 let temp_dir = tempfile::tempdir().expect("create temp dir");
570 let project_root = temp_dir.path();
571 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
572 .await
573 .expect("write mecha10.json");
574
575 let service = InitService::new();
576
577 service
578 .add_node_to_config(project_root, "my-custom", false)
579 .await
580 .expect("add_node_to_config should succeed");
581
582 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
583 .await
584 .expect("reload config");
585
586 assert!(
587 config.nodes.identifiers().contains(&"my-custom"),
588 "expected plain identifier 'my-custom' in nodes, got: {:?}",
589 config.nodes.identifiers()
590 );
591 assert!(
592 !config.nodes.identifiers().contains(&"@local/my-custom"),
593 "new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
594 config.nodes.identifiers()
595 );
596 }
597
598 #[tokio::test]
601 async fn preserves_template_version_written_by_mecha10_init() {
602 let temp_dir = tempfile::tempdir().expect("create temp dir");
603 let project_root = temp_dir.path();
604 tokio::fs::write(
605 project_root.join(paths::PROJECT_CONFIG),
606 r#"{
607 "name": "test-project",
608 "version": "0.6.5",
609 "template_version": "0.6.5",
610 "robot_id": "test-robot"
611 }"#,
612 )
613 .await
614 .expect("write mecha10.json");
615
616 let service = InitService::new();
617
618 service
619 .add_node_to_config(project_root, "speaker", true)
620 .await
621 .expect("add_node_to_config should succeed");
622
623 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
624 .await
625 .expect("reload config");
626
627 assert_eq!(
628 config.template_version,
629 Some("0.6.5".to_string()),
630 "template_version written at `mecha10 init` time must survive add_node_to_config's save"
631 );
632 }
633
634 #[tokio::test]
635 async fn mints_framework_nodes_with_mecha10_scope() {
636 let temp_dir = tempfile::tempdir().expect("create temp dir");
637 let project_root = temp_dir.path();
638 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
639 .await
640 .expect("write mecha10.json");
641
642 let service = InitService::new();
643
644 service
645 .add_node_to_config(project_root, "speaker", true)
646 .await
647 .expect("add_node_to_config should succeed");
648
649 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
650 .await
651 .expect("reload config");
652
653 assert!(
654 config.nodes.identifiers().contains(&"@mecha10/speaker"),
655 "expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
656 config.nodes.identifiers()
657 );
658 }
659
660 #[tokio::test]
665 async fn mints_detailed_entry_with_capabilities_from_local_config_json() {
666 let temp_dir = tempfile::tempdir().expect("create temp dir");
667 let project_root = temp_dir.path();
668 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
669 .await
670 .expect("write mecha10.json");
671
672 let node_config_dir = project_root.join("configs/nodes/@mecha10/speaker");
673 tokio::fs::create_dir_all(&node_config_dir)
674 .await
675 .expect("create node config dir");
676 tokio::fs::write(
677 node_config_dir.join("config.json"),
678 serde_json::json!({ "run_target": "robot", "capabilities": ["audio"] }).to_string(),
679 )
680 .await
681 .expect("write node config.json");
682
683 let service = InitService::new();
684
685 service
686 .add_node_to_config(project_root, "speaker", true)
687 .await
688 .expect("add_node_to_config should succeed");
689
690 let raw = tokio::fs::read_to_string(project_root.join(paths::PROJECT_CONFIG))
691 .await
692 .expect("read mecha10.json");
693 let raw: serde_json::Value = serde_json::from_str(&raw).expect("parse mecha10.json");
694
695 assert_eq!(
696 raw["nodes"]["@mecha10/speaker"],
697 serde_json::json!({
698 "version": env!("CARGO_PKG_VERSION"),
699 "enabled": true,
700 "capabilities": ["audio"],
701 })
702 );
703 }
704
705 #[tokio::test]
709 async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
710 let temp_dir = tempfile::tempdir().expect("create temp dir");
711 let project_root = temp_dir.path();
712 let config_with_pinned_node = r#"{
713 "name": "test-project",
714 "version": "0.1.0",
715 "robot_id": "test-robot",
716 "nodes": { "@mecha10/speaker": "0.7.0" }
717 }"#;
718 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
719 .await
720 .expect("write mecha10.json");
721
722 let service = InitService::new();
723
724 service
725 .add_node_to_config(project_root, "speaker", true)
726 .await
727 .expect("add_node_to_config should succeed");
728
729 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
730 .await
731 .expect("reload config");
732
733 assert_eq!(
734 config.nodes.get_version("@mecha10/speaker"),
735 Some("0.7.0"),
736 "the live-catalog-pinned version must survive add_node_to_config's save, not be \
737 reset to this crate's own CARGO_PKG_VERSION"
738 );
739 }
740
741 #[tokio::test]
744 async fn does_not_touch_targets_at_all() {
745 let temp_dir = tempfile::tempdir().expect("create temp dir");
746 let project_root = temp_dir.path();
747 let config_with_targets = r#"{
748 "name": "test-project",
749 "version": "0.1.0",
750 "robot_id": "test-robot",
751 "targets": {
752 "robot": { "nodes": ["@mecha10/camera"] }
753 }
754 }"#;
755 tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
756 .await
757 .expect("write mecha10.json");
758
759 let service = InitService::new();
760
761 service
762 .add_node_to_config(project_root, "image-classifier", true)
763 .await
764 .expect("add_node_to_config should succeed");
765
766 let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
767 .await
768 .expect("reload config");
769
770 assert!(
771 config.targets.simulation.is_none(),
772 "add_node_to_config must not synthesize any targets.* entry anymore"
773 );
774 let robot = config
775 .targets
776 .robot
777 .expect("pre-existing targets.robot must be preserved");
778 assert_eq!(
779 robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
780 vec!["@mecha10/camera".to_string()],
781 "add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
782 robot.nodes
783 );
784 }
785}