1use 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
19pub(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 #[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 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 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 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 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 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 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}