mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Target command handler
//!
//! `mecha10 target` shows the targets configured in mecha10.json (nodes + platforms);
//! `mecha10 target platform` interactively updates which platform(s) a target (robot/remote/rl)
//! builds for - the same platform-selection menu `mecha10 build <target>` opens (LAB-2152),
//! reachable without going through a full build.

use crate::commands::{TargetCommand, TargetPlatformArgs};
use crate::context::CliContext;
use crate::paths;
use crate::services::build_platforms::{label_for_triple, BUILD_PLATFORMS};
use anyhow::{Context, Result};
use mecha10_cli_core::types::project::TargetsConfig;

const PLATFORM_TARGETS: &[&str] = &["robot", "remote", "rl"];

/// Handle the target command
pub async fn handle_target(ctx: &mut CliContext, command: Option<&TargetCommand>) -> Result<()> {
    match command {
        None => show_targets(ctx).await,
        Some(TargetCommand::Platform(args)) => handle_target_platform(ctx, args).await,
    }
}

/// `mecha10 target`: print every configured target's nodes, and - for robot/remote/rl -
/// which platform(s) it builds for.
async fn show_targets(ctx: &mut CliContext) -> Result<()> {
    println!();

    if !ctx.is_project_initialized() {
        println!("Not inside a mecha10 project (no mecha10.json found).");
        println!();
        println!("Run `mecha10 init` to create a new project.");
        println!();
        return Ok(());
    }

    let project_config = ctx.load_project_config().await?;
    let entries = project_config.targets.present_entries();

    if entries.is_empty() {
        println!("No targets configured in mecha10.json.");
        println!();
        return Ok(());
    }

    for (name, nodes) in entries {
        println!("{}:", name);
        if nodes.is_empty() {
            println!("  Nodes:     (none)");
        } else {
            let labels: Vec<String> = nodes.iter().map(ToString::to_string).collect();
            println!("  Nodes:     {}", labels.join(", "));
        }

        if PLATFORM_TARGETS.contains(&name) {
            let platforms = platforms_for(&project_config.targets, name);
            if platforms.is_empty() {
                println!("  Platforms: host architecture (default)");
            } else {
                let labels: Vec<&str> = platforms.iter().map(|t| label_for_triple(t)).collect();
                println!("  Platforms: {}", labels.join(", "));
            }
        }
        println!();
    }

    Ok(())
}

/// `mecha10 target platform [<target>]`: show the platform-selection checklist for one target
/// and write the result back into `mecha10.json`. Prompts to pick a target first when `<target>`
/// is omitted.
async fn handle_target_platform(ctx: &mut CliContext, args: &TargetPlatformArgs) -> Result<()> {
    println!();

    if !ctx.is_project_initialized() {
        println!("Not inside a mecha10 project (no mecha10.json found).");
        println!();
        println!("Run `mecha10 init` to create a new project.");
        println!();
        return Ok(());
    }

    let target_key = resolve_target_key(args.target.as_deref())?;

    let project_config = ctx.load_project_config().await?;
    let configured = platforms_for(&project_config.targets, &target_key);

    let labels: Vec<&str> = BUILD_PLATFORMS.iter().map(|p| p.label).collect();
    let checked = platforms_checked(&configured);

    let selected = crate::ui::prompts::multiselect(
        &format!("Select platform(s) for target \"{}\"", target_key),
        &labels,
        &checked,
    )?;

    let selected_triples: Vec<String> = selected
        .into_iter()
        .map(|i| BUILD_PLATFORMS[i].triple.to_string())
        .collect();

    if selected_triples == configured {
        println!("No change.");
        println!();
        return Ok(());
    }

    update_platforms(ctx, &target_key, &selected_triples).await
}

/// Resolve which target to update: the explicit `target`, validated against [`PLATFORM_TARGETS`],
/// or - when omitted - an interactive select among them.
fn resolve_target_key(target: Option<&str>) -> Result<String> {
    if let Some(target) = target {
        if !PLATFORM_TARGETS.contains(&target) {
            anyhow::bail!(
                "Unknown target \"{}\" - expected one of: {}",
                target,
                PLATFORM_TARGETS.join(", ")
            );
        }
        return Ok(target.to_string());
    }

    let selected = crate::ui::prompts::select("Which target do you want to modify?", PLATFORM_TARGETS)?;
    Ok(PLATFORM_TARGETS[selected].to_string())
}

/// Which [`BUILD_PLATFORMS`] entries (by index) already match a triple in `configured` - the
/// pre-checked state the platform-selection menu opens with. Mirrors
/// `handlers::build::platforms_checked`.
fn platforms_checked(configured: &[String]) -> Vec<bool> {
    BUILD_PLATFORMS
        .iter()
        .map(|p| configured.iter().any(|triple| triple == p.triple))
        .collect()
}

/// The configured platform triples for one target key, or an empty vec when unset.
fn platforms_for(targets: &TargetsConfig, target_key: &str) -> Vec<String> {
    match target_key {
        "robot" => targets.robot.clone().and_then(|t| t.platforms).unwrap_or_default(),
        "remote" => targets.remote.clone().and_then(|t| t.platforms).unwrap_or_default(),
        "rl" => targets.rl.clone().and_then(|t| t.platforms).unwrap_or_default(),
        _ => Vec::new(),
    }
}

/// Write the newly-selected platform triples into `mecha10.json`'s `targets.<target_key>.platforms`.
/// Mirrors `handlers::build::update_platforms`.
async fn update_platforms(ctx: &CliContext, target_key: &str, platforms: &[String]) -> 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")?;

    if platforms.is_empty() {
        if let Some(entry) = config["targets"][target_key].as_object_mut() {
            entry.remove("platforms");
        }
    } else {
        config["targets"][target_key]["platforms"] = serde_json::json!(platforms);
    }

    let updated_content = serde_json::to_string_pretty(&config)?;
    tokio::fs::write(&config_path, updated_content)
        .await
        .context("Failed to write mecha10.json")?;

    let summary = if platforms.is_empty() {
        "host architecture".to_string()
    } else {
        platforms.join(", ")
    };
    println!(
        "📝 Updated mecha10.json: targets.{}.platforms → {}",
        target_key, summary
    );
    println!();

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::context::CliContextBuilder;
    use tempfile::tempdir;

    #[test]
    fn resolve_target_key_accepts_known_targets() {
        assert_eq!(resolve_target_key(Some("robot")).unwrap(), "robot");
        assert_eq!(resolve_target_key(Some("remote")).unwrap(), "remote");
        assert_eq!(resolve_target_key(Some("rl")).unwrap(), "rl");
    }

    #[test]
    fn resolve_target_key_rejects_unknown_target() {
        let err = resolve_target_key(Some("dev")).unwrap_err();
        assert!(err.to_string().contains("Unknown target"));
    }

    #[test]
    fn platforms_checked_matches_configured_triples() {
        let configured = vec!["x86_64-unknown-linux-gnu".to_string()];
        let checked = platforms_checked(&configured);
        let expected: Vec<bool> = BUILD_PLATFORMS
            .iter()
            .map(|p| p.triple == "x86_64-unknown-linux-gnu")
            .collect();
        assert_eq!(checked, expected);
    }

    #[test]
    fn platforms_for_reads_the_matching_target_entry() {
        let targets: TargetsConfig = serde_json::from_value(serde_json::json!({
            "robot": { "nodes": ["@mecha10/motor"], "platforms": ["aarch64-unknown-linux-gnu"] }
        }))
        .unwrap();

        assert_eq!(platforms_for(&targets, "robot"), vec!["aarch64-unknown-linux-gnu"]);
        assert_eq!(platforms_for(&targets, "remote"), Vec::<String>::new());
        assert_eq!(platforms_for(&targets, "rl"), Vec::<String>::new());
    }

    #[test]
    fn platforms_for_reads_the_rl_target_entry() {
        let targets: TargetsConfig = serde_json::from_value(serde_json::json!({
            "rl": { "nodes": ["@mecha10/rl-policy"], "platforms": ["x86_64-unknown-linux-gnu"] }
        }))
        .unwrap();

        assert_eq!(platforms_for(&targets, "rl"), vec!["x86_64-unknown-linux-gnu"]);
    }

    #[tokio::test]
    async fn update_platforms_writes_the_new_triples_into_the_target_entry() {
        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",
                "targets": {
                    "remote": { "nodes": ["@mecha10/object-detector"], "platforms": ["x86_64-unknown-linux-gnu"] }
                }
            })
            .to_string(),
        )
        .unwrap();

        let ctx = CliContextBuilder::new()
            .working_dir(dir.path().to_path_buf())
            .config_path(Some(config_path.clone()))
            .build()
            .unwrap();

        update_platforms(&ctx, "remote", &["aarch64-unknown-linux-gnu".to_string()])
            .await
            .unwrap();

        let content = std::fs::read_to_string(&config_path).unwrap();
        let value: serde_json::Value = serde_json::from_str(&content).unwrap();
        assert_eq!(
            value["targets"]["remote"]["platforms"],
            serde_json::json!(["aarch64-unknown-linux-gnu"])
        );
    }
}