1use std::collections::BTreeMap;
7
8use chrono::Duration;
9
10use crate::data_context::{CredentialError, ExtraUsage, JsonlError, UsageBucket, UsageError};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PercentFormat {
15 Percent,
16 Progress,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DurationFormat {
22 Duration,
23 Progress,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub(crate) enum ResetWindow {
30 FiveHour,
31 SevenDay,
32}
33
34impl ResetWindow {
35 fn total(self) -> Duration {
36 match self {
37 Self::FiveHour => Duration::hours(5),
38 Self::SevenDay => Duration::days(7),
39 }
40 }
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ExtraUsageFormat {
46 Currency,
47 Percent,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct CommonRateLimitConfig {
56 pub icon: String,
57 pub label: String,
58 pub stale_marker: String,
59 pub progress_width: u16,
60 pub invalid_progress_width: bool,
66}
67
68impl CommonRateLimitConfig {
69 #[must_use]
70 pub fn new(label: impl Into<String>) -> Self {
71 Self {
72 icon: String::new(),
73 label: label.into(),
74 stale_marker: "~".into(),
75 progress_width: 20,
76 invalid_progress_width: false,
77 }
78 }
79}
80
81pub(crate) fn apply_common_extras(
86 cfg: &mut CommonRateLimitConfig,
87 extras: &BTreeMap<String, toml::Value>,
88 id: &str,
89 warn: &mut impl FnMut(&str),
90) {
91 if let Some(v) = extras.get("icon") {
92 if let Some(s) = v.as_str() {
93 cfg.icon = s.to_string();
94 } else {
95 warn(&format!("segments.{id}.icon: expected string; ignoring"));
96 }
97 }
98 if let Some(v) = extras.get("label") {
99 if let Some(s) = v.as_str() {
100 cfg.label = s.to_string();
101 } else {
102 warn(&format!("segments.{id}.label: expected string; ignoring"));
103 }
104 }
105 if let Some(v) = extras.get("stale_marker") {
106 if let Some(s) = v.as_str() {
107 cfg.stale_marker = s.to_string();
108 } else {
109 warn(&format!(
110 "segments.{id}.stale_marker: expected string; ignoring"
111 ));
112 }
113 }
114 if let Some(v) = extras.get("progress_width") {
115 match v.as_integer() {
116 Some(n) if (1..=i64::from(u16::MAX)).contains(&n) => {
117 cfg.progress_width = n as u16;
118 }
119 _ => {
120 warn(&format!(
125 "segments.{id}.progress_width: expected 1..={}; ignoring",
126 u16::MAX,
127 ));
128 cfg.invalid_progress_width = true;
129 }
130 }
131 }
132}
133
134#[must_use]
138pub(crate) fn parse_percent_format(
139 extras: &BTreeMap<String, toml::Value>,
140 id: &str,
141 warn: &mut impl FnMut(&str),
142) -> Option<PercentFormat> {
143 match extras.get("format")?.as_str() {
144 Some("percent") => Some(PercentFormat::Percent),
145 Some("progress") => Some(PercentFormat::Progress),
146 _ => {
147 warn(&format!(
148 "segments.{id}.format: expected \"percent\" or \"progress\"; ignoring"
149 ));
150 None
151 }
152 }
153}
154
155#[must_use]
157pub(crate) fn parse_duration_format(
158 extras: &BTreeMap<String, toml::Value>,
159 id: &str,
160 warn: &mut impl FnMut(&str),
161) -> Option<DurationFormat> {
162 match extras.get("format")?.as_str() {
163 Some("duration") => Some(DurationFormat::Duration),
164 Some("progress") => Some(DurationFormat::Progress),
165 _ => {
166 warn(&format!(
167 "segments.{id}.format: expected \"duration\" or \"progress\"; ignoring"
168 ));
169 None
170 }
171 }
172}
173
174#[must_use]
176pub(crate) fn parse_extra_usage_format(
177 extras: &BTreeMap<String, toml::Value>,
178 id: &str,
179 warn: &mut impl FnMut(&str),
180) -> Option<ExtraUsageFormat> {
181 match extras.get("format")?.as_str() {
182 Some("currency") => Some(ExtraUsageFormat::Currency),
183 Some("percent") => Some(ExtraUsageFormat::Percent),
184 _ => {
185 warn(&format!(
186 "segments.{id}.format: expected \"currency\" or \"percent\"; ignoring"
187 ));
188 None
189 }
190 }
191}
192
193#[must_use]
196pub(crate) fn parse_bool(
197 extras: &BTreeMap<String, toml::Value>,
198 key: &str,
199 id: &str,
200 warn: &mut impl FnMut(&str),
201) -> Option<bool> {
202 let v = extras.get(key)?;
203 match v.as_bool() {
204 Some(b) => Some(b),
205 None => {
206 warn(&format!("segments.{id}.{key}: expected bool; ignoring"));
207 None
208 }
209 }
210}
211
212#[must_use]
219pub(crate) fn format_percent(
220 bucket: &UsageBucket,
221 format: PercentFormat,
222 invert: bool,
223 cfg: &CommonRateLimitConfig,
224) -> String {
225 let raw = f64::from(bucket.utilization.value());
226 let shown = if invert { 100.0 - raw } else { raw };
227 let value = match format {
228 PercentFormat::Percent => format!("{shown:.1}%"),
229 PercentFormat::Progress => format_progress_bar(shown, cfg.progress_width),
230 };
231 wrap(&value, false, cfg)
232}
233
234#[must_use]
241pub(crate) fn format_jsonl_tokens(total: u64, cfg: &CommonRateLimitConfig) -> String {
242 wrap(&format_tokens(total), true, cfg)
243}
244
245#[must_use]
250pub(crate) fn format_duration(
251 remaining: Duration,
252 format: DurationFormat,
253 compact: bool,
254 use_days: bool,
255 window: ResetWindow,
256 jsonl: bool,
257 cfg: &CommonRateLimitConfig,
258) -> String {
259 let value = match format {
260 DurationFormat::Duration => format_duration_text(remaining, compact, use_days),
261 DurationFormat::Progress => {
262 format_progress_bar(reset_progress_pct(remaining, window), cfg.progress_width)
263 }
264 };
265 wrap(&value, jsonl, cfg)
266}
267
268#[must_use]
273pub(crate) fn format_extra_usage(
274 extra: &ExtraUsage,
275 format: ExtraUsageFormat,
276 cfg: &CommonRateLimitConfig,
277) -> Option<String> {
278 let value = match format {
279 ExtraUsageFormat::Currency => match (extra.monthly_limit, extra.used_credits) {
280 (Some(limit), Some(used)) => {
281 Some(format_currency(limit - used, extra.currency.as_deref()))
282 }
283 _ => extra.utilization.map(|p| format!("{:.1}%", p.value())),
284 },
285 ExtraUsageFormat::Percent => extra.utilization.map(|p| format!("{:.1}%", p.value())),
286 }?;
287 Some(wrap(&value, false, cfg))
288}
289
290#[must_use]
295pub(crate) fn render_error(err: &UsageError, cfg: &CommonRateLimitConfig) -> String {
296 let body = match err {
297 UsageError::NoCredentials => "[No credentials]",
298 UsageError::Credentials(inner) => match inner {
299 CredentialError::NoCredentials
300 | CredentialError::MissingField { .. }
301 | CredentialError::EmptyToken { .. } => "[No credentials]",
302 CredentialError::SubprocessFailed(_) => "[Keychain error]",
303 CredentialError::IoError { .. } => "[Credentials unreadable]",
304 CredentialError::ParseError { .. } => "[Parse error]",
305 },
306 UsageError::Timeout => "[Timeout]",
307 UsageError::RateLimited { .. } => "[Rate limited]",
308 UsageError::NetworkError => "[Network error]",
309 UsageError::ParseError => "[Parse error]",
310 UsageError::Unauthorized => "[Unauthorized]",
311 UsageError::Jsonl(JsonlError::NoEntries | JsonlError::DirectoryMissing) => "[No data]",
315 UsageError::Jsonl(_) => "[Parse error]",
316 };
317 wrap_label_only(body, cfg)
318}
319
320fn wrap(value: &str, jsonl: bool, cfg: &CommonRateLimitConfig) -> String {
321 let marker = if jsonl { cfg.stale_marker.as_str() } else { "" };
322 let label_sep = if cfg.label.is_empty() { "" } else { ": " };
323 let icon_sep = if cfg.icon.is_empty() { "" } else { " " };
324 format!(
325 "{marker}{icon}{icon_sep}{label}{label_sep}{value}",
326 icon = cfg.icon,
327 label = cfg.label,
328 )
329}
330
331fn wrap_label_only(body: &str, cfg: &CommonRateLimitConfig) -> String {
335 let label_sep = if cfg.label.is_empty() { "" } else { ": " };
336 let icon_sep = if cfg.icon.is_empty() { "" } else { " " };
337 format!(
338 "{icon}{icon_sep}{label}{label_sep}{body}",
339 icon = cfg.icon,
340 label = cfg.label,
341 )
342}
343
344#[must_use]
345pub(crate) fn format_progress_bar(pct: f64, width: u16) -> String {
346 if width == 0 {
350 return format!("{pct:.1}%");
351 }
352 let clamped = pct.clamp(0.0, 100.0);
353 let filled = ((clamped / 100.0) * f64::from(width)).round() as usize;
354 let empty = usize::from(width).saturating_sub(filled);
355 format!(
356 "{filled_bar}{empty_bar} {clamped:.1}%",
357 filled_bar = "█".repeat(filled),
358 empty_bar = "░".repeat(empty),
359 )
360}
361
362fn format_duration_text(remaining: Duration, compact: bool, use_days: bool) -> String {
366 let total_minutes = remaining.num_minutes().max(0);
367 if total_minutes == 0 {
368 return "<1m".into();
370 }
371
372 let total_hours = total_minutes / 60;
373 let sub_hour_minutes = total_minutes - total_hours * 60;
374
375 if use_days && total_hours >= 24 {
376 let days = (total_hours / 24).min(9999);
378 let hours_within_day = total_hours - (total_hours / 24) * 24;
379 return format_two(days, 'd', hours_within_day, 'h', compact, true);
380 }
381
382 if total_hours > 0 {
383 return if sub_hour_minutes == 0 {
384 format_one(total_hours, 'h', compact, true)
385 } else {
386 format_two(total_hours, 'h', sub_hour_minutes, 'm', compact, false)
387 };
388 }
389
390 format_one(sub_hour_minutes, 'm', compact, false)
391}
392
393fn format_one(value: i64, unit: char, compact: bool, hours_suffix: bool) -> String {
394 match (compact, unit, hours_suffix) {
395 (true, u, _) => format!("{value}{u}"),
396 (false, 'd', _) => format!("{value}d"),
397 (false, 'h', true) => format!("{value}hr"),
398 (false, 'm', _) => format!("{value}m"),
399 (false, c, _) => format!("{value}{c}"),
400 }
401}
402
403fn format_two(
404 a: i64,
405 unit_a: char,
406 b: i64,
407 unit_b: char,
408 compact: bool,
409 hours_suffix: bool,
410) -> String {
411 if compact {
415 if b == 0 {
416 return format!("{a}{unit_a}");
417 }
418 return format!("{a}{unit_a}{b}{unit_b}");
419 }
420 let a_str = match unit_a {
421 'd' => format!("{a}d"),
422 'h' => format!("{a}hr"),
423 _ => format!("{a}{unit_a}"),
424 };
425 if b == 0 {
426 return a_str;
427 }
428 let b_str = match (unit_b, hours_suffix) {
429 ('h', true) => format!("{b}hr"),
430 _ => format!("{b}{unit_b}"),
431 };
432 format!("{a_str} {b_str}")
433}
434
435fn reset_progress_pct(remaining: Duration, window: ResetWindow) -> f64 {
438 let total = window.total();
439 let elapsed = total - remaining;
440 (elapsed.num_milliseconds() as f64 / total.num_milliseconds() as f64).clamp(0.0, 1.0) * 100.0
441}
442
443#[must_use]
449pub(crate) fn format_tokens(total: u64) -> String {
450 const K: u64 = 1_000;
451 const M: u64 = 1_000_000;
452 const G: u64 = 1_000_000_000;
453 if total < K {
454 return total.to_string();
455 }
456 if total < M {
457 return format_unit(total, K, 'k');
458 }
459 if total < G {
460 return format_unit(total, M, 'M');
461 }
462 format_unit(total, G, 'G')
463}
464
465fn format_unit(total: u64, divisor: u64, unit: char) -> String {
466 if total < divisor * 10 {
470 let scaled = (total as f64) / (divisor as f64);
471 format!("{scaled:.1}{unit}")
472 } else {
473 let scaled = total / divisor;
474 format!("{scaled}{unit}")
475 }
476}
477
478fn format_currency(amount: f64, currency: Option<&str>) -> String {
482 let clamped = amount.max(0.0);
483 match currency {
484 None | Some("USD") => format!("${clamped:.2}"),
485 Some(code) => format!("{code} {clamped:.2}"),
486 }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use crate::data_context::{ExtraUsage, UsageBucket};
493 use crate::input::Percent;
494
495 fn cfg() -> CommonRateLimitConfig {
496 CommonRateLimitConfig::new("5h")
497 }
498
499 fn bucket(pct: f64) -> UsageBucket {
500 UsageBucket {
501 utilization: Percent::new(pct as f32).unwrap(),
502 resets_at: None,
503 }
504 }
505
506 #[test]
507 fn percent_format_rounds_to_one_decimal_with_label() {
508 let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &cfg());
509 assert_eq!(s, "5h: 22.0%");
510 }
511
512 #[test]
513 fn percent_format_inverts_when_configured() {
514 let s = format_percent(&bucket(22.0), PercentFormat::Percent, true, &cfg());
515 assert_eq!(s, "5h: 78.0%");
516 }
517
518 #[test]
519 fn percent_format_progress_bar_at_50pct() {
520 let s = format_percent(&bucket(50.0), PercentFormat::Progress, false, &cfg());
521 assert!(s.starts_with("5h: "), "{s}");
522 assert!(s.contains("█"));
523 assert!(s.contains("░"));
524 assert!(s.ends_with("50.0%"), "{s}");
525 }
526
527 #[test]
528 fn jsonl_tokens_compact_format_with_stale_marker() {
529 let s = format_jsonl_tokens(420_000, &cfg());
532 assert_eq!(s, "~5h: 420k");
533 }
534
535 #[test]
536 fn jsonl_tokens_renders_megabytes_when_above_one_million() {
537 let s = format_jsonl_tokens(1_200_000, &cfg());
538 assert_eq!(s, "~5h: 1.2M");
539 }
540
541 #[test]
542 fn jsonl_tokens_empty_stale_marker_suppresses_prefix() {
543 let mut c = cfg();
544 c.stale_marker = String::new();
545 let s = format_jsonl_tokens(420_000, &c);
546 assert_eq!(s, "5h: 420k");
547 }
548
549 #[test]
550 fn empty_label_drops_colon_separator() {
551 let mut c = cfg();
552 c.label = String::new();
553 let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &c);
554 assert_eq!(s, "22.0%");
555 }
556
557 #[test]
558 fn icon_renders_with_space_separator() {
559 let mut c = cfg();
560 c.icon = "⏱".into();
561 let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &c);
562 assert_eq!(s, "⏱ 5h: 22.0%");
563 }
564
565 #[test]
566 fn format_tokens_under_one_thousand_renders_integer() {
567 assert_eq!(format_tokens(0), "0");
568 assert_eq!(format_tokens(999), "999");
569 }
570
571 #[test]
572 fn format_tokens_single_decimal_for_small_k_values() {
573 assert_eq!(format_tokens(5_400), "5.4k");
574 assert_eq!(format_tokens(1_200), "1.2k");
575 }
576
577 #[test]
578 fn format_tokens_drops_decimal_at_ten_k_and_above() {
579 assert_eq!(format_tokens(10_000), "10k");
580 assert_eq!(format_tokens(420_000), "420k");
581 }
582
583 #[test]
584 fn format_tokens_switches_to_megabytes_and_gigabytes() {
585 assert_eq!(format_tokens(1_200_000), "1.2M");
586 assert_eq!(format_tokens(10_000_000), "10M");
587 assert_eq!(format_tokens(1_200_000_000), "1.2G");
588 }
589
590 #[test]
591 fn format_tokens_pins_unit_boundaries() {
592 assert_eq!(format_tokens(1_000), "1.0k");
594 assert_eq!(format_tokens(1_000_000), "1.0M");
595 assert_eq!(format_tokens(1_000_000_000), "1.0G");
596 assert_eq!(format_tokens(9_999), "10.0k");
599 assert_eq!(format_tokens(999_999), "999k");
604 }
605
606 #[test]
607 fn format_tokens_handles_u64_max_without_panic() {
608 let s = format_tokens(u64::MAX);
612 assert!(s.ends_with('G'), "expected G suffix, got {s}");
613 }
614
615 #[test]
616 fn progress_bar_full_at_100pct() {
617 let s = format_progress_bar(100.0, 4);
618 assert_eq!(s, "████ 100.0%");
619 }
620
621 #[test]
622 fn progress_bar_empty_at_zero() {
623 let s = format_progress_bar(0.0, 4);
624 assert_eq!(s, "░░░░ 0.0%");
625 }
626
627 #[test]
628 fn progress_bar_zero_width_collapses_to_percent() {
629 let s = format_progress_bar(50.0, 0);
630 assert_eq!(s, "50.0%");
631 }
632
633 #[test]
634 fn duration_text_sub_minute_renders_lt_1m() {
635 assert_eq!(
636 format_duration_text(Duration::seconds(30), false, true),
637 "<1m"
638 );
639 }
640
641 #[test]
642 fn duration_text_minutes_only() {
643 assert_eq!(
644 format_duration_text(Duration::minutes(45), false, true),
645 "45m"
646 );
647 }
648
649 #[test]
650 fn duration_text_hours_and_minutes_non_compact() {
651 assert_eq!(
652 format_duration_text(Duration::minutes(4 * 60 + 37), false, true),
653 "4hr 37m"
654 );
655 }
656
657 #[test]
658 fn duration_text_hours_and_minutes_compact() {
659 assert_eq!(
660 format_duration_text(Duration::minutes(4 * 60 + 37), true, true),
661 "4h37m"
662 );
663 }
664
665 #[test]
666 fn duration_text_uses_days_when_configured() {
667 assert_eq!(
668 format_duration_text(Duration::minutes(27 * 60), false, true),
669 "1d 3hr"
670 );
671 }
672
673 #[test]
674 fn duration_text_skips_days_when_use_days_false() {
675 assert_eq!(
676 format_duration_text(Duration::minutes(27 * 60), false, false),
677 "27hr"
678 );
679 }
680
681 #[test]
682 fn duration_text_clamps_days_to_four_digits() {
683 let huge = Duration::days(99_999);
684 let s = format_duration_text(huge, false, true);
685 assert!(s.starts_with("9999d"), "{s}");
686 }
687
688 #[test]
689 fn duration_text_round_hour_drops_minutes() {
690 assert_eq!(format_duration_text(Duration::hours(3), false, true), "3hr");
691 }
692
693 #[test]
694 fn currency_defaults_to_dollar_when_unset() {
695 assert_eq!(format_currency(12.5, None), "$12.50");
696 }
697
698 #[test]
699 fn currency_uses_dollar_for_usd() {
700 assert_eq!(format_currency(12.5, Some("USD")), "$12.50");
701 }
702
703 #[test]
704 fn currency_uses_iso_code_prefix_for_non_usd() {
705 assert_eq!(format_currency(12.5, Some("EUR")), "EUR 12.50");
706 }
707
708 #[test]
709 fn currency_clamps_negative_to_zero() {
710 assert_eq!(format_currency(-5.0, None), "$0.00");
711 }
712
713 fn enabled_extra(limit: Option<f64>, used: Option<f64>, pct: Option<f32>) -> ExtraUsage {
714 ExtraUsage {
715 is_enabled: Some(true),
716 utilization: pct.map(|v| Percent::new(v).unwrap()),
717 monthly_limit: limit,
718 used_credits: used,
719 currency: Some("USD".into()),
720 }
721 }
722
723 #[test]
724 fn extra_currency_renders_remaining_credits() {
725 let e = enabled_extra(Some(100.0), Some(40.0), None);
726 let mut c = cfg();
727 c.label = "extra".into();
728 let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &c).expect("renders");
729 assert_eq!(s, "extra: $60.00");
730 }
731
732 #[test]
733 fn extra_currency_falls_back_to_percent_when_monthly_limit_missing() {
734 let e = enabled_extra(None, Some(40.0), Some(42.5));
737 let mut c = cfg();
738 c.label = "extra".into();
739 let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &c).expect("renders");
740 assert_eq!(s, "extra: 42.5%");
741 }
742
743 #[test]
744 fn extra_returns_none_when_no_data_available() {
745 let e = enabled_extra(None, None, None);
746 let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &cfg());
747 assert_eq!(s, None);
748 }
749
750 #[test]
751 fn error_rendering_covers_spec_table() {
752 let c = cfg();
753 for (err, expected) in [
754 (UsageError::NoCredentials, "5h: [No credentials]"),
755 (
756 UsageError::Credentials(CredentialError::SubprocessFailed(std::io::Error::other(
757 "x",
758 ))),
759 "5h: [Keychain error]",
760 ),
761 (
762 UsageError::Credentials(CredentialError::IoError {
763 path: std::path::PathBuf::from("/x"),
764 cause: std::io::Error::other("x"),
765 }),
766 "5h: [Credentials unreadable]",
767 ),
768 (
769 UsageError::Credentials(CredentialError::ParseError {
770 path: std::path::PathBuf::from("/x"),
771 cause: serde_json::Error::io(std::io::Error::other("x")),
772 }),
773 "5h: [Parse error]",
774 ),
775 (
776 UsageError::Credentials(CredentialError::MissingField {
777 path: std::path::PathBuf::from("/x"),
778 }),
779 "5h: [No credentials]",
780 ),
781 (
782 UsageError::Credentials(CredentialError::EmptyToken {
783 path: std::path::PathBuf::from("/x"),
784 }),
785 "5h: [No credentials]",
786 ),
787 (UsageError::Timeout, "5h: [Timeout]"),
788 (
789 UsageError::RateLimited { retry_after: None },
790 "5h: [Rate limited]",
791 ),
792 (UsageError::NetworkError, "5h: [Network error]"),
793 (UsageError::ParseError, "5h: [Parse error]"),
794 (UsageError::Unauthorized, "5h: [Unauthorized]"),
795 (UsageError::Jsonl(JsonlError::NoEntries), "5h: [No data]"),
796 (
797 UsageError::Jsonl(JsonlError::DirectoryMissing),
798 "5h: [No data]",
799 ),
800 ] {
801 assert_eq!(render_error(&err, &c), expected, "err = {err:?}");
802 }
803 }
804
805 #[test]
806 fn error_rendering_drops_label_when_empty() {
807 let mut c = cfg();
808 c.label = String::new();
809 assert_eq!(render_error(&UsageError::Timeout, &c), "[Timeout]");
810 }
811}