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::templates::RustTemplates;
use anyhow::{Context, Result};
use mecha10_core::enum_str_method;
use std::process::Command;
#[derive(Debug, Clone, Copy)]
pub enum BuildTarget {
Robot,
Remote,
}
enum_str_method!(
fn features(BuildTarget) -> &'static str {
Robot => "target-robot",
Remote => "target-remote",
}
);
enum_str_method!(
fn display_name(BuildTarget) -> &'static str {
Robot => "Robot",
Remote => "Remote",
}
);
enum_str_method!(
fn description(BuildTarget) -> &'static str {
Robot => "edge/on-robot nodes",
Remote => "cloud/remote server nodes",
}
);
enum_str_method!(
fn as_str(BuildTarget) -> &'static str {
Robot => "robot",
Remote => "remote",
}
);
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,
}
}
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 = 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
};
println!("Project: {}", project_name);
println!("Target: {} ({})", target.display_name(), target.description());
println!();
regenerate_main_rs(ctx, &project_name).await?;
ensure_core_messaging_feature(ctx).await?;
update_core_dependency_lock(ctx).await?;
let result = build_with_cargo(ctx, 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!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
let upload_args = UploadArgs {
binary: None,
config: None,
registry_url: None,
version: None,
arch: None,
target: None,
dry_run: false,
};
handle_upload(ctx, &upload_args).await?;
Ok(())
}
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(())
}
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} = {{{inner}, features = [\"messaging\"] }}")
} 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(())
}
async fn update_core_dependency_lock(ctx: &CliContext) -> Result<()> {
if !ctx.working_dir.join("Cargo.lock").exists() {
return Ok(());
}
let status = Command::new("cargo")
.arg("update")
.arg("-p")
.arg("mecha10-core")
.current_dir(&ctx.working_dir)
.status()
.context("Failed to run cargo update -p mecha10-core")?;
if !status.success() {
println!("⚠️ cargo update -p mecha10-core 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 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,
};
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}"
);
}
}