mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Config command handlers
//!
//! Handles pushing, pulling, and listing robot configurations.
//! Uses shared sync logic from mecha10-robot-config crate.

use crate::commands::config::{ConfigListArgs, ConfigPullArgs, ConfigPushArgs};
use crate::context::CliContext;
use crate::services::credentials::CredentialsService;
use anyhow::Result;
use mecha10_robot_config::{
    collect_configs_from_dir, write_configs_to_dir, CollectOptions, RobotConfigClient, WriteOptions,
};

/// Robot config API path (appended to control plane URL)
const ROBOT_CONFIG_PATH: &str = "/api/robot-config";

/// Handle the config push command
///
/// Pushes local config files to the robot-config service.
pub async fn handle_config_push(ctx: &mut CliContext, args: &ConfigPushArgs) -> Result<()> {
    println!();
    println!("Pushing configurations...");
    println!();

    // Get robot ID from args or project config
    let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;

    // Find configs directory
    let configs_dir = match &args.path {
        Some(path) => path.clone(),
        None => ctx.working_dir.join("configs"),
    };

    // Collect configs using shared function
    let options = CollectOptions {
        include_mecha10_json: true,
        project_root: Some(ctx.working_dir.clone()),
    };

    let configs = collect_configs_from_dir(&configs_dir, &options)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to collect configs: {}", e))?;

    if configs.is_empty() {
        println!("No config files found");
        println!();
        println!("Expected:");
        println!("  - configs/ directory with node configs");
        println!("  - mecha10.json at project root");
        return Ok(());
    }

    println!("Robot ID:     {}", robot_id);
    if configs_dir.exists() {
        println!("Configs dir:  {}", configs_dir.display());
    }
    println!();
    println!("Found {} config(s):", configs.len());
    for (key, _) in &configs {
        println!("  - {}", key);
    }
    println!();

    if args.dry_run {
        println!("DRY RUN - No changes will be made");
        return Ok(());
    }

    // Create client and push configs
    let client = create_config_client(ctx).await?;

    match client.bulk_upsert_configs(&robot_id, configs).await {
        Ok(result) => {
            println!(
                "Pushed {} config(s) ({} created, {} updated)",
                result.configs.len(),
                result.created,
                result.updated
            );
        }
        Err(e) => {
            return Err(anyhow::anyhow!("Failed to push configs: {}", e));
        }
    }

    println!();
    Ok(())
}

/// Handle the config pull command
///
/// Pulls configs from the robot-config service to local files.
pub async fn handle_config_pull(ctx: &mut CliContext, args: &ConfigPullArgs) -> Result<()> {
    println!();
    println!("Pulling configurations...");
    println!();

    // Get robot ID from args or project config
    let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;

    // Output directory
    let output_dir = args.output.clone().unwrap_or_else(|| ctx.working_dir.join("configs"));

    println!("Robot ID:    {}", robot_id);
    println!("Output dir:  {}", output_dir.display());
    println!();

    // Create client and fetch configs
    let client = create_config_client(ctx).await?;

    let configs = match client.get_configs(&robot_id).await {
        Ok(configs) => configs,
        Err(e) => {
            return Err(anyhow::anyhow!("Failed to fetch configs: {}", e));
        }
    };

    if configs.is_empty() {
        println!("No configs found for robot {}", robot_id);
        return Ok(());
    }

    println!("Found {} config(s):", configs.len());
    for config in &configs {
        println!("  - {}", config.config_key);
    }
    println!();

    // Write configs using shared function
    let write_options = WriteOptions {
        force: args.force,
        project_root: Some(ctx.working_dir.clone()),
    };

    let result = write_configs_to_dir(&configs, &output_dir, &write_options)
        .await
        .map_err(|e| anyhow::anyhow!("Failed to write configs: {}", e))?;

    // Print what was written/skipped
    for key in &result.written_keys {
        let display_name = if key == "mecha10.json" {
            "mecha10.json".to_string()
        } else {
            format!("{}.json", key.replace('/', "_"))
        };
        println!("  Wrote {}", display_name);
    }
    for key in &result.skipped_keys {
        let display_name = if key == "mecha10.json" {
            "mecha10.json".to_string()
        } else {
            format!("{}.json", key.replace('/', "_"))
        };
        println!("  Skipped {} (exists, use --force to overwrite)", display_name);
    }

    println!();
    println!("Pulled {} config(s), skipped {}", result.written, result.skipped);
    println!();

    Ok(())
}

/// Handle the config list command
///
/// Lists all configs for a robot.
pub async fn handle_config_list(ctx: &mut CliContext, args: &ConfigListArgs) -> Result<()> {
    println!();

    // Get robot ID from args or project config
    let robot_id = get_robot_id(ctx, args.robot_id.as_deref()).await?;

    println!("Configs for robot: {}", robot_id);
    println!();

    // Create client and fetch configs
    let client = create_config_client(ctx).await?;

    let configs = match client.get_configs(&robot_id).await {
        Ok(configs) => configs,
        Err(e) => {
            return Err(anyhow::anyhow!("Failed to fetch configs: {}", e));
        }
    };

    if configs.is_empty() {
        println!("No configs found");
        println!();
        return Ok(());
    }

    for config in configs {
        println!("{}:", config.config_key);
        println!("  Updated: {}", config.updated_at.format("%Y-%m-%d %H:%M:%S UTC"));
        if let Some(ref user) = config.updated_by {
            println!("  By: {}", user);
        }

        if args.verbose {
            let value_str = serde_json::to_string_pretty(&config.config_value).unwrap_or_else(|_| "{}".to_string());
            // Indent the JSON
            for line in value_str.lines() {
                println!("    {}", line);
            }
        }
        println!();
    }

    Ok(())
}

/// Get robot ID from args or project config
async fn get_robot_id(ctx: &mut CliContext, arg_robot_id: Option<&str>) -> Result<String> {
    if let Some(id) = arg_robot_id {
        return Ok(id.to_string());
    }

    // Try to get from project config
    if ctx.is_project_initialized() {
        let project_config = ctx.load_project_config().await?;
        return Ok(project_config.robot.id);
    }

    Err(anyhow::anyhow!(
        "No robot ID specified. Use --robot-id or run from a mecha10 project directory."
    ))
}

/// Create a RobotConfigClient with credentials
async fn create_config_client(ctx: &mut CliContext) -> Result<RobotConfigClient> {
    // Load credentials
    let credentials_service = CredentialsService::new();
    let credentials = credentials_service
        .load()?
        .ok_or_else(|| anyhow::anyhow!("Not logged in. Run `mecha10 auth login` first."))?;

    // Get control plane URL from project config
    let project_config = ctx.load_project_config().await?;
    let control_plane_url = project_config.environments.control_plane_url();
    let config_url = format!("{}{}", control_plane_url, ROBOT_CONFIG_PATH);

    Ok(RobotConfigClient::with_user_id(
        config_url,
        credentials.api_key,
        Some(credentials.user_id),
    ))
}