zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use std::io::{self, BufRead, IsTerminal, Write};

use anyhow::{bail, Context, Result};
use serde_json::json;

use crate::api::client::ApiClient;
use crate::api::error::ApiError;
use crate::config::credentials::resolve_api_key;
use crate::config::manager::ConfigManager;
use crate::model::loader::Models;

use super::dispatch::{print_output_with_opts, OutputOpts};

struct CreateOpts {
    cluster_id: Option<String>,
    backup_type: Option<String>,
    db_name: Option<String>,
    collection_name: Option<String>,
}

fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result<Option<String>> {
    *i += 1;
    match args.get(*i) {
        Some(v) if v.starts_with("--") => bail!("{} requires a value", flag),
        Some(v) => Ok(Some(v.clone())),
        None => bail!("{} requires a value", flag),
    }
}

fn parse_create_args(raw_args: &[String]) -> Result<CreateOpts> {
    let mut opts = CreateOpts {
        cluster_id: None,
        backup_type: None,
        db_name: None,
        collection_name: None,
    };
    let mut i = 0;
    while i < raw_args.len() {
        match raw_args[i].as_str() {
            "--cluster-id" => opts.cluster_id = take_value(raw_args, &mut i, "--cluster-id")?,
            "--backup-type" => opts.backup_type = take_value(raw_args, &mut i, "--backup-type")?,
            "--database" => opts.db_name = take_value(raw_args, &mut i, "--database")?,
            "--collection" => opts.collection_name = take_value(raw_args, &mut i, "--collection")?,
            other => {
                bail!("Unknown flag '{}'. Available: --cluster-id, --backup-type, --database, --collection", other);
            }
        }
        i += 1;
    }
    Ok(opts)
}

pub async fn create(
    models: &Models,
    config_mgr: &ConfigManager,
    raw_args: &[String],
    output_opts: &OutputOpts<'_>,
) -> Result<()> {
    if raw_args.iter().any(|a| a == "-h" || a == "--help") {
        print!(
            "Create a backup.\n\n\
             Usage: zilliz backup create [OPTIONS]\n\n\
             Options:\n\
             \x20 {:24}{:30}Cluster ID (prompted if omitted)\n\
             \x20 {:24}{:30}Backup type: CLUSTER or COLLECTION [default: CLUSTER]\n\
             \x20 {:24}{:30}Database name (for COLLECTION backup) [default: default]\n\
             \x20 {:24}{:30}Collection name (for COLLECTION backup)\n",
            "--cluster-id",
            "<string>",
            "--type",
            "<string>",
            "--database",
            "<string>",
            "--collection",
            "<string>",
        );
        return Ok(());
    }
    let mut opts = parse_create_args(raw_args)?;

    let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
    let base_url =
        super::endpoint::resolve_control_plane_url(config_mgr, &models.control_plane, None);
    let client = ApiClient::new(api_key, base_url);

    let is_tty = io::stdin().is_terminal();

    // Resolve cluster ID
    let cluster_id = match opts.cluster_id.take() {
        Some(id) => id,
        None if is_tty => prompt_cluster(&client).await?,
        None => bail!("Missing required option: --cluster-id"),
    };

    // Resolve backup type
    let backup_type = match opts.backup_type.take() {
        Some(t) => {
            let upper = t.to_uppercase();
            if upper != "CLUSTER" && upper != "COLLECTION" {
                bail!("Invalid backup type '{}'. Use: CLUSTER or COLLECTION", t);
            }
            upper
        }
        None if opts.collection_name.is_some() => "COLLECTION".to_string(),
        None if is_tty => prompt_backup_type()?,
        None => "CLUSTER".to_string(),
    };

    // For COLLECTION backup, resolve database and collection
    let mut body = json!({
        "backupType": backup_type,
    });

    if backup_type == "COLLECTION" {
        let db_name;
        let collection_name;

        if opts.db_name.is_some() || opts.collection_name.is_some() {
            db_name = opts.db_name.take().unwrap_or_else(|| "default".to_string());
            collection_name = opts
                .collection_name
                .take()
                .context("--collection is required for COLLECTION backup")?;
        } else if is_tty {
            eprintln!();
            eprintln!("Configure additional options? [y/N]: ");
            let mut input = String::new();
            let bytes_read = io::stdin().lock().read_line(&mut input)?;
            if bytes_read == 0 {
                bail!("Unexpected end of input");
            }
            if input.trim().eq_ignore_ascii_case("y") {
                db_name = prompt_string("  database", Some("default"))?;
                collection_name = prompt_string("  collection", None)?;
            } else {
                bail!("--database and --collection are required for COLLECTION backup");
            }
        } else {
            bail!("--database and --collection are required for COLLECTION backup");
        }

        body["dbCollections"] = json!([{
            "dbName": db_name,
            "collectionNames": [collection_name],
        }]);
    }

    let path = format!("/v2/clusters/{}/backups/create", cluster_id);
    let result = client.call("POST", &path, None, Some(&body)).await?;
    print_output_with_opts(&result, output_opts, None);

    Ok(())
}

async fn prompt_cluster(client: &ApiClient) -> Result<String> {
    let body = json!({ "pageSize": 100, "currentPage": 1 });
    let result = client
        .call("GET", "/v2/clusters", None, Some(&body))
        .await?;

    let clusters = result
        .get("data")
        .or(Some(&result))
        .and_then(|v| v.get("clusters").or_else(|| v.as_array().map(|_| v)))
        .and_then(|v| v.as_array())
        .context("Failed to fetch clusters")?;

    if clusters.is_empty() {
        bail!("No clusters found.");
    }

    eprintln!("Select cluster:");
    for (i, c) in clusters.iter().enumerate() {
        let name = c.get("clusterName").and_then(|v| v.as_str()).unwrap_or("?");
        let id = c.get("clusterId").and_then(|v| v.as_str()).unwrap_or("?");
        eprintln!("  [{}] {} ({})", i + 1, name, id);
    }
    eprint!("Select [1-{}]: ", clusters.len());
    io::stderr().flush()?;

    let mut input = String::new();
    let bytes_read = io::stdin().lock().read_line(&mut input)?;
    if bytes_read == 0 {
        bail!("Unexpected end of input");
    }
    let idx: usize = input.trim().parse().context("Invalid selection")?;
    if idx < 1 || idx > clusters.len() {
        bail!("Selection out of range");
    }

    clusters[idx - 1]
        .get("clusterId")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .context("Cluster has no ID")
}

fn prompt_backup_type() -> Result<String> {
    let choices = ["CLUSTER", "COLLECTION"];
    eprintln!("Select backup type:");
    for (i, choice) in choices.iter().enumerate() {
        eprintln!("  [{}] {}", i + 1, choice);
    }
    loop {
        eprint!("Select [1-{}]: ", choices.len());
        io::stderr().flush()?;
        let mut input = String::new();
        let bytes_read = io::stdin().lock().read_line(&mut input)?;
        if bytes_read == 0 {
            bail!("Unexpected end of input");
        }
        if let Ok(n) = input.trim().parse::<usize>() {
            if n >= 1 && n <= choices.len() {
                return Ok(choices[n - 1].to_string());
            }
        }
        eprintln!("Invalid selection, please try again.");
    }
}

fn prompt_string(label: &str, default: Option<&str>) -> Result<String> {
    loop {
        if let Some(def) = default {
            eprint!("{} [{}]: ", label, def);
        } else {
            eprint!("{}: ", label);
        }
        io::stderr().flush()?;
        let mut input = String::new();
        let bytes_read = io::stdin().lock().read_line(&mut input)?;
        if bytes_read == 0 {
            bail!("Unexpected end of input");
        }
        let trimmed = input.trim();
        if !trimmed.is_empty() {
            return Ok(trimmed.to_string());
        }
        if let Some(def) = default {
            return Ok(def.to_string());
        }
        eprintln!("Value required, please try again.");
    }
}