1use chrono::{DateTime, Utc};
9
10use super::client::{RunCodexBarUsageOptions, run_codexbar_usage};
11use super::errors::CodexBarError;
12use super::types::{CodexBarUsageResponse, CodexBarWindow};
13
14#[derive(Debug, Clone)]
16pub enum AgentLimit {
17 NotLimited,
19 Limited { reset_time: Option<DateTime<Utc>> },
22}
23
24const FALLBACK_RESET_SECS: i64 = 5 * 60;
27
28fn parse_resets_at(resets_at: Option<&str>, now: DateTime<Utc>) -> DateTime<Utc> {
29 if let Some(s) = resets_at
30 && let Ok(parsed) = DateTime::parse_from_rfc3339(s)
31 {
32 return parsed.with_timezone(&Utc);
33 }
34 now + chrono::Duration::seconds(FALLBACK_RESET_SECS)
35}
36
37fn is_limited(window: &CodexBarWindow) -> bool {
38 window.used_percent >= 100.0
39}
40
41fn classify(response: &CodexBarUsageResponse, now: DateTime<Utc>) -> AgentLimit {
42 let usage = &response.usage;
43 let mut windows: Vec<&CodexBarWindow> = Vec::new();
44 windows.extend(usage.primary.as_ref());
45 windows.extend(usage.secondary.as_ref());
46 windows.extend(usage.tertiary.as_ref());
47 if let Some(extra) = &usage.extra_rate_windows {
48 windows.extend(extra.iter().map(|named| &named.window));
49 }
50
51 let earliest = windows
52 .into_iter()
53 .filter(|w| is_limited(w))
54 .map(|w| parse_resets_at(w.resets_at.as_deref(), now))
55 .min();
56
57 match earliest {
58 Some(reset_time) => AgentLimit::Limited {
59 reset_time: Some(reset_time),
60 },
61 None => AgentLimit::NotLimited,
62 }
63}
64
65pub async fn check_limit(provider: &str) -> Result<AgentLimit, CodexBarError> {
71 check_limit_with(provider, &RunCodexBarUsageOptions::default()).await
72}
73
74pub async fn check_limit_with(
80 provider: &str,
81 opts: &RunCodexBarUsageOptions,
82) -> Result<AgentLimit, CodexBarError> {
83 let response = run_codexbar_usage(provider, opts).await?;
84 Ok(classify(&response, Utc::now()))
85}
86
87#[cfg(test)]
88#[expect(clippy::expect_used, reason = "tests may panic on unexpected fixtures")]
89mod tests {
90 use super::*;
91
92 fn window(used_percent: f64, resets_at: Option<&str>) -> CodexBarWindow {
93 CodexBarWindow {
94 used_percent,
95 window_minutes: None,
96 resets_at: resets_at.map(ToString::to_string),
97 reset_description: None,
98 next_regen_percent: None,
99 }
100 }
101
102 fn response(primary: CodexBarWindow, secondary: CodexBarWindow) -> CodexBarUsageResponse {
103 CodexBarUsageResponse {
104 provider: "codex".to_string(),
105 usage: super::super::types::CodexBarUsage {
106 primary: Some(primary),
107 secondary: Some(secondary),
108 tertiary: None,
109 extra_rate_windows: None,
110 },
111 }
112 }
113
114 #[test]
115 fn not_limited_when_all_windows_under_100() {
116 let resp = response(window(50.0, None), window(30.0, None));
117 assert!(matches!(
118 classify(&resp, Utc::now()),
119 AgentLimit::NotLimited
120 ));
121 }
122
123 #[test]
124 fn limited_when_any_window_at_100() {
125 let resp = response(
126 window(100.0, Some("2099-01-01T00:00:00Z")),
127 window(30.0, None),
128 );
129 match classify(&resp, Utc::now()) {
130 AgentLimit::Limited { reset_time } => {
131 let reset = reset_time.expect("reset present");
132 assert_eq!(
133 reset,
134 DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
135 .expect("parse")
136 .with_timezone(&Utc)
137 );
138 }
139 AgentLimit::NotLimited => panic!("expected limited"),
140 }
141 }
142
143 #[test]
144 fn picks_earliest_reset_across_limited_windows() {
145 let resp = response(
146 window(100.0, Some("2099-06-01T00:00:00Z")),
147 window(100.0, Some("2099-01-01T00:00:00Z")),
148 );
149 match classify(&resp, Utc::now()) {
150 AgentLimit::Limited { reset_time } => {
151 let reset = reset_time.expect("reset present");
152 assert_eq!(
153 reset,
154 DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
155 .expect("parse")
156 .with_timezone(&Utc)
157 );
158 }
159 AgentLimit::NotLimited => panic!("expected limited"),
160 }
161 }
162
163 #[test]
164 fn limited_window_without_resets_at_uses_fallback() {
165 let now = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
166 .expect("parse")
167 .with_timezone(&Utc);
168 let resp = response(window(100.0, None), window(10.0, None));
169 match classify(&resp, now) {
170 AgentLimit::Limited { reset_time } => {
171 let reset = reset_time.expect("reset present");
172 assert_eq!(reset, now + chrono::Duration::seconds(FALLBACK_RESET_SECS));
173 }
174 AgentLimit::NotLimited => panic!("expected limited"),
175 }
176 }
177
178 #[test]
179 fn counts_extra_rate_windows() {
180 let mut resp = response(window(10.0, None), window(20.0, None));
181 resp.usage.extra_rate_windows = Some(vec![super::super::types::NamedCodexBarWindow {
182 id: "daily".to_string(),
183 title: "Daily".to_string(),
184 window: window(100.0, Some("2099-03-03T00:00:00Z")),
185 }]);
186 assert!(matches!(
187 classify(&resp, Utc::now()),
188 AgentLimit::Limited { .. }
189 ));
190 }
191}