use crate::commands::update::{self, is_newer};
use crate::output::{self, Format};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub const CHECK_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const CHECK_TIMEOUT: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cache {
pub checked_at: u64,
pub latest: String,
}
pub fn cache_path() -> PathBuf {
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("bb").join("update-check.json");
}
}
let home = std::env::var_os("HOME").unwrap_or_default();
PathBuf::from(home)
.join(".config")
.join("bb")
.join("update-check.json")
}
pub fn load_cache() -> Option<Cache> {
let raw = std::fs::read_to_string(cache_path()).ok()?;
serde_json::from_str(&raw).ok()
}
pub fn save_cache(cache: &Cache) -> std::io::Result<()> {
let path = cache_path();
let Some(parent) = path.parent() else {
return Ok(());
};
std::fs::create_dir_all(parent)?;
let json = serde_json::to_string(cache)
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?;
let tmp = parent.join(format!(
".update-check.json.tmp.{}.{}",
std::process::id(),
now_secs()
));
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &path)
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn is_stale(cache: Option<&Cache>, now: u64, ttl: Duration) -> bool {
match cache {
None => true,
Some(cache) => cache.checked_at > now || now - cache.checked_at >= ttl.as_secs(),
}
}
pub fn notice(latest: &str, current: &str, hint: &str) -> Option<String> {
if !is_newer(latest, current) {
return None;
}
let version = latest.trim().trim_start_matches('v');
Some(format!(
"bb {version} is available (you have {current}) — upgrade with: {hint}"
))
}
fn hint_for_this_install() -> &'static str {
match std::env::current_exe() {
Ok(exe) => update::upgrade_hint(update::classify_install(&exe)),
Err(_) => "bb update",
}
}
fn client() -> Option<reqwest::Client> {
reqwest::Client::builder()
.connect_timeout(CHECK_TIMEOUT)
.timeout(CHECK_TIMEOUT)
.user_agent(concat!("bbcloud/", env!("CARGO_PKG_VERSION")))
.build()
.ok()
}
pub async fn maybe_notify(format: Format, base_url: &str) {
let _ = format;
if std::env::var_os("BB_NO_UPDATE_CHECK").is_some() {
return;
}
let current = env!("CARGO_PKG_VERSION");
let mut cache = load_cache();
if is_stale(cache.as_ref(), now_secs(), CHECK_TTL) {
if let Some(http) = client() {
if let Ok(latest) = update::latest_tag(&http, base_url).await {
let fresh = Cache {
checked_at: now_secs(),
latest,
};
let _ = save_cache(&fresh);
cache = Some(fresh);
}
}
}
if let Some(line) = cache
.as_ref()
.and_then(|c| notice(&c.latest, current, hint_for_this_install()))
{
output::warn(&line);
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn notice_names_version_and_command() {
let line = notice("v0.20.0", "0.19.4", "bb update").unwrap();
assert!(line.contains("bb 0.20.0 is available"), "{line}");
assert!(line.contains("you have 0.19.4"), "{line}");
assert!(line.ends_with("bb update"), "{line}");
}
#[test]
fn no_notice_when_current_or_ahead() {
assert!(notice("v0.19.4", "0.19.4", "bb update").is_none());
assert!(notice("v0.19.3", "0.19.4", "bb update").is_none());
}
#[test]
fn unparseable_tag_is_never_a_notice() {
assert!(notice("nightly", "0.19.4", "bb update").is_none());
assert!(notice("", "0.19.4", "bb update").is_none());
}
#[test]
fn missing_cache_is_stale() {
assert!(is_stale(None, 1_000_000, CHECK_TTL));
}
#[test]
fn fresh_cache_is_not_stale() {
let cache = Cache {
checked_at: 1_000_000,
latest: "v0.19.4".to_string(),
};
assert!(!is_stale(Some(&cache), 1_000_000 + 60, CHECK_TTL));
}
#[test]
fn cache_older_than_ttl_is_stale() {
let cache = Cache {
checked_at: 1_000_000,
latest: "v0.19.4".to_string(),
};
assert!(is_stale(
Some(&cache),
1_000_000 + CHECK_TTL.as_secs(),
CHECK_TTL
));
}
#[test]
fn cache_stamped_in_the_future_is_stale() {
let cache = Cache {
checked_at: 2_000_000,
latest: "v0.19.4".to_string(),
};
assert!(is_stale(Some(&cache), 1_000_000, CHECK_TTL));
}
}