Skip to main content

linesmith_core/segments/
rate_limit_format.rs

1//! Shared render helpers for the rate-limit segments.
2//!
3//! Canonical spec: `docs/specs/rate-limit-segments.md` §Render
4//! semantics and §Error message table.
5
6use std::collections::BTreeMap;
7
8use chrono::Duration;
9
10use crate::data_context::{CredentialError, ExtraUsage, JsonlError, UsageBucket, UsageError};
11
12/// Config-driven rendering format for the percent/progress segments.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum PercentFormat {
15    Percent,
16    Progress,
17}
18
19/// Config-driven rendering format for the reset segments.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum DurationFormat {
22    Duration,
23    Progress,
24}
25
26/// Which rolling window a reset segment represents. Determines the
27/// denominator when [`DurationFormat::Progress`] computes elapsed %.
28#[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/// Config-driven rendering format for `extra_usage`.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ExtraUsageFormat {
46    Currency,
47    Percent,
48}
49
50/// Common config shared by every rate-limit segment per the spec's
51/// §Config schema. Each concrete segment adds its own type-specific
52/// knobs (`format`, `invert`, `compact`, `use_days`) on top.
53#[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    /// Set by `apply_common_extras` when the user supplied an
61    /// out-of-range `progress_width`. Segments use it to flip
62    /// `format = Progress` back to the per-family default so the
63    /// spec's "fall back to percent format" rule at
64    /// `rate-limit-segments.md` §Edge cases is honored, not just warned.
65    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
81/// Apply `[segments.<id>]` common overrides (`icon`, `label`,
82/// `stale_marker`, `progress_width`) onto `cfg`. Wrong-type values
83/// warn and leave the default; unknown keys are silently skipped
84/// here because `config::validate_keys` owns that diagnostic.
85pub(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                // Spec §Edge cases: 0/negative is invalid and forces
121                // a fallback to percent/duration format at the
122                // segment layer. Flag here; the segment flips its
123                // `format` field after parsing.
124                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/// Read `format` from `[segments.<id>]` as `"percent"` or
135/// `"progress"`. Unknown values warn and return `None` so callers
136/// keep their default.
137#[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/// Read `format` from `[segments.<id>]` as "duration" | "progress".
156#[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/// Read `format` from `[segments.<id>]` as "currency" | "percent".
175#[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/// Read a boolean knob from `[segments.<id>]`. Wrong-type values
194/// emit a warning and return `None`.
195#[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/// Render `rate_limit_5h` / `rate_limit_7d` from endpoint-sourced data:
213/// apply `invert`, clamp, and pick the percent/progress form, then wrap
214/// in label + icon. JSONL-sourced renders use [`format_jsonl_tokens`]
215/// instead — the two shapes diverge in both unit (`%` vs compact tokens)
216/// and modifier support (`invert` / `progress` don't apply without a
217/// ceiling).
218#[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/// Render the JSONL-mode token total for `rate_limit_5h` / `rate_limit_7d`
235/// per `docs/specs/rate-limit-segments.md` §JSONL-fallback display.
236/// Applies the `stale_marker` prefix. Callers skip `invert` /
237/// `progress_width` when routing to this function — those knobs
238/// require a 0-100 axis JSONL lacks — so they never reach the
239/// signature.
240#[must_use]
241pub(crate) fn format_jsonl_tokens(total: u64, cfg: &CommonRateLimitConfig) -> String {
242    wrap(&format_tokens(total), true, cfg)
243}
244
245/// Render `rate_limit_5h_reset` / `rate_limit_7d_reset`. Assumes the
246/// caller already gated on `remaining > 0`. `window` identifies the
247/// rolling window so the progress bar divides by the right total.
248/// `jsonl` adds the stale-marker prefix.
249#[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/// Render `extra_usage` (endpoint only). Falls back from currency →
269/// percent when the account is missing `monthly_limit` (spec §Edge
270/// cases). Returns `None` when no value can be rendered; callers hide
271/// the segment in that case.
272#[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/// Render a `UsageError` to the user-facing bracket string from
291/// `docs/specs/rate-limit-segments.md` §Error message table. Wrapping
292/// variants (`Credentials`, `Jsonl`) dispatch on the inner variant so
293/// `[Keychain error]` / `[Credentials unreadable]` surface per-spec.
294#[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        // JSONL-layer failures only surface when the endpoint path
312        // also failed AND JSONL itself couldn't aggregate. Render
313        // generically; doctor command carries the detail.
314        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
331/// Label + icon wrap for error strings: the bracketed error body
332/// replaces the value slot. Stale marker is never applied here — if
333/// we're rendering an error, the source is moot.
334fn 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    // Progress-width 0 means "disabled" per spec §Edge cases; callers
347    // should have already fallen back to percent format, but defend
348    // here so a stray `progress_width = 0` config doesn't panic.
349    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
362/// Duration → text per spec §Config schema: `compact=true` collapses
363/// to `"4h37m"`, `compact=false` uses `"4hr 37m"`; `use_days=true`
364/// emits `"1d 3hr"` once the remainder is >= 1 day.
365fn 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        // Per spec: never show "0m"; compact/non-compact both say "<1m".
369        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        // Clamp days to 4 digits per spec §Edge cases.
377        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    // Non-compact form uses "hr" for hours regardless of whether days
412    // appear (`1d 3hr`, not `1d 3h`) — the only rule not obvious from
413    // the spec config comments.
414    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
435/// Reset-progress as a percentage: how far the window has elapsed,
436/// computed as `1 - remaining / window_total`.
437fn 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/// Compact token formatting matching the spec's JSONL-mode examples
444/// (`420k`, `1.2M`). Under 1k renders as a bare integer; k/M/G use one
445/// decimal only when it adds precision (i.e. the integer part has < 2
446/// significant digits) so values like `420_000` render as `420k`, not
447/// `420.0k`.
448#[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    // Keep one decimal for values below 10× the divisor (`5.4k`,
467    // `1.2M`); drop it above so `420_000` → `420k` matches the spec
468    // example exactly.
469    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
478/// Render `amount` as "$X.XX" for USD, or "CODE X.XX" for any other
479/// ISO currency (spec §Precision and clamping). Negatives clamp to
480/// zero.
481fn 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        // Spec §JSONL-fallback display: under JSONL `rate_limit_5h`
530        // renders raw tokens with the `~` prefix.
531        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        // Exact unit thresholds: 1k, 1M, 1G must flip branches cleanly.
593        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        // Just under the decimal-drop threshold renders `10.0k` (one
597        // decimal) rather than `10k`; at 10_000 the branch flips.
598        assert_eq!(format_tokens(9_999), "10.0k");
599        // Integer truncation on the no-decimal branch: 999_999 / 1_000
600        // = 999, not rounded up to 1000. Acceptable UX (the next unit
601        // flips at the exact boundary), pinned so future rounding
602        // changes are deliberate.
603        assert_eq!(format_tokens(999_999), "999k");
604    }
605
606    #[test]
607    fn format_tokens_handles_u64_max_without_panic() {
608        // Past any realistic aggregation but the u64 cast path must
609        // stay panic-free. Precision decays past f64's 53-bit mantissa
610        // (~9e15) — the value becomes an approximation, not a wrap.
611        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        // Spec §Edge cases: `monthly_limit` missing with is_enabled=true
735        // falls back to percent format from utilization.
736        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}