zilliz 1.0.0

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use anyhow::{bail, Context, Result};
use serde_json::{json, Value};

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};
use super::formatter;

fn resolve_base_url(models: &Models) -> String {
    models
        .control_plane
        .endpoint
        .clone()
        .unwrap_or_else(|| "https://api.cloud.zilliz.com".to_string())
}

pub async fn usage(
    models: &Models,
    config_mgr: &ConfigManager,
    raw_args: &[String],
    output_opts: &OutputOpts<'_>,
) -> Result<()> {
    if raw_args.iter().any(|a| a == "-h" || a == "--help") {
        print!(
            "Show billing usage summary.\n\n\
             Usage: zz billing usage [OPTIONS]\n\n\
             Options:\n\
             \x20 {:24}{:30}Month: YYYY-MM, 'this', or 'last' [default: today]\n\
             \x20 {:24}{:30}Relative period: 7d, 1m, 1y\n\
             \x20 {:24}{:30}Start time (ISO 8601 or YYYY-MM-DD)\n\
             \x20 {:24}{:30}End time (ISO 8601 or YYYY-MM-DD)\n",
            "--month", "<string>",
            "--last", "<string>",
            "--start", "<string>",
            "--end", "<string>",
        );
        return Ok(());
    }
    let mut month: Option<String> = None;
    let mut last: Option<String> = None;
    let mut start: Option<String> = None;
    let mut end: Option<String> = None;
    let mut i = 0;

    while i < raw_args.len() {
        match raw_args[i].as_str() {
            "--month" => {
                i += 1;
                month = raw_args.get(i).cloned();
            }
            "--last" => {
                i += 1;
                last = raw_args.get(i).cloned();
            }
            "--start" => {
                i += 1;
                start = raw_args.get(i).cloned();
            }
            "--end" => {
                i += 1;
                end = raw_args.get(i).cloned();
            }
            _ => {}
        }
        i += 1;
    }

    let (start_ts, end_ts) = resolve_time_range(month, last, start, end)?;

    let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
    let client = ApiClient::new(api_key, resolve_base_url(models));

    let body = json!({
        "start": start_ts,
        "end": end_ts,
    });

    let result = client.call("POST", "/v2/usage/query", None, Some(&body)).await?;
    print_output_with_opts(&result, output_opts, None);

    Ok(())
}

pub async fn invoices(
    models: &Models,
    config_mgr: &ConfigManager,
    raw_args: &[String],
    output_opts: &OutputOpts<'_>,
) -> Result<()> {
    if raw_args.iter().any(|a| a == "-h" || a == "--help") {
        print!(
            "List invoices.\n\n\
             Usage: zz billing invoices [OPTIONS]\n\n\
             Options:\n\
             \x20 {:24}{:30}Number of invoices per page [default: 20]\n\
             \x20 {:24}{:30}Page number [default: 1]\n",
            "--page-size", "<integer>",
            "--page", "<integer>",
        );
        return Ok(());
    }
    let mut page_size = 20;
    let mut page = 1;
    let mut i = 0;

    while i < raw_args.len() {
        match raw_args[i].as_str() {
            "--page-size" => {
                i += 1;
                if let Some(v) = raw_args.get(i) {
                    page_size = v.parse().unwrap_or(20);
                }
            }
            "--page" => {
                i += 1;
                if let Some(v) = raw_args.get(i) {
                    page = v.parse().unwrap_or(1);
                }
            }
            _ => {}
        }
        i += 1;
    }

    let api_key = resolve_api_key(output_opts.api_key, config_mgr).ok_or(ApiError::NoApiKey)?;
    let client = ApiClient::new(api_key, resolve_base_url(models));

    let body = json!({
        "pageSize": page_size,
        "currentPage": page,
    });

    let result = client.call("GET", "/v2/invoices", None, Some(&body)).await?;

    if output_opts.format == "table" && output_opts.query.is_none() {
        print_invoice_table(&result, output_opts.no_header);
    } else {
        print_output_with_opts(&result, output_opts, None);
    }

    Ok(())
}

fn print_invoice_table(result: &Value, no_header: bool) {
    let invoices = result
        .get("invoices")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

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

    let columns = &["Invoice ID", "Period", "Status", "Amount Due", "Due Date"];
    let is_tty = super::formatter::terminal_width().is_some();
    let rows: Vec<Vec<String>> = invoices
        .iter()
        .map(|inv| {
            let id = inv.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();

            // Period: YYYY-MM or YYYY-MM ~ YYYY-MM
            let period_start = inv
                .get("periodStart")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let period_end = inv
                .get("periodEnd")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let start_month = period_start.get(..7).unwrap_or("");
            let end_month = period_end.get(..7).unwrap_or("");
            let period = if start_month == end_month || end_month.is_empty() {
                start_month.to_string()
            } else {
                format!("{} ~ {}", start_month, end_month)
            };

            // Status: green for "paid"
            let status_raw = inv.get("status").and_then(|v| v.as_str()).unwrap_or("");
            let status = if is_tty && status_raw == "paid" {
                format!("\x1b[32m{}\x1b[0m", status_raw)
            } else {
                status_raw.to_string()
            };

            // Amount Due: cents to $X.XX, green
            let amount_cents = inv
                .get("amountDue")
                .and_then(|v| v.as_f64())
                .unwrap_or(0.0);
            let amount = if is_tty {
                format!("\x1b[32m${:.2}\x1b[0m", amount_cents / 100.0)
            } else {
                format!("${:.2}", amount_cents / 100.0)
            };

            // Due Date: truncate to YYYY-MM-DD
            let due_date = inv
                .get("dueDate")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let due_date_short = due_date.get(..10).unwrap_or(due_date).to_string();

            vec![id, period, status, amount, due_date_short]
        })
        .collect();

    let mut table = formatter::create_table(columns, no_header);
    for row in rows {
        table.add_row(row);
    }
    println!("{}", table);
}

fn resolve_time_range(
    month: Option<String>,
    last: Option<String>,
    start: Option<String>,
    end: Option<String>,
) -> Result<(String, String)> {
    use chrono::{Datelike, Local, NaiveDate};

    let now = Local::now();

    if let Some(ref l) = last {
        let (num, unit) = parse_relative_duration(l)?;
        let days = match unit {
            'd' => num,
            'm' => num * 30,
            'y' => num * 365,
            _ => bail!("Invalid unit in --last. Use d (days), m (months), y (years)"),
        };
        let start_date = now - chrono::Duration::days(days as i64);
        return Ok((
            start_date.format("%Y-%m-%dT00:00:00Z").to_string(),
            now.format("%Y-%m-%dT23:59:59Z").to_string(),
        ));
    }

    if let Some(ref m) = month {
        let (year, mon) = match m.as_str() {
            "this" => (now.year(), now.month()),
            "last" => {
                if now.month() == 1 {
                    (now.year() - 1, 12)
                } else {
                    (now.year(), now.month() - 1)
                }
            }
            _ => {
                // YYYY-MM format
                let parts: Vec<&str> = m.split('-').collect();
                if parts.len() != 2 {
                    bail!("Invalid --month format. Use YYYY-MM, 'this', or 'last'");
                }
                let y: i32 = parts[0].parse().context("Invalid year")?;
                let mo: u32 = parts[1].parse().context("Invalid month")?;
                (y, mo)
            }
        };
        let start_date = NaiveDate::from_ymd_opt(year, mon, 1).context("Invalid date")?;
        let end_date = if mon == 12 {
            NaiveDate::from_ymd_opt(year + 1, 1, 1)
        } else {
            NaiveDate::from_ymd_opt(year, mon + 1, 1)
        }
        .context("Invalid date")?
            - chrono::Duration::days(1);

        return Ok((
            format!("{}T00:00:00Z", start_date),
            format!("{}T23:59:59Z", end_date),
        ));
    }

    if let (Some(s), Some(e)) = (start, end) {
        let s = normalize_ts(&s);
        let e = normalize_ts(&e);
        return Ok((s, e));
    }

    // Default: today
    Ok((
        now.format("%Y-%m-%dT00:00:00Z").to_string(),
        now.format("%Y-%m-%dT23:59:59Z").to_string(),
    ))
}

fn normalize_ts(ts: &str) -> String {
    let ts = ts.trim();
    if ts.len() == 10 {
        format!("{}T00:00:00Z", ts)
    } else if ts.ends_with('Z') {
        ts.to_string()
    } else {
        format!("{}Z", ts)
    }
}

fn parse_relative_duration(s: &str) -> Result<(u64, char)> {
    let s = s.trim();
    if s.is_empty() {
        bail!("Empty duration");
    }
    let unit = s.chars().last().unwrap().to_ascii_lowercase();
    let num: u64 = s[..s.len() - 1]
        .parse()
        .context("Invalid number in duration")?;
    Ok((num, unit))
}