use crate::paths;
use anyhow::{Context, Result};
use mecha10_cli_core::utils::build_client;
use std::path::Path;
const CRATES_IO_USER_AGENT: &str = "mecha10-cli (+https://github.com/mecha10/mecha10; contact: releases@mecha10.dev)";
#[derive(serde::Deserialize)]
struct CratesIoCrateResponse {
#[serde(rename = "crate")]
krate: CratesIoCrateMeta,
}
#[derive(serde::Deserialize)]
struct CratesIoCrateMeta {
max_version: String,
}
async fn latest_published_crate_version(package_name: &str) -> Result<String> {
let url = format!("https://crates.io/api/v1/crates/{}", package_name);
let client = build_client()?;
let response = client
.get(&url)
.header("User-Agent", CRATES_IO_USER_AGENT)
.send()
.await
.with_context(|| format!("Failed to query crates.io for latest version of {}", package_name))?;
if !response.status().is_success() {
anyhow::bail!(
"Failed to look up latest published version of {} on crates.io: HTTP {}\nURL: {}",
package_name,
response.status(),
url
);
}
let body = response
.text()
.await
.with_context(|| format!("Failed to read crates.io response body for {}", package_name))?;
parse_max_version(&body, package_name)
}
fn parse_max_version(body: &str, package_name: &str) -> Result<String> {
let parsed: CratesIoCrateResponse = serde_json::from_str(body)
.with_context(|| format!("Failed to parse crates.io response for {}", package_name))?;
Ok(parsed.krate.max_version)
}
pub struct InitService;
impl InitService {
pub fn new() -> Self {
Self
}
pub async fn create_project_directories(&self, path: &Path) -> Result<()> {
let dirs = vec![
paths::project::NODES_DIR,
paths::project::DRIVERS_DIR,
paths::project::TYPES_DIR,
paths::project::BEHAVIORS_DIR,
paths::project::LOGS_DIR,
paths::project::MODELS_DIR,
paths::project::MODELS_CATALOG_DIR,
paths::project::SIMULATION_DIR,
paths::project::SIMULATION_MODELS_DIR,
paths::project::SIMULATION_ENVIRONMENTS_DIR,
paths::project::SIMULATION_IMAGES_DIR,
paths::project::RL_TASKS_DIR,
paths::config::NODES_DIR,
];
for dir in dirs {
let dir_path = path.join(dir);
tokio::fs::create_dir_all(&dir_path).await?;
}
Ok(())
}
pub async fn add_example_node(&self, project_root: &Path, component_name: &str) -> Result<()> {
use crate::component_catalog::ComponentCatalog;
let catalog = ComponentCatalog::new();
let components = catalog.search(component_name);
if components.is_empty() {
return Err(anyhow::anyhow!("Component '{}' not found in catalog", component_name));
}
let component = &components[0];
for file_template in &component.files {
let file_path = project_root.join(&file_template.path);
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&file_path, &file_template.content).await?;
}
if !component.files.is_empty() {
self.add_workspace_member(project_root, component_name).await?;
}
let is_monorepo_node = component.files.is_empty();
if is_monorepo_node {
self.add_dependency(project_root, component).await?;
}
self.add_node_to_config(project_root, component_name, is_monorepo_node)
.await?;
Ok(())
}
async fn add_workspace_member(&self, project_root: &Path, component_name: &str) -> Result<()> {
let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
let content = tokio::fs::read_to_string(&cargo_toml_path).await?;
let new_member = format!("nodes/{}", component_name);
let updated_content = if content.contains("members = []") {
content.replace("members = []", &format!("members = [\n \"{}\",\n]", new_member))
} else if let Some(start) = content.find("members = [") {
if let Some(end) = content[start..].find(']') {
let insert_pos = start + end;
let members_section = &content[start..insert_pos];
if members_section.contains('\"') {
format!(
"{}\n \"{}\",{}",
&content[..insert_pos],
new_member,
&content[insert_pos..]
)
} else {
format!(
"{}\n \"{}\",\n{}",
&content[..insert_pos],
new_member,
&content[insert_pos..]
)
}
} else {
content
}
} else {
content
};
tokio::fs::write(&cargo_toml_path, updated_content).await?;
Ok(())
}
fn external_dependency_names(component: &crate::component_catalog::Component) -> Vec<String> {
if component.cargo_dependencies.is_empty() {
return vec![format!("mecha10-nodes-{}", component.id)];
}
component
.cargo_dependencies
.iter()
.filter(|dep| dep.path.is_none())
.map(|dep| dep.name.clone())
.collect()
}
fn insert_dependency_version(content: &str, package_name: &str, version: &str) -> String {
let Some(pos) = content.find("mecha10-core = \"0.1\"") else {
return content.to_string();
};
let Some(newline_pos) = content[pos..].find('\n') else {
return content.to_string();
};
let insert_pos = pos + newline_pos + 1;
format!(
"{}{} = \"{}\"\n{}",
&content[..insert_pos],
package_name,
version,
&content[insert_pos..]
)
}
async fn add_dependency(&self, project_root: &Path, component: &crate::component_catalog::Component) -> Result<()> {
let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
let mut content = tokio::fs::read_to_string(&cargo_toml_path).await?;
let mut changed = false;
for package_name in Self::external_dependency_names(component) {
if content.contains(&package_name) {
continue;
}
let version = latest_published_crate_version(&package_name).await?;
content = Self::insert_dependency_version(&content, &package_name, &version);
changed = true;
}
if changed {
tokio::fs::write(&cargo_toml_path, content).await?;
}
Ok(())
}
async fn add_node_to_config(
&self,
project_root: &Path,
component_name: &str,
is_monorepo_node: bool,
) -> Result<()> {
use crate::services::ConfigService;
let config_path = project_root.join(paths::PROJECT_CONFIG);
let mut config = ConfigService::load_from(&config_path).await?;
let (node_identifier, version) = if is_monorepo_node {
(
format!("@mecha10/{}", component_name),
env!("CARGO_PKG_VERSION").to_string(),
)
} else {
(component_name.to_string(), "0.1.0".to_string())
};
if config.nodes.get_version(&node_identifier).is_none() {
config.nodes.add_node(&node_identifier, &version);
let config_json = serde_json::to_string_pretty(&config)?;
tokio::fs::write(&config_path, config_json).await?;
}
Ok(())
}
}
impl Default for InitService {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod parse_max_version_tests {
use super::*;
#[test]
fn reads_max_version_out_of_a_real_shaped_crates_io_payload() {
let body = r#"{
"crate": {
"id": "mecha10-nodes-behavior-executor",
"max_version": "0.4.2",
"max_stable_version": "0.4.2"
},
"versions": []
}"#;
let version = parse_max_version(body, "mecha10-nodes-behavior-executor").expect("parse version");
assert_eq!(version, "0.4.2");
}
#[test]
fn errors_instead_of_silently_falling_back_on_malformed_body() {
let result = parse_max_version("not json", "mecha10-nodes-behavior-executor");
assert!(result.is_err());
}
}
#[cfg(test)]
mod add_dependency_tests {
use super::*;
fn minimal_cargo_toml() -> &'static str {
"[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\n"
}
#[tokio::test]
async fn does_not_query_crates_io_when_the_dependency_is_already_present() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
let existing = format!("{}mecha10-nodes-behavior-executor = \"0.3.0\"\n", minimal_cargo_toml());
tokio::fs::write(&cargo_toml_path, &existing)
.await
.expect("write Cargo.toml");
let catalog = crate::component_catalog::ComponentCatalog::new();
let component = catalog
.get("behavior-executor")
.expect("behavior-executor is in the catalog")
.clone();
let service = InitService::new();
service
.add_dependency(project_root, &component)
.await
.expect("add_dependency should no-op without touching the network");
let updated = tokio::fs::read_to_string(&cargo_toml_path)
.await
.expect("read Cargo.toml");
assert_eq!(
updated, existing,
"an already-present dependency must be left untouched"
);
}
#[tokio::test]
async fn does_not_query_crates_io_for_a_driver_when_both_its_dependencies_are_already_present() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
let existing = format!("{}realsense-rust = \"0.6\"\n", minimal_cargo_toml());
tokio::fs::write(&cargo_toml_path, &existing)
.await
.expect("write Cargo.toml");
let catalog = crate::component_catalog::ComponentCatalog::new();
let component = catalog
.get("realsense-camera")
.expect("realsense-camera is in the catalog")
.clone();
let service = InitService::new();
service
.add_dependency(project_root, &component)
.await
.expect("add_dependency should no-op without touching the network");
let updated = tokio::fs::read_to_string(&cargo_toml_path)
.await
.expect("read Cargo.toml");
assert_eq!(
updated, existing,
"a driver whose real crate (and local mecha10-core) are already present must be left untouched"
);
}
mod external_dependency_names_tests {
use super::*;
#[test]
fn resolves_only_the_single_external_crate_for_a_package_component() {
let catalog = crate::component_catalog::ComponentCatalog::new();
let component = catalog
.get("behavior-executor")
.expect("behavior-executor is in the catalog");
let names = InitService::external_dependency_names(component);
assert_eq!(
names,
vec!["mecha10-nodes-behavior-executor".to_string()],
"a Package component must resolve exactly its own node crate"
);
}
#[test]
fn resolves_the_real_third_party_crate_and_skips_the_local_mecha10_core_path_dep_for_a_driver() {
let catalog = crate::component_catalog::ComponentCatalog::new();
let component = catalog
.get("realsense-camera")
.expect("realsense-camera is in the catalog");
let names = InitService::external_dependency_names(component);
assert_eq!(
names,
vec!["realsense-rust".to_string()],
"a Driver component must resolve its real third-party crate, not its local mecha10-core path dep"
);
}
}
mod insert_dependency_version_tests {
use super::*;
#[test]
fn inserts_right_after_the_mecha10_core_anchor_line() {
let content = minimal_cargo_toml();
let updated = InitService::insert_dependency_version(content, "realsense-rust", "0.6.1");
assert_eq!(
updated,
"[workspace]\nmembers = []\n\n[workspace.dependencies]\nmecha10-core = \"0.1\"\nrealsense-rust = \"0.6.1\"\n"
);
}
}
#[tokio::test]
async fn adds_the_drivers_real_crate_with_a_pre_resolved_version_and_leaves_mecha10_core_alone() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
let cargo_toml_path = project_root.join(paths::rust::CARGO_TOML);
tokio::fs::write(&cargo_toml_path, minimal_cargo_toml())
.await
.expect("write Cargo.toml");
let catalog = crate::component_catalog::ComponentCatalog::new();
let component = catalog
.get("realsense-camera")
.expect("realsense-camera is in the catalog")
.clone();
let names = InitService::external_dependency_names(&component);
assert_eq!(names, vec!["realsense-rust".to_string()]);
let content = tokio::fs::read_to_string(&cargo_toml_path)
.await
.expect("read Cargo.toml");
let updated = InitService::insert_dependency_version(&content, &names[0], "0.6.1");
tokio::fs::write(&cargo_toml_path, &updated)
.await
.expect("write Cargo.toml");
let final_content = tokio::fs::read_to_string(&cargo_toml_path)
.await
.expect("read Cargo.toml");
assert!(
final_content.contains("realsense-rust = \"0.6.1\""),
"the driver's real third-party crate must be added, got: {}",
final_content
);
assert_eq!(
final_content.matches("mecha10-core").count(),
1,
"mecha10-core must not be duplicated, got: {}",
final_content
);
}
}
#[cfg(test)]
mod add_node_to_config_tests {
use super::*;
use mecha10_cli_core::services::ConfigService;
fn minimal_project_config_json() -> &'static str {
r#"{
"name": "test-project",
"version": "0.1.0",
"robot_id": "test-robot"
}"#
}
#[tokio::test]
async fn mints_project_nodes_with_plain_identifier() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
.await
.expect("write mecha10.json");
let service = InitService::new();
service
.add_node_to_config(project_root, "my-custom", false)
.await
.expect("add_node_to_config should succeed");
let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
.await
.expect("reload config");
assert!(
config.nodes.identifiers().contains(&"my-custom"),
"expected plain identifier 'my-custom' in nodes, got: {:?}",
config.nodes.identifiers()
);
assert!(
!config.nodes.identifiers().contains(&"@local/my-custom"),
"new project nodes must not be minted with the legacy @local/ prefix, got: {:?}",
config.nodes.identifiers()
);
}
#[tokio::test]
async fn preserves_template_version_written_by_mecha10_init() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
tokio::fs::write(
project_root.join(paths::PROJECT_CONFIG),
r#"{
"name": "test-project",
"version": "0.6.5",
"template_version": "0.6.5",
"robot_id": "test-robot"
}"#,
)
.await
.expect("write mecha10.json");
let service = InitService::new();
service
.add_node_to_config(project_root, "speaker", true)
.await
.expect("add_node_to_config should succeed");
let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
.await
.expect("reload config");
assert_eq!(
config.template_version,
Some("0.6.5".to_string()),
"template_version written at `mecha10 init` time must survive add_node_to_config's save"
);
}
#[tokio::test]
async fn mints_framework_nodes_with_mecha10_scope() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), minimal_project_config_json())
.await
.expect("write mecha10.json");
let service = InitService::new();
service
.add_node_to_config(project_root, "speaker", true)
.await
.expect("add_node_to_config should succeed");
let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
.await
.expect("reload config");
assert!(
config.nodes.identifiers().contains(&"@mecha10/speaker"),
"expected framework identifier '@mecha10/speaker' in nodes, got: {:?}",
config.nodes.identifiers()
);
}
#[tokio::test]
async fn does_not_overwrite_a_version_already_pinned_by_build_node_placement() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
let config_with_pinned_node = r#"{
"name": "test-project",
"version": "0.1.0",
"robot_id": "test-robot",
"nodes": { "@mecha10/speaker": "0.7.0" }
}"#;
tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_pinned_node)
.await
.expect("write mecha10.json");
let service = InitService::new();
service
.add_node_to_config(project_root, "speaker", true)
.await
.expect("add_node_to_config should succeed");
let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
.await
.expect("reload config");
assert_eq!(
config.nodes.get_version("@mecha10/speaker"),
Some("0.7.0"),
"the live-catalog-pinned version must survive add_node_to_config's save, not be \
reset to this crate's own CARGO_PKG_VERSION"
);
}
#[tokio::test]
async fn does_not_touch_targets_at_all() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let project_root = temp_dir.path();
let config_with_targets = r#"{
"name": "test-project",
"version": "0.1.0",
"robot_id": "test-robot",
"targets": {
"robot": { "nodes": ["@mecha10/camera"] }
}
}"#;
tokio::fs::write(project_root.join(paths::PROJECT_CONFIG), config_with_targets)
.await
.expect("write mecha10.json");
let service = InitService::new();
service
.add_node_to_config(project_root, "image-classifier", true)
.await
.expect("add_node_to_config should succeed");
let config = ConfigService::load_from(&project_root.join(paths::PROJECT_CONFIG))
.await
.expect("reload config");
assert!(
config.targets.simulation.is_none(),
"add_node_to_config must not synthesize any targets.* entry anymore"
);
let robot = config
.targets
.robot
.expect("pre-existing targets.robot must be preserved");
assert_eq!(
robot.nodes.iter().map(|n| n.name().to_string()).collect::<Vec<_>>(),
vec!["@mecha10/camera".to_string()],
"add_node_to_config must leave pre-existing targets.robot.nodes untouched, got {:?}",
robot.nodes
);
}
}