zilliz 0.1.1

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
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};

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<()> {
    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(None, 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<()> {
    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(None, 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?;
    print_output_with_opts(&result, output_opts, None);

    Ok(())
}

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))
}