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,
}
#[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)),
}
}
}
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))
}
pub fn load_cache() -> Option<UpdateCache> {
let path = cache_path()?;
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice::<UpdateCache>(&bytes).ok()
}
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)
}
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
}
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
))
}
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())
}
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());
}
}
#[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)
}
}