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> {
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),
};
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)?;
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(3))
.timeout(Duration::from_secs(8))
.build();
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),
})
}
}
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> {
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> {
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(),
},
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> {
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)));
}
}