pygmy 0.3.0

Ping me — notifications from AI agents (Telegram, Discord)
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Context;
use serde::{Deserialize, Serialize};

const GITHUB_REPO: &str = "flipbit03/pygmy";
const CACHE_TTL_SECS: u64 = 24 * 60 * 60;

#[derive(Debug, Serialize, Deserialize)]
struct VersionCache {
    checked_at: u64,
    latest_version: String,
}

fn cache_path() -> Option<PathBuf> {
    let cache_dir = std::env::var("XDG_CACHE_HOME")
        .ok()
        .map(PathBuf::from)
        .or_else(|| home::home_dir().map(|h| h.join(".cache")));
    cache_dir.map(|d| d.join("pygmy").join("latest_version_check.json"))
}

fn read_cache() -> Option<VersionCache> {
    let path = cache_path()?;
    let data = std::fs::read_to_string(&path).ok()?;
    serde_json::from_str(&data).ok()
}

fn write_cache(cache: &VersionCache) {
    if let Some(path) = cache_path() {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        let _ = std::fs::write(
            &path,
            serde_json::to_string_pretty(cache).unwrap_or_default(),
        );
    }
}

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

async fn fetch_latest_version() -> anyhow::Result<String> {
    let url = format!("https://api.github.com/repos/{GITHUB_REPO}/releases/latest");
    let client = reqwest::Client::new();
    let resp: serde_json::Value = client
        .get(&url)
        .header("User-Agent", "pygmy-self-update")
        .send()
        .await
        .context("failed to reach GitHub API")?
        .error_for_status()
        .context("GitHub API returned an error")?
        .json()
        .await
        .context("failed to parse GitHub API response")?;

    let tag = resp["tag_name"]
        .as_str()
        .context("no tag_name in GitHub release")?;
    Ok(tag.strip_prefix('v').unwrap_or(tag).to_string())
}

pub async fn get_latest_version(force: bool) -> Option<String> {
    if !force
        && let Some(cache) = read_cache()
        && now_secs().saturating_sub(cache.checked_at) < CACHE_TTL_SECS
    {
        return Some(cache.latest_version);
    }

    match fetch_latest_version().await {
        Ok(version) => {
            write_cache(&VersionCache {
                checked_at: now_secs(),
                latest_version: version.clone(),
            });
            Some(version)
        }
        Err(_) => read_cache().map(|c| c.latest_version),
    }
}

pub fn get_cached_version() -> Option<String> {
    let cache = read_cache()?;
    if now_secs().saturating_sub(cache.checked_at) < CACHE_TTL_SECS {
        Some(cache.latest_version)
    } else {
        None
    }
}

pub fn current_version() -> &'static str {
    env!("CARGO_PKG_VERSION")
}

pub fn is_dev_build() -> bool {
    current_version() == "0.0.0"
}

pub fn is_newer(current: &str, latest: &str) -> bool {
    match (
        semver::Version::parse(current),
        semver::Version::parse(latest),
    ) {
        (Ok(c), Ok(l)) => l > c,
        _ => latest != current,
    }
}

pub fn release_asset_url(tag: &str) -> anyhow::Result<String> {
    let os_tag = match std::env::consts::OS {
        "linux" => "linux",
        "macos" => "macos",
        _ => anyhow::bail!("unsupported OS: {}", std::env::consts::OS),
    };
    let arch_tag = match std::env::consts::ARCH {
        "x86_64" => "x86_64",
        "aarch64" => "aarch64",
        _ => anyhow::bail!("unsupported architecture: {}", std::env::consts::ARCH),
    };

    if os_tag == "macos" && arch_tag == "x86_64" {
        anyhow::bail!("macOS x86_64 binaries are not provided — use `cargo install pygmy`");
    }

    Ok(format!(
        "https://github.com/{GITHUB_REPO}/releases/download/v{tag}/pygmy_{os_tag}_{arch_tag}"
    ))
}