cufflink-cli 0.14.2

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::config::CliConfig;
use comfy_table::{presets::NOTHING, Table, TableComponent};
use std::io::IsTerminal;
use std::process::Command;

pub async fn list(limit: Option<usize>, env: Option<&str>) -> eyre::Result<()> {
    let (config, service_id, service_name) = resolve_service(env).await?;
    let url = match limit {
        Some(n) => format!(
            "{}/api/services/{}/deployments?limit={}",
            config.api_url, service_id, n
        ),
        None => format!("{}/api/services/{}/deployments", config.api_url, service_id),
    };
    let client = config.http_client();
    let resp = config
        .auth_request(&client, reqwest::Method::GET, &url)
        .send()
        .await?;
    let status = resp.status();
    let body: serde_json::Value = resp.json().await?;
    if !status.is_success() {
        eyre::bail!("List failed ({}): {}", status, body);
    }
    let deployments = body["deployments"]
        .as_array()
        .ok_or_else(|| eyre::eyre!("Malformed response: missing `deployments`"))?;
    let total = body["total"].as_u64().unwrap_or(deployments.len() as u64);
    let current = body["current_version"].as_i64().unwrap_or(-1);

    let mut table = Table::new();
    table.load_preset(NOTHING);
    table.set_style(TableComponent::HeaderLines, '-');
    table.set_style(TableComponent::MiddleHeaderIntersections, ' ');
    table.set_header(vec![
        "VERSION",
        "STATUS",
        "DEPLOYED_AT",
        "MANIFEST",
        "WASM",
        "COMMIT",
        "CURRENT",
    ]);
    for d in deployments {
        let version = d["version"].as_i64().unwrap_or(0);
        table.add_row(vec![
            format!("v{version}"),
            d["status"].as_str().unwrap_or("?").to_string(),
            truncate_timestamp(d["deployed_at"].as_str().unwrap_or("?")),
            short_hash(d["manifest_hash"].as_str()),
            short_hash(d["wasm_hash"].as_str()),
            short_hash(d["commit_hash"].as_str()),
            if version == current { "" } else { "" }.to_string(),
        ]);
    }
    println!("{table}");
    println!(
        "\n{} deployment(s) for '{}' · current: v{}",
        total, service_name, current
    );
    Ok(())
}

pub async fn prune(keep: usize, yes: bool, env: Option<&str>) -> eyre::Result<()> {
    if keep == 0 {
        eyre::bail!("--keep must be >= 1 (the currently-active version is always retained)");
    }
    let (config, service_id, service_name) = resolve_service(env).await?;
    let client = config.http_client();

    let preview_resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services/{}/deployments", config.api_url, service_id),
        )
        .send()
        .await?;
    let preview: serde_json::Value = preview_resp.json().await?;
    let deployments = preview["deployments"]
        .as_array()
        .ok_or_else(|| eyre::eyre!("Malformed response: missing `deployments`"))?;
    let current = preview["current_version"].as_i64().unwrap_or(-1) as i32;
    let versions: Vec<i32> = deployments
        .iter()
        .filter_map(|d| d["version"].as_i64().map(|v| v as i32))
        .collect();
    let would_delete = compute_prune_preview(&versions, current, keep);

    if would_delete.is_empty() {
        println!(
            "Nothing to prune for '{}': {} deployment(s), keeping {}",
            service_name,
            versions.len(),
            keep
        );
        return Ok(());
    }

    println!(
        "Pruning '{}': will delete {} deployment(s) (keeping {} most recent + current v{}):",
        service_name,
        would_delete.len(),
        keep,
        current
    );
    for v in &would_delete {
        println!("  - v{v}");
    }
    confirm(
        yes,
        "This will permanently remove these deployments and their WASM artifacts.",
    )?;

    let resp = config
        .auth_request(
            &client,
            reqwest::Method::POST,
            &format!(
                "{}/api/services/{}/deployments/prune",
                config.api_url, service_id
            ),
        )
        .json(&serde_json::json!({ "keep": keep }))
        .send()
        .await?;
    let status = resp.status();
    let body: serde_json::Value = resp.json().await?;
    if !status.is_success() {
        eyre::bail!("Prune failed ({}): {}", status, body);
    }
    let deleted = body["deleted_count"].as_u64().unwrap_or(0);
    println!("Pruned {} deployment(s) of '{}'", deleted, service_name);
    Ok(())
}

pub async fn delete(version: i32, yes: bool, env: Option<&str>) -> eyre::Result<()> {
    let (config, service_id, service_name) = resolve_service(env).await?;
    println!("Delete deployment v{} of '{}'?", version, service_name);
    confirm(
        yes,
        "This will permanently remove the deployment record and its WASM artifact.",
    )?;

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::DELETE,
            &format!(
                "{}/api/services/{}/deployments/{}",
                config.api_url, service_id, version
            ),
        )
        .send()
        .await?;
    let status = resp.status();
    let body: serde_json::Value = resp.json().await?;
    if !status.is_success() {
        eyre::bail!("Delete failed ({}): {}", status, body);
    }
    println!(
        "Deleted v{} of '{}'",
        body["deleted_version"].as_i64().unwrap_or(version as i64),
        service_name
    );
    Ok(())
}

async fn resolve_service(env: Option<&str>) -> eyre::Result<(CliConfig, String, String)> {
    let config = CliConfig::load_with_env(env)?;
    if let Some(ref name) = config.env_name {
        println!("Environment: {}", name);
    }
    let manifest = emit_manifest()?;
    let service_name = manifest["name"]
        .as_str()
        .ok_or_else(|| eyre::eyre!("Manifest is missing `name`"))?
        .to_string();
    let service_id = config.find_service_id(&service_name).await?;
    Ok((config, service_id, service_name))
}

fn emit_manifest() -> eyre::Result<serde_json::Value> {
    let output = Command::new("cargo")
        .args(["run", "--", "--emit-manifest"])
        .output()?;
    if !output.status.success() {
        eyre::bail!("Failed to emit manifest. Run from a cufflink service directory.");
    }
    let stdout = String::from_utf8(output.stdout)?;
    Ok(serde_json::from_str(stdout.trim())?)
}

fn confirm(skip: bool, warning: &str) -> eyre::Result<()> {
    if skip {
        return Ok(());
    }
    if !std::io::stdin().is_terminal() {
        eyre::bail!(
            "stdin is not a TTY; pass --yes to skip the confirmation prompt non-interactively"
        );
    }
    eprintln!("{warning} Continue? [y/N] ");
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?;
    if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
        eyre::bail!("Aborted");
    }
    Ok(())
}

fn compute_prune_preview(versions: &[i32], current_version: i32, keep: usize) -> Vec<i32> {
    let retain: std::collections::HashSet<i32> = versions
        .iter()
        .take(keep)
        .copied()
        .chain(std::iter::once(current_version))
        .collect();
    versions
        .iter()
        .copied()
        .filter(|v| !retain.contains(v))
        .collect()
}

fn short_hash(hash: Option<&str>) -> String {
    match hash {
        Some(h) if h.len() > 12 => h[..12].to_string(),
        Some(h) => h.to_string(),
        None => "-".to_string(),
    }
}

fn truncate_timestamp(ts: &str) -> String {
    ts.split('.').next().unwrap_or(ts).replace('T', " ")
}

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

    #[test]
    fn short_hash_truncates_long_inputs() {
        assert_eq!(short_hash(Some("abcdef1234567890abcdef")), "abcdef123456");
    }

    #[test]
    fn short_hash_passes_short_inputs() {
        assert_eq!(short_hash(Some("abc123")), "abc123");
    }

    #[test]
    fn short_hash_renders_dash_for_none() {
        assert_eq!(short_hash(None), "-");
    }

    #[test]
    fn truncate_timestamp_drops_fractional_seconds_and_normalizes_t() {
        assert_eq!(
            truncate_timestamp("2026-05-23T10:40:30.123456789Z"),
            "2026-05-23 10:40:30"
        );
    }

    #[test]
    fn compute_prune_preview_mirrors_platform_logic() {
        assert_eq!(compute_prune_preview(&[10, 9, 8, 7], 10, 2), vec![8, 7]);
        assert_eq!(compute_prune_preview(&[10, 9, 8, 7], 7, 2), vec![8]);
        assert_eq!(compute_prune_preview(&[5, 4, 3], 5, 5), Vec::<i32>::new());
        assert_eq!(compute_prune_preview(&[], 1, 3), Vec::<i32>::new());
    }
}