use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UsageData {
#[serde(default)]
pub captured_at: Option<String>,
#[serde(default)]
pub profiles: HashMap<String, ProfileUsage>,
#[serde(default)]
pub errors: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileUsage {
#[serde(default)]
pub captured_at: Option<String>,
#[serde(default)]
pub session: Option<UsageSection>,
#[serde(default)]
pub week_all: Option<UsageSection>,
#[serde(default)]
pub week_sonnet: Option<UsageSection>,
#[serde(default)]
pub session_stats: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UsageSection {
pub pct: i64,
#[serde(default)]
pub resets: Option<String>,
}
impl UsageData {
pub fn current_usage(&self, profile: &str) -> Option<(i64, i64)> {
if let Some(errors) = &self.errors {
if errors.contains_key(profile) {
return None;
}
}
let pu = self.profiles.get(profile)?;
let sess_pct = pu.session.as_ref().map(|s| s.pct).unwrap_or(-1);
let week_pct = pu.week_all.as_ref().map(|s| s.pct).unwrap_or(-1);
Some((sess_pct, week_pct))
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CACHE_JSON: &str = r#"
{
"captured_at": "2026-06-17T07:13:19Z",
"profiles": {
"home": {
"captured_at": "2026-06-17T07:13:17Z",
"session": {
"pct": 42,
"resets": "9pm (Asia/Seoul)"
},
"week_all": {
"pct": 31,
"resets": "Jun 18 at 9pm (Asia/Seoul)"
},
"week_sonnet": {
"pct": 15,
"resets": "Jun 18 at 9pm (Asia/Seoul)"
},
"session_stats": ["12000 tokens used", "88000 remaining"]
},
"work": {
"captured_at": "2026-06-17T07:13:18Z",
"session": {
"pct": 5,
"resets": null
},
"week_all": {
"pct": 67,
"resets": "Jun 20 at 8:20pm (Asia/Seoul)"
},
"week_sonnet": null,
"session_stats": []
}
},
"errors": {
"broken_profile": "HTTP 401: no credentials"
}
}
"#;
#[test]
fn deserialize_sample_cache_json() {
let data: UsageData =
serde_json::from_str(SAMPLE_CACHE_JSON).expect("should parse sample cache JSON");
assert_eq!(
data.captured_at.as_deref(),
Some("2026-06-17T07:13:19Z"),
"top-level captured_at"
);
let home = data.profiles.get("home").expect("home profile");
let sess = home.session.as_ref().expect("home.session");
assert_eq!(sess.pct, 42);
assert_eq!(sess.resets.as_deref(), Some("9pm (Asia/Seoul)"));
let week_all = home.week_all.as_ref().expect("home.week_all");
assert_eq!(week_all.pct, 31);
assert_eq!(
week_all.resets.as_deref(),
Some("Jun 18 at 9pm (Asia/Seoul)")
);
let week_sonnet = home.week_sonnet.as_ref().expect("home.week_sonnet");
assert_eq!(week_sonnet.pct, 15);
assert_eq!(home.session_stats.len(), 2);
let work = data.profiles.get("work").expect("work profile");
assert!(
work.week_sonnet.is_none(),
"work.week_sonnet should be None (null in JSON)"
);
let esess = work.session.as_ref().expect("work.session");
assert_eq!(esess.pct, 5);
assert!(esess.resets.is_none(), "work.session.resets should be None");
assert!(work.session_stats.is_empty());
let errors = data.errors.as_ref().expect("errors map");
assert!(errors.contains_key("broken_profile"));
assert!(
errors["broken_profile"].contains("401"),
"error message should mention 401"
);
}
#[test]
fn deserialize_minimal_json_no_errors_key() {
let json = r#"{"profiles": {"home": {"session": {"pct": 10}, "week_all": {"pct": 20}}}}"#;
let data: UsageData = serde_json::from_str(json).expect("minimal JSON");
assert!(
data.errors.is_none(),
"errors should be None when key absent"
);
let p = data.profiles.get("home").expect("home");
assert!(p.week_sonnet.is_none());
}
#[test]
fn current_usage_returns_correct_pcts() {
let data: UsageData =
serde_json::from_str(SAMPLE_CACHE_JSON).expect("parse for current_usage test");
let (sess, week) = data.current_usage("home").expect("home present");
assert_eq!(sess, 42);
assert_eq!(week, 31);
let (sess, week) = data.current_usage("work").expect("work present");
assert_eq!(sess, 5);
assert_eq!(week, 67);
}
#[test]
fn current_usage_none_for_errored_profile() {
let data: UsageData =
serde_json::from_str(SAMPLE_CACHE_JSON).expect("parse for error test");
assert!(
data.current_usage("broken_profile").is_none(),
"errored profile must return None"
);
}
#[test]
fn current_usage_none_for_absent_profile() {
let data: UsageData =
serde_json::from_str(SAMPLE_CACHE_JSON).expect("parse for absent test");
assert!(
data.current_usage("no_such_profile").is_none(),
"absent profile must return None"
);
}
#[test]
fn absent_session_encodes_as_minus_one() {
let json = r#"{"profiles": {"p": {"session": null, "week_all": {"pct": 55}}}}"#;
let data: UsageData = serde_json::from_str(json).expect("parse");
let (sess, week) = data.current_usage("p").expect("p present");
assert_eq!(sess, -1, "absent session.pct must encode as -1");
assert_eq!(week, 55);
}
#[test]
fn roundtrip_serialize_deserialize() {
let data: UsageData = serde_json::from_str(SAMPLE_CACHE_JSON).expect("initial parse");
let serialized = serde_json::to_string(&data).expect("serialize");
let data2: UsageData = serde_json::from_str(&serialized).expect("re-parse");
assert_eq!(
data.profiles["home"].session.as_ref().unwrap().pct,
data2.profiles["home"].session.as_ref().unwrap().pct
);
}
}