use std::fs;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use super::latest::{fetch_latest, is_newer};
use super::receipt::arete_home;
pub fn check_interval() -> chrono::Duration {
chrono::Duration::hours(24)
}
pub const FETCH_TIMEOUT: Duration = Duration::from_secs(2);
const QUIET_COMMANDS: &[&str] = &["self", "upgrade", "mcp", "stream"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateCheck {
pub checked_at: String,
pub latest: String,
}
pub fn update_check_path() -> Result<PathBuf> {
Ok(arete_home()?.join("update-check.json"))
}
pub fn message(latest: &str, current: &str) -> String {
format!("a4 {latest} is available (you have {current}). Run: a4 self update")
}
pub fn allowed(command_name: &str, json: bool, stderr_is_tty: bool) -> bool {
if json || !stderr_is_tty {
return false;
}
if QUIET_COMMANDS.contains(&command_name) {
return false;
}
if std::env::var_os("CI").is_some() {
return false;
}
if std::env::var_os("A4_NO_UPDATE_CHECK").is_some_and(|value| !value.is_empty()) {
return false;
}
true
}
fn load_state(path: &Path) -> Option<UpdateCheck> {
let content = fs::read_to_string(path).ok()?;
serde_json::from_str(&content).ok()
}
fn save_state(path: &Path, state: &UpdateCheck) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
let content = serde_json::to_string_pretty(state)?;
fs::write(path, format!("{content}\n"))
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub fn run_nudge(
now: DateTime<Utc>,
current: &str,
fetch: impl FnOnce() -> Result<String>,
state_path: &Path,
) -> Option<String> {
if let Some(state) = load_state(state_path) {
if let Ok(checked_at) = DateTime::parse_from_rfc3339(&state.checked_at) {
let checked_at = checked_at.with_timezone(&Utc);
if now - checked_at < check_interval() && now >= checked_at {
return None;
}
}
}
let latest = fetch().ok()?;
let state = UpdateCheck {
checked_at: now.to_rfc3339_opts(SecondsFormat::Secs, true),
latest: latest.clone(),
};
let _ = save_state(state_path, &state);
if is_newer(&latest, current)? {
Some(message(&latest, current))
} else {
None
}
}
pub fn maybe_nudge(command_name: &str, json: bool) {
use std::io::IsTerminal;
if !allowed(command_name, json, std::io::stderr().is_terminal()) {
return;
}
let Ok(state_path) = update_check_path() else {
return;
};
let fetch = || fetch_latest(FETCH_TIMEOUT).map(|pointer| pointer.version);
if let Some(text) = run_nudge(Utc::now(), env!("CARGO_PKG_VERSION"), fetch, &state_path) {
eprintln!("{text}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn at(rfc3339: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(rfc3339)
.unwrap()
.with_timezone(&Utc)
}
#[test]
fn nudges_once_then_throttles_for_24h() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("update-check.json");
let fetches = Cell::new(0);
let fetch = || {
fetches.set(fetches.get() + 1);
Ok("0.14.0".to_string())
};
let t0 = at("2026-09-10T12:00:00Z");
assert_eq!(
run_nudge(t0, "0.13.0", fetch, &state),
Some("a4 0.14.0 is available (you have 0.13.0). Run: a4 self update".to_string())
);
assert_eq!(fetches.get(), 1);
let saved: UpdateCheck =
serde_json::from_str(&fs::read_to_string(&state).unwrap()).unwrap();
assert_eq!(saved.checked_at, "2026-09-10T12:00:00Z");
assert_eq!(saved.latest, "0.14.0");
assert_eq!(
run_nudge(t0 + chrono::Duration::hours(23), "0.13.0", fetch, &state),
None
);
assert_eq!(fetches.get(), 1);
assert!(run_nudge(t0 + chrono::Duration::hours(25), "0.13.0", fetch, &state).is_some());
assert_eq!(fetches.get(), 2);
}
#[test]
fn silent_when_current_or_on_error() {
let dir = tempfile::tempdir().unwrap();
let state = dir.path().join("update-check.json");
let now = at("2026-09-10T12:00:00Z");
assert_eq!(
run_nudge(now, "0.14.0", || Ok("0.14.0".to_string()), &state),
None
);
assert!(state.exists());
let failing = || Err(anyhow::anyhow!("offline"));
let later = now + chrono::Duration::hours(30);
assert_eq!(run_nudge(later, "0.13.0", failing, &state), None);
fs::write(&state, "{ not json").unwrap();
assert!(run_nudge(later, "0.13.0", || Ok("9.0.0".to_string()), &state).is_some());
}
#[test]
fn allowed_respects_json_tty_and_quiet_commands() {
assert!(!allowed("init", true, true));
assert!(!allowed("init", false, false));
for quiet in ["self", "upgrade", "mcp", "stream"] {
assert!(!allowed(quiet, false, true), "{quiet} must not nudge");
}
}
}