use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::cli::Command;
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub(crate) struct VersionCache {
pub last_checked_unix: u64,
pub latest_version: String,
}
pub(crate) fn cache_path() -> PathBuf {
let xdg = std::env::var("XDG_CACHE_HOME").ok();
let home = std::env::var("HOME")
.ok()
.or_else(|| std::env::var("USERPROFILE").ok());
cache_path_with_env(xdg.as_deref(), home.as_deref())
}
pub(crate) fn cache_path_with_env(xdg: Option<&str>, home: Option<&str>) -> PathBuf {
let cache_home = xdg
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
let home = home.filter(|s| !s.is_empty()).unwrap_or(".");
PathBuf::from(home).join(".cache")
});
cache_home.join("cct").join("update_check.json")
}
pub(crate) fn read_cache_at(path: &std::path::Path) -> Option<VersionCache> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub(crate) fn write_cache_atomic_at(
path: &std::path::Path,
cache: &VersionCache,
) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!("tmp.{}", std::process::id()));
let json = serde_json::to_vec_pretty(cache)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub(crate) fn should_refresh(cache: Option<&VersionCache>, now_unix: u64) -> bool {
match cache {
None => true,
Some(c) => now_unix.saturating_sub(c.last_checked_unix) > CACHE_TTL_SECS,
}
}
pub(crate) fn format_banner_if_newer(current: &str, latest: &str) -> Option<String> {
let latest_clean = latest.trim_start_matches('v');
if !self_update::version::bump_is_greater(current, latest_clean).unwrap_or(false) {
return None;
}
let line1 = format!("Update available: {current} → {latest_clean}");
let line2 = "Run `cct update` to upgrade.".to_string();
const MIN_INNER: usize = 44;
let inner_width = MIN_INNER
.max(line1.chars().count())
.max(line2.chars().count());
let border = "─".repeat(inner_width + 2);
let pad = |s: &str| {
let n = s.chars().count();
let fill = inner_width.saturating_sub(n);
format!("│ {s}{} │", " ".repeat(fill))
};
Some(format!(
"\n╭{border}╮\n{}\n{}\n╰{border}╯\n",
pad(&line1),
pad(&line2),
))
}
pub(crate) fn is_disabled_by_env(no_update_check: Option<&str>, ci: Option<&str>) -> bool {
is_truthy(no_update_check) || is_truthy(ci)
}
fn is_truthy(v: Option<&str>) -> bool {
matches!(
v.map(|s| s.trim().to_ascii_lowercase()).as_deref(),
Some("1") | Some("true") | Some("yes") | Some("on")
)
}
pub(crate) fn skip_for_subcommand(cmd: &Command) -> bool {
matches!(cmd, Command::Update(_))
}
pub(crate) fn deterministic_gates_open(cmd: &Command, env_disabled: bool) -> bool {
!skip_for_subcommand(cmd) && !env_disabled
}
const REPO_OWNER: &str = "Alfredvc";
const REPO_NAME: &str = "cct";
pub(crate) fn fetch_latest_version() -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let release = self_update::backends::github::Update::configure()
.repo_owner(REPO_OWNER)
.repo_name(REPO_NAME)
.bin_name("cct")
.bin_install_path("/dev/null")
.current_version(self_update::cargo_crate_version!())
.build()?
.get_latest_release()?;
Ok(release.version)
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn read_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|s| !s.is_empty())
}
fn gates_open(cmd: &Command) -> bool {
let no_update = read_env("CCT_NO_UPDATE_CHECK");
let ci = read_env("CI");
let env_disabled = is_disabled_by_env(no_update.as_deref(), ci.as_deref());
if !deterministic_gates_open(cmd, env_disabled) {
return false;
}
std::io::stderr().is_terminal()
}
pub(crate) fn sweep_old_tmp_files(dir: &std::path::Path, max_age_secs: u64, now: SystemTime) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if !name_str.starts_with("update_check.tmp.") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(mtime) = meta.modified() else { continue };
let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
if age > max_age_secs {
let _ = std::fs::remove_file(entry.path());
}
}
}
const TMP_SWEEP_MAX_AGE_SECS: u64 = 7 * 24 * 60 * 60;
pub fn maybe_spawn_check(cmd: &Command) {
spawn_check_with(cache_path(), gates_open(cmd));
}
pub(crate) fn spawn_check_with(path: PathBuf, gates_open: bool) {
if !gates_open {
return;
}
let existing = read_cache_at(&path);
if !should_refresh(existing.as_ref(), now_unix()) {
return;
}
std::thread::spawn(move || {
let _ = std::panic::catch_unwind(move || {
if let Some(parent) = path.parent() {
sweep_old_tmp_files(parent, TMP_SWEEP_MAX_AGE_SECS, SystemTime::now());
}
let fetched = fetch_latest_version();
let prev_version = read_cache_at(&path)
.map(|c| c.latest_version)
.unwrap_or_default();
let cache = VersionCache {
last_checked_unix: now_unix(),
latest_version: fetched.unwrap_or(prev_version),
};
let _ = write_cache_atomic_at(&path, &cache);
});
});
}
pub fn maybe_print_banner(cmd: &Command) {
if let Some(banner) = compute_banner_with(
&cache_path(),
gates_open(cmd),
self_update::cargo_crate_version!(),
) {
eprint!("{banner}");
}
}
pub(crate) fn compute_banner_with(
path: &std::path::Path,
gates_open: bool,
current: &str,
) -> Option<String> {
if !gates_open {
return None;
}
let cache = read_cache_at(path)?;
format_banner_if_newer(current, &cache.latest_version)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn read_returns_none_when_file_missing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.json");
assert!(read_cache_at(&path).is_none());
}
#[test]
fn read_returns_none_when_file_malformed() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.json");
std::fs::write(&path, b"not json").unwrap();
assert!(read_cache_at(&path).is_none());
}
#[test]
fn write_then_read_round_trip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.json");
let cache = VersionCache {
last_checked_unix: 1_700_000_000,
latest_version: "0.2.0".to_string(),
};
write_cache_atomic_at(&path, &cache).unwrap();
let read = read_cache_at(&path).unwrap();
assert_eq!(read, cache);
}
#[test]
fn write_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nested").join("dirs").join("cache.json");
let cache = VersionCache {
last_checked_unix: 1,
latest_version: "0.0.1".to_string(),
};
write_cache_atomic_at(&path, &cache).unwrap();
assert!(path.exists());
}
#[test]
fn should_refresh_when_no_cache() {
assert!(should_refresh(None, 1_000_000_000));
}
#[test]
fn should_refresh_when_cache_is_stale() {
let cache = VersionCache {
last_checked_unix: 1_000_000_000,
latest_version: "0.1.9".to_string(),
};
let now = 1_000_000_000 + CACHE_TTL_SECS + 1;
assert!(should_refresh(Some(&cache), now));
}
#[test]
fn should_not_refresh_when_cache_is_fresh() {
let cache = VersionCache {
last_checked_unix: 1_000_000_000,
latest_version: "0.1.9".to_string(),
};
let now = 1_000_000_000 + CACHE_TTL_SECS - 1;
assert!(!should_refresh(Some(&cache), now));
}
#[test]
fn should_not_refresh_when_cache_is_at_exact_ttl_boundary() {
let cache = VersionCache {
last_checked_unix: 1_000_000_000,
latest_version: "0.1.9".to_string(),
};
let now = 1_000_000_000 + CACHE_TTL_SECS;
assert!(!should_refresh(Some(&cache), now));
}
#[test]
fn should_refresh_when_clock_went_backwards() {
let cache = VersionCache {
last_checked_unix: 2_000_000_000,
latest_version: "0.1.9".to_string(),
};
let now = 1_000_000_000;
assert!(!should_refresh(Some(&cache), now));
}
#[test]
fn banner_returns_none_when_versions_equal() {
assert!(format_banner_if_newer("0.1.9", "0.1.9").is_none());
}
#[test]
fn banner_returns_none_when_latest_is_older() {
assert!(format_banner_if_newer("0.2.0", "0.1.9").is_none());
}
#[test]
fn banner_returns_none_when_latest_is_unparseable() {
assert!(format_banner_if_newer("0.1.9", "not-a-version").is_none());
}
#[test]
fn banner_renders_when_latest_is_newer() {
let out = format_banner_if_newer("0.1.9", "0.2.0").expect("expected banner");
assert!(out.contains("0.1.9"), "missing current version: {out}");
assert!(out.contains("0.2.0"), "missing latest version: {out}");
assert!(out.contains("cct update"), "missing upgrade hint: {out}");
assert_box_well_formed(&out);
}
#[test]
fn banner_strips_leading_v_from_latest() {
let out = format_banner_if_newer("0.1.9", "v0.2.0").expect("expected banner");
assert!(out.contains("0.2.0"));
assert!(
!out.contains("v0.2.0"),
"leading v should be stripped: {out}"
);
assert_box_well_formed(&out);
}
#[test]
fn banner_grows_with_long_version_strings() {
let out = format_banner_if_newer("0.1.9", "999.999.999-rc.1+verylongbuildmeta")
.expect("expected banner");
assert!(out.contains("999.999.999-rc.1+verylongbuildmeta"));
assert_box_well_formed(&out);
}
fn assert_box_well_formed(out: &str) {
let lines: Vec<&str> = out.lines().filter(|l| !l.is_empty()).collect();
assert!(lines.len() >= 4, "expected >=4 non-empty lines: {out:?}");
let top = lines[0];
let bottom = lines[lines.len() - 1];
assert!(top.starts_with('╭') && top.ends_with('╮'), "top: {top:?}");
assert!(
bottom.starts_with('╰') && bottom.ends_with('╯'),
"bottom: {bottom:?}"
);
let top_width = top.chars().count();
let bottom_width = bottom.chars().count();
assert_eq!(top_width, bottom_width, "top/bottom width mismatch");
for content in &lines[1..lines.len() - 1] {
assert!(
content.starts_with("│ "),
"content line missing left border: {content:?}"
);
assert!(
content.ends_with(" │"),
"content line missing right border: {content:?}"
);
assert_eq!(
content.chars().count(),
top_width,
"content line width != border width: {content:?}"
);
}
}
#[test]
fn disabled_when_cct_no_update_check_is_truthy() {
assert!(is_disabled_by_env(Some("1"), None));
assert!(is_disabled_by_env(Some("true"), None));
assert!(is_disabled_by_env(Some("yes"), None));
}
#[test]
fn disabled_when_ci_is_truthy() {
assert!(is_disabled_by_env(None, Some("true")));
assert!(is_disabled_by_env(None, Some("1")));
}
#[test]
fn not_disabled_when_envs_are_unset_or_empty() {
assert!(!is_disabled_by_env(None, None));
assert!(!is_disabled_by_env(Some(""), Some("")));
assert!(!is_disabled_by_env(Some("0"), Some("0")));
assert!(!is_disabled_by_env(Some("false"), Some("false")));
}
#[test]
fn skip_for_update_subcommand_only() {
use crate::cli::{Command, InfoArgs, UpdateArgs};
let info = Command::Info(InfoArgs {
db: std::path::PathBuf::from("/tmp/x.duckdb"),
});
let update = Command::Update(UpdateArgs {
version: None,
yes: false,
});
assert!(!skip_for_subcommand(&info));
assert!(skip_for_subcommand(&update));
}
#[test]
fn deterministic_gates_open_for_info_when_env_clean() {
use crate::cli::{Command, InfoArgs};
let info = Command::Info(InfoArgs {
db: std::path::PathBuf::from("/tmp/x.duckdb"),
});
assert!(deterministic_gates_open(&info, false));
}
#[test]
fn gates_closed_for_update_command_regardless_of_env() {
use crate::cli::{Command, UpdateArgs};
let update = Command::Update(UpdateArgs {
version: None,
yes: false,
});
assert!(!deterministic_gates_open(&update, false));
assert!(!deterministic_gates_open(&update, true));
}
#[test]
fn deterministic_gates_closed_for_info_when_env_disabled() {
use crate::cli::{Command, InfoArgs};
let info = Command::Info(InfoArgs {
db: std::path::PathBuf::from("/tmp/x.duckdb"),
});
assert!(!deterministic_gates_open(&info, true));
}
#[test]
fn cache_path_uses_xdg_when_set() {
let p = cache_path_with_env(Some("/tmp/xdg"), Some("/home/u"));
assert_eq!(p, PathBuf::from("/tmp/xdg/cct/update_check.json"));
}
#[test]
fn cache_path_uses_home_when_xdg_blank_or_unset() {
let p = cache_path_with_env(None, Some("/home/u"));
assert_eq!(p, PathBuf::from("/home/u/.cache/cct/update_check.json"));
let p = cache_path_with_env(Some(""), Some("/home/u"));
assert_eq!(p, PathBuf::from("/home/u/.cache/cct/update_check.json"));
}
#[test]
fn cache_path_falls_back_to_cwd_when_both_envs_unset() {
let p = cache_path_with_env(None, None);
assert_eq!(p, PathBuf::from("./.cache/cct/update_check.json"));
let p = cache_path_with_env(Some(""), Some(""));
assert_eq!(p, PathBuf::from("./.cache/cct/update_check.json"));
}
#[test]
fn sweep_removes_only_old_tmp_files() {
let dir = TempDir::new().unwrap();
let old_tmp = dir.path().join("update_check.tmp.1234");
let other_tmp = dir.path().join("update_check.tmp.5678");
let unrelated = dir.path().join("update_check.json");
std::fs::write(&old_tmp, b"x").unwrap();
std::fs::write(&other_tmp, b"y").unwrap();
std::fs::write(&unrelated, b"z").unwrap();
let later = SystemTime::now() + std::time::Duration::from_secs(1);
sweep_old_tmp_files(dir.path(), 0, later);
assert!(!old_tmp.exists(), "old tmp should be swept");
assert!(!other_tmp.exists(), "old tmp should be swept");
assert!(unrelated.exists(), "non-tmp file must be preserved");
}
#[test]
fn sweep_keeps_recent_tmp_files() {
let dir = TempDir::new().unwrap();
let recent = dir.path().join("update_check.tmp.999");
std::fs::write(&recent, b"x").unwrap();
sweep_old_tmp_files(dir.path(), 24 * 60 * 60, SystemTime::now());
assert!(recent.exists());
}
#[test]
fn sweep_swallows_missing_dir() {
sweep_old_tmp_files(
std::path::Path::new("/nonexistent/cct/sweep/dir"),
0,
SystemTime::now(),
);
}
#[test]
fn spawn_check_with_gates_closed_does_not_touch_cache() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("cct").join("update_check.json");
spawn_check_with(path.clone(), false);
assert!(!path.exists());
assert!(!path.parent().unwrap().exists());
}
#[test]
fn compute_banner_returns_none_when_gates_closed() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.json");
write_cache_atomic_at(
&path,
&VersionCache {
last_checked_unix: 1,
latest_version: "99.0.0".to_string(),
},
)
.unwrap();
assert!(compute_banner_with(&path, false, "0.1.9").is_none());
}
#[test]
fn compute_banner_returns_none_when_cache_missing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.json");
assert!(compute_banner_with(&path, true, "0.1.9").is_none());
}
#[test]
fn compute_banner_returns_some_when_cache_has_newer_version() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("update_check.json");
write_cache_atomic_at(
&path,
&VersionCache {
last_checked_unix: 1,
latest_version: "99.0.0".to_string(),
},
)
.unwrap();
let banner =
compute_banner_with(&path, true, "0.1.9").expect("expected banner");
assert!(banner.contains("0.1.9"));
assert!(banner.contains("99.0.0"));
}
}