use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UsageData {
pub five_hour: UsagePeriod,
pub seven_day: UsagePeriod,
#[serde(default)]
pub seven_day_sonnet: Option<UsagePeriod>,
#[serde(default)]
pub extra_usage: Option<ExtraUsage>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UsagePeriod {
pub utilization: f64,
#[serde(default)]
pub resets_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtraUsage {
pub is_enabled: bool,
#[serde(default)]
pub amount_used: Option<f64>,
#[serde(default)]
pub limit: Option<f64>,
}
impl UsagePeriod {
pub fn time_until_reset(&self) -> Option<chrono::TimeDelta> {
self.resets_at.map(|reset| reset - Utc::now())
}
pub fn time_elapsed_percent(&self, period_hours: u32) -> Option<f64> {
self.time_until_reset().map(|remaining| {
let total_seconds = period_hours as f64 * 3600.0;
let remaining_seconds = remaining.num_seconds() as f64;
let elapsed_seconds = total_seconds - remaining_seconds;
(elapsed_seconds / total_seconds * 100.0).clamp(0.0, 100.0)
})
}
pub fn is_on_pace(&self, period_hours: u32) -> Option<bool> {
self.time_elapsed_percent(period_hours)
.map(|elapsed| self.utilization <= elapsed)
}
}
impl UsageData {
pub fn five_hour_on_pace(&self) -> Option<bool> {
self.five_hour.is_on_pace(5)
}
pub fn seven_day_on_pace(&self) -> Option<bool> {
self.seven_day.is_on_pace(7 * 24)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
fn sample_usage_period(utilization: f64, hours_until_reset: i64) -> UsagePeriod {
UsagePeriod {
utilization,
resets_at: Some(Utc::now() + Duration::hours(hours_until_reset)),
}
}
#[test]
fn test_parse_full_response() {
let json = r#"{
"five_hour": {
"utilization": 8.0,
"resets_at": "2026-01-22T09:00:00Z"
},
"seven_day": {
"utilization": 77.0,
"resets_at": "2026-01-22T19:00:00Z"
},
"seven_day_sonnet": {
"utilization": 0.0,
"resets_at": "2026-01-25T00:00:00Z"
},
"extra_usage": {
"is_enabled": false
}
}"#;
let usage: UsageData = serde_json::from_str(json).expect("should parse");
assert!((usage.five_hour.utilization - 8.0).abs() < f64::EPSILON);
assert!((usage.seven_day.utilization - 77.0).abs() < f64::EPSILON);
assert!(usage.seven_day_sonnet.is_some());
assert!(usage.extra_usage.is_some());
assert!(!usage.extra_usage.expect("extra_usage present").is_enabled);
}
#[test]
fn test_parse_minimal_response() {
let json = r#"{
"five_hour": {
"utilization": 50.0,
"resets_at": "2026-01-22T09:00:00Z"
},
"seven_day": {
"utilization": 25.0,
"resets_at": "2026-01-22T19:00:00Z"
}
}"#;
let usage: UsageData = serde_json::from_str(json).expect("should parse");
assert!((usage.five_hour.utilization - 50.0).abs() < f64::EPSILON);
assert!((usage.seven_day.utilization - 25.0).abs() < f64::EPSILON);
assert!(usage.seven_day_sonnet.is_none());
assert!(usage.extra_usage.is_none());
}
#[test]
fn test_extra_usage_with_amounts() {
let json = r#"{
"five_hour": { "utilization": 0.0, "resets_at": "2026-01-22T09:00:00Z" },
"seven_day": { "utilization": 0.0, "resets_at": "2026-01-22T19:00:00Z" },
"extra_usage": {
"is_enabled": true,
"amount_used": 5.50,
"limit": 100.0
}
}"#;
let usage: UsageData = serde_json::from_str(json).expect("should parse");
let extra = usage.extra_usage.expect("extra_usage should be present");
assert!(extra.is_enabled);
assert!((extra.amount_used.expect("amount") - 5.50).abs() < f64::EPSILON);
assert!((extra.limit.expect("limit") - 100.0).abs() < f64::EPSILON);
}
#[test]
fn test_time_elapsed_percent_at_start() {
let period = sample_usage_period(0.0, 5);
let elapsed = period
.time_elapsed_percent(5)
.expect("reset time available");
assert!(elapsed < 1.0, "elapsed should be near 0%: {}", elapsed);
}
#[test]
fn test_time_elapsed_percent_at_half() {
let period = UsagePeriod {
utilization: 50.0,
resets_at: Some(Utc::now() + Duration::minutes(150)), };
let elapsed = period
.time_elapsed_percent(5)
.expect("reset time available");
assert!(
(elapsed - 50.0).abs() < 1.0,
"elapsed should be near 50%: {}",
elapsed
);
}
#[test]
fn test_time_elapsed_percent_no_reset() {
let period = UsagePeriod {
utilization: 50.0,
resets_at: None,
};
assert!(period.time_elapsed_percent(5).is_none());
}
#[test]
fn test_is_on_pace_when_behind() {
let period = UsagePeriod {
utilization: 30.0,
resets_at: Some(Utc::now() + Duration::minutes(150)), };
assert!(
period.is_on_pace(5).expect("reset time available"),
"30% usage at 50% time should be on pace"
);
}
#[test]
fn test_is_on_pace_when_ahead() {
let period = UsagePeriod {
utilization: 70.0,
resets_at: Some(Utc::now() + Duration::minutes(150)), };
assert!(
!period.is_on_pace(5).expect("reset time available"),
"70% usage at 50% time should not be on pace"
);
}
#[test]
fn test_five_hour_on_pace() {
let usage = UsageData {
five_hour: sample_usage_period(10.0, 4), seven_day: sample_usage_period(50.0, 84), seven_day_sonnet: None,
extra_usage: None,
};
assert!(usage.five_hour_on_pace().expect("reset time available"));
}
#[test]
fn test_seven_day_on_pace() {
let usage = UsageData {
five_hour: sample_usage_period(80.0, 1),
seven_day: sample_usage_period(40.0, 84), seven_day_sonnet: None,
extra_usage: None,
};
assert!(usage.seven_day_on_pace().expect("reset time available"));
}
#[test]
fn test_serialize_round_trip() {
let now = Utc::now();
let usage = UsageData {
five_hour: UsagePeriod {
utilization: 42.5,
resets_at: Some(now),
},
seven_day: UsagePeriod {
utilization: 88.0,
resets_at: Some(now),
},
seven_day_sonnet: Some(UsagePeriod {
utilization: 0.0,
resets_at: Some(now),
}),
extra_usage: Some(ExtraUsage {
is_enabled: true,
amount_used: Some(10.0),
limit: Some(50.0),
}),
};
let json = serde_json::to_string(&usage).expect("serialize");
let parsed: UsageData = serde_json::from_str(&json).expect("deserialize");
assert!((parsed.five_hour.utilization - 42.5).abs() < f64::EPSILON);
assert!((parsed.seven_day.utilization - 88.0).abs() < f64::EPSILON);
}
#[test]
fn test_parse_null_resets_at() {
let json = r#"{
"five_hour": {
"utilization": 50.0,
"resets_at": null
},
"seven_day": {
"utilization": 25.0,
"resets_at": null
}
}"#;
let usage: UsageData = serde_json::from_str(json).expect("should parse");
assert!((usage.five_hour.utilization - 50.0).abs() < f64::EPSILON);
assert!(usage.five_hour.resets_at.is_none());
assert!(usage.seven_day.resets_at.is_none());
}
}