1use super::rate_limit_5h::PRIORITY;
6use std::collections::BTreeMap;
7
8use super::rate_limit_format::{
9 apply_common_extras, format_duration, parse_bool, parse_duration_format, render_error,
10 CommonRateLimitConfig, DurationFormat, ResetWindow,
11};
12use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
13use crate::data_context::{DataContext, DataDep, UsageData};
14use crate::theme::Role;
15
16#[non_exhaustive]
17pub struct RateLimit5hResetSegment {
18 pub format: DurationFormat,
19 pub compact: bool,
20 pub use_days: bool,
21 pub config: CommonRateLimitConfig,
22}
23
24impl Default for RateLimit5hResetSegment {
25 fn default() -> Self {
26 Self {
27 format: DurationFormat::Duration,
28 compact: false,
29 use_days: true,
30 config: CommonRateLimitConfig::new("5h reset"),
31 }
32 }
33}
34
35impl RateLimit5hResetSegment {
36 #[must_use]
37 pub fn from_extras(
38 extras: &BTreeMap<String, toml::Value>,
39 warn: &mut impl FnMut(&str),
40 ) -> Self {
41 let mut seg = Self::default();
42 apply_common_extras(&mut seg.config, extras, "rate_limit_5h_reset", warn);
43 if let Some(f) = parse_duration_format(extras, "rate_limit_5h_reset", warn) {
44 seg.format = f;
45 }
46 if let Some(b) = parse_bool(extras, "compact", "rate_limit_5h_reset", warn) {
47 seg.compact = b;
48 }
49 if let Some(b) = parse_bool(extras, "use_days", "rate_limit_5h_reset", warn) {
50 seg.use_days = b;
51 }
52 if seg.config.invalid_progress_width {
53 seg.format = DurationFormat::Duration;
54 }
55 seg
56 }
57}
58
59impl Segment for RateLimit5hResetSegment {
60 fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
61 let usage = ctx.usage();
62 let text = match &*usage {
63 Ok(data) => {
64 let (resets_at, jsonl) = match data {
65 UsageData::Endpoint(e) => {
66 let Some(bucket) = e.five_hour.as_ref() else {
67 crate::lsm_debug!(
68 "rate_limit_5h_reset: endpoint usage.five_hour absent; hiding"
69 );
70 return Ok(None);
71 };
72 let Some(resets_at) = bucket.resets_at else {
73 crate::lsm_debug!(
74 "rate_limit_5h_reset: five_hour.resets_at absent; hiding"
75 );
76 return Ok(None);
77 };
78 (resets_at, false)
79 }
80 UsageData::Jsonl(j) => {
81 let Some(window) = j.five_hour.as_ref() else {
86 crate::lsm_debug!(
87 "rate_limit_5h_reset: jsonl five_hour block inactive; hiding"
88 );
89 return Ok(None);
90 };
91 (window.ends_at(), true)
92 }
93 };
94 let remaining = resets_at.signed_duration_since(chrono::Utc::now());
95 if remaining <= chrono::Duration::zero() {
96 crate::lsm_debug!(
97 "rate_limit_5h_reset: resets_at in the past ({resets_at}); hiding"
98 );
99 return Ok(None);
100 }
101 format_duration(
102 remaining,
103 self.format,
104 self.compact,
105 self.use_days,
106 ResetWindow::FiveHour,
107 jsonl,
108 &self.config,
109 )
110 }
111 Err(err) => render_error(err, &self.config),
112 };
113 Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
114 }
115
116 fn data_deps(&self) -> &'static [DataDep] {
117 &[DataDep::Usage]
118 }
119
120 fn defaults(&self) -> SegmentDefaults {
121 SegmentDefaults::with_priority(PRIORITY)
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::data_context::{
129 EndpointUsage, FiveHourWindow, JsonlUsage, SevenDayWindow, TokenCounts, UsageBucket,
130 UsageData, UsageError,
131 };
132 use crate::input::{ModelInfo, Percent, StatusContext, Tool, WorkspaceInfo};
133 use chrono::Duration;
134 use std::path::PathBuf;
135 use std::sync::Arc;
136
137 fn rc() -> RenderContext {
138 RenderContext::new(80)
139 }
140
141 fn ctx_with_usage(usage: Result<UsageData, UsageError>) -> DataContext {
142 let dc = DataContext::new(StatusContext {
143 tool: Tool::ClaudeCode,
144 model: Some(ModelInfo {
145 display_name: "X".into(),
146 }),
147 workspace: Some(WorkspaceInfo {
148 project_dir: PathBuf::from("/repo"),
149 git_worktree: None,
150 }),
151 context_window: None,
152 cost: None,
153 effort: None,
154 vim: None,
155 output_style: None,
156 agent_name: None,
157 version: None,
158 raw: Arc::new(serde_json::Value::Null),
159 });
160 dc.preseed_usage(usage).expect("seed");
161 dc
162 }
163
164 fn data_with_reset_in(minutes: i64) -> UsageData {
165 let slack = if minutes > 0 {
169 Duration::seconds(30)
170 } else {
171 Duration::zero()
172 };
173 UsageData::Endpoint(EndpointUsage {
174 five_hour: Some(UsageBucket {
175 utilization: Percent::new(42.0).unwrap(),
176 resets_at: Some(chrono::Utc::now() + Duration::minutes(minutes) + slack),
177 }),
178 seven_day: None,
179 seven_day_opus: None,
180 seven_day_sonnet: None,
181 seven_day_oauth_apps: None,
182 extra_usage: None,
183 unknown_buckets: std::collections::HashMap::new(),
184 })
185 }
186
187 fn jsonl_data_with_reset_in(minutes: i64) -> UsageData {
188 let slack = if minutes > 0 {
189 Duration::seconds(30)
190 } else {
191 Duration::zero()
192 };
193 let start = chrono::Utc::now() + Duration::minutes(minutes) + slack - Duration::hours(5);
196 UsageData::Jsonl(JsonlUsage::new(
197 Some(FiveHourWindow::new(TokenCounts::default(), start)),
198 SevenDayWindow::new(TokenCounts::default()),
199 ))
200 }
201
202 #[test]
203 fn renders_countdown_in_default_format() {
204 let dc = ctx_with_usage(Ok(data_with_reset_in(4 * 60 + 37)));
205 let rendered = RateLimit5hResetSegment::default()
206 .render(&dc, &rc())
207 .unwrap()
208 .expect("visible");
209 assert_eq!(rendered.text(), "5h reset: 4hr 37m");
210 }
211
212 #[test]
213 fn hidden_when_resets_at_in_past() {
214 let dc = ctx_with_usage(Ok(data_with_reset_in(-10)));
215 assert_eq!(
216 RateLimit5hResetSegment::default()
217 .render(&dc, &rc())
218 .unwrap(),
219 None
220 );
221 }
222
223 #[test]
224 fn hidden_when_resets_at_missing() {
225 let data = UsageData::Endpoint(EndpointUsage {
226 five_hour: Some(UsageBucket {
227 utilization: Percent::new(42.0).unwrap(),
228 resets_at: None,
229 }),
230 seven_day: None,
231 seven_day_opus: None,
232 seven_day_sonnet: None,
233 seven_day_oauth_apps: None,
234 extra_usage: None,
235 unknown_buckets: std::collections::HashMap::new(),
236 });
237 assert_eq!(
238 RateLimit5hResetSegment::default()
239 .render(&ctx_with_usage(Ok(data)), &rc())
240 .unwrap(),
241 None,
242 );
243 }
244
245 #[test]
246 fn hidden_when_five_hour_bucket_absent() {
247 let data = UsageData::Endpoint(EndpointUsage {
248 five_hour: None,
249 seven_day: None,
250 seven_day_opus: None,
251 seven_day_sonnet: None,
252 seven_day_oauth_apps: None,
253 extra_usage: None,
254 unknown_buckets: std::collections::HashMap::new(),
255 });
256 assert_eq!(
257 RateLimit5hResetSegment::default()
258 .render(&ctx_with_usage(Ok(data)), &rc())
259 .unwrap(),
260 None,
261 );
262 }
263
264 #[test]
265 fn compact_format_drops_suffix_spaces() {
266 let seg = RateLimit5hResetSegment {
267 compact: true,
268 ..Default::default()
269 };
270 let dc = ctx_with_usage(Ok(data_with_reset_in(4 * 60 + 37)));
271 let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
272 assert_eq!(rendered.text(), "5h reset: 4h37m");
273 }
274
275 #[test]
276 fn renders_error_when_usage_fails() {
277 let dc = ctx_with_usage(Err(UsageError::Timeout));
278 let rendered = RateLimit5hResetSegment::default()
279 .render(&dc, &rc())
280 .unwrap()
281 .expect("visible");
282 assert_eq!(rendered.text(), "5h reset: [Timeout]");
283 }
284
285 #[test]
286 fn progress_format_divides_by_five_hour_window_not_seven_day() {
287 let dc = ctx_with_usage(Ok(data_with_reset_in(30)));
291 let seg = RateLimit5hResetSegment {
292 format: DurationFormat::Progress,
293 ..Default::default()
294 };
295 let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
296 let pct_str = rendered
297 .text()
298 .rsplit(' ')
299 .next()
300 .expect("percent suffix")
301 .trim_end_matches('%');
302 let pct: f64 = pct_str.parse().expect("numeric percent");
303 assert!(
304 (88.0..=92.0).contains(&pct),
305 "expected ~90% elapsed, got {pct}% from {:?}",
306 rendered.text(),
307 );
308 }
309
310 #[test]
311 fn jsonl_mode_derives_reset_from_five_hour_window_ends_at() {
312 let dc = ctx_with_usage(Ok(jsonl_data_with_reset_in(4 * 60 + 37)));
316 let rendered = RateLimit5hResetSegment::default()
317 .render(&dc, &rc())
318 .unwrap()
319 .expect("visible");
320 assert_eq!(rendered.text(), "~5h reset: 4hr 37m");
321 }
322
323 #[test]
324 fn jsonl_mode_hides_when_block_inactive() {
325 let dc = ctx_with_usage(Ok(UsageData::Jsonl(JsonlUsage::new(
326 None,
327 SevenDayWindow::new(TokenCounts::default()),
328 ))));
329 assert_eq!(
330 RateLimit5hResetSegment::default()
331 .render(&dc, &rc())
332 .unwrap(),
333 None,
334 );
335 }
336
337 #[test]
338 fn jsonl_mode_hides_when_ends_at_in_past() {
339 let dc = ctx_with_usage(Ok(jsonl_data_with_reset_in(-10)));
346 assert_eq!(
347 RateLimit5hResetSegment::default()
348 .render(&dc, &rc())
349 .unwrap(),
350 None,
351 );
352 }
353
354 #[test]
355 fn declares_usage_as_its_only_data_dep() {
356 assert_eq!(
357 RateLimit5hResetSegment::default().data_deps(),
358 &[DataDep::Usage],
359 );
360 }
361
362 #[test]
363 fn from_extras_applies_duration_format_knobs() {
364 let mut extras = std::collections::BTreeMap::new();
365 extras.insert("format".into(), toml::Value::String("duration".into()));
366 extras.insert("compact".into(), toml::Value::Boolean(true));
367 extras.insert("use_days".into(), toml::Value::Boolean(false));
368 let mut warnings = Vec::new();
369 let seg =
370 RateLimit5hResetSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
371 assert!(warnings.is_empty(), "{warnings:?}");
372 assert_eq!(seg.format, DurationFormat::Duration);
373 assert!(seg.compact);
374 assert!(!seg.use_days);
375 }
376
377 #[test]
378 fn from_extras_warns_on_percent_format_string() {
379 let mut extras = std::collections::BTreeMap::new();
382 extras.insert("format".into(), toml::Value::String("percent".into()));
383 let mut warnings = Vec::new();
384 let _ =
385 RateLimit5hResetSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
386 assert_eq!(warnings.len(), 1);
387 assert!(warnings[0].contains("format"), "{:?}", warnings[0]);
388 }
389}