mecha10-cli 0.8.0

Mecha10 CLI tool
Documentation
//! `mecha10 robot` command handler.
//!
//! Lists and deletes robots registered under the caller's account against the control plane's
//! `/api/robots*` endpoints. Deletion resolves a human-readable name to the robot's id via
//! `list_robots` first, since the backend's delete endpoint takes an id (LAB robot-delete-list-cli).

use crate::commands::RobotCommand;
use crate::context::CliContext;
use crate::handlers::config::create_config_client;
use anyhow::{Context, Result};
use console::style;
use dialoguer::Confirm;
use mecha10_robot_config::RobotSummary;

/// Dispatch a `mecha10 robot` subcommand.
pub async fn handle_robot(ctx: &mut CliContext, cmd: &RobotCommand) -> Result<()> {
    match cmd {
        RobotCommand::List => handle_robot_list(ctx).await,
        RobotCommand::Delete { name, yes } => handle_robot_delete(ctx, name, *yes).await,
    }
}

/// List all robots registered under the caller's account.
pub async fn handle_robot_list(ctx: &mut CliContext) -> Result<()> {
    let client = create_config_client(ctx).await?;
    let robots = client.list_robots().await.context("Failed to list robots")?;

    if robots.is_empty() {
        println!("No robots registered.");
        return Ok(());
    }

    println!();
    println!("{}", style("Robots").bold());
    println!("{}", style("━".repeat(50)).dim());
    for robot in &robots {
        println!(
            "  {} {} ({})",
            style("•").cyan(),
            style(&robot.name).bold(),
            style(&robot.id).dim()
        );
    }
    println!();

    Ok(())
}

/// Resolve `name` to a robot id via `list_robots`, erroring clearly if no robot by that name is
/// registered under the caller's account.
fn resolve_robot_by_name<'a>(robots: &'a [RobotSummary], name: &str) -> Result<&'a RobotSummary> {
    robots
        .iter()
        .find(|r| r.name == name)
        .ok_or_else(|| anyhow::anyhow!("No robot named '{}' found for this account.", name))
}

/// Delete a robot by name.
///
/// Resolves `name` to an id via `list_robots`, confirms unless `yes` is set, then soft-deletes
/// it. Training runs, models, and configs tied to the robot are not affected, and the name can
/// be reused for a new robot afterward - the confirmation and success messages say so explicitly
/// so the user isn't surprised either way.
pub async fn handle_robot_delete(ctx: &mut CliContext, name: &str, yes: bool) -> Result<()> {
    let client = create_config_client(ctx).await?;
    let robots = client.list_robots().await.context("Failed to list robots")?;
    let robot = resolve_robot_by_name(&robots, name)?;
    let id = robot.id.clone();

    if !yes {
        let confirm = Confirm::new()
            .with_prompt(format!(
                "Delete robot '{}'? Training runs, models, and configs are NOT deleted, and the name \
                 can be reused for a new robot afterward.",
                name
            ))
            .default(false)
            .interact()?;

        if !confirm {
            println!("Cancelled.");
            return Ok(());
        }
    }

    let deleted = client.delete_robot(&id).await.context("Failed to delete robot")?;

    println!(
        "{} Deleted robot '{}'. Training runs, models, and configs for this robot were not deleted, and the name \
         is available to register again.",
        style("✓").green().bold(),
        deleted.name
    );

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn robot(id: &str, name: &str) -> RobotSummary {
        RobotSummary {
            id: id.to_string(),
            name: name.to_string(),
        }
    }

    #[test]
    fn resolve_robot_by_name_finds_matching_robot() {
        let robots = vec![robot("r1", "rover-1"), robot("r2", "rover-2")];
        let found = resolve_robot_by_name(&robots, "rover-2").unwrap();
        assert_eq!(found.id, "r2");
    }

    #[test]
    fn resolve_robot_by_name_errors_when_not_found() {
        let robots = vec![robot("r1", "rover-1")];
        let err = resolve_robot_by_name(&robots, "missing").unwrap_err();
        assert!(err.to_string().contains("No robot named 'missing'"));
    }

    #[test]
    fn resolve_robot_by_name_errors_on_empty_list() {
        let robots: Vec<RobotSummary> = vec![];
        let err = resolve_robot_by_name(&robots, "rover-1").unwrap_err();
        assert!(err.to_string().contains("No robot named 'rover-1'"));
    }
}