use crate::commands::{BuildCommand, BuildTargetArgs, TargetArch, VersionBump};
use crate::context::CliContext;
use crate::paths;
use crate::templates::RustTemplates;
use anyhow::{Context, Result};
use std::path::Path;
use std::process::Command;
#[derive(Debug, Clone, Copy)]
pub enum BuildTarget {
Robot,
Remote,
}
impl BuildTarget {
fn features(&self) -> &'static str {
match self {
BuildTarget::Robot => "target-robot,gpio",
BuildTarget::Remote => "target-remote",
}
}
fn display_name(&self) -> &'static str {
match self {
BuildTarget::Robot => "Robot",
BuildTarget::Remote => "Remote",
}
}
fn description(&self) -> &'static str {
match self {
BuildTarget::Robot => "edge/on-robot nodes",
BuildTarget::Remote => "cloud/remote server nodes",
}
}
fn as_str(&self) -> &'static str {
match self {
BuildTarget::Robot => "robot",
BuildTarget::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()?;
let default_mode = if args.dev { "dev" } else { "production" };
update_default_mode(ctx, default_mode).await?;
println!("Environment: {}", default_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?;
let result = if args.docker {
build_with_docker(ctx, target, args, &project_name).await
} else {
build_with_cargo(ctx, target, args).await
};
if let Some(v) = version {
if result.is_ok() {
println!("📦 Built version: {}", v);
println!();
}
}
result
}
async fn build_with_cargo(ctx: &CliContext, target: BuildTarget, args: &BuildTargetArgs) -> Result<()> {
println!("Feature: --features {}", target.features());
if args.release {
println!("Mode: Release (optimized)");
} else {
println!("Mode: Debug");
}
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);
if args.release {
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 build_profile = if args.release { "release" } else { "debug" };
let target_dir = paths::target_path_with_triple(args.target.as_deref(), build_profile);
println!("Binaries location: {}", target_dir);
println!();
} else {
println!("❌ Build failed");
println!();
return Err(anyhow::anyhow!("Build failed"));
}
Ok(())
}
async fn build_with_docker(
ctx: &CliContext,
target: BuildTarget,
args: &BuildTargetArgs,
project_name: &str,
) -> Result<()> {
let arch = args.arch;
println!("Build: Docker cross-compilation");
println!("Arch: {} ({})", arch.build_arg(), arch.docker_platform());
println!("Mode: Release (Docker builds are always optimized)");
println!();
let dockerfile_path = ctx.working_dir.join(paths::docker::DOCKERFILE_ROBOT_BUILDER);
if !dockerfile_path.exists() {
println!("⚠️ robot-builder.Dockerfile not found");
println!();
println!("This file should have been created by `mecha10 init`.");
println!("You can create it manually or re-initialize your project.");
println!();
return Err(anyhow::anyhow!(
"Missing docker/robot-builder.Dockerfile. Run `mecha10 init` to create it."
));
}
let dist_dir = ctx.working_dir.join("dist");
tokio::fs::create_dir_all(&dist_dir).await?;
let cargo_lock_path = ctx.working_dir.join("Cargo.lock");
if !cargo_lock_path.exists() {
println!("Generating Cargo.lock...");
let status = Command::new("cargo")
.arg("generate-lockfile")
.current_dir(&ctx.working_dir)
.status()
.context("Failed to run cargo generate-lockfile")?;
if !status.success() {
return Err(anyhow::anyhow!("Failed to generate Cargo.lock"));
}
println!("✅ Cargo.lock generated");
println!();
}
let image_tag = format!("{}-builder-{}", project_name, arch.build_arg());
println!("Step 1/3: Building Docker image...");
println!();
let build_status = build_docker_image(ctx, &dockerfile_path, &image_tag, arch)?;
if !build_status.success() {
println!();
println!("❌ Docker build failed");
return Err(anyhow::anyhow!("Docker build failed"));
}
println!();
println!("✅ Docker image built: {}", image_tag);
println!();
println!("Step 2/3: Extracting binary...");
println!();
extract_binary_from_image(&image_tag, &dist_dir, project_name)?;
println!("✅ Binary extracted to dist/");
println!();
println!("Step 3/3: Copying configuration...");
println!();
extract_config_from_image(&image_tag, &dist_dir)?;
add_build_metadata(&dist_dir, target, arch)?;
println!("✅ Configuration extracted to dist/");
println!();
println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
println!("✅ Build completed successfully!");
println!();
println!("Output files:");
println!(" dist/{}", project_name);
println!(" dist/mecha10.json");
if dist_dir.join("configs").exists() {
println!(" dist/configs/");
}
println!();
println!("Target: Linux {} ({})", arch.build_arg(), arch.docker_platform());
println!();
Ok(())
}
fn build_docker_image(
ctx: &CliContext,
dockerfile_path: &Path,
image_tag: &str,
arch: TargetArch,
) -> Result<std::process::ExitStatus> {
let mut cmd = Command::new("docker");
cmd.arg("build")
.arg("-f")
.arg(dockerfile_path)
.arg("--build-arg")
.arg(format!("TARGET_ARCH={}", arch.build_arg()))
.arg("--platform")
.arg(arch.docker_platform())
.arg("-t")
.arg(image_tag)
.arg(".")
.current_dir(&ctx.working_dir);
cmd.status().context("Failed to run docker build")
}
fn extract_binary_from_image(image_tag: &str, dist_dir: &Path, project_name: &str) -> Result<()> {
let container_name = format!("{}-extract", image_tag);
let create_status = Command::new("docker")
.args(["create", "--name", &container_name, image_tag])
.status()
.context("Failed to create extraction container")?;
if !create_status.success() {
return Err(anyhow::anyhow!("Failed to create extraction container"));
}
let binary_result = Command::new("docker")
.args([
"cp",
&format!("{}:/output/{}", container_name, project_name),
dist_dir.join(project_name).to_str().unwrap(),
])
.status();
let _ = Command::new("docker").args(["rm", &container_name]).status();
let copy_status = binary_result.context("Failed to copy binary from container")?;
if !copy_status.success() {
return Err(anyhow::anyhow!("Failed to copy binary from container"));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let binary_path = dist_dir.join(project_name);
let mut perms = std::fs::metadata(&binary_path)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&binary_path, perms)?;
}
Ok(())
}
fn extract_config_from_image(image_tag: &str, dist_dir: &Path) -> Result<()> {
let container_name = format!("{}-extract-config", image_tag);
let create_status = Command::new("docker")
.args(["create", "--name", &container_name, image_tag])
.status()
.context("Failed to create extraction container")?;
if !create_status.success() {
return Err(anyhow::anyhow!("Failed to create extraction container"));
}
let config_result = Command::new("docker")
.args([
"cp",
&format!("{}:/output/mecha10.json", container_name),
dist_dir.join("mecha10.json").to_str().unwrap(),
])
.status();
let configs_result = Command::new("docker")
.args([
"cp",
&format!("{}:/output/configs", container_name),
dist_dir.to_str().unwrap(),
])
.status();
let _ = Command::new("docker").args(["rm", &container_name]).status();
let copy_status = config_result.context("Failed to copy config from container")?;
if !copy_status.success() {
return Err(anyhow::anyhow!("Failed to copy mecha10.json from container"));
}
if let Ok(status) = configs_result {
if status.success() {
println!(" Extracted configs/ directory");
}
}
Ok(())
}
async fn update_default_mode(ctx: &CliContext, mode: &str) -> Result<()> {
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_mode = config["lifecycle"]["default_mode"]
.as_str()
.unwrap_or("dev")
.to_string();
if current_mode != mode {
config["lifecycle"]["default_mode"] = serde_json::json!(mode);
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 default_mode: {} → {}", current_mode, 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(())
}
fn add_build_metadata(dist_dir: &Path, target: BuildTarget, arch: TargetArch) -> Result<()> {
let config_path = dist_dir.join("mecha10.json");
let config_content = std::fs::read_to_string(&config_path).context("Failed to read dist/mecha10.json")?;
let mut config: serde_json::Value =
serde_json::from_str(&config_content).context("Failed to parse dist/mecha10.json")?;
config["build_target"] = serde_json::json!(target.as_str());
config["build_arch"] = serde_json::json!(arch.build_arg());
let updated_content = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, updated_content).context("Failed to write dist/mecha10.json")?;
Ok(())
}