use std::path::{Path, PathBuf};
use std::sync::mpsc;
#[derive(Debug, Clone, Default)]
pub struct ClaudeAccountUsage {
pub name: String,
pub usage: ClaudeUsage,
pub is_active: bool,
pub email: Option<String>,
pub org_name: Option<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ClaudeUsage {
pub percent: u16,
pub weekly_percent: u16,
pub resets_at: u64,
pub weekly_resets_at: u64,
pub scoped_limits: Vec<ScopedLimit>,
pub tokens_5h: u64,
pub fetched_at: u64,
pub last_error: Option<String>,
pub retry_after_at: u64,
}
#[derive(Debug, Clone, Default)]
pub struct ScopedLimit {
pub model_display_name: String,
pub percent: u16,
pub resets_at: u64,
}
#[derive(Debug, Clone, Default)]
pub struct CodexUsage {
pub tokens_today: u64,
pub sessions_today: u64,
pub fetched_at: u64,
pub last_error: Option<String>,
}
pub fn claude_token_path() -> Option<PathBuf> {
Some(crate::data_root::data_root().join("ai_token"))
}
pub fn read_claude_token() -> Option<String> {
let path = claude_token_path()?;
let raw = std::fs::read_to_string(path).ok()?;
let s = raw.trim();
if s.is_empty() {
return None;
}
if s.starts_with('{') {
let v: serde_json::Value = serde_json::from_str(s).ok()?;
let inner = v.get("claudeAiOauth").unwrap_or(&v);
let token = inner.get("accessToken")?.as_str()?.trim().to_string();
if token.is_empty() { None } else { Some(token) }
} else {
Some(s.to_string())
}
}
pub fn read_claude_refresh_token() -> Option<String> {
let path = claude_token_path()?;
read_refresh_token_at(&path)
}
pub fn write_claude_token(token: &str) -> Result<PathBuf, String> {
let path = claude_token_path().ok_or_else(|| "no $HOME".to_string())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
let trimmed = token.trim();
let to_write = if trimmed.starts_with('{') {
serde_json::from_str::<serde_json::Value>(trimmed)
.and_then(|v| serde_json::to_string_pretty(&v))
.unwrap_or_else(|_| trimmed.to_string())
} else {
trimmed.to_string()
};
write_secret_file(&path, to_write.as_bytes())?;
Ok(path)
}
pub fn write_claude_token_to(path: &Path, token: &str) -> Result<PathBuf, String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("mkdir: {e}"))?;
}
let trimmed = token.trim();
let to_write = if trimmed.starts_with('{') {
serde_json::from_str::<serde_json::Value>(trimmed)
.and_then(|v| serde_json::to_string_pretty(&v))
.unwrap_or_else(|_| trimmed.to_string())
} else {
trimmed.to_string()
};
write_secret_file(path, to_write.as_bytes())?;
Ok(path.to_path_buf())
}
fn read_keychain_claude_token_blocking() -> Option<String> {
#[cfg(target_os = "macos")]
{
let out = std::process::Command::new("security")
.args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() { None } else { Some(raw) }
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
fn parse_access_token(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() {
return None;
}
if s.starts_with('{') {
let v: serde_json::Value = serde_json::from_str(s).ok()?;
let inner = v.get("claudeAiOauth").unwrap_or(&v);
let token = inner.get("accessToken")?.as_str()?.trim().to_string();
if token.is_empty() { None } else { Some(token) }
} else {
Some(s.to_string())
}
}
fn write_secret_file(path: &Path, bytes: &[u8]) -> Result<(), String> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut f = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("open: {e}"))?;
f.write_all(bytes).map_err(|e| format!("write: {e}"))?;
f.flush().map_err(|e| format!("flush: {e}"))?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, bytes).map_err(|e| format!("write: {e}"))
}
}
const CLAUDE_OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
fn try_refresh_claude_token(
client: &reqwest::blocking::Client,
refresh_token: &str,
write_back_path: Option<&Path>,
) -> Result<String, String> {
#[derive(serde::Deserialize)]
struct TokenResp {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
}
let body = serde_json::json!({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": CLAUDE_OAUTH_CLIENT_ID,
});
let body_str = serde_json::to_string(&body).map_err(|e| format!("refresh body: {e}"))?;
let resp = client
.post("https://console.anthropic.com/v1/oauth/token")
.header("Content-Type", "application/json")
.body(body_str)
.send()
.map_err(|e| format!("refresh: {e}"))?;
let status = resp.status();
let text = resp.text().map_err(|e| format!("refresh body read: {e}"))?;
if !status.is_success() {
return Err(format!("refresh HTTP {}", status.as_u16()));
}
let tr: TokenResp = serde_json::from_str(&text).map_err(|e| format!("refresh parse: {e}"))?;
let expires_at_ms = tr
.expires_in
.map(|s| (now_unix().saturating_add(s)).saturating_mul(1000))
.unwrap_or(0);
let new_refresh = tr.refresh_token.as_deref().unwrap_or(refresh_token);
let blob = serde_json::json!({
"claudeAiOauth": {
"accessToken": tr.access_token,
"refreshToken": new_refresh,
"expiresAt": expires_at_ms,
}
});
match write_back_path {
Some(p) => {
let s = serde_json::to_string_pretty(&blob).unwrap_or_else(|_| blob.to_string());
let _ = write_secret_file(p, s.as_bytes());
}
None => {
let _ = write_claude_token(&blob.to_string());
}
}
Ok(tr.access_token)
}
pub fn spawn_keychain_claude_token() -> mpsc::Receiver<Result<String, String>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = match std::process::Command::new("security")
.args([
"find-generic-password",
"-s",
"Claude Code-credentials",
"-w",
])
.output()
{
Ok(out) if out.status.success() => {
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
if raw.is_empty() {
Err("keychain returned empty — is Claude Code auth'd?".to_string())
} else {
Ok(raw)
}
}
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
Err(format!(
"keychain lookup failed: {}",
stderr.trim().lines().next().unwrap_or("unknown error")
))
}
Err(e) => Err(format!("could not run `security`: {e}")),
};
let _ = tx.send(result);
});
rx
}
pub fn spawn_claude_fetch() -> mpsc::Receiver<Result<ClaudeUsage, FetchErr>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = fetch_claude_blocking();
let _ = tx.send(result);
});
rx
}
pub fn spawn_claude_fetch_account(
name: String,
token_path: PathBuf,
) -> mpsc::Receiver<Result<ClaudeAccountUsage, FetchErr>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = fetch_claude_account_blocking(&name, &token_path).map(|usage| {
let profile = std::fs::read_to_string(&token_path)
.ok()
.and_then(|raw| parse_token_blob(&raw))
.and_then(|token| fetch_claude_profile_best_effort(&token));
ClaudeAccountUsage {
name: name.clone(),
usage,
is_active: false,
email: profile.as_ref().and_then(|p| p.email.clone()),
org_name: profile.and_then(|p| p.org_name),
}
});
let _ = tx.send(result);
});
rx
}
pub fn fetch_claude_account_blocking(
_name: &str,
token_path: &Path,
) -> Result<ClaudeUsage, FetchErr> {
let raw = std::fs::read_to_string(token_path)
.map_err(|e| FetchErr::new(format!("read token {}: {e}", token_path.display())))?;
let token = parse_token_blob(&raw).ok_or_else(|| FetchErr::new("not linked"))?;
fetch_claude_with_token(&token, Some(token_path))
}
fn parse_token_blob(raw: &str) -> Option<String> {
let s = raw.trim();
if s.is_empty() {
return None;
}
if s.starts_with('{') {
let v: serde_json::Value = serde_json::from_str(s).ok()?;
let inner = v.get("claudeAiOauth").unwrap_or(&v);
let token = inner.get("accessToken")?.as_str()?.trim().to_string();
if token.is_empty() { None } else { Some(token) }
} else {
Some(s.to_string())
}
}
fn read_refresh_token_at(token_path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(token_path).ok()?;
let s = raw.trim();
if !s.starts_with('{') {
return None;
}
let v: serde_json::Value = serde_json::from_str(s).ok()?;
let inner = v.get("claudeAiOauth").unwrap_or(&v);
inner
.get("refreshToken")
.and_then(|x| x.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
#[derive(Debug, Clone)]
pub struct FetchErr {
pub message: String,
pub retry_after_secs: Option<u64>,
}
impl FetchErr {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
retry_after_secs: None,
}
}
fn with_retry_after(mut self, secs: u64) -> Self {
self.retry_after_secs = Some(secs);
self
}
}
impl From<String> for FetchErr {
fn from(s: String) -> Self {
Self::new(s)
}
}
fn fetch_claude_blocking() -> Result<ClaudeUsage, FetchErr> {
let token = read_claude_token().ok_or_else(|| FetchErr::new("not linked"))?;
fetch_claude_with_token(&token, claude_token_path().as_deref())
}
fn fetch_claude_with_token(
token: &str,
refresh_write_back: Option<&Path>,
) -> Result<ClaudeUsage, FetchErr> {
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("mnml/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| FetchErr::new(format!("http client: {e}")))?;
let mut resp = client
.get("https://api.anthropic.com/api/oauth/usage")
.header("Authorization", format!("Bearer {token}"))
.send()
.map_err(|e| FetchErr::new(format!("fetch: {e}")))?;
if (resp.status() == 401 || resp.status() == 403)
&& let Some(back) = refresh_write_back
&& let Some(refresh) = read_refresh_token_at(back)
&& let Ok(new_access) = try_refresh_claude_token(&client, &refresh, Some(back))
{
resp = client
.get("https://api.anthropic.com/api/oauth/usage")
.header("Authorization", format!("Bearer {new_access}"))
.send()
.map_err(|e| FetchErr::new(format!("fetch (post-refresh): {e}")))?;
}
if (resp.status() == 401 || resp.status() == 403)
&& let Some(back) = refresh_write_back
&& let Some(keychain_blob) = read_keychain_claude_token_blocking()
&& keychain_blob.trim() != token.trim()
{
let new_access =
parse_access_token(&keychain_blob).unwrap_or_else(|| keychain_blob.clone());
resp = client
.get("https://api.anthropic.com/api/oauth/usage")
.header("Authorization", format!("Bearer {new_access}"))
.send()
.map_err(|e| FetchErr::new(format!("fetch (post-keychain-resync): {e}")))?;
if resp.status().is_success() {
let _ = write_claude_token_to(back, &keychain_blob);
}
}
let status = resp.status();
let retry_after_secs = resp
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.trim().parse::<u64>().ok());
let text = resp
.text()
.map_err(|e| FetchErr::new(format!("body read: {e}")))?;
let dir = crate::data_root::data_root().join("cache");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("ai_last_response.json");
let scrubbed = redact_bearer(&text);
let _ = write_secret_file(
&path,
format!(
"// HTTP {}\n// fetched_at: {}\n{}\n",
status.as_u16(),
now_unix(),
scrubbed
)
.as_bytes(),
);
if !status.is_success() {
let msg = if status.as_u16() == 401 || status.as_u16() == 403 {
"token rejected — re-link via :ai.link_claude_token".to_string()
} else {
truncate(&redact_bearer(&text), 80)
};
let err = FetchErr::new(format!("HTTP {}: {}", status.as_u16(), msg));
let err = if status.as_u16() == 429
&& let Some(secs) = retry_after_secs
{
err.with_retry_after(secs)
} else {
err
};
return Err(err);
}
parse_claude_response(&text).map_err(FetchErr::new)
}
fn redact_bearer(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(idx) = rest.find(['s', 'B']) {
out.push_str(&rest[..idx]);
let tail = &rest[idx..];
let end = tail
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
.unwrap_or(tail.len());
let candidate = &tail[..end];
let looks_bearer = candidate.starts_with("sk-")
|| candidate.starts_with("Bearer ")
|| candidate.starts_with("sk_ant")
|| (candidate.len() > 40 && candidate.starts_with("sk"));
if looks_bearer {
out.push_str("<redacted>");
} else {
out.push_str(candidate);
}
rest = &tail[end..];
}
out.push_str(rest);
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct ProfileInfo {
email: Option<String>,
org_name: Option<String>,
}
fn fetch_claude_profile_best_effort(token: &str) -> Option<ProfileInfo> {
let client = reqwest::blocking::Client::builder()
.user_agent(concat!("mnml/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(5))
.build()
.ok()?;
let resp = client
.get("https://api.anthropic.com/api/oauth/profile")
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/json")
.send()
.ok()?;
if !resp.status().is_success() {
return None;
}
let text = resp.text().ok()?;
parse_profile_response(&text)
}
fn parse_profile_response(text: &str) -> Option<ProfileInfo> {
let v: serde_json::Value = serde_json::from_str(text).ok()?;
let email = v
.get("account")
.and_then(|a| a.get("email"))
.and_then(|x| x.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let org_name = v
.get("organization")
.and_then(|o| o.get("name"))
.and_then(|x| x.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if email.is_none() && org_name.is_none() {
return None;
}
Some(ProfileInfo { email, org_name })
}
fn parse_claude_response(text: &str) -> Result<ClaudeUsage, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("parse json: {e}"))?;
let (percent, resets_at) = extract_session(&v);
let (weekly_percent, weekly_resets_at) = extract_weekly(&v);
let scoped_limits = extract_scoped_limits(&v);
Ok(ClaudeUsage {
percent,
weekly_percent,
resets_at,
weekly_resets_at,
scoped_limits,
tokens_5h: 0, fetched_at: now_unix(),
last_error: None,
retry_after_at: 0,
})
}
fn extract_scoped_limits(v: &serde_json::Value) -> Vec<ScopedLimit> {
let Some(arr) = v.get("limits").and_then(|x| x.as_array()) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in arr {
let kind = entry.get("kind").and_then(|x| x.as_str()).unwrap_or("");
if kind != "weekly_scoped" {
continue;
}
let name = entry
.get("scope")
.and_then(|s| s.get("model"))
.and_then(|m| m.get("display_name"))
.and_then(|x| x.as_str())
.unwrap_or("?")
.to_string();
let pct = entry
.get("percent")
.and_then(|x| x.as_f64())
.map(|n| n.round().clamp(0.0, 999.0) as u16)
.unwrap_or(0);
let resets = entry
.get("resets_at")
.and_then(|x| x.as_str())
.and_then(parse_iso8601_secs)
.unwrap_or(0);
out.push(ScopedLimit {
model_display_name: name,
percent: pct,
resets_at: resets,
});
}
out
}
fn extract_session(v: &serde_json::Value) -> (u16, u64) {
if let Some(fh) = v.get("five_hour") {
let util = fh
.get("utilization")
.and_then(|x| x.as_f64())
.map(|n| n.round().clamp(0.0, 999.0) as u16)
.unwrap_or(0);
let resets = fh
.get("resets_at")
.and_then(|x| x.as_str())
.and_then(parse_iso8601_secs)
.unwrap_or(0);
if util > 0 || resets > 0 {
return (util, resets);
}
}
if let Some(limits) = v.get("limits").and_then(|x| x.as_array()) {
for entry in limits {
let kind = entry.get("kind").and_then(|x| x.as_str()).unwrap_or("");
if kind == "session" {
let pct = entry
.get("percent")
.and_then(|x| x.as_f64())
.map(|n| n.round().clamp(0.0, 999.0) as u16)
.unwrap_or(0);
let resets = entry
.get("resets_at")
.and_then(|x| x.as_str())
.and_then(parse_iso8601_secs)
.unwrap_or(0);
return (pct, resets);
}
}
}
(0, 0)
}
fn extract_weekly(v: &serde_json::Value) -> (u16, u64) {
if let Some(sd) = v.get("seven_day") {
let util = sd
.get("utilization")
.and_then(|x| x.as_f64())
.map(|n| n.round().clamp(0.0, 999.0) as u16)
.unwrap_or(0);
let resets = sd
.get("resets_at")
.and_then(|x| x.as_str())
.and_then(parse_iso8601_secs)
.unwrap_or(0);
if util > 0 || resets > 0 {
return (util, resets);
}
}
if let Some(limits) = v.get("limits").and_then(|x| x.as_array()) {
for entry in limits {
let kind = entry.get("kind").and_then(|x| x.as_str()).unwrap_or("");
if kind == "weekly_all" {
let pct = entry
.get("percent")
.and_then(|x| x.as_f64())
.map(|n| n.round().clamp(0.0, 999.0) as u16)
.unwrap_or(0);
let resets = entry
.get("resets_at")
.and_then(|x| x.as_str())
.and_then(parse_iso8601_secs)
.unwrap_or(0);
return (pct, resets);
}
}
}
(0, 0)
}
fn walk<'a>(v: &'a serde_json::Value, key: &str, depth: usize) -> Option<&'a serde_json::Value> {
const MAX_DEPTH: usize = 8;
if depth > MAX_DEPTH {
return None;
}
if let Some(map) = v.as_object() {
if let Some(hit) = map.get(key) {
return Some(hit);
}
for val in map.values() {
if let Some(hit) = walk(val, key, depth + 1) {
return Some(hit);
}
}
}
if let Some(arr) = v.as_array() {
for item in arr {
if let Some(hit) = walk(item, key, depth + 1) {
return Some(hit);
}
}
}
None
}
fn parse_iso8601_secs(s: &str) -> Option<u64> {
if s.len() < 19 {
return None;
}
let (y, rest) = (s.get(0..4)?.parse::<i64>().ok()?, s.get(5..)?);
let (mo, rest) = (rest.get(0..2)?.parse::<u64>().ok()?, rest.get(3..)?);
let (d, rest) = (rest.get(0..2)?.parse::<u64>().ok()?, rest.get(3..)?);
let (h, rest) = (rest.get(0..2)?.parse::<u64>().ok()?, rest.get(3..)?);
let (mi, rest) = (rest.get(0..2)?.parse::<u64>().ok()?, rest.get(3..)?);
let sec: u64 = rest.get(0..2)?.parse().ok()?;
let tz_start = rest.get(2..)?;
let tz_str = if let Some(dot) = tz_start.strip_prefix('.') {
dot.trim_start_matches(|c: char| c.is_ascii_digit())
} else {
tz_start
};
let tz_offset_secs: i64 = match tz_str.chars().next() {
Some('Z') | Some('z') | None => 0,
Some(sign_ch) if sign_ch == '+' || sign_ch == '-' => {
let sign: i64 = if sign_ch == '-' { -1 } else { 1 };
let body = tz_str.get(1..)?;
let (hh, mm) = if let Some((h_str, m_str)) = body.split_once(':') {
(h_str.parse::<i64>().ok()?, m_str.parse::<i64>().ok()?)
} else if body.len() >= 4 {
(
body.get(0..2)?.parse::<i64>().ok()?,
body.get(2..4)?.parse::<i64>().ok()?,
)
} else {
return None;
};
sign * (hh * 3600 + mm * 60)
}
_ => 0,
};
let year_days = |y: i64| -> i64 {
let y = y - 1;
(y * 365) + (y / 4) - (y / 100) + (y / 400)
};
let is_leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
let month_days = if is_leap {
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
} else {
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
let mut d_of_year: i64 = (d as i64) - 1;
for m_idx in 0..(mo as usize).saturating_sub(1) {
d_of_year += month_days.get(m_idx).copied().unwrap_or(0) as i64;
}
let days_since_epoch = year_days(y) - year_days(1970) + d_of_year;
let local_secs = days_since_epoch * 86400 + (h as i64) * 3600 + (mi as i64) * 60 + sec as i64;
let utc_secs = local_secs - tz_offset_secs;
if utc_secs < 0 {
None
} else {
Some(utc_secs as u64)
}
}
pub fn spawn_codex_fetch() -> mpsc::Receiver<Result<CodexUsage, String>> {
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let result = fetch_codex_blocking();
let _ = tx.send(result);
});
rx
}
fn fetch_codex_blocking() -> Result<CodexUsage, String> {
let sessions_dir = std::env::var_os("HOME")
.map(PathBuf::from)
.map(|h| h.join(".codex").join("sessions"))
.ok_or_else(|| "no $HOME".to_string())?;
if !sessions_dir.exists() {
return Ok(CodexUsage {
last_error: Some("~/.codex/sessions not found".into()),
fetched_at: now_unix(),
..Default::default()
});
}
let mut tokens_today = 0u64;
let mut sessions_today = 0u64;
let entries = std::fs::read_dir(&sessions_dir).map_err(|e| format!("read_dir: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("jsonl") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
let Ok(mtime) = meta.modified() else { continue };
if !is_today(mtime) {
continue;
}
sessions_today += 1;
tokens_today += sum_tokens_in_jsonl(&path).unwrap_or(0);
}
Ok(CodexUsage {
tokens_today,
sessions_today,
fetched_at: now_unix(),
last_error: None,
})
}
fn sum_tokens_in_jsonl(path: &Path) -> Option<u64> {
use std::io::{BufRead, BufReader};
const MAX_LINES_PER_FILE: usize = 100_000;
let file = std::fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut sum = 0u64;
for (i, line) in reader.lines().enumerate() {
if i >= MAX_LINES_PER_FILE {
break;
}
let Ok(line) = line else { continue };
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let usage = walk(&v, "last_token_usage", 0);
if let Some(usage) = usage {
let inp = usage
.get("input_tokens")
.and_then(|x| x.as_u64())
.unwrap_or(0);
let out = usage
.get("output_tokens")
.and_then(|x| x.as_u64())
.unwrap_or(0);
let total = usage
.get("total_tokens")
.and_then(|x| x.as_u64())
.unwrap_or(inp + out);
sum += total;
}
}
Some(sum)
}
fn now_unix() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn is_today(t: std::time::SystemTime) -> bool {
let Ok(dur) = t.duration_since(std::time::UNIX_EPOCH) else {
return false;
};
let secs = dur.as_secs() as i64;
let now = now_unix() as i64;
(secs / 86400) == (now / 86400)
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
s.chars().take(n).collect::<String>() + "…"
}
}
#[cfg(test)]
mod tests {
use super::*;
const PROFILE_FIXTURE: &str = r#"{
"account": {
"uuid": "00000000-0000-4000-8000-000000000000",
"full_name": "Test User",
"display_name": "Test",
"email": "test@example.com",
"has_claude_max": true,
"has_claude_pro": false,
"created_at": "2024-06-04T15:49:25.622079Z"
},
"organization": {
"uuid": "00000000-0000-4000-8000-000000000001",
"name": "Test Org",
"organization_type": "claude_max",
"billing_type": "stripe_subscription"
},
"application": {
"uuid": "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
}
}"#;
#[test]
fn parse_profile_returns_email_and_org() {
let got = parse_profile_response(PROFILE_FIXTURE).expect("some");
assert_eq!(got.email.as_deref(), Some("test@example.com"));
assert_eq!(got.org_name.as_deref(), Some("Test Org"));
}
#[test]
fn parse_profile_tolerates_missing_organization() {
let json = r#"{"account":{"email":"only@example.com"}}"#;
let got = parse_profile_response(json).expect("some");
assert_eq!(got.email.as_deref(), Some("only@example.com"));
assert!(got.org_name.is_none());
}
#[test]
fn parse_profile_tolerates_missing_account() {
let json = r#"{"organization":{"name":"Anthropic"}}"#;
let got = parse_profile_response(json).expect("some");
assert!(got.email.is_none());
assert_eq!(got.org_name.as_deref(), Some("Anthropic"));
}
#[test]
fn parse_profile_returns_none_when_both_fields_absent() {
let json = r#"{"account":{"uuid":"x"},"organization":{"uuid":"y"}}"#;
assert!(parse_profile_response(json).is_none());
}
#[test]
fn parse_profile_returns_none_on_bad_json() {
assert!(parse_profile_response("not json").is_none());
assert!(parse_profile_response("").is_none());
}
#[test]
fn parse_profile_ignores_empty_strings() {
let json = r#"{"account":{"email":" "},"organization":{"name":""}}"#;
assert!(parse_profile_response(json).is_none());
}
}