mecha10-cli 0.7.0

Mecha10 CLI tool
Documentation
//! Infrastructure command handlers
//!
//! Orchestrates both control plane (monorepo) and project-level Docker infrastructure management.

#![allow(dead_code)]

use super::types::{InfrastructureAction, InfrastructureArgs};
use crate::context::CliContext;
use crate::services::{ConfigService, DockerService, ProjectService};
use anyhow::Result;

/// Handle infrastructure command - Manages Docker infrastructure for control plane or project
pub async fn handle_infrastructure(ctx: &mut CliContext, args: &InfrastructureArgs) -> Result<()> {
    println!();
    let docker = ctx.docker();

    if docker.check_installation().is_err() || docker.check_daemon().is_err() {
        anyhow::bail!("Docker is not available. Please install Docker and start the daemon.");
    }

    let current_dir = std::env::current_dir()?;
    match ProjectService::detect(&current_dir) {
        Ok(project) => {
            println!("🤖 Project Infrastructure - {}", project.name()?);
            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            println!();
            handle_project_infrastructure(ctx, args, &project).await?;
        }
        Err(_) => {
            println!("🔧 Control Plane Infrastructure");
            println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            println!();
            handle_control_plane_infrastructure(docker, args).await?;
        }
    }
    Ok(())
}

/// Handle project-level infrastructure
async fn handle_project_infrastructure(
    _ctx: &mut CliContext,
    args: &InfrastructureArgs,
    project: &ProjectService,
) -> Result<()> {
    let config = ConfigService::load_from(&project.config_path()).await?;

    let compose_path = project.root().join(&config.docker.compose_file);
    if !compose_path.exists() {
        anyhow::bail!("Docker Compose file not found: {}", compose_path.display());
    }
    let docker = DockerService::with_compose_file(compose_path.to_string_lossy().to_string());

    let services_to_manage = if args.services.is_empty() {
        config.docker.robot.clone()
    } else {
        args.services.clone()
    };

    match args.action {
        InfrastructureAction::Start => handle_start_project(&docker, &services_to_manage).await?,
        InfrastructureAction::Stop => handle_stop(&docker, &services_to_manage).await?,
        InfrastructureAction::Restart => handle_restart(&docker, &services_to_manage).await?,
        InfrastructureAction::Status => handle_status(&docker, &services_to_manage).await?,
        InfrastructureAction::Logs => handle_logs(&docker, &args.services).await?,
    }

    Ok(())
}

/// Handle control plane infrastructure (monorepo)
async fn handle_control_plane_infrastructure(docker: &DockerService, args: &InfrastructureArgs) -> Result<()> {
    match args.action {
        InfrastructureAction::Start => handle_start(docker, &args.services).await?,
        InfrastructureAction::Stop => handle_stop(docker, &args.services).await?,
        InfrastructureAction::Restart => handle_restart(docker, &args.services).await?,
        InfrastructureAction::Status => handle_status(docker, &args.services).await?,
        InfrastructureAction::Logs => handle_logs(docker, &args.services).await?,
    }

    Ok(())
}

/// Run an infrastructure action (start/stop/restart): a bulk operation when `services` is empty,
/// otherwise the same operation applied one service at a time, both wrapped in the `"<Verb-ing>
/// ..."` / `"✅ ... <verb-ed>"` progress messages shared by `handle_start`/`handle_stop`/
/// `handle_restart`.
async fn run_infrastructure_action<Bulk, BulkFut, PerService, PerServiceFut>(
    services: &[String],
    verb_ing: &str,
    verb_ed: &str,
    bulk: Bulk,
    per_service: PerService,
) -> Result<()>
where
    Bulk: FnOnce() -> BulkFut,
    BulkFut: std::future::Future<Output = Result<()>>,
    PerService: Fn(&str) -> PerServiceFut,
    PerServiceFut: std::future::Future<Output = Result<()>>,
{
    println!("{} infrastructure services...\n", verb_ing);
    if services.is_empty() {
        bulk().await?;
        println!("✅ All services {}\n", verb_ed);
    } else {
        for service in services {
            println!("{} {}...", verb_ing, service);
            per_service(service).await?;
            println!("✅ {} {}", service, verb_ed);
        }
        println!();
    }
    Ok(())
}

/// Handle start action
async fn handle_start(docker: &DockerService, services: &[String]) -> Result<()> {
    run_infrastructure_action(
        services,
        "Starting",
        "started",
        || docker.compose_up(true),
        |service: &str| {
            let service = service.to_string();
            async move { docker.compose_up_service(&service, true).await }
        },
    )
    .await
}

/// Handle start action for project infrastructure
async fn handle_start_project(docker: &DockerService, services: &[String]) -> Result<()> {
    if services.is_empty() {
        println!("No services configured\n");
        return Ok(());
    }
    println!("Starting robot infrastructure...\n");
    for service in services {
        println!("  Starting {}...", service);
        docker.compose_up_service(service, true).await?;
        println!("  ✅ {} started", service);
    }
    println!("\n✅ All services started successfully\n");
    println!("💡 Tip: Use 'mecha10 infrastructure status' to check service health");
    Ok(())
}

/// Handle stop action
async fn handle_stop(docker: &DockerService, services: &[String]) -> Result<()> {
    run_infrastructure_action(
        services,
        "Stopping",
        "stopped",
        || docker.compose_down(),
        |service: &str| {
            let service = service.to_string();
            async move { docker.compose_stop(Some(&service)).await }
        },
    )
    .await
}

/// Handle restart action
async fn handle_restart(docker: &DockerService, services: &[String]) -> Result<()> {
    run_infrastructure_action(
        services,
        "Restarting",
        "restarted",
        || async {
            docker.compose_down().await?;
            docker.compose_up(true).await
        },
        |service: &str| {
            let service = service.to_string();
            async move { docker.compose_restart(Some(&service)).await }
        },
    )
    .await
}

/// Handle status action
async fn handle_status(docker: &DockerService, services: &[String]) -> Result<()> {
    println!("Infrastructure Status:\n");
    let containers = docker.list_containers().await?;

    if services.is_empty() {
        for container in &containers {
            let running = container.status.to_lowercase().contains("up");
            let status = if running { "✅ Running" } else { "⬜ Stopped" };
            println!("  {} {} ({})", status, container.name, container.status);
        }
    } else {
        for service in services {
            let container = containers.iter().find(|c| c.name.contains(service));
            let running = container
                .map(|c| c.status.to_lowercase().contains("up"))
                .unwrap_or(false);
            let status = if running { "✅ Running" } else { "⬜ Stopped" };
            let detail = container.map(|c| c.status.as_str()).unwrap_or("Not found");
            println!("  {} {} ({})", status, service, detail);
        }
    }
    println!();
    Ok(())
}

/// Handle logs action
async fn handle_logs(docker: &DockerService, services: &[String]) -> Result<()> {
    if services.is_empty() {
        anyhow::bail!("Please specify a service to view logs.\nExample: mecha10 infrastructure logs redis");
    }
    let service = &services[0];
    println!("Viewing logs for: {}\n", service);
    docker.compose_logs(Some(service), false, Some(50))?;
    Ok(())
}

#[cfg(test)]
mod run_infrastructure_action_tests {
    use super::run_infrastructure_action;
    use std::sync::atomic::{AtomicUsize, Ordering};

    #[tokio::test]
    async fn empty_services_runs_the_bulk_action_once_and_never_touches_per_service() {
        let bulk_calls = AtomicUsize::new(0);
        let per_service_calls = AtomicUsize::new(0);

        run_infrastructure_action(
            &[],
            "Starting",
            "started",
            || async {
                bulk_calls.fetch_add(1, Ordering::SeqCst);
                Ok(())
            },
            |_service| async {
                per_service_calls.fetch_add(1, Ordering::SeqCst);
                Ok(())
            },
        )
        .await
        .unwrap();

        assert_eq!(bulk_calls.load(Ordering::SeqCst), 1);
        assert_eq!(per_service_calls.load(Ordering::SeqCst), 0);
    }

    #[tokio::test]
    async fn non_empty_services_runs_the_per_service_action_for_each_and_never_touches_bulk() {
        let bulk_calls = AtomicUsize::new(0);
        let seen = std::sync::Mutex::new(Vec::new());
        let services = vec!["redis".to_string(), "postgres".to_string()];

        run_infrastructure_action(
            &services,
            "Stopping",
            "stopped",
            || async {
                bulk_calls.fetch_add(1, Ordering::SeqCst);
                Ok(())
            },
            |service| {
                seen.lock().unwrap().push(service.to_string());
                async { Ok(()) }
            },
        )
        .await
        .unwrap();

        assert_eq!(bulk_calls.load(Ordering::SeqCst), 0);
        assert_eq!(*seen.lock().unwrap(), vec!["redis".to_string(), "postgres".to_string()]);
    }

    #[tokio::test]
    async fn propagates_a_per_service_error_and_stops_early() {
        let seen = std::sync::Mutex::new(Vec::new());
        let services = vec!["redis".to_string(), "postgres".to_string()];

        let result = run_infrastructure_action(
            &services,
            "Restarting",
            "restarted",
            || async { Ok(()) },
            |service| {
                seen.lock().unwrap().push(service.to_string());
                async { anyhow::bail!("boom") }
            },
        )
        .await;

        assert!(result.is_err());
        assert_eq!(*seen.lock().unwrap(), vec!["redis".to_string()]);
    }
}