use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::mpsc::Sender;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::sampler::Snapshot;
const MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
const USER_AGENT: &str =
concat!("whirr/", env!("CARGO_PKG_VERSION"), " (https://github.com/scoobynko/whirr)");
const ENDPOINT: &str = "https://crates.io/api/v1/crates/whirr";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Update {
pub latest: String,
pub hint: &'static str,
}
pub fn parse_latest(json: &str) -> Option<String> {
let key = "\"max_stable_version\":";
let rest = json.find(key).map(|i| &json[i + key.len()..])?;
let rest = rest.trim_start();
let inner = rest.strip_prefix('"')?;
let end = inner.find('"')?;
let version = &inner[..end];
(!version.is_empty()).then(|| version.to_string())
}
pub fn is_newer(current: &str, latest: &str) -> bool {
fn parts(v: &str) -> Option<Vec<u64>> {
v.split(['-', '+']).next()?.split('.').map(|p| p.parse().ok()).collect()
}
match (parts(current), parts(latest)) {
(Some(c), Some(l)) => l > c,
_ => false,
}
}
pub fn upgrade_hint(exe: &Path) -> &'static str {
let p = exe.to_string_lossy();
if p.contains("/Cellar/") || p.contains("/homebrew/") || p.contains("/linuxbrew/") {
"brew update && brew upgrade whirr"
} else if p.contains("/.cargo/") {
"cargo install whirr --force"
} else {
"see github.com/scoobynko/whirr/releases"
}
}
fn cache_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?;
Some(base.join("whirr").join("update-check"))
}
fn encode_cache(checked_at: u64, latest: &str) -> String {
format!("{checked_at}\n{latest}\n")
}
fn decode_cache(text: &str) -> Option<(u64, String)> {
let mut lines = text.lines();
let at: u64 = lines.next()?.trim().parse().ok()?;
let version = lines.next()?.trim();
(!version.is_empty()).then(|| (at, version.to_string()))
}
fn now_secs() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
}
fn cached_fresh(now: u64) -> Option<String> {
let text = std::fs::read_to_string(cache_path()?).ok()?;
let (at, version) = decode_cache(&text)?;
(now >= at && now - at < MAX_AGE.as_secs()).then_some(version)
}
fn write_cache(now: u64, latest: &str) {
let Some(path) = cache_path() else { return };
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let _ = std::fs::write(path, encode_cache(now, latest));
}
fn fetch() -> Option<String> {
let out = Command::new("curl")
.args(["-sS", "--max-time", "5", "-A", USER_AGENT, ENDPOINT])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_latest(&String::from_utf8_lossy(&out.stdout))
}
pub fn spawn(tx: Sender<Snapshot>) {
std::thread::spawn(move || {
let now = now_secs();
let latest = match cached_fresh(now) {
Some(v) => v,
None => {
let v = fetch()?;
write_cache(now, &v);
v
}
};
if !is_newer(env!("CARGO_PKG_VERSION"), &latest) {
return None;
}
let hint = std::env::current_exe()
.map(|p| upgrade_hint(&p))
.unwrap_or("see github.com/scoobynko/whirr/releases");
tx.send(Snapshot::Update(Update { latest, hint })).ok()
});
}
#[cfg(test)]
mod tests {
use super::*;
const PAYLOAD: &str = r#"{"categories":[],"crate":{"id":"whirr","name":"whirr",
"newest_version":"0.4.0-rc.1","max_stable_version":"0.3.6","description":"x"}}"#;
#[test]
fn the_latest_stable_version_is_read_from_a_crates_io_payload() {
assert_eq!(parse_latest(PAYLOAD).as_deref(), Some("0.3.6"));
}
#[test]
fn a_crate_with_no_stable_release_reports_nothing() {
assert_eq!(parse_latest(r#"{"crate":{"max_stable_version":null}}"#), None);
assert_eq!(parse_latest(r#"{"crate":{"max_stable_version":""}}"#), None);
}
#[test]
fn a_response_that_is_not_the_expected_shape_reports_nothing() {
assert_eq!(parse_latest(""), None);
assert_eq!(parse_latest("<html>503 Service Unavailable</html>"), None);
assert_eq!(parse_latest(r#"{"errors":[{"detail":"Not Found"}]}"#), None);
}
#[test]
fn a_higher_version_is_newer_and_nothing_else_is() {
assert!(is_newer("0.3.5", "0.3.6"));
assert!(is_newer("0.3.5", "0.4.0"));
assert!(is_newer("0.3.5", "1.0.0"));
assert!(!is_newer("0.3.5", "0.3.5"));
assert!(!is_newer("0.3.5", "0.3.4"));
assert!(!is_newer("1.0.0", "0.9.9"));
}
#[test]
fn versions_are_compared_as_numbers_not_strings() {
assert!(is_newer("0.3.9", "0.3.10"));
assert!(!is_newer("0.3.10", "0.3.9"));
}
#[test]
fn an_unparseable_version_never_claims_an_update() {
assert!(!is_newer("0.3.5", "not-a-version"));
assert!(!is_newer("garbage", "0.3.6"));
assert!(!is_newer("0.3.5", ""));
}
#[test]
fn the_upgrade_hint_matches_how_this_copy_was_installed() {
let brew = upgrade_hint(Path::new("/opt/homebrew/Cellar/whirr/0.3.5/bin/whirr"));
assert!(brew.contains("brew"), "homebrew install should get a brew command");
let linked = upgrade_hint(Path::new("/opt/homebrew/bin/whirr"));
assert!(linked.contains("brew"), "the symlinked path is a homebrew install too");
let cargo = upgrade_hint(Path::new("/Users/me/.cargo/bin/whirr"));
assert!(cargo.contains("cargo install"), "cargo install should get a cargo command");
}
#[test]
fn an_unrecognised_location_gets_a_neutral_hint() {
let hint = upgrade_hint(Path::new("/usr/local/bin/whirr"));
assert!(!hint.contains("brew"), "must not guess homebrew: {hint}");
assert!(!hint.contains("cargo"), "must not guess cargo: {hint}");
assert!(hint.contains("releases"), "should point somewhere useful: {hint}");
}
#[test]
fn a_cache_entry_survives_a_round_trip() {
let text = encode_cache(1_700_000_000, "0.3.6");
assert_eq!(decode_cache(&text), Some((1_700_000_000, "0.3.6".to_string())));
}
#[test]
fn a_damaged_cache_is_rejected_rather_than_misread() {
assert_eq!(decode_cache(""), None);
assert_eq!(decode_cache("not-a-timestamp\n0.3.6\n"), None);
assert_eq!(decode_cache("1700000000\n"), None);
assert_eq!(decode_cache("1700000000\n\n"), None);
}
}