use crate::commands::{BuildCommand, BuildTargetArgs, UploadArgs, VersionBump};
use crate::context::CliContext;
use crate::handlers::config::push_configs;
use crate::handlers::upload::handle_upload;
use crate::paths;
use crate::services::build_platforms::label_for_triple;
use crate::services::credentials::CredentialsService;
use crate::services::publish_status::check_published_version;
use crate::templates::RustTemplates;
use crate::ui::prompts::is_interactive_terminal;
use anyhow::{Context, Result};
use dialoguer::{Confirm, Input};
use mecha10_cli_core::types::ProjectConfig;
use mecha10_core::enum_str_method;
use std::process::Command;
#[derive(Debug, Clone, Copy)]
pub enum BuildTarget {
Robot,
Remote,
Rl,
}
enum_str_method!(
fn features(BuildTarget) -> &'static str {
Robot => "target-robot",
Remote => "target-remote",
Rl => "target-rl",
}
);
enum_str_method!(
fn display_name(BuildTarget) -> &'static str {
Robot => "Robot",
Remote => "Remote",
Rl => "RL",
}
);
enum_str_method!(
fn description(BuildTarget) -> &'static str {
Robot => "edge/on-robot nodes",
Remote => "cloud/remote server nodes",
Rl => "trained RL policy nodes (production inference, excludes the simulator)",
}
);
enum_str_method!(
fn as_str(BuildTarget) -> &'static str {
Robot => "robot",
Remote => "remote",
Rl => "rl",
}
);
impl BuildTarget {
fn configured_platforms(&self, config: &ProjectConfig) -> Vec<String> {
match self {
BuildTarget::Robot => config
.targets
.robot
.clone()
.and_then(|t| t.platforms)
.unwrap_or_default(),
BuildTarget::Remote => config
.targets
.remote
.clone()
.and_then(|t| t.platforms)
.unwrap_or_default(),
BuildTarget::Rl => config.targets.rl.clone().and_then(|t| t.platforms).unwrap_or_default(),
}
}
}
pub async fn handle_build(ctx: &mut CliContext, command: &BuildCommand) -> Result<()> {
match command {
BuildCommand::Robot(args) => build_target(ctx, BuildTarget::Robot, args).await,
BuildCommand::Remote(args) => build_target(ctx, BuildTarget::Remote, args).await,
BuildCommand::Rl(args) => build_target(ctx, BuildTarget::Rl, args).await,
}
}
fn resolve_build_targets(explicit_target: Option<&str>, configured_targets: Vec<String>) -> Vec<Option<String>> {
if let Some(explicit) = explicit_target {
return vec![Some(explicit.to_string())];
}
if configured_targets.is_empty() {
return vec![None];
}
configured_targets.into_iter().map(Some).collect()
}
async fn build_target(ctx: &mut CliContext, target: BuildTarget, args: &BuildTargetArgs) -> Result<()> {
println!();
println!("🔨 Building Mecha10 Project - {} Target", target.display_name());
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!();
if !ctx.is_project_initialized() {
println!("⚠️ Not in a Mecha10 project directory");
println!();
return Err(anyhow::anyhow!("Not in a Mecha10 project"));
}
let project = ctx.project()?;
let project_name = project.name()?;
println!("Validating configs (mecha10.json + node configs)...");
crate::config_validation::validate_config(None).context("Config validation failed")?;
println!("✅ Config validation passed");
println!();
let mode = if args.dev { "dev" } else { "production" };
update_env_mode(ctx, mode).await?;
println!("Environment: {}", mode);
let version_explicit = args.bump.is_some() || args.version.is_some();
let mut version = if let Some(bump) = &args.bump {
let new_version = update_version(ctx, Some(*bump), None).await?;
println!("Version: {} (bumped)", new_version);
Some(new_version)
} else if let Some(version) = &args.version {
let new_version = update_version(ctx, None, Some(version.clone())).await?;
println!("Version: {} (set)", new_version);
Some(new_version)
} else {
let config = ctx.load_project_config().await?;
println!("Version: {}", config.version);
None
};
if !version_explicit {
let current_version = ctx.load_project_config().await?.version;
let interactive = is_interactive_terminal() && !args.non_interactive;
match gate_on_already_published(ctx, &project_name, ¤t_version, target, interactive).await? {
Some(resolved_version) => {
if resolved_version != current_version {
version = Some(resolved_version);
}
}
None => {
println!();
println!("Build cancelled.");
return Ok(());
}
}
}
println!("Project: {}", project_name);
println!("Target: {} ({})", target.display_name(), target.description());
let configured_targets = if args.target.is_none() {
let config = ctx.load_project_config().await?;
target.configured_platforms(&config)
} else {
Vec::new()
};
let build_targets = resolve_build_targets(args.target.as_deref(), configured_targets);
if build_targets.len() > 1 {
let labels: Vec<&str> = build_targets
.iter()
.map(|t| t.as_deref().map_or("host", label_for_triple))
.collect();
println!("Configured targets: {}", labels.join(", "));
}
println!();
regenerate_main_rs(ctx, &project_name).await?;
ensure_core_messaging_feature(ctx).await?;
update_core_dependency_lock(ctx).await?;
for build_target_triple in &build_targets {
if build_targets.len() > 1 {
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!(
"── {} ──",
build_target_triple.as_deref().unwrap_or("host architecture")
);
println!();
}
let per_target_args = BuildTargetArgs {
target: build_target_triple.clone(),
..args.clone()
};
if let Some(triple) = build_target_triple {
ensure_cross_compile_linker_config(ctx, triple)?;
}
let result = build_with_cargo(ctx, target, &per_target_args).await;
if let Some(v) = &version {
if result.is_ok() {
println!("📦 Built version: {}", v);
println!();
}
}
result?;
}
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("📋 Pushing configs...");
println!();
push_configs(ctx).await?;
println!();
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
for build_target_triple in &build_targets {
let binary_path = ctx
.working_dir
.join(paths::target_path_with_triple(
build_target_triple.as_deref(),
"release",
))
.join(&project_name);
let upload_args = UploadArgs {
binary: Some(binary_path),
config: None,
registry_url: None,
version: None,
arch: None,
target: Some(target.as_str().to_string()),
dry_run: false,
};
handle_upload(ctx, &upload_args).await?;
}
Ok(())
}
async fn gate_on_already_published(
ctx: &CliContext,
project_name: &str,
version: &str,
target: BuildTarget,
interactive: bool,
) -> Result<Option<String>> {
let credentials_service = CredentialsService::new();
let credentials = credentials_service.load().unwrap_or(None);
let api_key = credentials.as_ref().map(|c| c.api_key.as_str());
let user_id = credentials.as_ref().map(|c| c.user_id.as_str());
let project_config = ctx.load_project_config().await?;
let control_plane_url = project_config.environments.control_plane_url();
let status = match check_published_version(
&control_plane_url,
project_name,
version,
target.as_str(),
api_key,
user_id,
)
.await
{
Ok(status) => status,
Err(e) => {
println!("⚠️ Could not check whether {} is already published: {e:#}", version);
println!(" Continuing without the publish-status check.");
println!();
return Ok(Some(version.to_string()));
}
};
if !status.exists {
return Ok(Some(version.to_string()));
}
println!(
"⚠️ Version {} has already been deployed for {}",
version,
target.as_str()
);
if !status.platforms.is_empty() {
println!(" Already published for: {}", status.platforms.join(", "));
}
println!();
if !interactive {
println!(
"Non-interactive mode: proceeding with the already-published version {}.",
version
);
println!();
return Ok(Some(version.to_string()));
}
let should_bump = Confirm::new()
.with_prompt("Would you like to bump the version?")
.default(false)
.interact()
.map_err(|e| anyhow::anyhow!("Failed to read confirmation: {e}"))?;
if should_bump {
let suggested = bump_semver(version, VersionBump::Patch)?;
let new_version: String = Input::new()
.with_prompt("New version")
.with_initial_text(&suggested)
.interact_text()
.map_err(|e| anyhow::anyhow!("Failed to read new version: {e}"))?;
validate_semver(&new_version)?;
update_version(ctx, None, Some(new_version.clone())).await?;
return Ok(Some(new_version));
}
let proceed = Confirm::new()
.with_prompt("Proceed with build anyway?")
.default(false)
.interact()
.map_err(|e| anyhow::anyhow!("Failed to read confirmation: {e}"))?;
if proceed {
Ok(Some(version.to_string()))
} else {
Ok(None)
}
}
async fn build_with_cargo(ctx: &CliContext, target: BuildTarget, args: &BuildTargetArgs) -> Result<()> {
println!("Feature: --features {}", target.features());
println!("Mode: Release (optimized)");
if let Some(arch) = &args.target {
println!("Arch: {} (cross-compilation)", arch);
} else {
println!("Arch: Host architecture");
}
println!();
println!("Running cargo build...");
println!();
let mut cmd = Command::new("cargo");
cmd.arg("build")
.arg("--no-default-features")
.arg("--features")
.arg(target.features())
.current_dir(&ctx.working_dir);
cmd.arg("--release");
if let Some(target_arch) = &args.target {
cmd.arg("--target").arg(target_arch);
}
let status = cmd.status()?;
println!();
if status.success() {
println!("✅ Build completed successfully");
println!();
let target_dir = paths::target_path_with_triple(args.target.as_deref(), "release");
println!("Binaries location: {}", target_dir);
println!();
} else {
println!("❌ Build failed");
println!();
return Err(anyhow::anyhow!("Build failed"));
}
Ok(())
}
async fn update_env_mode(ctx: &CliContext, mode: &str) -> Result<()> {
let env_path = ctx.working_dir.join(paths::env::FILE);
let existing = match tokio::fs::read_to_string(&env_path).await {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).context("Failed to read .env"),
};
let new_line = format!("MECHA10_ENV={mode}");
let mut previous_value: Option<String> = None;
let mut lines: Vec<String> = existing
.lines()
.map(|line| {
if let Some(value) = line.strip_prefix("MECHA10_ENV=") {
previous_value = Some(value.to_string());
new_line.clone()
} else {
line.to_string()
}
})
.collect();
let unchanged = previous_value.as_deref() == Some(mode);
if previous_value.is_none() {
lines.push(new_line);
}
if !unchanged {
let mut updated_content = lines.join("\n");
updated_content.push('\n');
tokio::fs::write(&env_path, updated_content)
.await
.context("Failed to write .env")?;
match previous_value {
Some(prev) => println!("📝 Updated MECHA10_ENV in .env: {} → {}", prev, mode),
None => println!("📝 Set MECHA10_ENV={} in .env", mode),
}
}
Ok(())
}
fn ensure_cross_compile_linker_config(ctx: &CliContext, triple: &str) -> Result<()> {
if !needs_cross_compile_linker_config(std::env::consts::OS, triple) {
return Ok(());
}
let config_path = ctx.working_dir.join(".cargo").join("config.toml");
let existing = match std::fs::read_to_string(&config_path) {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e).context("Failed to read .cargo/config.toml"),
};
let Some(updated) = append_linker_section_if_missing(&existing, triple) else {
return Ok(());
};
let cross_gcc = cross_gcc_name(triple);
if !is_on_path(&cross_gcc) {
anyhow::bail!(
"❌ Cross-compiling for {triple} from macOS needs `{cross_gcc}` on PATH.\n\n\
Install the cross-toolchain:\n\
\x20\x20brew tap messense/macos-cross-toolchains\n\
\x20\x20brew install {triple}\n\
\x20\x20rustup target add {triple}"
);
}
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).context("Failed to create .cargo directory")?;
}
std::fs::write(&config_path, updated).context("Failed to write .cargo/config.toml")?;
println!(
"📝 Added [{}] linker override to .cargo/config.toml (cross-compiling from macOS)",
triple
);
println!();
Ok(())
}
fn needs_cross_compile_linker_config(host_os: &str, triple: &str) -> bool {
host_os == "macos" && triple.contains("-linux-")
}
fn cross_gcc_name(triple: &str) -> String {
format!("{triple}-gcc")
}
fn append_linker_section_if_missing(existing: &str, triple: &str) -> Option<String> {
let section_header = format!("[target.{triple}]");
if existing.contains(§ion_header) {
return None;
}
let mut updated = existing.to_string();
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
if !updated.is_empty() {
updated.push('\n');
}
updated.push_str(&format!("{section_header}\nlinker = \"{}\"\n", cross_gcc_name(triple)));
Some(updated)
}
fn is_on_path(binary: &str) -> bool {
let Some(path_var) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&path_var).any(|dir| dir.join(binary).is_file())
}
async fn update_version(ctx: &CliContext, bump: Option<VersionBump>, set_version: Option<String>) -> Result<String> {
let config_path = ctx.working_dir.join(paths::PROJECT_CONFIG);
let config_content = tokio::fs::read_to_string(&config_path)
.await
.context("Failed to read mecha10.json")?;
let mut config: serde_json::Value =
serde_json::from_str(&config_content).context("Failed to parse mecha10.json")?;
let current_version = config["version"].as_str().unwrap_or("0.1.0").to_string();
let new_version = if let Some(bump_type) = bump {
bump_semver(¤t_version, bump_type)?
} else if let Some(version) = set_version {
validate_semver(&version)?;
version
} else {
return Ok(current_version);
};
config["version"] = serde_json::json!(new_version);
let updated_content = serde_json::to_string_pretty(&config)?;
tokio::fs::write(&config_path, updated_content)
.await
.context("Failed to write mecha10.json")?;
println!("📝 Updated mecha10.json: {} → {}", current_version, new_version);
Ok(new_version)
}
fn bump_semver(version: &str, bump: VersionBump) -> Result<String> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() < 3 {
return Err(anyhow::anyhow!(
"Invalid version format '{}'. Expected semver (e.g., 1.2.3)",
version
));
}
let major: u32 = parts[0].parse().context("Invalid major version number")?;
let minor: u32 = parts[1].parse().context("Invalid minor version number")?;
let patch: u32 = parts[2]
.split('-')
.next()
.unwrap_or("0")
.parse()
.context("Invalid patch version number")?;
let (new_major, new_minor, new_patch) = match bump {
VersionBump::Major => (major + 1, 0, 0),
VersionBump::Minor => (major, minor + 1, 0),
VersionBump::Patch => (major, minor, patch + 1),
};
Ok(format!("{}.{}.{}", new_major, new_minor, new_patch))
}
fn validate_semver(version: &str) -> Result<()> {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() < 3 {
return Err(anyhow::anyhow!(
"Invalid version format '{}'. Expected semver (e.g., 1.2.3)",
version
));
}
parts[0].parse::<u32>().context("Invalid major version number")?;
parts[1].parse::<u32>().context("Invalid minor version number")?;
parts[2]
.split('-')
.next()
.unwrap_or("0")
.parse::<u32>()
.context("Invalid patch version number")?;
Ok(())
}
async fn regenerate_main_rs(ctx: &CliContext, project_name: &str) -> Result<()> {
let main_rs_path = ctx.working_dir.join(paths::rust::MAIN_RS);
if !main_rs_path.exists() {
return Ok(());
}
println!("Updating src/main.rs from current template...");
let rust_templates = RustTemplates::new();
rust_templates
.create_main_rs(&ctx.working_dir, project_name)
.await
.context("Failed to regenerate main.rs")?;
println!("✅ src/main.rs updated");
println!();
Ok(())
}
async fn ensure_core_messaging_feature(ctx: &CliContext) -> Result<()> {
let cargo_toml_path = ctx.working_dir.join(paths::rust::CARGO_TOML);
let content = match tokio::fs::read_to_string(&cargo_toml_path).await {
Ok(content) => content,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).context("Failed to read Cargo.toml"),
};
let mut changed = false;
let updated_lines: Vec<String> = content
.lines()
.map(|line| {
let trimmed = line.trim_start();
if !trimmed.starts_with("mecha10-core") || trimmed.contains("messaging") {
return line.to_string();
}
let Some(eq_pos) = trimmed.find('=') else {
return line.to_string();
};
let key = trimmed[..eq_pos].trim();
let value = trimmed[eq_pos + 1..].trim();
changed = true;
if let Some(inner) = value.strip_prefix('{').and_then(|v| v.strip_suffix('}')) {
format!("{key} = {{ {}, features = [\"messaging\"] }}", inner.trim())
} else {
format!("{key} = {{ version = {value}, features = [\"messaging\"] }}")
}
})
.collect();
if changed {
let mut updated_content = updated_lines.join("\n");
if content.ends_with('\n') {
updated_content.push('\n');
}
tokio::fs::write(&cargo_toml_path, updated_content)
.await
.context("Failed to write Cargo.toml")?;
println!(
"📝 Enabled \"messaging\" feature on mecha10-core in Cargo.toml (required for mecha10_core::command_relay)"
);
println!();
}
Ok(())
}
const FRAMEWORK_DEPENDENCY_CRATES: &[&str] = &["mecha10-core", "mecha10-node-resolver"];
async fn update_core_dependency_lock(ctx: &CliContext) -> Result<()> {
if !ctx.working_dir.join("Cargo.lock").exists() {
return Ok(());
}
for crate_name in FRAMEWORK_DEPENDENCY_CRATES {
let status = Command::new("cargo")
.arg("update")
.arg("-p")
.arg(crate_name)
.current_dir(&ctx.working_dir)
.status()
.with_context(|| format!("Failed to run cargo update -p {crate_name}"))?;
if !status.success() {
println!("⚠️ cargo update -p {crate_name} failed - continuing with the existing Cargo.lock entry");
println!();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::context::CliContextBuilder;
use tempfile::tempdir;
#[test]
fn build_target_robot_metadata() {
let target = BuildTarget::Robot;
assert_eq!(target.features(), "target-robot");
assert_eq!(target.display_name(), "Robot");
assert_eq!(target.description(), "edge/on-robot nodes");
assert_eq!(target.as_str(), "robot");
}
#[test]
fn build_target_remote_metadata() {
let target = BuildTarget::Remote;
assert_eq!(target.features(), "target-remote");
assert_eq!(target.display_name(), "Remote");
assert_eq!(target.description(), "cloud/remote server nodes");
assert_eq!(target.as_str(), "remote");
}
#[test]
fn build_target_rl_metadata() {
let target = BuildTarget::Rl;
assert_eq!(target.features(), "target-rl");
assert_eq!(target.display_name(), "RL");
assert_eq!(target.as_str(), "rl");
}
#[test]
fn configured_platforms_reads_targets_rl_platforms_from_mecha10_json() {
let config: ProjectConfig = serde_json::from_value(serde_json::json!({
"name": "proj",
"version": "1.0.0",
"robot_id": "robot-1",
"targets": {
"rl": { "platforms": ["aarch64-unknown-linux-gnu"] }
}
}))
.unwrap();
assert_eq!(
BuildTarget::Rl.configured_platforms(&config),
vec!["aarch64-unknown-linux-gnu".to_string()]
);
}
#[test]
fn configured_platforms_is_empty_for_rl_when_unset() {
let config: ProjectConfig = serde_json::from_value(serde_json::json!({
"name": "proj",
"version": "1.0.0",
"robot_id": "robot-1"
}))
.unwrap();
assert!(BuildTarget::Rl.configured_platforms(&config).is_empty());
}
#[test]
fn resolve_build_targets_falls_back_to_host_arch_when_nothing_configured() {
assert_eq!(resolve_build_targets(None, Vec::new()), vec![None]);
}
#[test]
fn resolve_build_targets_builds_every_configured_triple_when_no_explicit_flag() {
let configured = vec![
"aarch64-unknown-linux-gnu".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
];
assert_eq!(
resolve_build_targets(None, configured),
vec![
Some("aarch64-unknown-linux-gnu".to_string()),
Some("x86_64-unknown-linux-gnu".to_string())
]
);
}
#[test]
fn resolve_build_targets_explicit_flag_narrows_to_one_triple_ignoring_config() {
let configured = vec![
"aarch64-unknown-linux-gnu".to_string(),
"x86_64-unknown-linux-gnu".to_string(),
];
assert_eq!(
resolve_build_targets(Some("aarch64-apple-darwin"), configured),
vec![Some("aarch64-apple-darwin".to_string())]
);
}
#[test]
fn resolve_build_targets_explicit_flag_wins_even_with_no_config() {
assert_eq!(
resolve_build_targets(Some("x86_64-unknown-linux-gnu"), Vec::new()),
vec![Some("x86_64-unknown-linux-gnu".to_string())]
);
}
#[test]
fn needs_cross_compile_linker_config_only_for_macos_targeting_linux() {
assert!(needs_cross_compile_linker_config("macos", "aarch64-unknown-linux-gnu"));
assert!(needs_cross_compile_linker_config("macos", "x86_64-unknown-linux-musl"));
}
#[test]
fn needs_cross_compile_linker_config_false_on_non_macos_hosts() {
assert!(!needs_cross_compile_linker_config("linux", "aarch64-unknown-linux-gnu"));
assert!(!needs_cross_compile_linker_config(
"windows",
"aarch64-unknown-linux-gnu"
));
}
#[test]
fn needs_cross_compile_linker_config_false_for_non_linux_targets() {
assert!(!needs_cross_compile_linker_config("macos", "aarch64-apple-darwin"));
}
#[test]
fn append_linker_section_if_missing_writes_a_new_section_to_an_empty_file() {
let result = append_linker_section_if_missing("", "aarch64-unknown-linux-gnu");
assert_eq!(
result,
Some("[target.aarch64-unknown-linux-gnu]\nlinker = \"aarch64-unknown-linux-gnu-gcc\"\n".to_string())
);
}
#[test]
fn append_linker_section_if_missing_appends_after_existing_unrelated_content() {
let existing = "[target.x86_64-apple-darwin]\nlinker = \"clang\"\n";
let result = append_linker_section_if_missing(existing, "aarch64-unknown-linux-gnu").unwrap();
assert!(result.starts_with(existing));
assert!(result.contains("[target.aarch64-unknown-linux-gnu]\nlinker = \"aarch64-unknown-linux-gnu-gcc\"\n"));
}
#[test]
fn append_linker_section_if_missing_is_none_when_section_already_present() {
let existing = "[target.aarch64-unknown-linux-gnu]\nlinker = \"some-other-linker\"\n";
assert!(append_linker_section_if_missing(existing, "aarch64-unknown-linux-gnu").is_none());
}
#[test]
fn cross_gcc_name_matches_the_macos_cross_toolchains_tap_convention() {
assert_eq!(
cross_gcc_name("aarch64-unknown-linux-gnu"),
"aarch64-unknown-linux-gnu-gcc"
);
}
#[tokio::test]
async fn ensure_cross_compile_linker_config_is_a_noop_on_this_non_macos_test_host() {
if std::env::consts::OS == "macos" {
return;
}
let dir = tempdir().unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
ensure_cross_compile_linker_config(&ctx, "aarch64-unknown-linux-gnu").unwrap();
assert!(!dir.path().join(".cargo").join("config.toml").exists());
}
#[test]
fn is_interactive_terminal_does_not_panic() {
let _ = is_interactive_terminal();
}
#[tokio::test]
async fn gate_on_already_published_proceeds_when_publish_check_cannot_be_completed() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "1.0.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path))
.build()
.unwrap();
let resolved = gate_on_already_published(&ctx, "proj", "1.0.0", BuildTarget::Robot, false)
.await
.unwrap();
assert_eq!(resolved, Some("1.0.0".to_string()));
}
#[test]
fn bump_semver_patch() {
assert_eq!(bump_semver("1.2.3", VersionBump::Patch).unwrap(), "1.2.4");
}
#[test]
fn bump_semver_minor_resets_patch() {
assert_eq!(bump_semver("1.2.3", VersionBump::Minor).unwrap(), "1.3.0");
}
#[test]
fn bump_semver_major_resets_minor_and_patch() {
assert_eq!(bump_semver("1.2.3", VersionBump::Major).unwrap(), "2.0.0");
}
#[test]
fn bump_semver_handles_prerelease_suffix() {
assert_eq!(bump_semver("1.2.3-beta", VersionBump::Patch).unwrap(), "1.2.4");
}
#[test]
fn bump_semver_rejects_malformed_version() {
let err = bump_semver("1.2", VersionBump::Patch).unwrap_err();
assert!(err.to_string().contains("Invalid version format"));
}
#[test]
fn bump_semver_rejects_non_numeric_parts() {
assert!(bump_semver("a.b.c", VersionBump::Patch).is_err());
}
#[test]
fn validate_semver_accepts_valid_version() {
assert!(validate_semver("1.2.3").is_ok());
}
#[test]
fn validate_semver_accepts_prerelease_suffix() {
assert!(validate_semver("1.2.3-rc.1").is_ok());
}
#[test]
fn validate_semver_rejects_too_few_parts() {
let err = validate_semver("1.2").unwrap_err();
assert!(err.to_string().contains("Invalid version format"));
}
#[test]
fn validate_semver_rejects_non_numeric_major() {
assert!(validate_semver("x.2.3").is_err());
}
#[tokio::test]
async fn update_env_mode_creates_env_file_when_absent() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
update_env_mode(&ctx, "production").await.unwrap();
let content = std::fs::read_to_string(&env_path).unwrap();
assert_eq!(content, "MECHA10_ENV=production\n");
}
#[tokio::test]
async fn update_env_mode_updates_existing_line_in_place() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
std::fs::write(&env_path, "ROBOT_ID=robot-1\nMECHA10_ENV=dev\nLOG_LEVEL=info\n").unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
update_env_mode(&ctx, "production").await.unwrap();
let content = std::fs::read_to_string(&env_path).unwrap();
assert_eq!(content, "ROBOT_ID=robot-1\nMECHA10_ENV=production\nLOG_LEVEL=info\n");
}
#[tokio::test]
async fn update_env_mode_appends_line_when_env_file_has_no_existing_entry() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
std::fs::write(&env_path, "ROBOT_ID=robot-1\n# MECHA10_ENV=\n").unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
update_env_mode(&ctx, "dev").await.unwrap();
let content = std::fs::read_to_string(&env_path).unwrap();
assert_eq!(content, "ROBOT_ID=robot-1\n# MECHA10_ENV=\nMECHA10_ENV=dev\n");
}
#[tokio::test]
async fn update_env_mode_is_noop_when_unchanged() {
let dir = tempdir().unwrap();
let env_path = dir.path().join(".env");
std::fs::write(&env_path, "MECHA10_ENV=production\n").unwrap();
let original_modified = std::fs::metadata(&env_path).unwrap().modified().unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
update_env_mode(&ctx, "production").await.unwrap();
let content = std::fs::read_to_string(&env_path).unwrap();
assert_eq!(content, "MECHA10_ENV=production\n");
assert_eq!(
std::fs::metadata(&env_path).unwrap().modified().unwrap(),
original_modified
);
}
#[tokio::test]
async fn update_version_bumps_when_requested() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "1.0.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
let new_version = update_version(&ctx, Some(VersionBump::Minor), None).await.unwrap();
assert_eq!(new_version, "1.1.0");
let content = std::fs::read_to_string(&config_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(value["version"], "1.1.0");
}
#[tokio::test]
async fn update_version_sets_explicit_version() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "1.0.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
let new_version = update_version(&ctx, None, Some("2.5.9".to_string())).await.unwrap();
assert_eq!(new_version, "2.5.9");
}
#[tokio::test]
async fn update_version_returns_current_when_neither_requested() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "1.0.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
let new_version = update_version(&ctx, None, None).await.unwrap();
assert_eq!(new_version, "1.0.0");
let content = std::fs::read_to_string(&config_path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(value["version"], "1.0.0");
}
#[tokio::test]
async fn update_version_rejects_invalid_explicit_version() {
let dir = tempdir().unwrap();
let config_path = dir.path().join("mecha10.json");
std::fs::write(
&config_path,
serde_json::json!({"name": "proj", "version": "1.0.0", "robot_id": "robot-1"}).to_string(),
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.config_path(Some(config_path.clone()))
.build()
.unwrap();
assert!(update_version(&ctx, None, Some("not-a-version".to_string()))
.await
.is_err());
}
#[tokio::test]
async fn ensure_core_messaging_feature_patches_simple_string_dependency() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[dependencies]\nmecha10-core = \"*\"\ntokio = \"1\"\n",
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
ensure_core_messaging_feature(&ctx).await.unwrap();
let content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(content.contains("mecha10-core = { version = \"*\", features = [\"messaging\"] }"));
assert!(content.contains("tokio = \"1\""));
}
#[tokio::test]
async fn ensure_core_messaging_feature_patches_inline_table_dependency() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[dependencies]\nmecha10-core = { version = \"2.0\" }\n",
)
.unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
ensure_core_messaging_feature(&ctx).await.unwrap();
let content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert!(content.contains("mecha10-core = { version = \"2.0\", features = [\"messaging\"] }"));
}
#[tokio::test]
async fn ensure_core_messaging_feature_is_noop_when_already_present() {
let dir = tempdir().unwrap();
let original = "[dependencies]\nmecha10-core = { version = \"*\", features = [\"messaging\"] }\n";
std::fs::write(dir.path().join("Cargo.toml"), original).unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
ensure_core_messaging_feature(&ctx).await.unwrap();
let content = std::fs::read_to_string(dir.path().join("Cargo.toml")).unwrap();
assert_eq!(content, original);
}
#[tokio::test]
async fn ensure_core_messaging_feature_is_noop_when_cargo_toml_missing() {
let dir = tempdir().unwrap();
let ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
assert!(ensure_core_messaging_feature(&ctx).await.is_ok());
assert!(!dir.path().join("Cargo.toml").exists());
}
static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn build_target_fails_fast_on_invalid_config_before_building() {
let _guard = CWD_LOCK.lock().unwrap();
let original_cwd = std::env::current_dir().unwrap();
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("mecha10.json"),
serde_json::json!({
"name": "test-robot",
"version": "1.0.0",
"nodes": {
"drivers": [
{"name": "camera", "path": "./drivers/camera", "config": "./config/missing.json"}
]
}
})
.to_string(),
)
.unwrap();
std::env::set_current_dir(dir.path()).unwrap();
let mut ctx = CliContextBuilder::new()
.working_dir(dir.path().to_path_buf())
.build()
.unwrap();
let args = BuildTargetArgs {
target: None,
bump: None,
version: None,
dev: false,
non_interactive: true,
};
let result = build_target(&mut ctx, BuildTarget::Robot, &args).await;
std::env::set_current_dir(&original_cwd).unwrap();
let err = result.expect_err("expected build to fail fast on an invalid config");
let err_chain = format!("{:#}", err);
assert!(
err_chain.contains("Config validation failed"),
"expected the config-validation gate to fire before any build work, got: {err_chain}"
);
}
}