Skip to main content

claude_usage/
types.rs

1//! Type definitions for Anthropic usage API responses.
2//!
3//! This module defines the structures that map to the JSON response
4//! from the Anthropic OAuth usage API.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9/// Main usage data returned by [`get_usage()`](crate::get_usage).
10///
11/// Contains utilization data for different time periods.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct UsageData {
14    /// 5-hour rolling window usage.
15    pub five_hour: UsagePeriod,
16
17    /// 7-day rolling window usage.
18    pub seven_day: UsagePeriod,
19
20    /// 7-day Sonnet-specific usage (if applicable).
21    #[serde(default)]
22    pub seven_day_sonnet: Option<UsagePeriod>,
23
24    /// Extra usage billing information (if enabled).
25    #[serde(default)]
26    pub extra_usage: Option<ExtraUsage>,
27}
28
29/// Usage data for a specific time period.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
31pub struct UsagePeriod {
32    /// Percentage of quota used (0.0 - 100.0+).
33    ///
34    /// Values over 100.0 indicate quota exceeded.
35    pub utilization: f64,
36
37    /// When this period's quota resets.
38    ///
39    /// May be `None` if the reset time is not available from the API.
40    #[serde(default)]
41    pub resets_at: Option<DateTime<Utc>>,
42}
43
44/// Extra usage billing information.
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
46pub struct ExtraUsage {
47    /// Whether extra usage billing is enabled.
48    pub is_enabled: bool,
49
50    /// Amount of extra usage consumed (in dollars, if enabled).
51    #[serde(default)]
52    pub amount_used: Option<f64>,
53
54    /// Extra usage spending limit (in dollars, if set).
55    #[serde(default)]
56    pub limit: Option<f64>,
57}
58
59impl UsagePeriod {
60    /// Calculate time remaining until this period resets.
61    ///
62    /// Returns `None` if reset time is not available from the API.
63    /// Returns a negative duration if the reset time has passed.
64    pub fn time_until_reset(&self) -> Option<chrono::TimeDelta> {
65        self.resets_at.map(|reset| reset - Utc::now())
66    }
67
68    /// Calculate percentage of time period elapsed.
69    ///
70    /// # Arguments
71    ///
72    /// * `period_hours` - Total duration of the period in hours
73    ///
74    /// # Returns
75    ///
76    /// `None` if reset time is not available.
77    /// Otherwise, percentage (0.0 - 100.0) of the period that has elapsed.
78    /// Clamped to valid range even if reset time is in the past.
79    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    /// Check if usage is on pace with time elapsed.
89    ///
90    /// Returns `None` if reset time is not available.
91    /// Returns `true` if utilization percentage is less than or equal to
92    /// the percentage of time elapsed. This indicates sustainable usage
93    /// that won't exceed quota before reset.
94    ///
95    /// # Arguments
96    ///
97    /// * `period_hours` - Total duration of the period in hours
98    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    /// Check if 5-hour usage is on pace.
106    ///
107    /// Returns `None` if reset time is not available.
108    /// Returns `true` if current 5-hour utilization is sustainable.
109    pub fn five_hour_on_pace(&self) -> Option<bool> {
110        self.five_hour.is_on_pace(5)
111    }
112
113    /// Check if 7-day usage is on pace.
114    ///
115    /// Returns `None` if reset time is not available.
116    /// Returns `true` if current 7-day utilization is sustainable.
117    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        // 5 hours remaining out of 5 = 0% elapsed
204        let period = sample_usage_period(0.0, 5);
205        let elapsed = period
206            .time_elapsed_percent(5)
207            .expect("reset time available");
208        // Allow small margin for test execution time
209        assert!(elapsed < 1.0, "elapsed should be near 0%: {}", elapsed);
210    }
211
212    #[test]
213    fn test_time_elapsed_percent_at_half() {
214        // 2.5 hours remaining out of 5 = 50% elapsed
215        let period = UsagePeriod {
216            utilization: 50.0,
217            resets_at: Some(Utc::now() + Duration::minutes(150)), // 2.5 hours
218        };
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        // 50% time elapsed but only 30% usage = on pace
241        let period = UsagePeriod {
242            utilization: 30.0,
243            resets_at: Some(Utc::now() + Duration::minutes(150)), // 50% remaining
244        };
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        // 50% time elapsed but 70% usage = not on pace
254        let period = UsagePeriod {
255            utilization: 70.0,
256            resets_at: Some(Utc::now() + Duration::minutes(150)), // 50% remaining
257        };
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), // 10% used, ~20% time elapsed
268            seven_day: sample_usage_period(50.0, 84), // 50% used, 50% time elapsed
269            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), // ~50% time remaining, 40% used
280            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}