use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::ui;
const FRESH_FOR: Duration = Duration::from_secs(24 * 60 * 60);
const GIVE_UP_AFTER: Duration = Duration::from_millis(1_500);
const OPT_OUT: &str = "PORTAKI_NO_UPDATE_CHECK";
pub async fn notify() {
if !wanted() {
return;
}
let Some(latest) = latest().await else {
return;
};
let running = env!("CARGO_PKG_VERSION");
if !outdated(running, &latest) {
return;
}
ui::blank();
ui::warn(format!("portaki {running} → {latest} is available"));
ui::detail("cargo install portaki-cli --locked --force");
ui::detail(format!("{OPT_OUT}=1 silences this"));
}
fn wanted() -> bool {
!ui::plain() && console::user_attended() && std::env::var_os(OPT_OUT).is_none()
}
async fn latest() -> Option<String> {
if let Some(cached) = read_cache() {
return Some(cached);
}
let fetched = tokio::time::timeout(GIVE_UP_AFTER, ask_crates_io())
.await
.ok()?
.ok()?;
write_cache(&fetched);
Some(fetched)
}
async fn ask_crates_io() -> Result<String, reqwest::Error> {
let body: serde_json::Value = reqwest::Client::new()
.get("https://crates.io/api/v1/crates/portaki-cli")
.header(
"User-Agent",
concat!("portaki-cli/", env!("CARGO_PKG_VERSION")),
)
.send()
.await?
.json()
.await?;
Ok(body
.pointer("/crate/max_stable_version")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string())
}
fn read_cache() -> Option<String> {
parse_cache(&std::fs::read_to_string(cache_path()?).ok()?, now())
}
fn parse_cache(raw: &str, now: Duration) -> Option<String> {
let (stamped, version) = raw.trim().split_once(' ')?;
let stamped = Duration::from_secs(stamped.parse().ok()?);
if version.is_empty() || now.checked_sub(stamped)? > FRESH_FOR {
return None;
}
Some(version.to_string())
}
fn write_cache(version: &str) {
let Some(path) = cache_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&path, format!("{} {version}", now().as_secs()));
}
fn cache_path() -> Option<std::path::PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|home| std::path::PathBuf::from(home).join(".cache"))
})?;
Some(base.join("portaki").join("latest-version"))
}
fn now() -> Duration {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
}
pub fn outdated(running: &str, latest: &str) -> bool {
parts(latest) > parts(running)
}
fn parts(version: &str) -> Vec<u64> {
version
.split('-')
.next()
.unwrap_or(version)
.split('.')
.map(|part| part.parse().unwrap_or(0))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_compare_as_numbers_not_as_text() {
assert!(outdated("2.9.0", "2.10.0"));
assert!(!outdated("2.10.0", "2.9.0"));
assert!(!outdated("2.4.0", "2.4.0"));
}
#[test]
fn a_prerelease_suffix_is_ignored() {
assert!(!outdated("2.5.0", "2.5.0-rc.1"));
}
const NOW: Duration = Duration::from_secs(1_000_000);
#[test]
fn a_fresh_cache_answers() {
let written = NOW.as_secs() - 60;
assert_eq!(
parse_cache(&format!("{written} 2.4.0"), NOW).as_deref(),
Some("2.4.0")
);
}
#[test]
fn a_stale_cache_asks_again() {
let written = NOW.as_secs() - FRESH_FOR.as_secs() - 1;
assert!(parse_cache(&format!("{written} 2.4.0"), NOW).is_none());
}
#[test]
fn a_damaged_cache_reads_as_no_answer() {
for raw in ["", " ", "n importe quoi", "pas-un-nombre 2.4.0", "1000 "] {
assert!(parse_cache(raw, NOW).is_none(), "accepté : {raw:?}");
}
}
#[test]
fn a_cache_written_in_the_future_is_asked_again() {
let written = NOW.as_secs() + 10;
assert!(parse_cache(&format!("{written} 2.4.0"), NOW).is_none());
}
}