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    pub resets_at: DateTime<Utc>,
39}
40
41/// Extra usage billing information.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
43pub struct ExtraUsage {
44    /// Whether extra usage billing is enabled.
45    pub is_enabled: bool,
46
47    /// Amount of extra usage consumed (in dollars, if enabled).
48    #[serde(default)]
49    pub amount_used: Option<f64>,
50
51    /// Extra usage spending limit (in dollars, if set).
52    #[serde(default)]
53    pub limit: Option<f64>,
54}
55
56impl UsagePeriod {
57    /// Calculate time remaining until this period resets.
58    ///
59    /// Returns a negative duration if the reset time has passed.
60    pub fn time_until_reset(&self) -> chrono::TimeDelta {
61        self.resets_at - Utc::now()
62    }
63
64    /// Calculate percentage of time period elapsed.
65    ///
66    /// # Arguments
67    ///
68    /// * `period_hours` - Total duration of the period in hours
69    ///
70    /// # Returns
71    ///
72    /// Percentage (0.0 - 100.0) of the period that has elapsed.
73    /// Clamped to valid range even if reset time is in the past.
74    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    /// Check if usage is on pace with time elapsed.
82    ///
83    /// Returns `true` if utilization percentage is less than or equal to
84    /// the percentage of time elapsed. This indicates sustainable usage
85    /// that won't exceed quota before reset.
86    ///
87    /// # Arguments
88    ///
89    /// * `period_hours` - Total duration of the period in hours
90    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    /// Check if 5-hour usage is on pace.
97    ///
98    /// Returns `true` if current 5-hour utilization is sustainable.
99    pub fn five_hour_on_pace(&self) -> bool {
100        self.five_hour.is_on_pace(5)
101    }
102
103    /// Check if 7-day usage is on pace.
104    ///
105    /// Returns `true` if current 7-day utilization is sustainable.
106    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        // 5 hours remaining out of 5 = 0% elapsed
193        let period = sample_usage_period(0.0, 5);
194        let elapsed = period.time_elapsed_percent(5);
195        // Allow small margin for test execution time
196        assert!(elapsed < 1.0, "elapsed should be near 0%: {}", elapsed);
197    }
198
199    #[test]
200    fn test_time_elapsed_percent_at_half() {
201        // 2.5 hours remaining out of 5 = 50% elapsed
202        let period = UsagePeriod {
203            utilization: 50.0,
204            resets_at: Utc::now() + Duration::minutes(150), // 2.5 hours
205        };
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        // 50% time elapsed but only 30% usage = on pace
217        let period = UsagePeriod {
218            utilization: 30.0,
219            resets_at: Utc::now() + Duration::minutes(150), // 50% remaining
220        };
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        // 50% time elapsed but 70% usage = not on pace
230        let period = UsagePeriod {
231            utilization: 70.0,
232            resets_at: Utc::now() + Duration::minutes(150), // 50% remaining
233        };
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), // 10% used, ~20% time elapsed
244            seven_day: sample_usage_period(50.0, 84), // 50% used, 50% time elapsed
245            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), // ~50% time remaining, 40% used
256            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}