cufflink-cli 0.8.30

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::config::CliConfig;
use crate::workspace_config::WorkspaceConfig;
use comfy_table::{presets::NOTHING, Table, TableComponent};
use std::path::PathBuf;
use std::time::Instant;

fn load_workspace() -> eyre::Result<(WorkspaceConfig, PathBuf)> {
    let (config, root) = WorkspaceConfig::find_and_load()?;
    config.validate_paths(&root)?;
    Ok((config, root))
}

fn make_table(headers: Vec<&str>) -> Table {
    let mut table = Table::new();
    table.load_preset(NOTHING);
    table.set_style(TableComponent::HeaderLines, '-');
    table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
    table.set_header(headers);
    table
}

pub async fn deploy(skip: &[String], env: Option<&str>) -> eyre::Result<()> {
    let (ws, root) = load_workspace()?;
    println!("Workspace: {}", ws.workspace.name);

    if let Some(name) = env {
        println!("Environment: {}", name);
    }

    let deploy_services = ws.deploy_list(env);
    let deploy_services: Vec<_> = deploy_services
        .into_iter()
        .filter(|s| !skip.contains(&s.name))
        .collect();

    if deploy_services.is_empty() {
        println!("Nothing to deploy.");
        return Ok(());
    }

    if !skip.is_empty() {
        println!("Skipping: {}", skip.join(", "));
    }

    let original_dir = std::env::current_dir()?;
    let mut results: Vec<(String, Result<(), String>)> = Vec::new();

    for svc in &deploy_services {
        let service_dir = root.join(&svc.path);
        println!("\nDeploying '{}'...", svc.name);
        std::env::set_current_dir(&service_dir)?;

        match super::deploy::run(false, None, env).await {
            Ok(_) => results.push((svc.name.clone(), Ok(()))),
            Err(e) => {
                let msg = format!("{}", e);
                eprintln!("  Failed: {}", msg);
                results.push((svc.name.clone(), Err(msg)));
            }
        }
    }

    std::env::set_current_dir(&original_dir)?;

    println!();
    let mut table = make_table(vec!["SERVICE", "STATUS"]);
    let mut failures = 0;
    let deployed: Vec<String> = results
        .iter()
        .filter(|(_, r)| r.is_ok())
        .map(|(n, _)| n.clone())
        .collect();

    for (name, result) in &results {
        let status = match result {
            Ok(_) => "deployed",
            Err(_) => {
                failures += 1;
                "FAILED"
            }
        };
        table.add_row(vec![name.as_str(), status]);
    }
    println!("{table}");

    // Seed phase
    let seed_services = ws.seed_list(env);
    let seed_services: Vec<_> = seed_services
        .into_iter()
        .filter(|s| deployed.contains(&s.name))
        .collect();

    if !seed_services.is_empty() {
        println!();
        run_seed_phase(&root, &seed_services, env).await;
    }

    if failures > 0 {
        eyre::bail!("{} service(s) failed to deploy", failures);
    }

    Ok(())
}

async fn run_seed_phase(
    root: &std::path::Path,
    seed_services: &[&crate::workspace_config::WorkspaceService],
    env: Option<&str>,
) {
    println!("Seeding...");

    let config = match CliConfig::load_with_env(env) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("Failed to load config for seeding: {}", e);
            return;
        }
    };

    for svc in seed_services {
        let seed_path = match &svc.seed {
            Some(p) => root.join(p),
            None => continue,
        };

        let service_id = match find_service_id(&config, &svc.name).await {
            Ok(id) => id,
            Err(e) => {
                eprintln!("  {} — failed to resolve: {}", svc.name, e);
                continue;
            }
        };

        match run_seed(&config, &service_id, &seed_path).await {
            Ok(rows) => println!("  {}{} rows seeded", svc.name, rows),
            Err(e) => eprintln!("  {} — seed failed: {}", svc.name, e),
        }
    }
}

async fn find_service_id(config: &CliConfig, service_name: &str) -> eyre::Result<String> {
    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services", config.api_url),
        )
        .send()
        .await?;

    let body: serde_json::Value = resp.json().await?;
    body["services"]
        .as_array()
        .and_then(|arr| {
            arr.iter()
                .find(|s| s["name"].as_str() == Some(service_name))
                .and_then(|s| s["id"].as_str())
        })
        .map(String::from)
        .ok_or_else(|| eyre::eyre!("Service '{}' not found", service_name))
}

async fn run_seed(
    config: &CliConfig,
    service_id: &str,
    seed_path: &std::path::Path,
) -> eyre::Result<u64> {
    let data = std::fs::read_to_string(seed_path)?;
    let mut payload: serde_json::Value = serde_json::from_str(&data)?;

    if payload.get("tables").is_none() {
        payload = serde_json::json!({ "tables": payload });
    }

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::POST,
            &format!("{}/api/services/{}/seed", config.api_url, service_id),
        )
        .json(&payload)
        .send()
        .await?;

    if !resp.status().is_success() {
        let status = resp.status();
        let body = resp.text().await.unwrap_or_default();
        eyre::bail!("Seed failed ({}): {}", status, body);
    }

    let body: serde_json::Value = resp.json().await?;
    Ok(body["total_rows"].as_u64().unwrap_or(0))
}

pub async fn test(skip: &[String], run_all: bool) -> eyre::Result<()> {
    let (ws, root) = load_workspace()?;
    println!("Workspace: {}", ws.workspace.name);
    println!();

    let services: Vec<_> = ws
        .services
        .iter()
        .filter(|s| !skip.contains(&s.name))
        .collect();

    if services.is_empty() {
        println!("No services to test.");
        return Ok(());
    }

    let original_dir = std::env::current_dir()?;
    let mut results: Vec<(String, bool, String)> = Vec::new();

    for svc in &services {
        let service_dir = root.join(&svc.path);

        // Skip directories without Cargo.toml (shared libraries, web apps)
        if !service_dir.join("Cargo.toml").exists() {
            results.push((svc.name.clone(), true, "skipped".to_string()));
            continue;
        }

        std::env::set_current_dir(&service_dir)?;
        print!("Testing {}... ", svc.name);

        let start = Instant::now();
        let mut args = vec!["test"];
        if !run_all {
            args.push("--quiet");
        }

        let output = std::process::Command::new("cargo").args(&args).output();

        let elapsed = start.elapsed();
        let time_str = format!("{:.1}s", elapsed.as_secs_f64());

        match output {
            Ok(o) if o.status.success() => {
                println!("passed ({})", time_str);
                results.push((svc.name.clone(), true, time_str));
            }
            Ok(o) => {
                println!("FAILED ({})", time_str);
                if run_all {
                    let stderr = String::from_utf8_lossy(&o.stderr);
                    let stdout = String::from_utf8_lossy(&o.stdout);
                    if !stdout.is_empty() {
                        eprintln!("{}", stdout);
                    }
                    if !stderr.is_empty() {
                        eprintln!("{}", stderr);
                    }
                }
                results.push((svc.name.clone(), false, time_str));
            }
            Err(e) => {
                println!("ERROR");
                results.push((svc.name.clone(), false, format!("error: {}", e)));
            }
        }
    }

    std::env::set_current_dir(&original_dir)?;

    println!();
    let mut table = make_table(vec!["SERVICE", "RESULT", "TIME"]);

    let mut passed = 0;
    let mut failed = 0;
    let mut skipped = 0;

    for (name, success, time) in &results {
        if time == "skipped" {
            skipped += 1;
            table.add_row(vec![name.as_str(), "skipped", "-"]);
        } else if *success {
            passed += 1;
            table.add_row(vec![name.as_str(), "passed", time.as_str()]);
        } else {
            failed += 1;
            table.add_row(vec![name.as_str(), "FAILED", time.as_str()]);
        }
    }

    println!("{table}");
    println!();

    let mut summary = format!("{} passed", passed);
    if failed > 0 {
        summary.push_str(&format!(", {} failed", failed));
    }
    if skipped > 0 {
        summary.push_str(&format!(", {} skipped", skipped));
    }
    println!("{}", summary);

    if failed > 0 {
        eyre::bail!("{} service(s) failed tests", failed);
    }

    Ok(())
}

pub async fn status(env: Option<&str>) -> eyre::Result<()> {
    let (ws, _root) = load_workspace()?;
    println!("Workspace: {}", ws.workspace.name);

    let config = CliConfig::load_with_env(env)?;
    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }
    println!();

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services", config.api_url),
        )
        .send()
        .await?;

    let body: serde_json::Value = resp.json().await?;
    let api_services = body["services"].as_array();

    let mut table = make_table(vec!["SERVICE", "VERSION", "STATUS", "MODE"]);

    for svc in &ws.services {
        let api_svc =
            api_services.and_then(|arr| arr.iter().find(|s| s["name"].as_str() == Some(&svc.name)));

        match api_svc {
            Some(s) => {
                let version = format!("v{}", s["current_version"]);
                let status = s["status"].as_str().unwrap_or("?");
                let mode = s["mode"].as_str().unwrap_or("?");
                table.add_row(vec![svc.name.as_str(), &version, status, mode]);
            }
            None => {
                table.add_row(vec![svc.name.as_str(), "-", "not deployed", "-"]);
            }
        }
    }

    println!("{table}");
    println!("\n{} service(s)", ws.services.len());

    Ok(())
}

pub async fn seed(env: Option<&str>) -> eyre::Result<()> {
    let (ws, root) = load_workspace()?;
    println!("Workspace: {}", ws.workspace.name);

    let seed_services = ws.seed_list(env);
    if seed_services.is_empty() {
        println!("No services configured for seeding in this environment.");
        return Ok(());
    }

    run_seed_phase(&root, &seed_services, env).await;
    Ok(())
}