Skip to main content

linesmith_core/segments/rate_limit/
five_hour.rs

1//! 5-hour rate-limit segments: utilization (`RateLimit5hSegment`) and
2//! reset countdown (`RateLimit5hResetSegment`). Both read `ctx.usage()`
3//! (`Arc<Result<UsageData, UsageError>>`) and route through the
4//! `UsageWindow::FiveHour` seam in `super::window`.
5//!
6//! - Utilization renders a percentage or progress bar from the endpoint
7//!   bucket, or the JSONL block's token total under the JSONL fallback.
8//!   Hidden when the bucket/block is absent. Spec:
9//!   `docs/specs/rate-limit-segments.md` §Render semantics.
10//! - Reset countdown renders the time remaining until `resets_at`
11//!   (endpoint) or `block.start + 5h` (JSONL fallback). Hidden when
12//!   `resets_at` is missing or in the past — spec §Edge cases.
13
14use std::collections::BTreeMap;
15
16use super::config::{
17    apply_common_extras, parse_percent_format, parse_reset_format, CommonRateLimitConfig,
18    PercentFormat, ResetFormat, PRIORITY,
19};
20use super::format::{format_jsonl_tokens, format_percent, format_reset, render_error, ResetWindow};
21use super::window::{resolve_five_hour_reset, ResetSource, UsageWindow, WindowResolution};
22use crate::data_context::{DataContext, DataDep};
23use crate::segments::extras::parse_bool;
24use crate::segments::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
25use crate::theme::Role;
26
27#[non_exhaustive]
28pub struct RateLimit5hSegment {
29    pub format: PercentFormat,
30    pub invert: bool,
31    pub config: CommonRateLimitConfig,
32}
33
34impl Default for RateLimit5hSegment {
35    fn default() -> Self {
36        Self {
37            format: PercentFormat::Percent,
38            invert: false,
39            config: CommonRateLimitConfig::new("5h"),
40        }
41    }
42}
43
44impl RateLimit5hSegment {
45    /// Build from a `[segments.rate_limit_5h]` TOML extras bag. Keys
46    /// absent from `extras` inherit the spec defaults.
47    #[must_use]
48    pub fn from_extras(
49        extras: &BTreeMap<String, toml::Value>,
50        warn: &mut impl FnMut(&str),
51    ) -> Self {
52        let mut seg = Self::default();
53        apply_common_extras(&mut seg.config, extras, "rate_limit_5h", warn);
54        if let Some(f) = parse_percent_format(extras, "rate_limit_5h", warn) {
55            seg.format = f;
56        }
57        if let Some(b) = parse_bool(extras, "invert", "rate_limit_5h", warn) {
58            seg.invert = b;
59        }
60        // Spec §Edge cases: invalid progress_width falls back to
61        // percent format, not just a default-width bar.
62        if seg.config.invalid_progress_width {
63            seg.format = PercentFormat::Percent;
64        }
65        seg
66    }
67}
68
69impl Segment for RateLimit5hSegment {
70    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
71        let usage = ctx.usage();
72        let text = match &*usage {
73            Ok(data) => match UsageWindow::FiveHour.resolve_percent(data) {
74                Ok(WindowResolution::Endpoint(bucket)) => {
75                    format_percent(bucket, self.format, self.invert, &self.config)
76                }
77                Ok(WindowResolution::JsonlTokens(total)) => {
78                    format_jsonl_tokens(total, &self.config)
79                }
80                Err(reason) => {
81                    crate::lsm_debug!("rate_limit_5h: {reason}; hiding");
82                    return Ok(None);
83                }
84            },
85            Err(err) => render_error(err, &self.config),
86        };
87        Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
88    }
89
90    fn data_deps(&self) -> &'static [DataDep] {
91        &[DataDep::Usage]
92    }
93
94    fn defaults(&self) -> SegmentDefaults {
95        SegmentDefaults::with_priority(PRIORITY)
96    }
97}
98
99#[non_exhaustive]
100pub struct RateLimit5hResetSegment {
101    pub format: ResetFormat,
102    pub compact: bool,
103    pub use_days: bool,
104    pub config: CommonRateLimitConfig,
105}
106
107impl Default for RateLimit5hResetSegment {
108    fn default() -> Self {
109        Self {
110            format: ResetFormat::Duration,
111            compact: false,
112            use_days: true,
113            config: CommonRateLimitConfig::new("5h reset"),
114        }
115    }
116}
117
118impl RateLimit5hResetSegment {
119    #[must_use]
120    pub fn from_extras(
121        extras: &BTreeMap<String, toml::Value>,
122        warn: &mut impl FnMut(&str),
123    ) -> Self {
124        let mut seg = Self::default();
125        apply_common_extras(&mut seg.config, extras, "rate_limit_5h_reset", warn);
126        if let Some(f) = parse_reset_format(extras, "rate_limit_5h_reset", warn) {
127            seg.format = f;
128        }
129        if let Some(b) = parse_bool(extras, "compact", "rate_limit_5h_reset", warn) {
130            seg.compact = b;
131        }
132        if let Some(b) = parse_bool(extras, "use_days", "rate_limit_5h_reset", warn) {
133            seg.use_days = b;
134        }
135        // `progress_width` only matters for `Progress`. An invalid
136        // value forced the spec's "fall back to duration" rule onto
137        // every variant pre-Absolute; now scope it so a stale
138        // `progress_width` doesn't clobber `format = "absolute"`.
139        if seg.config.invalid_progress_width && matches!(seg.format, ResetFormat::Progress) {
140            seg.format = ResetFormat::Duration;
141        }
142        seg
143    }
144}
145
146impl Segment for RateLimit5hResetSegment {
147    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
148        let usage = ctx.usage();
149        let text = match &*usage {
150            Ok(data) => {
151                let (resets_at, jsonl) = match resolve_five_hour_reset(data) {
152                    Ok(ResetSource::Endpoint(at)) => (at, false),
153                    Ok(ResetSource::JsonlBlockEnd(at)) => (at, true),
154                    Err(reason) => {
155                        crate::lsm_debug!("rate_limit_5h_reset: {reason}; hiding");
156                        return Ok(None);
157                    }
158                };
159                let remaining = resets_at.duration_since(jiff::Timestamp::now());
160                if remaining <= jiff::SignedDuration::ZERO {
161                    crate::lsm_debug!(
162                        "rate_limit_5h_reset: resets_at in the past ({resets_at}); hiding"
163                    );
164                    return Ok(None);
165                }
166                format_reset(
167                    resets_at,
168                    remaining,
169                    &self.format,
170                    self.compact,
171                    self.use_days,
172                    ResetWindow::FiveHour,
173                    jsonl,
174                    &self.config,
175                )
176            }
177            Err(err) => render_error(err, &self.config),
178        };
179        Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
180    }
181
182    fn data_deps(&self) -> &'static [DataDep] {
183        &[DataDep::Usage]
184    }
185
186    fn defaults(&self) -> SegmentDefaults {
187        SegmentDefaults::with_priority(PRIORITY)
188    }
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194    use crate::data_context::{
195        EndpointUsage, ExtraUsage, FiveHourWindow, JsonlUsage, SevenDayWindow, TokenCounts,
196        UsageBucket, UsageData, UsageError,
197    };
198    use crate::input::{ModelInfo, Percent, StatusContext, Tool, WorkspaceInfo};
199    use jiff::{SignedDuration, Timestamp};
200    use std::path::PathBuf;
201    use std::sync::Arc;
202
203    fn rc() -> RenderContext {
204        RenderContext::new(80)
205    }
206
207    fn ctx_with_usage(usage: Result<UsageData, UsageError>) -> DataContext {
208        let dc = DataContext::new(StatusContext {
209            tool: Tool::ClaudeCode,
210            model: Some(ModelInfo {
211                display_name: "X".into(),
212            }),
213            workspace: Some(WorkspaceInfo {
214                project_dir: PathBuf::from("/repo"),
215                git_worktree: None,
216            }),
217            context_window: None,
218            cost: None,
219            effort: None,
220            vim: None,
221            output_style: None,
222            agent_name: None,
223            version: None,
224            raw: Arc::new(serde_json::Value::Null),
225        });
226        dc.preseed_usage(usage).expect("seed");
227        dc
228    }
229
230    fn endpoint_data_with_five_hour(pct: f32) -> UsageData {
231        UsageData::Endpoint(EndpointUsage {
232            five_hour: Some(UsageBucket {
233                utilization: Percent::new(pct).unwrap(),
234                resets_at: None,
235            }),
236            seven_day: None,
237            seven_day_opus: None,
238            seven_day_sonnet: None,
239            seven_day_oauth_apps: None,
240            extra_usage: None,
241            unknown_buckets: std::collections::HashMap::new(),
242        })
243    }
244
245    fn endpoint_empty() -> UsageData {
246        UsageData::Endpoint(EndpointUsage {
247            five_hour: None,
248            seven_day: None,
249            seven_day_opus: None,
250            seven_day_sonnet: None,
251            seven_day_oauth_apps: None,
252            extra_usage: None,
253            unknown_buckets: std::collections::HashMap::new(),
254        })
255    }
256
257    fn jsonl_with_five_hour_tokens(total: u64) -> UsageData {
258        let tokens = TokenCounts::from_parts(total, 0, 0, 0);
259        // Block start ~1h ago → ends_at() lands ~4h in the future.
260        let start = Timestamp::now() - SignedDuration::from_hours(1);
261        UsageData::Jsonl(JsonlUsage::new(
262            Some(FiveHourWindow::new(tokens, start)),
263            SevenDayWindow::new(TokenCounts::default()),
264        ))
265    }
266
267    fn data_with_reset_in(minutes: i64) -> UsageData {
268        // 30s of slack so clock drift between setup and render doesn't
269        // round the `as_secs() / 60` boundary down (e.g. 277m → 276m),
270        // which would flip `"4hr 37m"` to `"4hr 36m"`.
271        let slack = if minutes > 0 {
272            SignedDuration::from_secs(30)
273        } else {
274            SignedDuration::ZERO
275        };
276        UsageData::Endpoint(EndpointUsage {
277            five_hour: Some(UsageBucket {
278                utilization: Percent::new(42.0).unwrap(),
279                resets_at: Some(Timestamp::now() + SignedDuration::from_mins(minutes) + slack),
280            }),
281            seven_day: None,
282            seven_day_opus: None,
283            seven_day_sonnet: None,
284            seven_day_oauth_apps: None,
285            extra_usage: None,
286            unknown_buckets: std::collections::HashMap::new(),
287        })
288    }
289
290    fn jsonl_data_with_reset_in(minutes: i64) -> UsageData {
291        let slack = if minutes > 0 {
292            SignedDuration::from_secs(30)
293        } else {
294            SignedDuration::ZERO
295        };
296        // Block must start 5h before the desired reset because
297        // `FiveHourWindow::ends_at()` is derived as `start + 5h`.
298        let start = Timestamp::now() + SignedDuration::from_mins(minutes) + slack
299            - SignedDuration::from_hours(5);
300        UsageData::Jsonl(JsonlUsage::new(
301            Some(FiveHourWindow::new(TokenCounts::default(), start)),
302            SevenDayWindow::new(TokenCounts::default()),
303        ))
304    }
305
306    #[test]
307    fn hidden_when_five_hour_bucket_absent() {
308        let rendered = RateLimit5hSegment::default()
309            .render(&ctx_with_usage(Ok(endpoint_empty())), &rc())
310            .expect("render ok");
311        assert_eq!(rendered, None);
312    }
313
314    #[test]
315    fn renders_percent_happy_path() {
316        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(22.0)));
317        let rendered = RateLimit5hSegment::default()
318            .render(&dc, &rc())
319            .expect("render ok")
320            .expect("visible");
321        assert_eq!(rendered.text(), "5h: 22.0%");
322    }
323
324    #[test]
325    fn renders_inverted_percent_when_configured() {
326        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(22.0)));
327        let seg = RateLimit5hSegment {
328            invert: true,
329            ..Default::default()
330        };
331        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
332        assert_eq!(rendered.text(), "5h: 78.0%");
333    }
334
335    #[test]
336    fn jsonl_mode_renders_compact_tokens_with_stale_marker() {
337        let dc = ctx_with_usage(Ok(jsonl_with_five_hour_tokens(420_000)));
338        let rendered = RateLimit5hSegment::default()
339            .render(&dc, &rc())
340            .unwrap()
341            .expect("visible");
342        assert_eq!(rendered.text(), "~5h: 420k");
343    }
344
345    #[test]
346    fn jsonl_mode_hides_when_no_active_block() {
347        // Inactive block → aggregator returns None for the 5h window,
348        // which matches endpoint's "bucket absent" semantic.
349        let dc = ctx_with_usage(Ok(UsageData::Jsonl(JsonlUsage::new(
350            None,
351            SevenDayWindow::new(TokenCounts::default()),
352        ))));
353        assert_eq!(
354            RateLimit5hSegment::default().render(&dc, &rc()).unwrap(),
355            None
356        );
357    }
358
359    #[test]
360    fn jsonl_mode_ignores_invert_and_progress_knobs() {
361        // Invert/progress only make sense against a 0-100 axis; under
362        // JSONL there's no ceiling so the segment still renders raw
363        // tokens with just the stale marker.
364        let dc = ctx_with_usage(Ok(jsonl_with_five_hour_tokens(1_200_000)));
365        let seg = RateLimit5hSegment {
366            format: PercentFormat::Progress,
367            invert: true,
368            ..Default::default()
369        };
370        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
371        assert_eq!(rendered.text(), "~5h: 1.2M");
372    }
373
374    #[test]
375    fn renders_progress_bar_when_format_is_progress() {
376        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(50.0)));
377        let seg = RateLimit5hSegment {
378            format: PercentFormat::Progress,
379            ..Default::default()
380        };
381        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
382        assert!(rendered.text().starts_with("5h: "), "{}", rendered.text());
383        assert!(rendered.text().contains("█"));
384        assert!(rendered.text().ends_with("50.0%"), "{}", rendered.text());
385    }
386
387    #[test]
388    fn progress_bar_at_zero_is_entirely_empty_cells() {
389        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(0.0)));
390        let seg = RateLimit5hSegment {
391            format: PercentFormat::Progress,
392            ..Default::default()
393        };
394        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
395        assert!(rendered.text().contains("░"));
396        assert!(!rendered.text().contains("█"));
397        assert!(rendered.text().ends_with("0.0%"), "{}", rendered.text());
398    }
399
400    #[test]
401    fn progress_bar_at_one_hundred_is_entirely_filled_cells() {
402        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(100.0)));
403        let seg = RateLimit5hSegment {
404            format: PercentFormat::Progress,
405            ..Default::default()
406        };
407        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
408        assert!(rendered.text().contains("█"));
409        assert!(!rendered.text().contains("░"));
410        assert!(rendered.text().ends_with("100.0%"), "{}", rendered.text());
411    }
412
413    #[test]
414    fn renders_error_table_strings() {
415        let dc = ctx_with_usage(Err(UsageError::Timeout));
416        let rendered = RateLimit5hSegment::default()
417            .render(&dc, &rc())
418            .unwrap()
419            .expect("visible");
420        assert_eq!(rendered.text(), "5h: [Timeout]");
421    }
422
423    #[test]
424    fn declares_usage_as_its_only_data_dep() {
425        let deps = RateLimit5hSegment::default().data_deps();
426        assert_eq!(deps, &[DataDep::Usage]);
427    }
428
429    #[test]
430    fn from_extras_applies_format_invert_and_common_knobs() {
431        let mut extras = std::collections::BTreeMap::new();
432        extras.insert("format".into(), toml::Value::String("progress".into()));
433        extras.insert("invert".into(), toml::Value::Boolean(true));
434        extras.insert("label".into(), toml::Value::String("five".into()));
435        extras.insert("icon".into(), toml::Value::String("⏱".into()));
436        extras.insert("stale_marker".into(), toml::Value::String("*".into()));
437        extras.insert("progress_width".into(), toml::Value::Integer(10));
438        let mut warnings = Vec::new();
439        let seg = RateLimit5hSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
440        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
441        assert_eq!(seg.format, PercentFormat::Progress);
442        assert!(seg.invert);
443        assert_eq!(seg.config.label, "five");
444        assert_eq!(seg.config.icon, "⏱");
445        assert_eq!(seg.config.stale_marker, "*");
446        assert_eq!(seg.config.progress_width, 10);
447    }
448
449    #[test]
450    fn from_extras_flips_progress_to_percent_on_invalid_width() {
451        // Spec §Edge cases: invalid `progress_width` warns AND forces
452        // fallback to percent format; a silent default-width bar
453        // would contradict the user's stated intent.
454        let mut extras = std::collections::BTreeMap::new();
455        extras.insert("format".into(), toml::Value::String("progress".into()));
456        extras.insert("progress_width".into(), toml::Value::Integer(0));
457        let mut warnings = Vec::new();
458        let seg = RateLimit5hSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
459        assert_eq!(warnings.len(), 1);
460        assert!(warnings[0].contains("progress_width"), "{:?}", warnings[0]);
461        assert_eq!(seg.format, PercentFormat::Percent);
462    }
463
464    #[test]
465    fn from_extras_warns_on_bad_format_string() {
466        let mut extras = std::collections::BTreeMap::new();
467        extras.insert("format".into(), toml::Value::String("bogus".into()));
468        let mut warnings = Vec::new();
469        let seg = RateLimit5hSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
470        assert_eq!(warnings.len(), 1);
471        assert!(warnings[0].contains("format"), "{:?}", warnings[0]);
472        assert_eq!(seg.format, PercentFormat::Percent);
473    }
474
475    #[test]
476    fn does_not_read_extra_usage_field() {
477        // Regression guard: the 5h segment must not accidentally
478        // render extra_usage state when its own bucket is absent.
479        let data = UsageData::Endpoint(EndpointUsage {
480            five_hour: None,
481            seven_day: None,
482            seven_day_opus: None,
483            seven_day_sonnet: None,
484            seven_day_oauth_apps: None,
485            extra_usage: Some(ExtraUsage {
486                is_enabled: Some(true),
487                utilization: Some(Percent::new(50.0).unwrap()),
488                monthly_limit: Some(100.0),
489                used_credits: Some(50.0),
490                currency: Some("USD".into()),
491            }),
492            unknown_buckets: std::collections::HashMap::new(),
493        });
494        let rendered = RateLimit5hSegment::default()
495            .render(&ctx_with_usage(Ok(data)), &rc())
496            .unwrap();
497        assert_eq!(rendered, None);
498    }
499
500    // ---- RateLimit5hResetSegment tests ----
501
502    #[test]
503    fn reset_renders_countdown_in_default_format() {
504        let dc = ctx_with_usage(Ok(data_with_reset_in(4 * 60 + 37)));
505        let rendered = RateLimit5hResetSegment::default()
506            .render(&dc, &rc())
507            .unwrap()
508            .expect("visible");
509        assert_eq!(rendered.text(), "5h reset: 4hr 37m");
510    }
511
512    #[test]
513    fn reset_hidden_when_resets_at_in_past() {
514        let dc = ctx_with_usage(Ok(data_with_reset_in(-10)));
515        assert_eq!(
516            RateLimit5hResetSegment::default()
517                .render(&dc, &rc())
518                .unwrap(),
519            None
520        );
521    }
522
523    #[test]
524    fn reset_hidden_when_resets_at_missing() {
525        let data = UsageData::Endpoint(EndpointUsage {
526            five_hour: Some(UsageBucket {
527                utilization: Percent::new(42.0).unwrap(),
528                resets_at: None,
529            }),
530            seven_day: None,
531            seven_day_opus: None,
532            seven_day_sonnet: None,
533            seven_day_oauth_apps: None,
534            extra_usage: None,
535            unknown_buckets: std::collections::HashMap::new(),
536        });
537        assert_eq!(
538            RateLimit5hResetSegment::default()
539                .render(&ctx_with_usage(Ok(data)), &rc())
540                .unwrap(),
541            None,
542        );
543    }
544
545    #[test]
546    fn reset_hidden_when_five_hour_bucket_absent() {
547        assert_eq!(
548            RateLimit5hResetSegment::default()
549                .render(&ctx_with_usage(Ok(endpoint_empty())), &rc())
550                .unwrap(),
551            None,
552        );
553    }
554
555    #[test]
556    fn reset_compact_format_drops_suffix_spaces() {
557        let seg = RateLimit5hResetSegment {
558            compact: true,
559            ..Default::default()
560        };
561        let dc = ctx_with_usage(Ok(data_with_reset_in(4 * 60 + 37)));
562        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
563        assert_eq!(rendered.text(), "5h reset: 4h37m");
564    }
565
566    #[test]
567    fn reset_renders_error_when_usage_fails() {
568        let dc = ctx_with_usage(Err(UsageError::Timeout));
569        let rendered = RateLimit5hResetSegment::default()
570            .render(&dc, &rc())
571            .unwrap()
572            .expect("visible");
573        assert_eq!(rendered.text(), "5h reset: [Timeout]");
574    }
575
576    #[test]
577    fn reset_progress_format_divides_by_five_hour_window_not_seven_day() {
578        // Regression guard matching the 7d_reset version: a 5h reset
579        // at 30m remaining must render ~90% elapsed against the 5h
580        // window, not ~0.3% against a 7d window.
581        let dc = ctx_with_usage(Ok(data_with_reset_in(30)));
582        let seg = RateLimit5hResetSegment {
583            format: ResetFormat::Progress,
584            ..Default::default()
585        };
586        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
587        let pct_str = rendered
588            .text()
589            .rsplit(' ')
590            .next()
591            .expect("percent suffix")
592            .trim_end_matches('%');
593        let pct: f64 = pct_str.parse().expect("numeric percent");
594        assert!(
595            (88.0..=92.0).contains(&pct),
596            "expected ~90% elapsed, got {pct}% from {:?}",
597            rendered.text(),
598        );
599    }
600
601    #[test]
602    fn reset_jsonl_mode_derives_reset_from_five_hour_window_ends_at() {
603        // Spec §JSONL-fallback display: reset timestamp derives from
604        // `FiveHourWindow.ends_at` (= block.start + 5h) rather than
605        // the endpoint's `resets_at`.
606        let dc = ctx_with_usage(Ok(jsonl_data_with_reset_in(4 * 60 + 37)));
607        let rendered = RateLimit5hResetSegment::default()
608            .render(&dc, &rc())
609            .unwrap()
610            .expect("visible");
611        assert_eq!(rendered.text(), "~5h reset: 4hr 37m");
612    }
613
614    #[test]
615    fn reset_jsonl_mode_hides_when_block_inactive() {
616        let dc = ctx_with_usage(Ok(UsageData::Jsonl(JsonlUsage::new(
617            None,
618            SevenDayWindow::new(TokenCounts::default()),
619        ))));
620        assert_eq!(
621            RateLimit5hResetSegment::default()
622                .render(&dc, &rc())
623                .unwrap(),
624            None,
625        );
626    }
627
628    #[test]
629    fn reset_jsonl_mode_hides_when_ends_at_in_past() {
630        // The aggregator's active-block invariant should prevent this
631        // (compute_active_block hides blocks whose last activity is
632        // >5h old), but the segment's `remaining <= 0` gate must also
633        // apply to the JSONL branch as defense in depth. Regression
634        // this catches: a future aggregator loosening that lets stale
635        // blocks through must not render "0m" or a negative duration.
636        let dc = ctx_with_usage(Ok(jsonl_data_with_reset_in(-10)));
637        assert_eq!(
638            RateLimit5hResetSegment::default()
639                .render(&dc, &rc())
640                .unwrap(),
641            None,
642        );
643    }
644
645    #[test]
646    fn reset_declares_usage_as_its_only_data_dep() {
647        assert_eq!(
648            RateLimit5hResetSegment::default().data_deps(),
649            &[DataDep::Usage],
650        );
651    }
652
653    #[test]
654    fn reset_from_extras_applies_duration_format_knobs() {
655        let mut extras = std::collections::BTreeMap::new();
656        extras.insert("format".into(), toml::Value::String("duration".into()));
657        extras.insert("compact".into(), toml::Value::Boolean(true));
658        extras.insert("use_days".into(), toml::Value::Boolean(false));
659        let mut warnings = Vec::new();
660        let seg =
661            RateLimit5hResetSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
662        assert!(warnings.is_empty(), "{warnings:?}");
663        assert_eq!(seg.format, ResetFormat::Duration);
664        assert!(seg.compact);
665        assert!(!seg.use_days);
666    }
667
668    #[test]
669    fn reset_from_extras_warns_on_percent_format_string() {
670        // `format = "percent"` is valid for utilization segments but
671        // NOT for reset segments; parse_reset_format rejects it.
672        let mut extras = std::collections::BTreeMap::new();
673        extras.insert("format".into(), toml::Value::String("percent".into()));
674        let mut warnings = Vec::new();
675        let _ =
676            RateLimit5hResetSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
677        assert_eq!(warnings.len(), 1);
678        assert!(warnings[0].contains("format"), "{:?}", warnings[0]);
679    }
680
681    #[test]
682    fn reset_invalid_progress_width_does_not_clobber_absolute_format() {
683        // Regression: pre-fix, an invalid `progress_width` rewrote
684        // every variant back to `Duration`, silently downgrading
685        // `format = "absolute"` for any user with a stale or mistyped
686        // width. `progress_width` is only meaningful for `Progress`,
687        // so the invalid-width fallback must scope to that variant.
688        let mut extras = std::collections::BTreeMap::new();
689        extras.insert("format".into(), toml::Value::String("absolute".into()));
690        extras.insert("progress_width".into(), toml::Value::Integer(0));
691        let mut warnings = Vec::new();
692        let seg =
693            RateLimit5hResetSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
694        assert!(
695            matches!(seg.format, ResetFormat::Absolute(_)),
696            "absolute survived: {:?}",
697            seg.format
698        );
699        assert!(
700            warnings.iter().any(|w| w.contains("progress_width")),
701            "expected progress_width warning: {warnings:?}"
702        );
703    }
704
705    #[test]
706    fn reset_absolute_format_renders_end_to_end_from_toml() {
707        // Integration test: TOML config string → from_extras
708        // → Segment::render → rendered string. Catches a regression
709        // where the parser keys, the from_extras consumer, and the
710        // render path drift apart.
711        use crate::config::Config;
712        use std::str::FromStr;
713
714        let cfg = Config::from_str(
715            r#"
716                [segments.rate_limit_5h_reset]
717                format = "absolute"
718                timezone = "America/Los_Angeles"
719                hour_format = "12h"
720                label = "5h reset"
721            "#,
722        )
723        .expect("config parses");
724        let extras = &cfg
725            .segments
726            .get("rate_limit_5h_reset")
727            .expect("segment block")
728            .extra;
729        let mut warnings = Vec::new();
730        let seg =
731            RateLimit5hResetSegment::from_extras(extras, &mut |m| warnings.push(m.to_string()));
732        assert!(warnings.is_empty(), "no warnings expected: {warnings:?}");
733        assert!(matches!(seg.format, ResetFormat::Absolute(_)));
734
735        // Render against a stub UsageData with `resets_at` 60 minutes
736        // out — 60 minutes is short enough that the test won't flake
737        // around DST boundaries on the test host's local clock.
738        let dc = ctx_with_usage(Ok(data_with_reset_in(60)));
739        let rendered = seg
740            .render(&dc, &rc())
741            .expect("render ok")
742            .expect("segment visible");
743        assert!(
744            rendered.text.contains("5h reset:"),
745            "label missing: {}",
746            rendered.text
747        );
748        assert!(
749            rendered.text.contains(" AM ") || rendered.text.contains(" PM "),
750            "12h marker missing: {}",
751            rendered.text
752        );
753    }
754}