quotch 0.5.2

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 USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
const OAUTH_BETA: &str = "oauth-2025-04-20";

pub struct Claude;

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

    fn discover(&self) -> Vec<Account> {
        // Emit the default account only when the ~/.claude dir exists. A missing
        // or expired credentials FILE still renders as auth_missing via fetch —
        // only a wholly absent ~/.claude dir (user doesn't use Claude Code) hides
        // the provider, so we don't nag.
        let dir = std::env::home_dir().unwrap_or_default().join(".claude");
        if !dir.is_dir() {
            return vec![];
        }
        let path = dir.join(".credentials.json");
        vec![Account {
            provider: "claude",
            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 creds: Value = std::fs::read_to_string(path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .ok_or(FetchError::AuthMissing)?;

        let (token, plan) = parse_creds(&creds)?;

        // ponytail: TLS handshake alone can take >5s on slow links; a 2s overall
        // timeout made every cold fetch fail and pinned the cache stale. Warm runs
        // never pay this — speed lives in the cache path.
        let agent = ureq::AgentBuilder::new()
            .timeout_connect(Duration::from_secs(3))
            .timeout(Duration::from_secs(8))
            .build();

        // token is used ONLY in the Authorization header, never logged or stored.
        let resp = match agent
            .get(USAGE_URL)
            .set("Authorization", &format!("Bearer {token}"))
            .set("anthropic-beta", OAUTH_BETA)
            .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: "claude".into(),
            account: acct.id.clone(),
            label: acct.label.clone(),
            plan,
            windows: parse_windows(&json),
            fetched_at: Utc::now(),
            status: Status::Ok,
            error: None,
            raw: Some(json),
        })
    }
}

// Extracted from `fetch` so the creds-parsing/expiry logic is testable without
// a live HTTP call.
fn parse_creds(creds: &Value) -> Result<(String, Option<String>), FetchError> {
    let oauth = &creds["claudeAiOauth"];
    let token = oauth["accessToken"]
        .as_str()
        .ok_or(FetchError::AuthMissing)?;
    let expires_at = oauth["expiresAt"].as_i64().ok_or(FetchError::AuthMissing)?;
    if expires_at <= Utc::now().timestamp_millis() {
        return Err(FetchError::AuthMissing);
    }
    let plan = oauth["subscriptionType"].as_str().map(str::to_string);
    Ok((token.to_string(), plan))
}

fn parse_windows(raw: &Value) -> Vec<Window> {
    // PRIMARY: the `limits` array. Only fall back when it is absent/null/empty.
    if let Some(limits) = raw.get("limits").and_then(Value::as_array)
        && !limits.is_empty()
    {
        return limits.iter().filter_map(limit_to_window).collect();
    }
    fallback_windows(raw)
}

fn limit_to_window(entry: &Value) -> Option<Window> {
    // A percent-less limit is meaningless data — skip it rather than fake a 0%.
    let used_pct = entry["percent"].as_f64()?;
    let kind = entry["kind"].as_str().unwrap_or_default();
    let key = match kind {
        "session" => "5h".to_string(),
        "weekly_all" => "7d".to_string(),
        "weekly_scoped" => match entry["scope"]["model"]["display_name"].as_str() {
            Some(name) => format!("7d:{}", name.to_lowercase()),
            None => "7d:scoped".to_string(),
        },
        // Unknown kinds pass through verbatim — never drop a window.
        other => other.to_string(),
    };
    Some(Window {
        key,
        kind: WindowKind::Rolling,
        unit: Unit::Percent,
        used_pct,
        used: None,
        limit: None,
        unlimited: false,
        resets_at: parse_rfc3339(&entry["resets_at"]),
    })
}

fn fallback_windows(raw: &Value) -> Vec<Window> {
    // Older flat shape. seven_day_sonnet/opus are commonly null → skip them.
    const FIELDS: [(&str, &str); 4] = [
        ("five_hour", "5h"),
        ("seven_day", "7d"),
        ("seven_day_sonnet", "7d:sonnet"),
        ("seven_day_opus", "7d:opus"),
    ];
    FIELDS
        .iter()
        .filter_map(|&(field, key)| {
            let obj = raw.get(field)?;
            let used_pct = obj.get("utilization")?.as_f64()?;
            Some(Window {
                key: key.to_string(),
                kind: WindowKind::Rolling,
                unit: Unit::Percent,
                used_pct,
                used: None,
                limit: None,
                unlimited: false,
                resets_at: parse_rfc3339(&obj["resets_at"]),
            })
        })
        .collect()
}

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/claude_usage.json"
    ));

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

    #[test]
    fn parses_limits_array() {
        let w = parse_windows(&fixture());
        assert_eq!(w.len(), 3);

        assert_eq!(w[0].key, "5h");
        assert_eq!(w[0].used_pct, 19.0);
        assert!(w[0].resets_at.is_some());

        assert_eq!(w[1].key, "7d");
        assert_eq!(w[1].used_pct, 21.0);
        assert!(w[1].resets_at.is_some());

        assert_eq!(w[2].key, "7d:fable");
        assert_eq!(w[2].used_pct, 35.0);
        assert!(w[2].resets_at.is_some());
    }

    #[test]
    fn falls_back_to_flat_shape() {
        let mut v = fixture();
        v.as_object_mut().unwrap().remove("limits");
        let w = parse_windows(&v);
        assert_eq!(w.len(), 2);
        assert_eq!(w[0].key, "5h");
        assert_eq!(w[0].used_pct, 19.0);
        assert_eq!(w[1].key, "7d");
        assert_eq!(w[1].used_pct, 21.0);
    }

    #[test]
    fn keeps_unknown_kind() {
        let v = serde_json::json!({ "limits": [ { "kind": "lunar_cycle", "percent": 5 } ] });
        let w = parse_windows(&v);
        assert_eq!(w.len(), 1);
        assert_eq!(w[0].key, "lunar_cycle");
        assert_eq!(w[0].used_pct, 5.0);
    }

    #[test]
    fn skips_limit_missing_percent() {
        let v = serde_json::json!({ "limits": [
            { "kind": "session", "percent": 19 },
            { "kind": "weekly_all" }
        ] });
        let w = parse_windows(&v);
        assert_eq!(w.len(), 1);
        assert_eq!(w[0].key, "5h");
    }

    #[test]
    fn creds_missing_expires_at_is_auth_missing() {
        let creds = serde_json::json!({
            "claudeAiOauth": {
                "accessToken": "sk-test-token"
            }
        });
        assert!(matches!(parse_creds(&creds), Err(FetchError::AuthMissing)));
    }
}