#![cfg_attr(not(test), allow(dead_code))]
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageLimit {
#[serde(default, alias = "title")]
pub label: Option<String>,
#[serde(default)]
pub percent: Option<f64>,
#[serde(default)]
pub resets_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DayUsage {
#[serde(default)]
pub date: String,
#[serde(default, rename = "messageCount")]
pub tokens: f64,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ModelUsage {
#[serde(default)]
pub input_tokens: f64,
#[serde(default)]
pub output_tokens: f64,
#[serde(default)]
pub cache_read_input_tokens: f64,
#[serde(default)]
pub cache_creation_input_tokens: f64,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct UsageBalance {
#[serde(default)]
pub remaining: f64,
#[serde(default)]
pub funded: f64,
#[serde(default)]
pub spent: f64,
#[serde(default)]
pub currency: String,
#[serde(default)]
pub estimated: bool,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentUsageRecord {
#[serde(default)]
pub schema_version: u32,
#[serde(default)]
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub updated_at: String,
#[serde(default)]
pub ready: bool,
#[serde(default)]
pub has_local_stats: bool,
#[serde(default)]
pub tier_label: Option<String>,
#[serde(default)]
pub usage_status_text: Option<String>,
#[serde(default)]
pub auth_help_text: Option<String>,
#[serde(default)]
pub limits: Vec<UsageLimit>,
#[serde(default)]
pub balance: Option<UsageBalance>,
#[serde(default)]
pub retry_advised: bool,
#[serde(default)]
pub total_prompts: Option<u64>,
#[serde(default)]
pub total_sessions: Option<u64>,
#[serde(default)]
pub active_days: Option<u64>,
#[serde(default)]
pub active_dates: Vec<String>,
#[serde(default)]
pub today_prompts: Option<u64>,
#[serde(default)]
pub today_sessions: Option<u64>,
#[serde(default)]
pub today_total_tokens: Option<f64>,
#[serde(default)]
pub today_tokens_by_model: HashMap<String, f64>,
#[serde(default)]
pub recent_days: Vec<DayUsage>,
#[serde(default)]
pub model_usage: HashMap<String, ModelUsage>,
}
pub(crate) fn parse_record(json: &str) -> Option<AgentUsageRecord> {
serde_json::from_str(json).ok()
}
pub(crate) fn should_display(record: &AgentUsageRecord, hidden: &HashSet<String>) -> bool {
record.ready && !hidden.contains(&record.id)
}
pub(crate) fn union_active_days(a: &AgentUsageRecord, b: &AgentUsageRecord) -> u64 {
let mut dates: HashSet<&str> = a.active_dates.iter().map(String::as_str).collect();
dates.extend(b.active_dates.iter().map(String::as_str));
let union_len = dates.len() as u64;
union_len
.max(a.active_days.unwrap_or(0))
.max(b.active_days.unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
const FULL_RECORD: &str = r#"{
"schemaVersion": 1,
"id": "claude",
"name": "Claude",
"updatedAt": "2026-09-20T17:06:14Z",
"ready": true,
"hasLocalStats": true,
"tierLabel": "Max 20x",
"usageStatusText": "",
"authHelpText": "",
"limits": [
{"label": "Session (5-hour)", "percent": 42.0, "resetsAt": "2026-09-20T19:00:00Z"},
{"label": "Weekly (7-day)", "percent": 71.5, "resetsAt": "2026-09-22T00:00:00Z"}
],
"totalPrompts": 1234,
"totalSessions": 56,
"activeDays": 87,
"activeDates": ["2026-09-19", "2026-09-20"],
"todayPrompts": 12,
"todaySessions": 2,
"todayTotalTokens": 8200000,
"todayTokensByModel": {"opus-5": 4100000, "sonnet-5": 4100000},
"recentDays": [
{"date": "2026-09-19", "messageCount": 6100000},
{"date": "2026-09-20", "messageCount": 8200000}
],
"modelUsage": {
"opus-5": {"inputTokens": 1000, "outputTokens": 2000,
"cacheReadInputTokens": 3000, "cacheCreationInputTokens": 4000}
}
}"#;
#[test]
fn full_record_parses_every_field() {
let record = parse_record(FULL_RECORD).expect("full fixture must parse");
assert_eq!(record.schema_version, 1);
assert_eq!(record.id, "claude");
assert_eq!(record.name, "Claude");
assert_eq!(record.updated_at, "2026-09-20T17:06:14Z");
assert!(record.ready && record.has_local_stats);
assert_eq!(record.tier_label.as_deref(), Some("Max 20x"));
assert_eq!(record.limits.len(), 2);
assert_eq!(record.limits[0].label.as_deref(), Some("Session (5-hour)"));
assert_eq!(record.limits[0].percent, Some(42.0));
assert_eq!(
record.limits[0].resets_at.as_deref(),
Some("2026-09-20T19:00:00Z")
);
assert_eq!(record.total_prompts, Some(1234));
assert_eq!(record.total_sessions, Some(56));
assert_eq!(record.active_days, Some(87));
assert_eq!(record.active_dates, vec!["2026-09-19", "2026-09-20"]);
assert_eq!(record.today_prompts, Some(12));
assert_eq!(record.today_sessions, Some(2));
assert_eq!(record.today_total_tokens, Some(8_200_000.0));
assert_eq!(
record.today_tokens_by_model.get("opus-5"),
Some(&4_100_000.0)
);
assert_eq!(record.recent_days.len(), 2);
assert_eq!(record.recent_days[1].date, "2026-09-20");
assert_eq!(record.recent_days[1].tokens, 8_200_000.0);
let opus = record
.model_usage
.get("opus-5")
.expect("modelUsage key present");
assert_eq!(opus.input_tokens, 1000.0);
assert_eq!(opus.output_tokens, 2000.0);
assert_eq!(opus.cache_read_input_tokens, 3000.0);
assert_eq!(opus.cache_creation_input_tokens, 4000.0);
assert!(record.balance.is_none());
assert!(!record.retry_advised);
}
#[test]
fn limit_display_name_accepts_the_title_spelling() {
let record = parse_record(
r#"{"id":"x","ready":true,"limits":[{"title":"Custom caps","percent":10}]}"#,
)
.expect("title alias must parse");
assert_eq!(record.limits[0].label.as_deref(), Some("Custom caps"));
assert_eq!(record.limits[0].percent, Some(10.0));
assert_eq!(record.limits[0].resets_at, None);
}
#[test]
fn prepaid_balance_parses() {
let record = parse_record(
r#"{"id":"fireworks","ready":true,
"balance":{"remaining":12.5,"funded":50.0,"spent":37.5,
"currency":"USD","estimated":false}}"#,
)
.expect("balance fixture must parse");
let balance = record.balance.expect("balance present");
assert_eq!(balance.remaining, 12.5);
assert_eq!(balance.funded, 50.0);
assert_eq!(balance.spent, 37.5);
assert_eq!(balance.currency, "USD");
assert!(!balance.estimated);
}
#[test]
fn auth_failure_shape_parses_and_does_not_display() {
let record = parse_record(
r#"{"schemaVersion":1,"id":"claude","name":"Claude",
"updatedAt":"2026-09-20T17:06:14Z","ready":false,
"hasLocalStats":false,"tierLabel":null,
"usageStatusText":"Claude limits unavailable",
"authHelpText":"Run `claude login`","limits":[]}"#,
)
.expect("auth-failure fixture must parse");
assert!(!record.ready);
assert_eq!(
record.usage_status_text.as_deref(),
Some("Claude limits unavailable")
);
assert_eq!(record.auth_help_text.as_deref(), Some("Run `claude login`"));
assert!(record.limits.is_empty());
assert!(!should_display(&record, &HashSet::new()));
}
#[test]
fn minimal_not_ready_record_parses_via_defaults() {
let record = parse_record(r#"{"id":"new-agent"}"#).expect("only the id is guaranteed");
assert_eq!(record.id, "new-agent");
assert!(!record.ready);
assert_eq!(record.schema_version, 0);
assert!(record.limits.is_empty() && record.recent_days.is_empty());
assert!(!should_display(&record, &HashSet::new()));
}
#[test]
fn unknown_fields_are_ignored() {
let record =
parse_record(r#"{"id":"x","ready":true,"weekTokens":{"opus":1},"futureThing":[1,2]}"#)
.expect("unknown keys must not reject the record");
assert!(record.ready);
}
#[test]
fn garbage_json_returns_none() {
assert!(parse_record("{not json").is_none());
assert!(parse_record(r#"{"id":"truncated,"#).is_none());
assert!(parse_record("").is_none());
}
#[test]
fn hidden_agent_does_not_display() {
let record = parse_record(FULL_RECORD).expect("fixture parses");
assert!(should_display(&record, &HashSet::new()));
let hidden: HashSet<String> = ["claude".to_string()].into();
assert!(!should_display(&record, &hidden));
}
#[test]
fn union_active_days_unions_dates_never_sums() {
let a =
parse_record(r#"{"id":"a","activeDays":2,"activeDates":["2026-09-18","2026-09-19"]}"#)
.expect("a parses");
let b =
parse_record(r#"{"id":"b","activeDays":2,"activeDates":["2026-09-19","2026-09-20"]}"#)
.expect("b parses");
assert_eq!(union_active_days(&a, &b), 3);
assert_eq!(union_active_days(&b, &a), 3);
}
#[test]
fn union_active_days_bounds_below_by_bare_counts() {
let a = parse_record(r#"{"id":"a","activeDays":5,"activeDates":["2026-09-20"]}"#)
.expect("a parses");
let b = parse_record(r#"{"id":"b"}"#).expect("b parses");
assert_eq!(union_active_days(&a, &b), 5);
}
}