use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
pub fn current_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
pub fn is_newer(current: &str, latest: &str) -> bool {
match (semver::Version::parse(current), semver::Version::parse(latest)) {
(Ok(c), Ok(l)) => l > c,
_ => false,
}
}
pub async fn check_for_update(client: &reqwest::Client) -> Option<String> {
let current = current_version();
if let Some(cache) = read_cache()
&& cache.is_fresh()
&& is_newer(current, &cache.latest)
{
return Some(cache.latest);
}
let latest = match tokio::time::timeout(
Duration::from_secs(2),
fetch_latest_version(client),
)
.await
{
Ok(Some(v)) => v,
_ => return None,
};
let _ = write_cache(&latest);
if is_newer(current, &latest) {
Some(latest)
} else {
None
}
}
async fn fetch_latest_version(client: &reqwest::Client) -> Option<String> {
let resp = client
.get("https://crates.io/api/v1/crates/isitme")
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let body: CratesResponse = resp.json().await.ok()?;
Some(body.crate_.max_stable_version)
}
#[derive(Deserialize)]
struct CratesResponse {
#[serde(rename = "crate")]
crate_: CrateInfo,
}
#[derive(Deserialize)]
struct CrateInfo {
max_stable_version: String,
}
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;
#[derive(Serialize, Deserialize)]
struct Cache {
checked_at: u64,
latest: String,
}
impl Cache {
fn is_fresh(&self) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
now.saturating_sub(self.checked_at) < CACHE_TTL_SECS
}
}
fn cache_path() -> Option<PathBuf> {
let base = std::env::var("XDG_CACHE_HOME")
.ok()
.filter(|p| !p.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".cache")))?;
Some(base.join("isitme").join("version.json"))
}
fn read_cache() -> Option<Cache> {
let path = cache_path()?;
let data = std::fs::read_to_string(&path).ok()?;
serde_json::from_str(&data).ok()
}
fn write_cache(latest: &str) -> Option<()> {
let path = cache_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok()?;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cache = Cache {
checked_at: now,
latest: latest.to_string(),
};
let data = serde_json::to_string(&cache).ok()?;
std::fs::write(&path, data).ok()?;
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_newer_basic() {
assert!(is_newer("0.4.0", "0.5.0"));
assert!(is_newer("0.4.0", "0.4.1"));
}
#[test]
fn is_newer_false_when_same_or_older() {
assert!(!is_newer("0.4.0", "0.4.0"));
assert!(!is_newer("0.4.0", "0.3.0"));
assert!(!is_newer("0.4.0", "0.4.0"));
}
#[test]
fn is_newer_handles_double_digits() {
assert!(is_newer("0.4.0", "0.10.0"));
assert!(is_newer("0.9.9", "0.10.0"));
assert!(!is_newer("0.10.0", "0.9.9"));
}
#[test]
fn is_newer_handles_major_bumps() {
assert!(is_newer("0.4.0", "1.0.0"));
assert!(is_newer("1.9.0", "2.0.0"));
}
#[test]
fn is_newer_false_on_invalid_input() {
assert!(!is_newer("not-a-version", "0.5.0"));
assert!(!is_newer("0.4.0", "garbage"));
assert!(!is_newer("", ""));
}
#[test]
fn cache_fresh_within_24h() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let c = Cache {
checked_at: now - 100,
latest: "0.5.0".to_string(),
};
assert!(c.is_fresh());
}
#[test]
fn cache_stale_after_24h() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let c = Cache {
checked_at: now - CACHE_TTL_SECS - 1,
latest: "0.5.0".to_string(),
};
assert!(!c.is_fresh());
}
#[test]
fn cache_fresh_at_boundary() {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let c = Cache {
checked_at: now - CACHE_TTL_SECS + 1,
latest: "0.5.0".to_string(),
};
assert!(c.is_fresh());
}
}