zilliz 1.4.2

TUI and CLI tool for managing Zilliz Cloud clusters and Milvus operations
Documentation
//! Background latest-version check with on-disk throttling.
//!
//! Spawned once per process from `async_main`. Fires a single GitHub releases
//! lookup at most once per 24 hours, persists the result, and emits a one-line
//! stderr tip at process exit if a newer release is available. Failure-silent
//! by design: never affects exit code, never pollutes stdout.

use std::cmp::Ordering;
use std::io::Write;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
use std::sync::Arc;
use std::time::Duration as StdDuration;

use chrono::{DateTime, Duration as ChronoDuration, Utc};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;

use crate::cli::upgrade::{compare_versions, fetch_latest_version};

const CACHE_FILE_NAME: &str = "update-check.json";
const THROTTLE_HOURS: i64 = 24;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpdateCache {
    pub last_checked_at: DateTime<Utc>,
    pub latest_version: String,
}

/// Handle held by the foreground process. Internally shares an `OnceCell` with
/// the optional background fetch task. Cheap to clone; safe to drop without
/// joining.
#[derive(Clone)]
pub struct UpdateCheckHandle {
    running_version: String,
    cached: Arc<OnceCell<UpdateCache>>,
    emitted: Arc<AtomicBool>,
}

impl UpdateCheckHandle {
    fn new(running_version: String) -> Self {
        Self {
            running_version,
            cached: Arc::new(OnceCell::new()),
            emitted: Arc::new(AtomicBool::new(false)),
        }
    }
}

/// Resolve the cache file path, honoring `ZILLIZ_CONFIG_DIR`.
fn cache_path() -> Option<PathBuf> {
    let dir = if let Ok(env_dir) = std::env::var("ZILLIZ_CONFIG_DIR") {
        PathBuf::from(env_dir)
    } else {
        dirs::home_dir()?.join(".zilliz")
    };
    Some(dir.join(CACHE_FILE_NAME))
}

/// Load the cache from disk. Returns `None` on any failure.
pub fn load_cache() -> Option<UpdateCache> {
    let path = cache_path()?;
    let bytes = std::fs::read(&path).ok()?;
    serde_json::from_slice::<UpdateCache>(&bytes).ok()
}

/// Atomically write the cache to disk via tmp + rename. Silent on failure.
pub fn write_cache(cache: &UpdateCache) {
    let Some(path) = cache_path() else { return };
    let Some(parent) = path.parent() else { return };
    if std::fs::create_dir_all(parent).is_err() {
        return;
    }
    let Ok(json) = serde_json::to_vec(cache) else {
        return;
    };
    let tmp = path.with_extension("json.tmp");
    if std::fs::write(&tmp, &json).is_err() {
        return;
    }
    let _ = std::fs::rename(&tmp, &path);
}

fn is_fresh(cache: &UpdateCache, now: DateTime<Utc>) -> bool {
    now.signed_duration_since(cache.last_checked_at) < ChronoDuration::hours(THROTTLE_HOURS)
}

/// Spawn the update check. If a fresh cache exists, no network request is
/// made. Otherwise a detached tokio task fetches the latest version.
pub fn spawn(running_version: &str) -> UpdateCheckHandle {
    let handle = UpdateCheckHandle::new(running_version.to_string());

    let now = Utc::now();
    let existing = load_cache();
    if let Some(cache) = existing.as_ref() {
        if is_fresh(cache, now) {
            let _ = handle.cached.set(cache.clone());
            return handle;
        }
    }

    let cached = handle.cached.clone();
    tokio::spawn(async move {
        let fetch = tokio::time::timeout(StdDuration::from_secs(5), fetch_latest_version()).await;
        let Ok(Ok(latest)) = fetch else {
            tracing::debug!("update check: fetch failed or timed out");
            return;
        };
        let entry = UpdateCache {
            last_checked_at: Utc::now(),
            latest_version: latest,
        };
        write_cache(&entry);
        let _ = cached.set(entry);
    });

    handle
}

/// Compute the tip line to emit, if any. Consumes the one-shot "emitted" flag
/// so a second call always returns `None`. Pure aside from the atomic flip.
pub fn take_tip_line(handle: &UpdateCheckHandle) -> Option<String> {
    if handle.emitted.swap(true, AtomicOrdering::SeqCst) {
        return None;
    }
    let cache = handle.cached.get()?;
    if !looks_like_version(&cache.latest_version) {
        return None;
    }
    if compare_versions(&handle.running_version, &cache.latest_version) != Ordering::Less {
        return None;
    }
    Some(format!(
        "Tips: A new version of zilliz ({}) is available. Run `zilliz upgrade` to update.",
        cache.latest_version
    ))
}

/// Reject obviously-malformed cached version strings before comparing.
/// `compare_versions` falls back to lexicographic on non-numeric parts, which
/// would emit a misleading tip for garbage like `"banana"`.
fn looks_like_version(s: &str) -> bool {
    let trimmed = s.trim_start_matches(|c: char| !c.is_ascii_digit());
    let first = trimmed.split('.').next().unwrap_or("");
    !first.is_empty() && first.chars().all(|c| c.is_ascii_digit())
}

/// Emit the upgrade tip to stderr if due. At most once per process. Never
/// writes to stdout. Never fails.
pub fn emit_tip_if_due(handle: &UpdateCheckHandle) {
    if let Some(line) = take_tip_line(handle) {
        let stderr = std::io::stderr();
        let mut lock = stderr.lock();
        let _ = writeln!(lock, "{}", line.yellow());
    }
}

// Test hooks: integration tests under `tests/` use these to seed handles
// without going through clap/dispatch or hitting the network.
#[doc(hidden)]
pub mod test_support {
    use super::*;

    pub fn seed_handle(running_version: &str, cached_version: &str) -> UpdateCheckHandle {
        let handle = UpdateCheckHandle::new(running_version.to_string());
        let _ = handle.cached.set(UpdateCache {
            last_checked_at: Utc::now(),
            latest_version: cached_version.to_string(),
        });
        handle
    }

    pub fn empty_handle(running_version: &str) -> UpdateCheckHandle {
        UpdateCheckHandle::new(running_version.to_string())
    }

    pub fn is_fresh_for_test(cache: &UpdateCache, now: DateTime<Utc>) -> bool {
        is_fresh(cache, now)
    }
}