Skip to main content

linesmith_core/segments/
rate_limit_5h_reset.rs

1//! 5-hour window reset countdown. Hidden when `resets_at` is missing
2//! or in the past (spec §Edge cases — stale data that cache TTL will
3//! refresh on the next render).
4
5use 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                        // Spec §JSONL-fallback display: derive the
82                        // reset timestamp from the active block's
83                        // `ends_at` (= block.start + 5h). Hide when no
84                        // active block — same hide rule as endpoint.
85                        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        // 30s of slack so clock drift between setup and render doesn't
166        // round `num_minutes()` down a boundary (e.g. 277m → 276m), which
167        // would flip `"4hr 37m"` to `"4hr 36m"`.
168        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        // Block must start 5h before the desired reset because
194        // `FiveHourWindow::ends_at()` is derived as `start + 5h`.
195        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        // Regression guard matching the 7d_reset version: a 5h reset
288        // at 30m remaining must render ~90% elapsed against the 5h
289        // window, not ~0.3% against a 7d window.
290        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        // Spec §JSONL-fallback display: reset timestamp derives from
313        // `FiveHourWindow.ends_at` (= block.start + 5h) rather than
314        // the endpoint's `resets_at`.
315        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        // The aggregator's active-block invariant should prevent this
340        // (compute_active_block hides blocks whose last activity is
341        // >5h old), but the segment's `remaining <= 0` gate must also
342        // apply to the JSONL branch as defense in depth. Regression
343        // this catches: a future aggregator loosening that lets stale
344        // blocks through must not render "0m" or a negative duration.
345        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        // `format = "percent"` is valid for utilization segments but
380        // NOT for reset segments; parse_duration_format rejects it.
381        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}