quotch 0.5.5

Fast cross-platform CLI for AI coding-agent usage limits
use std::time::Duration;

use chrono::{DateTime, Utc};
use serde_json::Value;

use crate::model::{Account, CredentialSource, Snapshot, Status, Unit, Window, WindowKind};
use crate::providers::{FetchError, Provider};

const USER_URL: &str = "https://api.github.com/copilot_internal/user";

pub struct Copilot;

impl Provider for Copilot {
    fn id(&self) -> &'static str {
        "copilot"
    }

    fn discover(&self) -> Vec<Account> {
        // Emit the default account only when apps.json exists. Unlike claude, a
        // wholly absent creds file here means the user doesn't use Copilot at
        // all, so we stay silent rather than nag with an auth_missing line. A
        // present-but-broken file still surfaces as auth_missing via fetch.
        let path = std::env::home_dir()
            .unwrap_or_default()
            .join(".config")
            .join("github-copilot")
            .join("apps.json");
        if !path.exists() {
            return vec![];
        }
        vec![Account {
            provider: "copilot",
            id: "default".into(),
            source: CredentialSource::FilePath(path),
            label: None,
            display: None,
        }]
    }

    fn fetch(&self, acct: &Account) -> Result<Snapshot, FetchError> {
        let path = match &acct.source {
            CredentialSource::FilePath(p) => p,
            _ => return Err(FetchError::AuthMissing),
        };

        // Missing file / unreadable / bad JSON all collapse to AuthMissing.
        let apps: Value = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .ok_or(FetchError::AuthMissing)?;

        let token = oauth_token(&apps)?;

        // ponytail: same 3s connect / 8s overall split as claude.rs — a cold TLS
        // handshake on a slow link can exceed a tighter overall timeout and pin
        // the cache stale. Warm runs never pay this; speed lives in the cache.
        let agent = ureq::AgentBuilder::new()
            .timeout_connect(Duration::from_secs(3))
            .timeout(Duration::from_secs(8))
            .build();

        // GitHub rejects requests without a User-Agent. token is used ONLY in the
        // Authorization header, never logged or stored.
        let resp = match agent
            .get(USER_URL)
            .set("Authorization", &format!("token {token}"))
            .set("User-Agent", concat!("quotch/", env!("CARGO_PKG_VERSION")))
            .set("Editor-Version", "vscode/1.100.0")
            .call()
        {
            Ok(r) => r,
            Err(ureq::Error::Status(code, _)) => {
                return Err(match code {
                    401 | 403 => FetchError::AuthMissing,
                    429 => FetchError::RateLimited,
                    other => FetchError::Network(format!("http {other}")),
                });
            }
            Err(ureq::Error::Transport(t)) => return Err(FetchError::Network(t.to_string())),
        };

        let body = resp
            .into_string()
            .map_err(|e| FetchError::Network(e.to_string()))?;
        let json: Value =
            serde_json::from_str(&body).map_err(|e| FetchError::Parse(e.to_string()))?;

        Ok(Snapshot {
            provider: "copilot".into(),
            account: acct.id.clone(),
            label: acct.label.clone(),
            plan: json["copilot_plan"].as_str().map(str::to_string),
            windows: parse_windows(&json),
            fetched_at: Utc::now(),
            status: Status::Ok,
            error: None,
            raw: Some(json),
        })
    }
}

// Extracted from `fetch` so the apps.json parsing is testable without a live
// HTTP call. apps.json is keyed like "github.com:<client_id>" → { oauth_token }.
// Take the first github.com entry's token; any missing piece is AuthMissing.
fn oauth_token(apps: &Value) -> Result<String, FetchError> {
    apps.as_object()
        .and_then(|obj| obj.iter().find(|(k, _)| k.starts_with("github.com")))
        .and_then(|(_, v)| v["oauth_token"].as_str())
        .map(str::to_string)
        .ok_or(FetchError::AuthMissing)
}

fn parse_windows(raw: &Value) -> Vec<Window> {
    let Some(snapshots) = raw.get("quota_snapshots").and_then(Value::as_object) else {
        return vec![];
    };

    // resets_at is a single top-level field shared by every window. Prefer the
    // RFC3339 quota_reset_date_utc; fall back to the date-only quota_reset_date.
    let resets_at = parse_rfc3339(&raw["quota_reset_date_utc"]).or_else(|| {
        raw["quota_reset_date"]
            .as_str()
            .and_then(|d| DateTime::parse_from_rfc3339(&format!("{d}T00:00:00Z")).ok())
            .map(|dt| dt.with_timezone(&Utc))
    });

    // Skip unlimited entries (zero signal). Deterministic order:
    // premium_interactions first if present, then the rest alphabetically.
    let mut keys: Vec<&String> = snapshots
        .iter()
        .filter(|(_, v)| v["unlimited"].as_bool() != Some(true))
        .map(|(k, _)| k)
        .collect();
    keys.sort_by(|a, b| {
        let rank = |k: &str| usize::from(k != "premium_interactions");
        rank(a).cmp(&rank(b)).then_with(|| a.cmp(b))
    });

    keys.into_iter()
        .filter_map(|k| snapshot_to_window(k, &snapshots[k], resets_at))
        .collect()
}

fn snapshot_to_window(key: &str, snap: &Value, resets_at: Option<DateTime<Utc>>) -> Option<Window> {
    // No percent_remaining → no usage signal; skip rather than fabricate 0%.
    // Clamp: percent_remaining > 100 must not yield a negative used_pct (schema
    // minimum is 0). Over-100 overage stays allowed.
    let used_pct = (100.0 - snap["percent_remaining"].as_f64()?).max(0.0);
    let window_key = if key == "premium_interactions" {
        "monthly".to_string()
    } else {
        format!("monthly:{key}")
    };
    Some(Window {
        key: window_key,
        kind: WindowKind::Calendar,
        unit: Unit::Requests,
        used_pct,
        used: snap["credits_used"].as_f64(),
        limit: snap["entitlement"].as_f64(),
        unlimited: false,
        resets_at,
    })
}

fn parse_rfc3339(v: &Value) -> Option<DateTime<Utc>> {
    v.as_str()
        .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
        .map(|dt| dt.with_timezone(&Utc))
}

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

    const FIXTURE: &str = include_str!(concat!(
        env!("CARGO_MANIFEST_DIR"),
        "/tests/fixtures/copilot_user.json"
    ));

    fn fixture() -> Value {
        serde_json::from_str(FIXTURE).unwrap()
    }

    fn expected_reset() -> DateTime<Utc> {
        DateTime::parse_from_rfc3339("2026-08-01T00:00:00Z")
            .unwrap()
            .with_timezone(&Utc)
    }

    #[test]
    fn parses_premium_interactions_window() {
        let w = parse_windows(&fixture());
        assert_eq!(w.len(), 1);
        assert_eq!(w[0].key, "monthly");
        assert_eq!(w[0].kind, WindowKind::Calendar);
        assert_eq!(w[0].unit, Unit::Requests);
        assert_eq!(w[0].used_pct, 11.0);
        assert_eq!(w[0].used, Some(2729.0));
        assert_eq!(w[0].limit, Some(25000.0));
        assert_eq!(w[0].resets_at, Some(expected_reset()));
    }

    #[test]
    fn skips_entry_missing_percent_remaining() {
        let v = serde_json::json!({
            "quota_reset_date_utc": "2026-08-01T00:00:00.000Z",
            "quota_snapshots": {
                "premium_interactions": {
                    "unlimited": false, "percent_remaining": 89.0,
                    "credits_used": 2729, "entitlement": 25000
                },
                "weird": { "unlimited": false, "credits_used": 5, "entitlement": 10 }
            }
        });
        let w = parse_windows(&v);
        assert_eq!(w.len(), 1);
        assert_eq!(w[0].key, "monthly");
    }

    #[test]
    fn all_unlimited_yields_empty() {
        let v = serde_json::json!({
            "quota_snapshots": {
                "chat": { "unlimited": true, "percent_remaining": 100.0 },
                "completions": { "unlimited": true, "percent_remaining": 100.0 }
            }
        });
        assert!(parse_windows(&v).is_empty());
    }

    #[test]
    fn used_pct_clamped_at_zero_when_over_full() {
        // percent_remaining > 100 would give a negative used_pct without the clamp.
        let v = serde_json::json!({
            "quota_snapshots": {
                "premium_interactions": { "unlimited": false, "percent_remaining": 150.0 }
            }
        });
        let w = parse_windows(&v);
        assert_eq!(w.len(), 1);
        assert_eq!(w[0].used_pct, 0.0);
    }

    #[test]
    fn falls_back_to_date_only_reset() {
        let mut v = fixture();
        v.as_object_mut().unwrap().remove("quota_reset_date_utc");
        let w = parse_windows(&v);
        assert_eq!(w.len(), 1);
        assert_eq!(w[0].resets_at, Some(expected_reset()));
    }
}