Skip to main content

linesmith_core/segments/
rate_limit_5h.rs

1//! 5-hour rate-limit utilization segment.
2//!
3//! Reads `ctx.usage()` (Arc<Result<UsageData, UsageError>>) and
4//! renders either a percentage, a progress bar, or one of the error
5//! strings from `docs/specs/rate-limit-segments.md` §Error message
6//! table. Hidden entirely when the bucket is missing from a
7//! successful response (accounts with no 5-hour window exposed).
8
9use std::collections::BTreeMap;
10
11use super::rate_limit_format::{
12    apply_common_extras, format_jsonl_tokens, format_percent, parse_bool, parse_percent_format,
13    render_error, CommonRateLimitConfig, PercentFormat,
14};
15use super::{RenderContext, RenderResult, RenderedSegment, Segment, SegmentDefaults};
16use crate::data_context::{DataContext, DataDep, UsageData};
17use crate::theme::Role;
18
19/// Between model (64) and effort (160). Rate-limit visibility is
20/// high-demand but the data is cached/delayed, so it yields before
21/// live-health metrics.
22pub(crate) const PRIORITY: u8 = 96;
23
24#[non_exhaustive]
25pub struct RateLimit5hSegment {
26    pub format: PercentFormat,
27    pub invert: bool,
28    pub config: CommonRateLimitConfig,
29}
30
31impl Default for RateLimit5hSegment {
32    fn default() -> Self {
33        Self {
34            format: PercentFormat::Percent,
35            invert: false,
36            config: CommonRateLimitConfig::new("5h"),
37        }
38    }
39}
40
41impl RateLimit5hSegment {
42    /// Build from a `[segments.rate_limit_5h]` TOML extras bag. Keys
43    /// absent from `extras` inherit the spec defaults.
44    #[must_use]
45    pub fn from_extras(
46        extras: &BTreeMap<String, toml::Value>,
47        warn: &mut impl FnMut(&str),
48    ) -> Self {
49        let mut seg = Self::default();
50        apply_common_extras(&mut seg.config, extras, "rate_limit_5h", warn);
51        if let Some(f) = parse_percent_format(extras, "rate_limit_5h", warn) {
52            seg.format = f;
53        }
54        if let Some(b) = parse_bool(extras, "invert", "rate_limit_5h", warn) {
55            seg.invert = b;
56        }
57        // Spec §Edge cases: invalid progress_width falls back to
58        // percent format, not just a default-width bar.
59        if seg.config.invalid_progress_width {
60            seg.format = PercentFormat::Percent;
61        }
62        seg
63    }
64}
65
66impl Segment for RateLimit5hSegment {
67    fn render(&self, ctx: &DataContext, _rc: &RenderContext) -> RenderResult {
68        let usage = ctx.usage();
69        let text = match &*usage {
70            Ok(UsageData::Endpoint(e)) => match &e.five_hour {
71                Some(bucket) => format_percent(bucket, self.format, self.invert, &self.config),
72                None => {
73                    crate::lsm_debug!("rate_limit_5h: endpoint usage.five_hour absent; hiding");
74                    return Ok(None);
75                }
76            },
77            Ok(UsageData::Jsonl(j)) => match &j.five_hour {
78                Some(window) => format_jsonl_tokens(window.tokens.total(), &self.config),
79                None => {
80                    crate::lsm_debug!("rate_limit_5h: jsonl five_hour block inactive; hiding");
81                    return Ok(None);
82                }
83            },
84            Err(err) => render_error(err, &self.config),
85        };
86        Ok(Some(RenderedSegment::new(text).with_role(Role::Info)))
87    }
88
89    fn data_deps(&self) -> &'static [DataDep] {
90        &[DataDep::Usage]
91    }
92
93    fn defaults(&self) -> SegmentDefaults {
94        SegmentDefaults::with_priority(PRIORITY)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::data_context::{
102        EndpointUsage, ExtraUsage, FiveHourWindow, JsonlUsage, SevenDayWindow, TokenCounts,
103        UsageBucket, UsageData, UsageError,
104    };
105    use crate::input::{ModelInfo, Percent, StatusContext, Tool, WorkspaceInfo};
106    use chrono::{Duration as ChronoDuration, Utc};
107    use std::path::PathBuf;
108    use std::sync::Arc;
109
110    fn rc() -> RenderContext {
111        RenderContext::new(80)
112    }
113
114    fn ctx_with_usage(usage: Result<UsageData, UsageError>) -> DataContext {
115        let dc = DataContext::new(StatusContext {
116            tool: Tool::ClaudeCode,
117            model: Some(ModelInfo {
118                display_name: "X".into(),
119            }),
120            workspace: Some(WorkspaceInfo {
121                project_dir: PathBuf::from("/repo"),
122                git_worktree: None,
123            }),
124            context_window: None,
125            cost: None,
126            effort: None,
127            vim: None,
128            output_style: None,
129            agent_name: None,
130            version: None,
131            raw: Arc::new(serde_json::Value::Null),
132        });
133        dc.preseed_usage(usage).expect("seed");
134        dc
135    }
136
137    fn endpoint_data_with_five_hour(pct: f32) -> UsageData {
138        UsageData::Endpoint(EndpointUsage {
139            five_hour: Some(UsageBucket {
140                utilization: Percent::new(pct).unwrap(),
141                resets_at: None,
142            }),
143            seven_day: None,
144            seven_day_opus: None,
145            seven_day_sonnet: None,
146            seven_day_oauth_apps: None,
147            extra_usage: None,
148            unknown_buckets: std::collections::HashMap::new(),
149        })
150    }
151
152    fn endpoint_empty() -> UsageData {
153        UsageData::Endpoint(EndpointUsage {
154            five_hour: None,
155            seven_day: None,
156            seven_day_opus: None,
157            seven_day_sonnet: None,
158            seven_day_oauth_apps: None,
159            extra_usage: None,
160            unknown_buckets: std::collections::HashMap::new(),
161        })
162    }
163
164    fn jsonl_with_five_hour_tokens(total: u64) -> UsageData {
165        let tokens = TokenCounts::from_parts(total, 0, 0, 0);
166        // Block start ~1h ago → ends_at() lands ~4h in the future.
167        let start = Utc::now() - ChronoDuration::hours(1);
168        UsageData::Jsonl(JsonlUsage::new(
169            Some(FiveHourWindow::new(tokens, start)),
170            SevenDayWindow::new(TokenCounts::default()),
171        ))
172    }
173
174    #[test]
175    fn hidden_when_five_hour_bucket_absent() {
176        let rendered = RateLimit5hSegment::default()
177            .render(&ctx_with_usage(Ok(endpoint_empty())), &rc())
178            .expect("render ok");
179        assert_eq!(rendered, None);
180    }
181
182    #[test]
183    fn renders_percent_happy_path() {
184        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(22.0)));
185        let rendered = RateLimit5hSegment::default()
186            .render(&dc, &rc())
187            .expect("render ok")
188            .expect("visible");
189        assert_eq!(rendered.text(), "5h: 22.0%");
190    }
191
192    #[test]
193    fn renders_inverted_percent_when_configured() {
194        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(22.0)));
195        let seg = RateLimit5hSegment {
196            invert: true,
197            ..Default::default()
198        };
199        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
200        assert_eq!(rendered.text(), "5h: 78.0%");
201    }
202
203    #[test]
204    fn jsonl_mode_renders_compact_tokens_with_stale_marker() {
205        let dc = ctx_with_usage(Ok(jsonl_with_five_hour_tokens(420_000)));
206        let rendered = RateLimit5hSegment::default()
207            .render(&dc, &rc())
208            .unwrap()
209            .expect("visible");
210        assert_eq!(rendered.text(), "~5h: 420k");
211    }
212
213    #[test]
214    fn jsonl_mode_hides_when_no_active_block() {
215        // Inactive block → aggregator returns None for the 5h window,
216        // which matches endpoint's "bucket absent" semantic.
217        let dc = ctx_with_usage(Ok(UsageData::Jsonl(JsonlUsage::new(
218            None,
219            SevenDayWindow::new(TokenCounts::default()),
220        ))));
221        assert_eq!(
222            RateLimit5hSegment::default().render(&dc, &rc()).unwrap(),
223            None
224        );
225    }
226
227    #[test]
228    fn jsonl_mode_ignores_invert_and_progress_knobs() {
229        // Invert/progress only make sense against a 0-100 axis; under
230        // JSONL there's no ceiling so the segment still renders raw
231        // tokens with just the stale marker.
232        let dc = ctx_with_usage(Ok(jsonl_with_five_hour_tokens(1_200_000)));
233        let seg = RateLimit5hSegment {
234            format: PercentFormat::Progress,
235            invert: true,
236            ..Default::default()
237        };
238        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
239        assert_eq!(rendered.text(), "~5h: 1.2M");
240    }
241
242    #[test]
243    fn renders_progress_bar_when_format_is_progress() {
244        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(50.0)));
245        let seg = RateLimit5hSegment {
246            format: PercentFormat::Progress,
247            ..Default::default()
248        };
249        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
250        assert!(rendered.text().starts_with("5h: "), "{}", rendered.text());
251        assert!(rendered.text().contains("█"));
252        assert!(rendered.text().ends_with("50.0%"), "{}", rendered.text());
253    }
254
255    #[test]
256    fn progress_bar_at_zero_is_entirely_empty_cells() {
257        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(0.0)));
258        let seg = RateLimit5hSegment {
259            format: PercentFormat::Progress,
260            ..Default::default()
261        };
262        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
263        assert!(rendered.text().contains("░"));
264        assert!(!rendered.text().contains("█"));
265        assert!(rendered.text().ends_with("0.0%"), "{}", rendered.text());
266    }
267
268    #[test]
269    fn progress_bar_at_one_hundred_is_entirely_filled_cells() {
270        let dc = ctx_with_usage(Ok(endpoint_data_with_five_hour(100.0)));
271        let seg = RateLimit5hSegment {
272            format: PercentFormat::Progress,
273            ..Default::default()
274        };
275        let rendered = seg.render(&dc, &rc()).unwrap().expect("visible");
276        assert!(rendered.text().contains("█"));
277        assert!(!rendered.text().contains("░"));
278        assert!(rendered.text().ends_with("100.0%"), "{}", rendered.text());
279    }
280
281    #[test]
282    fn renders_error_table_strings() {
283        let dc = ctx_with_usage(Err(UsageError::Timeout));
284        let rendered = RateLimit5hSegment::default()
285            .render(&dc, &rc())
286            .unwrap()
287            .expect("visible");
288        assert_eq!(rendered.text(), "5h: [Timeout]");
289    }
290
291    #[test]
292    fn declares_usage_as_its_only_data_dep() {
293        let deps = RateLimit5hSegment::default().data_deps();
294        assert_eq!(deps, &[DataDep::Usage]);
295    }
296
297    #[test]
298    fn from_extras_applies_format_invert_and_common_knobs() {
299        let mut extras = std::collections::BTreeMap::new();
300        extras.insert("format".into(), toml::Value::String("progress".into()));
301        extras.insert("invert".into(), toml::Value::Boolean(true));
302        extras.insert("label".into(), toml::Value::String("five".into()));
303        extras.insert("icon".into(), toml::Value::String("⏱".into()));
304        extras.insert("stale_marker".into(), toml::Value::String("*".into()));
305        extras.insert("progress_width".into(), toml::Value::Integer(10));
306        let mut warnings = Vec::new();
307        let seg = RateLimit5hSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
308        assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
309        assert_eq!(seg.format, PercentFormat::Progress);
310        assert!(seg.invert);
311        assert_eq!(seg.config.label, "five");
312        assert_eq!(seg.config.icon, "⏱");
313        assert_eq!(seg.config.stale_marker, "*");
314        assert_eq!(seg.config.progress_width, 10);
315    }
316
317    #[test]
318    fn from_extras_flips_progress_to_percent_on_invalid_width() {
319        // Spec §Edge cases: invalid `progress_width` warns AND forces
320        // fallback to percent format; a silent default-width bar
321        // would contradict the user's stated intent.
322        let mut extras = std::collections::BTreeMap::new();
323        extras.insert("format".into(), toml::Value::String("progress".into()));
324        extras.insert("progress_width".into(), toml::Value::Integer(0));
325        let mut warnings = Vec::new();
326        let seg = RateLimit5hSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
327        assert_eq!(warnings.len(), 1);
328        assert!(warnings[0].contains("progress_width"), "{:?}", warnings[0]);
329        assert_eq!(seg.format, PercentFormat::Percent);
330    }
331
332    #[test]
333    fn from_extras_warns_on_bad_format_string() {
334        let mut extras = std::collections::BTreeMap::new();
335        extras.insert("format".into(), toml::Value::String("bogus".into()));
336        let mut warnings = Vec::new();
337        let seg = RateLimit5hSegment::from_extras(&extras, &mut |m| warnings.push(m.to_string()));
338        assert_eq!(warnings.len(), 1);
339        assert!(warnings[0].contains("format"), "{:?}", warnings[0]);
340        assert_eq!(seg.format, PercentFormat::Percent);
341    }
342
343    #[test]
344    fn does_not_read_extra_usage_field() {
345        // Regression guard: the 5h segment must not accidentally
346        // render extra_usage state when its own bucket is absent.
347        let data = UsageData::Endpoint(EndpointUsage {
348            five_hour: None,
349            seven_day: None,
350            seven_day_opus: None,
351            seven_day_sonnet: None,
352            seven_day_oauth_apps: None,
353            extra_usage: Some(ExtraUsage {
354                is_enabled: Some(true),
355                utilization: Some(Percent::new(50.0).unwrap()),
356                monthly_limit: Some(100.0),
357                used_credits: Some(50.0),
358                currency: Some("USD".into()),
359            }),
360            unknown_buckets: std::collections::HashMap::new(),
361        });
362        let rendered = RateLimit5hSegment::default()
363            .render(&ctx_with_usage(Ok(data)), &rc())
364            .unwrap();
365        assert_eq!(rendered, None);
366    }
367}