zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
use std::collections::HashMap;

use indexmap::IndexMap;
use serde::Deserialize;

/// Canonical default control-plane API base URL for the global (US/EU) cloud.
/// Single source of truth — do not duplicate this string anywhere else
/// (Rust, JSON, or tests).
pub const DEFAULT_CONTROL_PLANE_ENDPOINT: &str = "https://api.cloud.zilliz.com";

/// Canonical default control-plane API base URL for the CN cloud. Selected by
/// `zilliz login --cn` and persisted to `~/.zilliz/config` so subsequent
/// invocations target CN without extra flags. Single source of truth.
pub const DEFAULT_CN_CONTROL_PLANE_ENDPOINT: &str = "https://api.cloud.zilliz.com.cn";

/// Canonical default control-plane API base URL for the UAT (dev) environment.
/// Selected by `zilliz login --dev` and persisted to `~/.zilliz/config` so
/// subsequent invocations target UAT without extra flags. Single source of truth.
pub const DEFAULT_DEV_CONTROL_PLANE_ENDPOINT: &str = "https://api.cloud-uat3.zilliz.com";

/// Top-level CLI model, parsed from control-plane.json or data-plane.json.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct CliModel {
    #[serde(default)]
    pub version: String,
    #[serde(default)]
    pub min_cli_version: String,
    pub endpoint: Option<String>,
    pub auth: Option<AuthConfig>,
    #[serde(default)]
    pub resources: IndexMap<String, Resource>,
}

impl CliModel {
    /// Resolve the API base URL: the JSON-configured `endpoint` if set,
    /// otherwise [`DEFAULT_CONTROL_PLANE_ENDPOINT`].
    pub fn resolved_endpoint(&self) -> String {
        self.endpoint
            .clone()
            .unwrap_or_else(|| DEFAULT_CONTROL_PLANE_ENDPOINT.to_string())
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct AuthConfig {
    #[serde(default, alias = "auth0Domain")]
    pub auth0_domain: String,
    #[serde(default, alias = "clientId")]
    pub client_id: String,
    #[serde(default, alias = "loginApi")]
    pub login_api: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Resource {
    pub description: Option<String>,
    #[serde(default)]
    pub dedicated_only: bool,
    #[serde(default)]
    pub operations: IndexMap<String, Operation>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct Operation {
    #[serde(default)]
    pub http: HttpConfig,
    #[serde(default)]
    pub params: Vec<Param>,
    pub body_param: Option<String>,
    #[serde(default)]
    pub output: OutputConfig,
    pub pagination: Option<Pagination>,
    pub description: Option<String>,
    #[serde(default)]
    pub examples: Vec<String>,
    #[serde(default)]
    pub dedicated_only: bool,
    pub body_transform: Option<serde_json::Value>,
    #[serde(default)]
    pub body_defaults: serde_json::Map<String, serde_json::Value>,
}

impl Operation {
    pub fn method(&self) -> &str {
        &self.http.method
    }

    pub fn path(&self) -> &str {
        &self.http.path
    }
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpConfig {
    #[serde(default = "default_method")]
    pub method: String,
    #[serde(default)]
    pub path: String,
}

fn default_method() -> String {
    "GET".to_string()
}

#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct OutputConfig {
    pub data_field: Option<String>,
    #[serde(default)]
    pub columns: Option<Vec<String>>,
    #[serde(default)]
    pub color_map: Option<HashMap<String, HashMap<String, String>>>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct Param {
    pub name: String,
    #[serde(default = "default_type")]
    #[serde(rename = "type")]
    pub param_type: String,
    #[serde(rename = "cli")]
    pub cli_name: Option<String>,
    #[serde(default)]
    pub required: bool,
    pub default: Option<serde_json::Value>,
    pub position: Option<String>,
    pub required_unless: Option<String>,
    pub required_when: Option<serde_json::Value>,
    pub description: Option<String>,
    pub choices: Option<Vec<String>>,
    pub transform: Option<serde_json::Value>,
}

fn default_type() -> String {
    "string".to_string()
}

impl Param {
    /// Get the CLI flag name (e.g., "--cluster-id").
    pub fn cli_flag(&self) -> String {
        self.cli_name
            .clone()
            .unwrap_or_else(|| format!("--{}", self.name))
    }

    /// Whether this param goes in the URL path vs query/body.
    pub fn is_path_param(&self) -> bool {
        self.position.as_deref() == Some("path")
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
pub struct Pagination {
    #[serde(default = "default_page_size_param")]
    pub page_size_param: String,
    #[serde(default = "default_page_param")]
    pub page_param: String,
    #[serde(default = "default_count_field")]
    pub count_field: String,
    #[serde(default = "default_data_field")]
    pub data_field: String,
    #[serde(default = "default_page_size")]
    pub default_page_size: u32,
}

fn default_page_size_param() -> String {
    "pageSize".to_string()
}
fn default_page_param() -> String {
    "currentPage".to_string()
}
fn default_count_field() -> String {
    "count".to_string()
}
fn default_data_field() -> String {
    "data".to_string()
}
fn default_page_size() -> u32 {
    100
}

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

    fn empty_model(endpoint: Option<String>) -> CliModel {
        CliModel {
            version: String::new(),
            min_cli_version: String::new(),
            endpoint,
            auth: None,
            resources: IndexMap::new(),
        }
    }

    #[test]
    fn resolved_endpoint_uses_configured_value_when_present() {
        let m = empty_model(Some("https://staging.example.com".to_string()));
        assert_eq!(m.resolved_endpoint(), "https://staging.example.com");
    }

    #[test]
    fn resolved_endpoint_falls_back_to_default_when_none() {
        let m = empty_model(None);
        assert_eq!(m.resolved_endpoint(), DEFAULT_CONTROL_PLANE_ENDPOINT);
    }

    #[test]
    fn global_and_cn_defaults_are_distinct() {
        assert_ne!(
            DEFAULT_CONTROL_PLANE_ENDPOINT, DEFAULT_CN_CONTROL_PLANE_ENDPOINT,
            "Global and CN defaults must not collide"
        );
    }

    #[test]
    fn cn_default_targets_cn_tld() {
        assert!(
            DEFAULT_CN_CONTROL_PLANE_ENDPOINT.ends_with(".cn"),
            "CN default endpoint must end in .cn"
        );
    }

    #[test]
    fn dev_default_is_distinct_from_global_and_cn() {
        assert_ne!(
            DEFAULT_DEV_CONTROL_PLANE_ENDPOINT, DEFAULT_CONTROL_PLANE_ENDPOINT,
            "Dev and Global defaults must not collide"
        );
        assert_ne!(
            DEFAULT_DEV_CONTROL_PLANE_ENDPOINT, DEFAULT_CN_CONTROL_PLANE_ENDPOINT,
            "Dev and CN defaults must not collide"
        );
    }

    #[test]
    fn dev_default_targets_uat3_host() {
        assert!(
            DEFAULT_DEV_CONTROL_PLANE_ENDPOINT.contains("cloud-uat3.zilliz.com"),
            "Dev default endpoint must target the cloud-uat3 host"
        );
    }

    #[test]
    fn resolved_endpoint_uses_dev_value_when_persisted() {
        let m = empty_model(Some(DEFAULT_DEV_CONTROL_PLANE_ENDPOINT.to_string()));
        assert_eq!(m.resolved_endpoint(), DEFAULT_DEV_CONTROL_PLANE_ENDPOINT);
    }
}