pbox 0.1.7

Disposable Proxmox LXC workspaces for development
use serde::Deserialize;
use std::{
    fs,
    time::{Duration, SystemTime, UNIX_EPOCH},
};

const CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);

#[derive(Debug, Deserialize, serde::Serialize)]
struct Cache {
    checked_at: u64,
    latest: String,
}

#[derive(Debug, Deserialize)]
struct CratesResponse {
    #[serde(rename = "crate")]
    crate_info: CrateInfo,
}

#[derive(Debug, Deserialize)]
struct CrateInfo {
    max_version: String,
}

pub(crate) fn maybe_notify(json: bool) {
    if json || !should_check() {
        return;
    }
    let Some(cache_path) = cache_path() else {
        return;
    };
    if let Some(cache) = read_cache(&cache_path)
        && now().saturating_sub(cache.checked_at) < CHECK_INTERVAL.as_secs()
    {
        notify(&cache.latest);
        return;
    }

    let result = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(2))
        .user_agent(format!("pbox/{}", env!("CARGO_PKG_VERSION")))
        .build()
        .ok()
        .and_then(|client| {
            client
                .get("https://crates.io/api/v1/crates/pbox")
                .send()
                .ok()
        })
        .and_then(|response| response.error_for_status().ok())
        .and_then(|response| response.json::<CratesResponse>().ok());

    let Some(response) = result else {
        return;
    };
    let cache = Cache {
        checked_at: now(),
        latest: response.crate_info.max_version,
    };
    let _ = fs::create_dir_all(
        cache_path
            .parent()
            .unwrap_or_else(|| std::path::Path::new(".")),
    );
    let _ = fs::write(&cache_path, serde_json::to_vec(&cache).unwrap_or_default());
    notify(&cache.latest);
}

fn should_check() -> bool {
    std::env::var_os("PBOX_NO_UPDATE_CHECK").is_none()
}

fn cache_path() -> Option<std::path::PathBuf> {
    dirs::cache_dir().map(|path| path.join("pbox/update-check.json"))
}

fn read_cache(path: &std::path::Path) -> Option<Cache> {
    serde_json::from_slice(&fs::read(path).ok()?).ok()
}

fn now() -> u64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default()
        .as_secs()
}

fn notify(latest: &str) {
    if newer_than_current(latest) {
        crate::ui::stderr().hint(&format!(
            "pbox {latest} is available; run `pbox update` to install it."
        ));
    }
}

fn newer_than_current(version: &str) -> bool {
    parse_version(version) > parse_version(env!("CARGO_PKG_VERSION"))
}

fn parse_version(version: &str) -> (u64, u64, u64) {
    let mut parts = version.split('.').map(|part| {
        part.chars()
            .take_while(char::is_ascii_digit)
            .collect::<String>()
            .parse::<u64>()
            .unwrap_or(0)
    });
    (
        parts.next().unwrap_or(0),
        parts.next().unwrap_or(0),
        parts.next().unwrap_or(0),
    )
}

#[cfg(test)]
mod tests {
    use super::parse_version;

    #[test]
    fn parses_published_versions() {
        assert!(parse_version("1.2.3") > parse_version("1.2.2"));
        assert_eq!(parse_version("0.1.1-beta.1"), (0, 1, 1));
    }
}