1use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct UsageData {
14 pub five_hour: UsagePeriod,
16
17 pub seven_day: UsagePeriod,
19
20 #[serde(default)]
22 pub seven_day_sonnet: Option<UsagePeriod>,
23
24 #[serde(default)]
26 pub extra_usage: Option<ExtraUsage>,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct UsagePeriod {
32 pub utilization: f64,
36
37 pub resets_at: DateTime<Utc>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct ExtraUsage {
44 pub is_enabled: bool,
46
47 #[serde(default)]
49 pub amount_used: Option<f64>,
50
51 #[serde(default)]
53 pub limit: Option<f64>,
54}
55
56impl UsagePeriod {
57 pub fn time_until_reset(&self) -> chrono::TimeDelta {
61 self.resets_at - Utc::now()
62 }
63
64 pub fn time_elapsed_percent(&self, period_hours: u32) -> f64 {
75 let total_seconds = period_hours as f64 * 3600.0;
76 let remaining_seconds = self.time_until_reset().num_seconds() as f64;
77 let elapsed_seconds = total_seconds - remaining_seconds;
78 (elapsed_seconds / total_seconds * 100.0).clamp(0.0, 100.0)
79 }
80
81 pub fn is_on_pace(&self, period_hours: u32) -> bool {
91 self.utilization <= self.time_elapsed_percent(period_hours)
92 }
93}
94
95impl UsageData {
96 pub fn five_hour_on_pace(&self) -> bool {
100 self.five_hour.is_on_pace(5)
101 }
102
103 pub fn seven_day_on_pace(&self) -> bool {
107 self.seven_day.is_on_pace(7 * 24)
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use chrono::Duration;
115
116 fn sample_usage_period(utilization: f64, hours_until_reset: i64) -> UsagePeriod {
117 UsagePeriod {
118 utilization,
119 resets_at: Utc::now() + Duration::hours(hours_until_reset),
120 }
121 }
122
123 #[test]
124 fn test_parse_full_response() {
125 let json = r#"{
126 "five_hour": {
127 "utilization": 8.0,
128 "resets_at": "2026-01-22T09:00:00Z"
129 },
130 "seven_day": {
131 "utilization": 77.0,
132 "resets_at": "2026-01-22T19:00:00Z"
133 },
134 "seven_day_sonnet": {
135 "utilization": 0.0,
136 "resets_at": "2026-01-25T00:00:00Z"
137 },
138 "extra_usage": {
139 "is_enabled": false
140 }
141 }"#;
142
143 let usage: UsageData = serde_json::from_str(json).expect("should parse");
144 assert!((usage.five_hour.utilization - 8.0).abs() < f64::EPSILON);
145 assert!((usage.seven_day.utilization - 77.0).abs() < f64::EPSILON);
146 assert!(usage.seven_day_sonnet.is_some());
147 assert!(usage.extra_usage.is_some());
148 assert!(!usage.extra_usage.expect("extra_usage present").is_enabled);
149 }
150
151 #[test]
152 fn test_parse_minimal_response() {
153 let json = r#"{
154 "five_hour": {
155 "utilization": 50.0,
156 "resets_at": "2026-01-22T09:00:00Z"
157 },
158 "seven_day": {
159 "utilization": 25.0,
160 "resets_at": "2026-01-22T19:00:00Z"
161 }
162 }"#;
163
164 let usage: UsageData = serde_json::from_str(json).expect("should parse");
165 assert!((usage.five_hour.utilization - 50.0).abs() < f64::EPSILON);
166 assert!((usage.seven_day.utilization - 25.0).abs() < f64::EPSILON);
167 assert!(usage.seven_day_sonnet.is_none());
168 assert!(usage.extra_usage.is_none());
169 }
170
171 #[test]
172 fn test_extra_usage_with_amounts() {
173 let json = r#"{
174 "five_hour": { "utilization": 0.0, "resets_at": "2026-01-22T09:00:00Z" },
175 "seven_day": { "utilization": 0.0, "resets_at": "2026-01-22T19:00:00Z" },
176 "extra_usage": {
177 "is_enabled": true,
178 "amount_used": 5.50,
179 "limit": 100.0
180 }
181 }"#;
182
183 let usage: UsageData = serde_json::from_str(json).expect("should parse");
184 let extra = usage.extra_usage.expect("extra_usage should be present");
185 assert!(extra.is_enabled);
186 assert!((extra.amount_used.expect("amount") - 5.50).abs() < f64::EPSILON);
187 assert!((extra.limit.expect("limit") - 100.0).abs() < f64::EPSILON);
188 }
189
190 #[test]
191 fn test_time_elapsed_percent_at_start() {
192 let period = sample_usage_period(0.0, 5);
194 let elapsed = period.time_elapsed_percent(5);
195 assert!(elapsed < 1.0, "elapsed should be near 0%: {}", elapsed);
197 }
198
199 #[test]
200 fn test_time_elapsed_percent_at_half() {
201 let period = UsagePeriod {
203 utilization: 50.0,
204 resets_at: Utc::now() + Duration::minutes(150), };
206 let elapsed = period.time_elapsed_percent(5);
207 assert!(
208 (elapsed - 50.0).abs() < 1.0,
209 "elapsed should be near 50%: {}",
210 elapsed
211 );
212 }
213
214 #[test]
215 fn test_is_on_pace_when_behind() {
216 let period = UsagePeriod {
218 utilization: 30.0,
219 resets_at: Utc::now() + Duration::minutes(150), };
221 assert!(
222 period.is_on_pace(5),
223 "30% usage at 50% time should be on pace"
224 );
225 }
226
227 #[test]
228 fn test_is_on_pace_when_ahead() {
229 let period = UsagePeriod {
231 utilization: 70.0,
232 resets_at: Utc::now() + Duration::minutes(150), };
234 assert!(
235 !period.is_on_pace(5),
236 "70% usage at 50% time should not be on pace"
237 );
238 }
239
240 #[test]
241 fn test_five_hour_on_pace() {
242 let usage = UsageData {
243 five_hour: sample_usage_period(10.0, 4), seven_day: sample_usage_period(50.0, 84), seven_day_sonnet: None,
246 extra_usage: None,
247 };
248 assert!(usage.five_hour_on_pace());
249 }
250
251 #[test]
252 fn test_seven_day_on_pace() {
253 let usage = UsageData {
254 five_hour: sample_usage_period(80.0, 1),
255 seven_day: sample_usage_period(40.0, 84), seven_day_sonnet: None,
257 extra_usage: None,
258 };
259 assert!(usage.seven_day_on_pace());
260 }
261
262 #[test]
263 fn test_serialize_round_trip() {
264 let usage = UsageData {
265 five_hour: UsagePeriod {
266 utilization: 42.5,
267 resets_at: Utc::now(),
268 },
269 seven_day: UsagePeriod {
270 utilization: 88.0,
271 resets_at: Utc::now(),
272 },
273 seven_day_sonnet: Some(UsagePeriod {
274 utilization: 0.0,
275 resets_at: Utc::now(),
276 }),
277 extra_usage: Some(ExtraUsage {
278 is_enabled: true,
279 amount_used: Some(10.0),
280 limit: Some(50.0),
281 }),
282 };
283
284 let json = serde_json::to_string(&usage).expect("serialize");
285 let parsed: UsageData = serde_json::from_str(&json).expect("deserialize");
286
287 assert!((parsed.five_hour.utilization - 42.5).abs() < f64::EPSILON);
288 assert!((parsed.seven_day.utilization - 88.0).abs() < f64::EPSILON);
289 }
290}