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 #[serde(default)]
41 pub resets_at: Option<DateTime<Utc>>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct ExtraUsage {
47 pub is_enabled: bool,
49
50 #[serde(default)]
52 pub amount_used: Option<f64>,
53
54 #[serde(default)]
56 pub limit: Option<f64>,
57}
58
59impl UsagePeriod {
60 pub fn time_until_reset(&self) -> Option<chrono::TimeDelta> {
65 self.resets_at.map(|reset| reset - Utc::now())
66 }
67
68 pub fn time_elapsed_percent(&self, period_hours: u32) -> Option<f64> {
80 self.time_until_reset().map(|remaining| {
81 let total_seconds = period_hours as f64 * 3600.0;
82 let remaining_seconds = remaining.num_seconds() as f64;
83 let elapsed_seconds = total_seconds - remaining_seconds;
84 (elapsed_seconds / total_seconds * 100.0).clamp(0.0, 100.0)
85 })
86 }
87
88 pub fn is_on_pace(&self, period_hours: u32) -> Option<bool> {
99 self.time_elapsed_percent(period_hours)
100 .map(|elapsed| self.utilization <= elapsed)
101 }
102}
103
104impl UsageData {
105 pub fn five_hour_on_pace(&self) -> Option<bool> {
110 self.five_hour.is_on_pace(5)
111 }
112
113 pub fn seven_day_on_pace(&self) -> Option<bool> {
118 self.seven_day.is_on_pace(7 * 24)
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use chrono::Duration;
126
127 fn sample_usage_period(utilization: f64, hours_until_reset: i64) -> UsagePeriod {
128 UsagePeriod {
129 utilization,
130 resets_at: Some(Utc::now() + Duration::hours(hours_until_reset)),
131 }
132 }
133
134 #[test]
135 fn test_parse_full_response() {
136 let json = r#"{
137 "five_hour": {
138 "utilization": 8.0,
139 "resets_at": "2026-01-22T09:00:00Z"
140 },
141 "seven_day": {
142 "utilization": 77.0,
143 "resets_at": "2026-01-22T19:00:00Z"
144 },
145 "seven_day_sonnet": {
146 "utilization": 0.0,
147 "resets_at": "2026-01-25T00:00:00Z"
148 },
149 "extra_usage": {
150 "is_enabled": false
151 }
152 }"#;
153
154 let usage: UsageData = serde_json::from_str(json).expect("should parse");
155 assert!((usage.five_hour.utilization - 8.0).abs() < f64::EPSILON);
156 assert!((usage.seven_day.utilization - 77.0).abs() < f64::EPSILON);
157 assert!(usage.seven_day_sonnet.is_some());
158 assert!(usage.extra_usage.is_some());
159 assert!(!usage.extra_usage.expect("extra_usage present").is_enabled);
160 }
161
162 #[test]
163 fn test_parse_minimal_response() {
164 let json = r#"{
165 "five_hour": {
166 "utilization": 50.0,
167 "resets_at": "2026-01-22T09:00:00Z"
168 },
169 "seven_day": {
170 "utilization": 25.0,
171 "resets_at": "2026-01-22T19:00:00Z"
172 }
173 }"#;
174
175 let usage: UsageData = serde_json::from_str(json).expect("should parse");
176 assert!((usage.five_hour.utilization - 50.0).abs() < f64::EPSILON);
177 assert!((usage.seven_day.utilization - 25.0).abs() < f64::EPSILON);
178 assert!(usage.seven_day_sonnet.is_none());
179 assert!(usage.extra_usage.is_none());
180 }
181
182 #[test]
183 fn test_extra_usage_with_amounts() {
184 let json = r#"{
185 "five_hour": { "utilization": 0.0, "resets_at": "2026-01-22T09:00:00Z" },
186 "seven_day": { "utilization": 0.0, "resets_at": "2026-01-22T19:00:00Z" },
187 "extra_usage": {
188 "is_enabled": true,
189 "amount_used": 5.50,
190 "limit": 100.0
191 }
192 }"#;
193
194 let usage: UsageData = serde_json::from_str(json).expect("should parse");
195 let extra = usage.extra_usage.expect("extra_usage should be present");
196 assert!(extra.is_enabled);
197 assert!((extra.amount_used.expect("amount") - 5.50).abs() < f64::EPSILON);
198 assert!((extra.limit.expect("limit") - 100.0).abs() < f64::EPSILON);
199 }
200
201 #[test]
202 fn test_time_elapsed_percent_at_start() {
203 let period = sample_usage_period(0.0, 5);
205 let elapsed = period
206 .time_elapsed_percent(5)
207 .expect("reset time available");
208 assert!(elapsed < 1.0, "elapsed should be near 0%: {}", elapsed);
210 }
211
212 #[test]
213 fn test_time_elapsed_percent_at_half() {
214 let period = UsagePeriod {
216 utilization: 50.0,
217 resets_at: Some(Utc::now() + Duration::minutes(150)), };
219 let elapsed = period
220 .time_elapsed_percent(5)
221 .expect("reset time available");
222 assert!(
223 (elapsed - 50.0).abs() < 1.0,
224 "elapsed should be near 50%: {}",
225 elapsed
226 );
227 }
228
229 #[test]
230 fn test_time_elapsed_percent_no_reset() {
231 let period = UsagePeriod {
232 utilization: 50.0,
233 resets_at: None,
234 };
235 assert!(period.time_elapsed_percent(5).is_none());
236 }
237
238 #[test]
239 fn test_is_on_pace_when_behind() {
240 let period = UsagePeriod {
242 utilization: 30.0,
243 resets_at: Some(Utc::now() + Duration::minutes(150)), };
245 assert!(
246 period.is_on_pace(5).expect("reset time available"),
247 "30% usage at 50% time should be on pace"
248 );
249 }
250
251 #[test]
252 fn test_is_on_pace_when_ahead() {
253 let period = UsagePeriod {
255 utilization: 70.0,
256 resets_at: Some(Utc::now() + Duration::minutes(150)), };
258 assert!(
259 !period.is_on_pace(5).expect("reset time available"),
260 "70% usage at 50% time should not be on pace"
261 );
262 }
263
264 #[test]
265 fn test_five_hour_on_pace() {
266 let usage = UsageData {
267 five_hour: sample_usage_period(10.0, 4), seven_day: sample_usage_period(50.0, 84), seven_day_sonnet: None,
270 extra_usage: None,
271 };
272 assert!(usage.five_hour_on_pace().expect("reset time available"));
273 }
274
275 #[test]
276 fn test_seven_day_on_pace() {
277 let usage = UsageData {
278 five_hour: sample_usage_period(80.0, 1),
279 seven_day: sample_usage_period(40.0, 84), seven_day_sonnet: None,
281 extra_usage: None,
282 };
283 assert!(usage.seven_day_on_pace().expect("reset time available"));
284 }
285
286 #[test]
287 fn test_serialize_round_trip() {
288 let now = Utc::now();
289 let usage = UsageData {
290 five_hour: UsagePeriod {
291 utilization: 42.5,
292 resets_at: Some(now),
293 },
294 seven_day: UsagePeriod {
295 utilization: 88.0,
296 resets_at: Some(now),
297 },
298 seven_day_sonnet: Some(UsagePeriod {
299 utilization: 0.0,
300 resets_at: Some(now),
301 }),
302 extra_usage: Some(ExtraUsage {
303 is_enabled: true,
304 amount_used: Some(10.0),
305 limit: Some(50.0),
306 }),
307 };
308
309 let json = serde_json::to_string(&usage).expect("serialize");
310 let parsed: UsageData = serde_json::from_str(&json).expect("deserialize");
311
312 assert!((parsed.five_hour.utilization - 42.5).abs() < f64::EPSILON);
313 assert!((parsed.seven_day.utilization - 88.0).abs() < f64::EPSILON);
314 }
315
316 #[test]
317 fn test_parse_null_resets_at() {
318 let json = r#"{
319 "five_hour": {
320 "utilization": 50.0,
321 "resets_at": null
322 },
323 "seven_day": {
324 "utilization": 25.0,
325 "resets_at": null
326 }
327 }"#;
328
329 let usage: UsageData = serde_json::from_str(json).expect("should parse");
330 assert!((usage.five_hour.utilization - 50.0).abs() < f64::EPSILON);
331 assert!(usage.five_hour.resets_at.is_none());
332 assert!(usage.seven_day.resets_at.is_none());
333 }
334}