Skip to main content

linesmith_core/segments/rate_limit/
format.rs

1//! Render helpers for the rate-limit segment family. TOML-parsing,
2//! the [`CommonRateLimitConfig`] struct, and the format enums live in
3//! the `config` sibling.
4//!
5//! Canonical spec: `docs/specs/rate-limit-segments.md` §Render
6//! semantics and §Error message table.
7
8#[cfg(test)]
9use jiff::civil;
10use jiff::{SignedDuration, Timestamp};
11
12use super::config::{
13    AbsoluteFormat, CommonRateLimitConfig, ExtraUsageFormat, HourFormat, Locale, PercentFormat,
14    ResetFormat, Timezone,
15};
16use crate::data_context::{CredentialError, ExtraUsage, JsonlError, UsageBucket, UsageError};
17
18/// Which rolling window a reset segment represents. Determines the
19/// denominator when [`ResetFormat::Progress`] computes elapsed %.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub(crate) enum ResetWindow {
22    FiveHour,
23    SevenDay,
24}
25
26impl ResetWindow {
27    fn total(self) -> SignedDuration {
28        match self {
29            Self::FiveHour => SignedDuration::from_hours(5),
30            Self::SevenDay => SignedDuration::from_hours(7 * 24),
31        }
32    }
33}
34
35/// Render `rate_limit_5h` / `rate_limit_7d` from endpoint-sourced data:
36/// apply `invert`, clamp, and pick the percent/progress form, then wrap
37/// in label + icon. JSONL-sourced renders use [`format_jsonl_tokens`]
38/// instead — the two shapes diverge in both unit (`%` vs compact tokens)
39/// and modifier support (`invert` / `progress` don't apply without a
40/// ceiling).
41#[must_use]
42pub(crate) fn format_percent(
43    bucket: &UsageBucket,
44    format: PercentFormat,
45    invert: bool,
46    cfg: &CommonRateLimitConfig,
47) -> String {
48    let raw = f64::from(bucket.utilization.value());
49    let shown = if invert { 100.0 - raw } else { raw };
50    let value = match format {
51        PercentFormat::Percent => format!("{shown:.1}%"),
52        PercentFormat::Progress => format_progress_bar(shown, cfg.progress_width),
53    };
54    wrap(&value, false, cfg)
55}
56
57/// Render the JSONL-mode token total for `rate_limit_5h` / `rate_limit_7d`
58/// per `docs/specs/rate-limit-segments.md` §JSONL-fallback display.
59/// Applies the `stale_marker` prefix. Callers skip `invert` /
60/// `progress_width` when routing to this function — those knobs
61/// require a 0-100 axis JSONL lacks — so they never reach the
62/// signature.
63#[must_use]
64pub(crate) fn format_jsonl_tokens(total: u64, cfg: &CommonRateLimitConfig) -> String {
65    wrap(&format_tokens(total), true, cfg)
66}
67
68/// Render `rate_limit_5h_reset` / `rate_limit_7d_reset`. Assumes the
69/// caller already gated on `remaining > 0`. `resets_at` is passed
70/// even for the duration/progress branches so the caller doesn't
71/// need to know which fields each variant consumes.
72#[must_use]
73#[allow(clippy::too_many_arguments)] // an args struct here just renames the indirection.
74pub(crate) fn format_reset(
75    resets_at: Timestamp,
76    remaining: SignedDuration,
77    format: &ResetFormat,
78    compact: bool,
79    use_days: bool,
80    window: ResetWindow,
81    jsonl: bool,
82    cfg: &CommonRateLimitConfig,
83) -> String {
84    let value = match format {
85        ResetFormat::Duration => format_duration_text(remaining, compact, use_days),
86        ResetFormat::Absolute(absolute) => format_absolute_text(resets_at, absolute),
87        ResetFormat::Progress => {
88            format_progress_bar(reset_progress_pct(remaining, window), cfg.progress_width)
89        }
90    };
91    wrap(&value, jsonl, cfg)
92}
93
94/// Render an absolute wall-clock time like `"7:00 PM PT"` (12h) or
95/// `"19:00 PT"` (24h).
96fn format_absolute_text(resets_at: Timestamp, cfg: &AbsoluteFormat) -> String {
97    let tz = match &cfg.timezone {
98        Timezone::SystemLocal => jiff::tz::TimeZone::system(),
99        Timezone::Iana(tz) => tz.clone(),
100    };
101    let zdt = resets_at.to_zoned(tz);
102    let pattern = match (cfg.hour, cfg.locale) {
103        (HourFormat::Hour24, Locale::EnUs) => "%H:%M %Z",
104        (HourFormat::Hour12, Locale::EnUs) => "%-I:%M %p %Z",
105    };
106    zdt.strftime(pattern).to_string()
107}
108
109/// Render `extra_usage` (endpoint only). Falls back from currency →
110/// percent when the account is missing `monthly_limit` (spec §Edge
111/// cases). Returns `None` when no value can be rendered; callers hide
112/// the segment in that case.
113#[must_use]
114pub(crate) fn format_extra_usage(
115    extra: &ExtraUsage,
116    format: ExtraUsageFormat,
117    cfg: &CommonRateLimitConfig,
118) -> Option<String> {
119    let value = match format {
120        ExtraUsageFormat::Currency => match (extra.monthly_limit, extra.used_credits) {
121            (Some(limit), Some(used)) => {
122                Some(format_currency(limit - used, extra.currency.as_deref()))
123            }
124            _ => extra.utilization.map(|p| format!("{:.1}%", p.value())),
125        },
126        ExtraUsageFormat::Percent => extra.utilization.map(|p| format!("{:.1}%", p.value())),
127    }?;
128    Some(wrap(&value, false, cfg))
129}
130
131/// Render a `UsageError` to the user-facing bracket string from
132/// `docs/specs/rate-limit-segments.md` §Error message table. Wrapping
133/// variants (`Credentials`, `Jsonl`) dispatch on the inner variant so
134/// `[Keychain error]` / `[Credentials unreadable]` surface per-spec.
135#[must_use]
136pub(crate) fn render_error(err: &UsageError, cfg: &CommonRateLimitConfig) -> String {
137    let body = match err {
138        UsageError::NoCredentials => "[No credentials]",
139        UsageError::Credentials(inner) => match inner {
140            CredentialError::NoCredentials
141            | CredentialError::MissingField { .. }
142            | CredentialError::EmptyToken { .. } => "[No credentials]",
143            CredentialError::SubprocessFailed(_) => "[Keychain error]",
144            CredentialError::IoError { .. } => "[Credentials unreadable]",
145            CredentialError::ParseError { .. } => "[Parse error]",
146        },
147        UsageError::Timeout => "[Timeout]",
148        UsageError::RateLimited { .. } => "[Rate limited]",
149        UsageError::NetworkError => "[Network error]",
150        UsageError::ParseError => "[Parse error]",
151        UsageError::Unauthorized => "[Unauthorized]",
152        // JSONL-layer failures only surface when the endpoint path
153        // also failed AND JSONL itself couldn't aggregate. Render
154        // generically; doctor command carries the detail.
155        UsageError::Jsonl(JsonlError::NoEntries | JsonlError::DirectoryMissing) => "[No data]",
156        UsageError::Jsonl(_) => "[Parse error]",
157    };
158    wrap_label_only(body, cfg)
159}
160
161fn wrap(value: &str, jsonl: bool, cfg: &CommonRateLimitConfig) -> String {
162    let marker = if jsonl { cfg.stale_marker.as_str() } else { "" };
163    let label_sep = if cfg.label.is_empty() { "" } else { ": " };
164    let icon_sep = if cfg.icon.is_empty() { "" } else { " " };
165    format!(
166        "{marker}{icon}{icon_sep}{label}{label_sep}{value}",
167        icon = cfg.icon,
168        label = cfg.label,
169    )
170}
171
172/// Label + icon wrap for error strings: the bracketed error body
173/// replaces the value slot. Stale marker is never applied here — if
174/// we're rendering an error, the source is moot.
175fn wrap_label_only(body: &str, cfg: &CommonRateLimitConfig) -> String {
176    let label_sep = if cfg.label.is_empty() { "" } else { ": " };
177    let icon_sep = if cfg.icon.is_empty() { "" } else { " " };
178    format!(
179        "{icon}{icon_sep}{label}{label_sep}{body}",
180        icon = cfg.icon,
181        label = cfg.label,
182    )
183}
184
185#[must_use]
186pub(crate) fn format_progress_bar(pct: f64, width: u16) -> String {
187    // Progress-width 0 means "disabled" per spec §Edge cases; callers
188    // should have already fallen back to percent format, but defend
189    // here so a stray `progress_width = 0` config doesn't panic.
190    if width == 0 {
191        return format!("{pct:.1}%");
192    }
193    let clamped = pct.clamp(0.0, 100.0);
194    let filled = ((clamped / 100.0) * f64::from(width)).round() as usize;
195    let empty = usize::from(width).saturating_sub(filled);
196    format!(
197        "{filled_bar}{empty_bar} {clamped:.1}%",
198        filled_bar = "█".repeat(filled),
199        empty_bar = "░".repeat(empty),
200    )
201}
202
203/// Duration → text per spec §Config schema: `compact=true` collapses
204/// to `"4h37m"`, `compact=false` uses `"4hr 37m"`; `use_days=true`
205/// emits `"1d 3hr"` once the remainder is >= 1 day.
206fn format_duration_text(remaining: SignedDuration, compact: bool, use_days: bool) -> String {
207    let total_minutes = (remaining.as_secs() / 60).max(0);
208    if total_minutes == 0 {
209        // Per spec: never show "0m"; compact/non-compact both say "<1m".
210        return "<1m".into();
211    }
212
213    let total_hours = total_minutes / 60;
214    let sub_hour_minutes = total_minutes - total_hours * 60;
215
216    if use_days && total_hours >= 24 {
217        // Clamp days to 4 digits per spec §Edge cases.
218        let days = (total_hours / 24).min(9999);
219        let hours_within_day = total_hours - (total_hours / 24) * 24;
220        return format_two(days, 'd', hours_within_day, 'h', compact, true);
221    }
222
223    if total_hours > 0 {
224        return if sub_hour_minutes == 0 {
225            format_one(total_hours, 'h', compact, true)
226        } else {
227            format_two(total_hours, 'h', sub_hour_minutes, 'm', compact, false)
228        };
229    }
230
231    format_one(sub_hour_minutes, 'm', compact, false)
232}
233
234fn format_one(value: i64, unit: char, compact: bool, hours_suffix: bool) -> String {
235    match (compact, unit, hours_suffix) {
236        (true, u, _) => format!("{value}{u}"),
237        (false, 'd', _) => format!("{value}d"),
238        (false, 'h', true) => format!("{value}hr"),
239        (false, 'm', _) => format!("{value}m"),
240        (false, c, _) => format!("{value}{c}"),
241    }
242}
243
244fn format_two(
245    a: i64,
246    unit_a: char,
247    b: i64,
248    unit_b: char,
249    compact: bool,
250    hours_suffix: bool,
251) -> String {
252    // Non-compact form uses "hr" for hours regardless of whether days
253    // appear (`1d 3hr`, not `1d 3h`) — the only rule not obvious from
254    // the spec config comments.
255    if compact {
256        if b == 0 {
257            return format!("{a}{unit_a}");
258        }
259        return format!("{a}{unit_a}{b}{unit_b}");
260    }
261    let a_str = match unit_a {
262        'd' => format!("{a}d"),
263        'h' => format!("{a}hr"),
264        _ => format!("{a}{unit_a}"),
265    };
266    if b == 0 {
267        return a_str;
268    }
269    let b_str = match (unit_b, hours_suffix) {
270        ('h', true) => format!("{b}hr"),
271        _ => format!("{b}{unit_b}"),
272    };
273    format!("{a_str} {b_str}")
274}
275
276/// Reset-progress as a percentage: how far the window has elapsed,
277/// computed as `1 - remaining / window_total`.
278fn reset_progress_pct(remaining: SignedDuration, window: ResetWindow) -> f64 {
279    let total = window.total();
280    let elapsed = total - remaining;
281    (elapsed.as_millis() as f64 / total.as_millis() as f64).clamp(0.0, 1.0) * 100.0
282}
283
284/// Compact token formatting matching the spec's JSONL-mode examples
285/// (`420k`, `1.2M`). Under 1k renders as a bare integer; k/M/G use one
286/// decimal only when it adds precision (i.e. the integer part has < 2
287/// significant digits) so values like `420_000` render as `420k`, not
288/// `420.0k`.
289#[must_use]
290pub(crate) fn format_tokens(total: u64) -> String {
291    const K: u64 = 1_000;
292    const M: u64 = 1_000_000;
293    const G: u64 = 1_000_000_000;
294    if total < K {
295        return total.to_string();
296    }
297    if total < M {
298        return format_unit(total, K, 'k');
299    }
300    if total < G {
301        return format_unit(total, M, 'M');
302    }
303    format_unit(total, G, 'G')
304}
305
306fn format_unit(total: u64, divisor: u64, unit: char) -> String {
307    // Keep one decimal for values below 10× the divisor (`5.4k`,
308    // `1.2M`); drop it above so `420_000` → `420k` matches the spec
309    // example exactly.
310    if total < divisor * 10 {
311        let scaled = (total as f64) / (divisor as f64);
312        format!("{scaled:.1}{unit}")
313    } else {
314        let scaled = total / divisor;
315        format!("{scaled}{unit}")
316    }
317}
318
319/// Render `amount` as "$X.XX" for USD, or "CODE X.XX" for any other
320/// ISO currency (spec §Precision and clamping). Negatives clamp to
321/// zero.
322fn format_currency(amount: f64, currency: Option<&str>) -> String {
323    let clamped = amount.max(0.0);
324    match currency {
325        None | Some("USD") => format!("${clamped:.2}"),
326        Some(code) => format!("{code} {clamped:.2}"),
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use crate::data_context::{ExtraUsage, UsageBucket};
334    use crate::input::Percent;
335
336    fn cfg() -> CommonRateLimitConfig {
337        CommonRateLimitConfig::new("5h")
338    }
339
340    fn bucket(pct: f64) -> UsageBucket {
341        UsageBucket {
342            utilization: Percent::new(pct as f32).unwrap(),
343            resets_at: None,
344        }
345    }
346
347    #[test]
348    fn percent_format_rounds_to_one_decimal_with_label() {
349        let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &cfg());
350        assert_eq!(s, "5h: 22.0%");
351    }
352
353    #[test]
354    fn percent_format_inverts_when_configured() {
355        let s = format_percent(&bucket(22.0), PercentFormat::Percent, true, &cfg());
356        assert_eq!(s, "5h: 78.0%");
357    }
358
359    #[test]
360    fn percent_format_progress_bar_at_50pct() {
361        let s = format_percent(&bucket(50.0), PercentFormat::Progress, false, &cfg());
362        assert!(s.starts_with("5h: "), "{s}");
363        assert!(s.contains("█"));
364        assert!(s.contains("░"));
365        assert!(s.ends_with("50.0%"), "{s}");
366    }
367
368    #[test]
369    fn jsonl_tokens_compact_format_with_stale_marker() {
370        // Spec §JSONL-fallback display: under JSONL `rate_limit_5h`
371        // renders raw tokens with the `~` prefix.
372        let s = format_jsonl_tokens(420_000, &cfg());
373        assert_eq!(s, "~5h: 420k");
374    }
375
376    #[test]
377    fn jsonl_tokens_renders_megabytes_when_above_one_million() {
378        let s = format_jsonl_tokens(1_200_000, &cfg());
379        assert_eq!(s, "~5h: 1.2M");
380    }
381
382    #[test]
383    fn jsonl_tokens_empty_stale_marker_suppresses_prefix() {
384        let mut c = cfg();
385        c.stale_marker = String::new();
386        let s = format_jsonl_tokens(420_000, &c);
387        assert_eq!(s, "5h: 420k");
388    }
389
390    #[test]
391    fn empty_label_drops_colon_separator() {
392        let mut c = cfg();
393        c.label = String::new();
394        let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &c);
395        assert_eq!(s, "22.0%");
396    }
397
398    #[test]
399    fn icon_renders_with_space_separator() {
400        let mut c = cfg();
401        c.icon = "⏱".into();
402        let s = format_percent(&bucket(22.0), PercentFormat::Percent, false, &c);
403        assert_eq!(s, "⏱ 5h: 22.0%");
404    }
405
406    #[test]
407    fn format_tokens_under_one_thousand_renders_integer() {
408        assert_eq!(format_tokens(0), "0");
409        assert_eq!(format_tokens(999), "999");
410    }
411
412    #[test]
413    fn format_tokens_single_decimal_for_small_k_values() {
414        assert_eq!(format_tokens(5_400), "5.4k");
415        assert_eq!(format_tokens(1_200), "1.2k");
416    }
417
418    #[test]
419    fn format_tokens_drops_decimal_at_ten_k_and_above() {
420        assert_eq!(format_tokens(10_000), "10k");
421        assert_eq!(format_tokens(420_000), "420k");
422    }
423
424    #[test]
425    fn format_tokens_switches_to_megabytes_and_gigabytes() {
426        assert_eq!(format_tokens(1_200_000), "1.2M");
427        assert_eq!(format_tokens(10_000_000), "10M");
428        assert_eq!(format_tokens(1_200_000_000), "1.2G");
429    }
430
431    #[test]
432    fn format_tokens_pins_unit_boundaries() {
433        // Exact unit thresholds: 1k, 1M, 1G must flip branches cleanly.
434        assert_eq!(format_tokens(1_000), "1.0k");
435        assert_eq!(format_tokens(1_000_000), "1.0M");
436        assert_eq!(format_tokens(1_000_000_000), "1.0G");
437        // Just under the decimal-drop threshold renders `10.0k` (one
438        // decimal) rather than `10k`; at 10_000 the branch flips.
439        assert_eq!(format_tokens(9_999), "10.0k");
440        // Integer truncation on the no-decimal branch: 999_999 / 1_000
441        // = 999, not rounded up to 1000. Acceptable UX (the next unit
442        // flips at the exact boundary), pinned so future rounding
443        // changes are deliberate.
444        assert_eq!(format_tokens(999_999), "999k");
445    }
446
447    #[test]
448    fn format_tokens_handles_u64_max_without_panic() {
449        // Past any realistic aggregation but the u64 cast path must
450        // stay panic-free. Precision decays past f64's 53-bit mantissa
451        // (~9e15) — the value becomes an approximation, not a wrap.
452        let s = format_tokens(u64::MAX);
453        assert!(s.ends_with('G'), "expected G suffix, got {s}");
454    }
455
456    #[test]
457    fn progress_bar_full_at_100pct() {
458        let s = format_progress_bar(100.0, 4);
459        assert_eq!(s, "████ 100.0%");
460    }
461
462    #[test]
463    fn progress_bar_empty_at_zero() {
464        let s = format_progress_bar(0.0, 4);
465        assert_eq!(s, "░░░░ 0.0%");
466    }
467
468    #[test]
469    fn progress_bar_zero_width_collapses_to_percent() {
470        let s = format_progress_bar(50.0, 0);
471        assert_eq!(s, "50.0%");
472    }
473
474    #[test]
475    fn duration_text_sub_minute_renders_lt_1m() {
476        assert_eq!(
477            format_duration_text(SignedDuration::from_secs(30), false, true),
478            "<1m"
479        );
480    }
481
482    #[test]
483    fn duration_text_minutes_only() {
484        assert_eq!(
485            format_duration_text(SignedDuration::from_mins(45), false, true),
486            "45m"
487        );
488    }
489
490    #[test]
491    fn duration_text_hours_and_minutes_non_compact() {
492        assert_eq!(
493            format_duration_text(SignedDuration::from_mins(4 * 60 + 37), false, true),
494            "4hr 37m"
495        );
496    }
497
498    #[test]
499    fn duration_text_hours_and_minutes_compact() {
500        assert_eq!(
501            format_duration_text(SignedDuration::from_mins(4 * 60 + 37), true, true),
502            "4h37m"
503        );
504    }
505
506    #[test]
507    fn duration_text_uses_days_when_configured() {
508        assert_eq!(
509            format_duration_text(SignedDuration::from_mins(27 * 60), false, true),
510            "1d 3hr"
511        );
512    }
513
514    #[test]
515    fn duration_text_skips_days_when_use_days_false() {
516        assert_eq!(
517            format_duration_text(SignedDuration::from_mins(27 * 60), false, false),
518            "27hr"
519        );
520    }
521
522    #[test]
523    fn duration_text_clamps_days_to_four_digits() {
524        let huge = SignedDuration::from_hours(99_999 * 24);
525        let s = format_duration_text(huge, false, true);
526        assert!(s.starts_with("9999d"), "{s}");
527    }
528
529    #[test]
530    fn duration_text_round_hour_drops_minutes() {
531        assert_eq!(
532            format_duration_text(SignedDuration::from_hours(3), false, true),
533            "3hr"
534        );
535    }
536
537    #[test]
538    fn currency_defaults_to_dollar_when_unset() {
539        assert_eq!(format_currency(12.5, None), "$12.50");
540    }
541
542    #[test]
543    fn currency_uses_dollar_for_usd() {
544        assert_eq!(format_currency(12.5, Some("USD")), "$12.50");
545    }
546
547    #[test]
548    fn currency_uses_iso_code_prefix_for_non_usd() {
549        assert_eq!(format_currency(12.5, Some("EUR")), "EUR 12.50");
550    }
551
552    #[test]
553    fn currency_clamps_negative_to_zero() {
554        assert_eq!(format_currency(-5.0, None), "$0.00");
555    }
556
557    fn enabled_extra(limit: Option<f64>, used: Option<f64>, pct: Option<f32>) -> ExtraUsage {
558        ExtraUsage {
559            is_enabled: Some(true),
560            utilization: pct.map(|v| Percent::new(v).unwrap()),
561            monthly_limit: limit,
562            used_credits: used,
563            currency: Some("USD".into()),
564        }
565    }
566
567    #[test]
568    fn extra_currency_renders_remaining_credits() {
569        let e = enabled_extra(Some(100.0), Some(40.0), None);
570        let mut c = cfg();
571        c.label = "extra".into();
572        let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &c).expect("renders");
573        assert_eq!(s, "extra: $60.00");
574    }
575
576    #[test]
577    fn extra_currency_falls_back_to_percent_when_monthly_limit_missing() {
578        // Spec §Edge cases: `monthly_limit` missing with is_enabled=true
579        // falls back to percent format from utilization.
580        let e = enabled_extra(None, Some(40.0), Some(42.5));
581        let mut c = cfg();
582        c.label = "extra".into();
583        let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &c).expect("renders");
584        assert_eq!(s, "extra: 42.5%");
585    }
586
587    #[test]
588    fn extra_returns_none_when_no_data_available() {
589        let e = enabled_extra(None, None, None);
590        let s = format_extra_usage(&e, ExtraUsageFormat::Currency, &cfg());
591        assert_eq!(s, None);
592    }
593
594    #[test]
595    fn error_rendering_covers_spec_table() {
596        let c = cfg();
597        for (err, expected) in [
598            (UsageError::NoCredentials, "5h: [No credentials]"),
599            (
600                UsageError::Credentials(CredentialError::SubprocessFailed(std::io::Error::other(
601                    "x",
602                ))),
603                "5h: [Keychain error]",
604            ),
605            (
606                UsageError::Credentials(CredentialError::IoError {
607                    path: std::path::PathBuf::from("/x"),
608                    cause: std::io::Error::other("x"),
609                }),
610                "5h: [Credentials unreadable]",
611            ),
612            (
613                UsageError::Credentials(CredentialError::ParseError {
614                    path: std::path::PathBuf::from("/x"),
615                    cause: serde_json::Error::io(std::io::Error::other("x")),
616                }),
617                "5h: [Parse error]",
618            ),
619            (
620                UsageError::Credentials(CredentialError::MissingField {
621                    path: std::path::PathBuf::from("/x"),
622                }),
623                "5h: [No credentials]",
624            ),
625            (
626                UsageError::Credentials(CredentialError::EmptyToken {
627                    path: std::path::PathBuf::from("/x"),
628                }),
629                "5h: [No credentials]",
630            ),
631            (UsageError::Timeout, "5h: [Timeout]"),
632            (
633                UsageError::RateLimited { retry_after: None },
634                "5h: [Rate limited]",
635            ),
636            (UsageError::NetworkError, "5h: [Network error]"),
637            (UsageError::ParseError, "5h: [Parse error]"),
638            (UsageError::Unauthorized, "5h: [Unauthorized]"),
639            (UsageError::Jsonl(JsonlError::NoEntries), "5h: [No data]"),
640            (
641                UsageError::Jsonl(JsonlError::DirectoryMissing),
642                "5h: [No data]",
643            ),
644        ] {
645            assert_eq!(render_error(&err, &c), expected, "err = {err:?}");
646        }
647    }
648
649    #[test]
650    fn error_rendering_drops_label_when_empty() {
651        let mut c = cfg();
652        c.label = String::new();
653        assert_eq!(render_error(&UsageError::Timeout, &c), "[Timeout]");
654    }
655
656    // --- absolute reset format ---
657
658    fn fixed_utc(year: i16, month: i8, day: i8, hour: i8, minute: i8) -> Timestamp {
659        civil::date(year, month, day)
660            .at(hour, minute, 0, 0)
661            .in_tz("UTC")
662            .expect("valid utc")
663            .timestamp()
664    }
665
666    #[test]
667    fn absolute_24h_renders_with_tz_abbreviation() {
668        // 19:00 UTC → 12:00 PDT in Los Angeles on July 15, 2025
669        let resets_at = fixed_utc(2025, 7, 15, 19, 0);
670        let cfg = AbsoluteFormat {
671            timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
672            hour: HourFormat::Hour24,
673            locale: Locale::EnUs,
674        };
675        assert_eq!(format_absolute_text(resets_at, &cfg), "12:00 PDT");
676    }
677
678    #[test]
679    fn absolute_12h_renders_with_am_pm() {
680        // 19:00 UTC → 12:00 PM PDT (12-hour clock, post-meridiem boundary)
681        let resets_at = fixed_utc(2025, 7, 15, 19, 0);
682        let cfg = AbsoluteFormat {
683            timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
684            hour: HourFormat::Hour12,
685            locale: Locale::EnUs,
686        };
687        assert_eq!(format_absolute_text(resets_at, &cfg), "12:00 PM PDT");
688    }
689
690    #[test]
691    fn absolute_12h_morning_strips_zero_pad() {
692        // 14:30 UTC → 7:30 AM PDT — `%-I` strips leading zero from
693        // single-digit 12h hours per the bead's "7:00 PM PT" example.
694        let resets_at = fixed_utc(2025, 7, 15, 14, 30);
695        let cfg = AbsoluteFormat {
696            timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
697            hour: HourFormat::Hour12,
698            locale: Locale::EnUs,
699        };
700        assert_eq!(format_absolute_text(resets_at, &cfg), "7:30 AM PDT");
701    }
702
703    #[test]
704    fn absolute_renders_in_explicit_zone_distinct_from_utc() {
705        // Tokyo is UTC+9 with no DST — pinning a non-American zone so
706        // any future tz-detection regression that silently picks UTC
707        // instead of the configured zone fails this test loudly.
708        let resets_at = fixed_utc(2025, 1, 15, 0, 0);
709        let cfg = AbsoluteFormat {
710            timezone: Timezone::Iana(jiff::tz::TimeZone::get("Asia/Tokyo").unwrap()),
711            hour: HourFormat::Hour24,
712            locale: Locale::EnUs,
713        };
714        assert_eq!(format_absolute_text(resets_at, &cfg), "09:00 JST");
715    }
716
717    #[test]
718    fn format_reset_dispatches_absolute_branch() {
719        // Pin: `ResetFormat::Absolute` reaches `format_absolute_text`
720        // and the result is wrapped with the common label, rather
721        // than silently collapsing to a duration string.
722        let cfg = cfg();
723        let resets_at = fixed_utc(2025, 7, 15, 19, 0);
724        let format = ResetFormat::Absolute(AbsoluteFormat {
725            timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
726            hour: HourFormat::Hour24,
727            locale: Locale::EnUs,
728        });
729        let out = format_reset(
730            resets_at,
731            SignedDuration::from_hours(1),
732            &format,
733            false,
734            true,
735            ResetWindow::FiveHour,
736            false,
737            &cfg,
738        );
739        assert!(
740            out.contains("12:00 PDT"),
741            "absolute output missing time string: {out}"
742        );
743        assert!(out.contains("5h"), "absolute output missing label: {out}");
744    }
745
746    #[test]
747    fn format_reset_absolute_under_jsonl_keeps_stale_marker() {
748        // Pin that the JSONL stale-marker prefix lands on the
749        // absolute-time string, not just on duration text.
750        let mut cfg = cfg();
751        cfg.stale_marker = "~".into();
752        let resets_at = fixed_utc(2025, 7, 15, 19, 0);
753        let format = ResetFormat::Absolute(AbsoluteFormat {
754            timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
755            hour: HourFormat::Hour24,
756            locale: Locale::EnUs,
757        });
758        let out = format_reset(
759            resets_at,
760            SignedDuration::from_hours(1),
761            &format,
762            false,
763            true,
764            ResetWindow::FiveHour,
765            true, // jsonl
766            &cfg,
767        );
768        assert!(
769            out.starts_with("~"),
770            "expected stale marker on JSONL absolute output: {out}"
771        );
772        assert!(
773            out.contains("12:00 PDT"),
774            "absolute output missing time string: {out}"
775        );
776    }
777
778    #[test]
779    fn absolute_renders_correct_zone_across_dst_transition() {
780        // 2025-03-09: America/Los_Angeles springs forward at 02:00
781        // PST → 03:00 PDT (= 10:00 UTC). Pin both sides of the jump
782        // so a regression that picks the wrong offset shows up.
783        let cfg = AbsoluteFormat {
784            timezone: Timezone::Iana(jiff::tz::TimeZone::get("America/Los_Angeles").unwrap()),
785            hour: HourFormat::Hour24,
786            locale: Locale::EnUs,
787        };
788        // 09:30 UTC = 01:30 PST (pre-jump, UTC-8).
789        let pre = fixed_utc(2025, 3, 9, 9, 30);
790        assert_eq!(format_absolute_text(pre, &cfg), "01:30 PST");
791        // 11:30 UTC = 04:30 PDT (post-jump, UTC-7).
792        let post = fixed_utc(2025, 3, 9, 11, 30);
793        assert_eq!(format_absolute_text(post, &cfg), "04:30 PDT");
794    }
795}