quotas 0.8.1

CLI that auto-detects AI provider credentials (Claude, Codex, MiniMax, Kimi, Z.ai) and shows your usage quotas in a TUI or JSON.
Documentation
use crate::Result;
use chrono::{DateTime, Utc};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;

pub const UPDATE_CACHE_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const CRATES_IO_URL: &str = "https://crates.io/api/v1/crates/quotas";

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateInfo {
    pub current_version: String,
    pub latest_version: String,
    pub checked_at: DateTime<Utc>,
}

impl UpdateInfo {
    pub fn is_update_available(&self) -> bool {
        is_newer_version(&self.latest_version, &self.current_version)
    }

    pub fn summary(&self) -> String {
        format!(
            "quotas {} available (current {})",
            self.latest_version, self.current_version
        )
    }
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct UpdateCache {
    pub latest_version: String,
    pub checked_at: DateTime<Utc>,
}

impl UpdateCache {
    pub fn to_info(&self, current_version: &str) -> UpdateInfo {
        UpdateInfo {
            current_version: current_version.to_string(),
            latest_version: self.latest_version.clone(),
            checked_at: self.checked_at,
        }
    }

    pub fn is_stale_at(&self, now: DateTime<Utc>, ttl: Duration) -> bool {
        let age = now.signed_duration_since(self.checked_at);
        age.num_seconds() < 0 || age.num_seconds() as u64 >= ttl.as_secs()
    }
}

pub fn cache_path() -> Option<PathBuf> {
    let quota_cache = crate::cache::cache_path()?;
    Some(quota_cache.with_file_name("update.json"))
}

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

pub fn write_cache(cache: &UpdateCache) {
    let Some(path) = cache_path() else {
        return;
    };
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if let Ok(json) = serde_json::to_string(cache) {
        let _ = std::fs::write(path, json);
    }
}

pub fn cached_update_info(current_version: &str) -> Option<UpdateInfo> {
    let info = read_cache()?.to_info(current_version);
    info.is_update_available().then_some(info)
}

pub fn should_check_for_update(now: DateTime<Utc>) -> bool {
    match read_cache() {
        Some(cache) => cache.is_stale_at(now, UPDATE_CACHE_TTL),
        None => true,
    }
}

pub async fn fetch_latest_version(client: &Client) -> Result<String> {
    let body: serde_json::Value = client
        .get(CRATES_IO_URL)
        .header("User-Agent", "quotas-cli update-check")
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;
    parse_latest_version(&body)
        .ok_or_else(|| crate::Error::Provider("crates.io response missing max_version".into()))
}

pub async fn refresh_update_cache(current_version: &str) -> Result<UpdateInfo> {
    let client = Client::new();
    let latest_version = fetch_latest_version(&client).await?;
    let cache = UpdateCache {
        latest_version: latest_version.clone(),
        checked_at: Utc::now(),
    };
    write_cache(&cache);
    Ok(cache.to_info(current_version))
}

pub fn is_newer_version(latest: &str, current: &str) -> bool {
    let Some(latest) = parse_stable_version(latest) else {
        return false;
    };
    let Some(current) = parse_stable_version(current) else {
        return false;
    };
    latest > current
}

pub fn parse_latest_version(body: &serde_json::Value) -> Option<String> {
    body.get("crate")?
        .get("max_version")?
        .as_str()
        .map(str::to_string)
}

fn parse_stable_version(version: &str) -> Option<Vec<u64>> {
    if version.contains('-') {
        return None;
    }
    let mut parts = Vec::new();
    for part in version.split('.') {
        parts.push(part.parse::<u64>().ok()?);
    }
    if parts.is_empty() {
        None
    } else {
        Some(parts)
    }
}

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

    #[test]
    fn compares_semver_like_versions() {
        assert!(is_newer_version("0.8.2", "0.8.1"));
        assert!(is_newer_version("0.9.0", "0.8.9"));
        assert!(is_newer_version("1.0.0", "0.99.99"));
        assert!(!is_newer_version("0.8.1", "0.8.1"));
        assert!(!is_newer_version("0.8.1", "0.8.2"));
    }

    #[test]
    fn ignores_prerelease_as_update_for_stable_current() {
        assert!(!is_newer_version("0.8.2-alpha.1", "0.8.1"));
    }

    #[test]
    fn parses_crates_io_latest_version() {
        let body = serde_json::json!({
            "crate": {"max_version": "0.8.2"},
            "versions": []
        });

        assert_eq!(parse_latest_version(&body).as_deref(), Some("0.8.2"));
    }

    #[test]
    fn stale_when_ttl_elapsed() {
        let now = Utc::now();
        let cache = UpdateCache {
            latest_version: "0.8.1".into(),
            checked_at: now - chrono::Duration::hours(25),
        };

        assert!(cache.is_stale_at(now, UPDATE_CACHE_TTL));
    }

    #[test]
    fn fresh_before_ttl_elapsed() {
        let now = Utc::now();
        let cache = UpdateCache {
            latest_version: "0.8.1".into(),
            checked_at: now - chrono::Duration::hours(23),
        };

        assert!(!cache.is_stale_at(now, UPDATE_CACHE_TTL));
    }
}