use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::auth::config_dir;
pub const CRATE_NAME: &str = "aristo-cli";
pub const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
const FETCH_TIMEOUT: Duration = Duration::from_secs(2);
const CACHE_FILENAME: &str = "update-check.toml";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cache {
pub last_check_unix: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub latest_seen: Option<String>,
}
pub fn check(current: &str) -> Option<String> {
let path = cache_path()?;
let now = now_unix()?;
let cache = read_cache(&path);
let latest = if should_fetch(now, cache.as_ref()) {
let fetched = fetch_latest_stable();
let next = next_cache(now, fetched, cache);
let _ = write_cache(&path, &next);
next.latest_seen
} else {
cache.and_then(|c| c.latest_seen)
};
notice(current, latest.as_deref())
}
pub fn should_fetch(now_unix: u64, cache: Option<&Cache>) -> bool {
match cache {
None => true,
Some(c) => now_unix.saturating_sub(c.last_check_unix) >= CHECK_INTERVAL.as_secs(),
}
}
pub fn next_cache(now_unix: u64, fetched: Option<String>, prev: Option<Cache>) -> Cache {
let latest_seen = fetched.or_else(|| prev.and_then(|c| c.latest_seen));
Cache {
last_check_unix: now_unix,
latest_seen,
}
}
pub fn notice(current: &str, latest: Option<&str>) -> Option<String> {
let latest = latest?;
if !is_newer(current, latest) {
return None;
}
Some(format!(
"\nA new release of aristo is available: {current} -> {latest}\n\
Update with: cargo install {CRATE_NAME} --locked --force"
))
}
pub fn is_newer(current: &str, latest: &str) -> bool {
match (
semver::Version::parse(current),
semver::Version::parse(latest),
) {
(Ok(cur), Ok(new)) => new > cur,
_ => false,
}
}
fn cache_path() -> Option<PathBuf> {
config_dir().ok().map(|d| d.join(CACHE_FILENAME))
}
fn now_unix() -> Option<u64> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs())
}
fn read_cache(path: &Path) -> Option<Cache> {
let text = std::fs::read_to_string(path).ok()?;
toml::from_str(&text).ok()
}
fn write_cache(path: &Path, cache: &Cache) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(cache)
.map_err(|e| std::io::Error::other(format!("serialize update cache: {e}")))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, text.as_bytes())?;
std::fs::rename(&tmp, path)
}
fn fetch_latest_stable() -> Option<String> {
let url = format!("https://crates.io/api/v1/crates/{CRATE_NAME}");
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_global(Some(FETCH_TIMEOUT))
.user_agent(format!(
"aristo-cli/{} (+https://github.com/aretta-ai/aristo)",
env!("CARGO_PKG_VERSION")
))
.build()
.into();
let mut resp = agent.get(&url).call().ok()?;
if resp.status().as_u16() != 200 {
return None;
}
let body = resp.body_mut().read_to_string().ok()?;
let parsed: CratesIoResponse = serde_json::from_str(&body).ok()?;
parsed.krate.max_stable_version
}
#[derive(Deserialize)]
struct CratesIoResponse {
#[serde(rename = "crate")]
krate: CrateInfo,
}
#[derive(Deserialize)]
struct CrateInfo {
max_stable_version: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_run_with_no_cache_triggers_fetch() {
assert!(should_fetch(1_000_000, None));
}
#[test]
fn fetch_skipped_within_interval() {
let cache = Cache {
last_check_unix: 1_000_000,
latest_seen: Some("0.2.0".into()),
};
assert!(!should_fetch(1_000_001, Some(&cache)));
}
#[test]
fn fetch_resumes_exactly_at_interval_boundary() {
let cache = Cache {
last_check_unix: 1_000_000,
latest_seen: None,
};
let boundary = 1_000_000 + CHECK_INTERVAL.as_secs();
assert!(should_fetch(boundary, Some(&cache)));
assert!(!should_fetch(boundary - 1, Some(&cache)));
}
#[test]
fn next_cache_records_attempt_and_new_version() {
let c = next_cache(42, Some("0.3.0".into()), None);
assert_eq!(c.last_check_unix, 42);
assert_eq!(c.latest_seen.as_deref(), Some("0.3.0"));
}
#[test]
fn next_cache_carries_previous_version_when_fetch_fails() {
let prev = Cache {
last_check_unix: 1,
latest_seen: Some("0.3.0".into()),
};
let c = next_cache(99, None, Some(prev));
assert_eq!(c.last_check_unix, 99);
assert_eq!(c.latest_seen.as_deref(), Some("0.3.0"));
}
#[test]
fn notice_emitted_only_when_strictly_newer() {
let n = notice("0.2.0", Some("0.3.0")).expect("newer -> notice");
assert!(n.contains("0.2.0 -> 0.3.0"));
assert!(n.contains("cargo install aristo-cli --locked --force"));
}
#[test]
fn no_notice_when_equal_older_or_absent() {
assert!(notice("0.2.0", Some("0.2.0")).is_none());
assert!(notice("0.2.0", Some("0.1.9")).is_none());
assert!(notice("0.2.0", None).is_none());
}
#[test]
fn no_notice_on_unparseable_versions() {
assert!(notice("not-semver", Some("0.3.0")).is_none());
assert!(notice("0.2.0", Some("garbage")).is_none());
}
#[test]
fn is_newer_across_patch_minor_major() {
assert!(is_newer("0.2.0", "0.2.1"));
assert!(is_newer("0.2.0", "0.3.0"));
assert!(is_newer("0.9.0", "1.0.0"));
assert!(!is_newer("1.0.0", "0.9.9"));
assert!(!is_newer("0.2.0", "0.2.0"));
}
#[test]
fn cache_round_trips_through_toml() {
let c = Cache {
last_check_unix: 1_717_000_000,
latest_seen: Some("0.4.2".into()),
};
let back: Cache = toml::from_str(&toml::to_string_pretty(&c).unwrap()).unwrap();
assert_eq!(c, back);
}
#[test]
fn cache_without_latest_seen_round_trips() {
let c = Cache {
last_check_unix: 5,
latest_seen: None,
};
let back: Cache = toml::from_str(&toml::to_string_pretty(&c).unwrap()).unwrap();
assert_eq!(c, back);
}
}