Skip to main content

ai_usagebar/
format.rs

1//! `{placeholder}` substitution for `--format` and `--tooltip-format`.
2//!
3//! Same surface as claudebar (claudebar:625-667): placeholders are surrounded
4//! by `{}`, unknown placeholders are left untouched (matching bash parameter
5//! expansion's default behavior — claudebar uses `${text//\{x\}/$val}` which
6//! is a no-op for unknown keys).
7//!
8//! Built on a `Map<&str, String>` so each vendor can register its own
9//! placeholder set and the rendering code doesn't need to know what they are.
10
11use std::collections::HashMap;
12use std::time::Duration;
13
14use chrono::{DateTime, Local, Utc};
15
16use crate::countdown;
17use crate::pacing;
18use crate::usage::{ResetCredit, ResetCredits, UsageWindow};
19use crate::vendor::RenderOpts;
20
21/// The five per-window placeholder values every windowed renderer registers
22/// (`pct`, `reset`, `elapsed` and the two pace glyphs). A window the account
23/// does not report yields empty strings, the missing-placeholder convention.
24#[derive(Default)]
25pub struct WindowPlaceholders {
26    pub pct: String,
27    pub reset: String,
28    pub elapsed: String,
29    pub ratio_pace: String,
30    pub point_pace: String,
31}
32
33pub fn window_placeholders(
34    window: Option<&UsageWindow>,
35    opts: &RenderOpts,
36    now: DateTime<Utc>,
37) -> WindowPlaceholders {
38    let Some(window) = window else {
39        return WindowPlaceholders::default();
40    };
41    let pace = pacing::calc(
42        window.utilization_pct,
43        window.resets_at,
44        now,
45        window.window_duration,
46        opts.pace_tolerance,
47    );
48    WindowPlaceholders {
49        pct: window.utilization_pct.to_string(),
50        reset: countdown::format(window.resets_at, now),
51        elapsed: pace.elapsed_pct.to_string(),
52        ratio_pace: pace.ratio_pace.glyph().to_string(),
53        point_pace: pace.point_pace.glyph().to_string(),
54    }
55}
56
57/// A monetary amount, with the sign outside the symbol. `format!("${v:.2}")`
58/// puts it inside — `$-5.71` — which reads as a typo rather than as debt, and
59/// several providers let a balance go negative: OpenRouter overruns its
60/// credits, Moonshot carries an explicit `cash_balance` debt.
61///
62/// The sign is decided from the *rounded* magnitude, so neither a negative
63/// zero off the wire nor a sub-cent debt can produce the nonsense `-$0.00`.
64///
65/// This is the one place that decides what money looks like. Every renderer
66/// goes through it so a debt cannot be spelled two ways in two panels.
67pub fn money(v: f64, currency: &str) -> String {
68    let magnitude = format!("{:.2}", v.abs());
69    let sign = if v < 0.0 && magnitude != "0.00" {
70        "-"
71    } else {
72        ""
73    };
74    with_currency(sign, &magnitude, Some(currency))
75}
76
77/// Attach a currency to an already-formatted magnitude and sign.
78///
79/// The one table that decides which currencies get a symbol. [`money`] works in
80/// `f64` at two decimals and [`crate::usage::fmt_minor`] works in integer minor
81/// units at the currency's own scale — two different numbers, but they must not
82/// disagree about what a euro looks like. They did: `money` rendered EUR as
83/// `3.50 EUR` while `fmt_minor` rendered the same currency as `€3.50`.
84///
85/// `None` means a payload that predates any currency field; those were always
86/// USD. A code with no symbol here trails the code instead of guessing one,
87/// which is still truthful — rendering R$ 141.57 as "$141.57" is a claim about
88/// the wrong currency, the same class of defect as a fabricated number.
89pub fn with_currency(sign: &str, number: &str, currency: Option<&str>) -> String {
90    match currency {
91        None | Some("USD") => format!("{sign}${number}"),
92        Some("BRL") => format!("{sign}R${number}"),
93        Some("EUR") => format!("{sign}€{number}"),
94        Some("GBP") => format!("{sign}£{number}"),
95        Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
96        Some(other) => format!("{sign}{number} {other}"),
97    }
98}
99
100/// Upper-case the first character, leaving the rest alone.
101///
102/// Vendor plans arrive lower-cased (`"pro"`, `"max"`, `"glm coding pro"`) and
103/// every one of them wants the same title-ish label. `char::to_uppercase` can
104/// yield more than one char, so this is not `s[..1].to_uppercase() + &s[1..]`.
105pub fn capitalize(s: &str) -> String {
106    let mut chars = s.chars();
107    match chars.next() {
108        Some(first) => {
109            let mut out = String::with_capacity(s.len());
110            out.extend(first.to_uppercase());
111            out.push_str(chars.as_str());
112            out
113        }
114        None => String::new(),
115    }
116}
117
118/// [`money`] for the providers that only ever bill in dollars.
119pub fn usd(v: f64) -> String {
120    money(v, "USD")
121}
122
123/// A token count in the compact form providers use on their own dashboards:
124/// `900`, `12.4K`, `812.4M`, `2.5B`.
125///
126/// One decimal wherever a unit applies, dropped when it would be `.0` so a
127/// round allowance reads `500M` rather than `500.0M`. Token allowances arrive
128/// in the billions; spelling them out pushes every meter off the row.
129pub fn tokens(v: i64) -> String {
130    let sign = if v < 0 { "-" } else { "" };
131    let magnitude = (v as f64).abs();
132    for (scale, suffix) in [(1e9, "B"), (1e6, "M"), (1e3, "K")] {
133        if magnitude >= scale {
134            let mut text = format!("{:.1}", magnitude / scale);
135            if text.ends_with(".0") {
136                text.truncate(text.len() - 2);
137            }
138            return format!("{sign}{text}{suffix}");
139        }
140    }
141    format!("{v}")
142}
143
144/// A raw percentage as a whole number a meter can draw: rounded, held to
145/// 0–100, and NaN-safe.
146///
147/// A meter whose denominator came off the wire can be handed a NaN
148/// (`0.0 / 0.0`) or an out-of-range ratio, and each caller inventing its own
149/// guard is how two gauges end up disagreeing about what "100%" means.
150///
151/// **A shared helper, not a chokepoint.** Every float percentage that is
152/// rounded and held to 0–100 routes through here, and an `i32` caller widens
153/// the result with `i32::from`. Nothing enforces that. Some percentages follow
154/// a different rule on purpose and do not come here: a whole-number percent
155/// that a parser already rounded is clamped as an integer before it is cast
156/// into `Metric.pct`, and a few vendors round without clamping, or reject an
157/// out-of-range value instead of holding it. Prefer this for new callers.
158///
159/// # Examples
160///
161/// ```
162/// use ai_usagebar::format::clamp_pct;
163///
164/// assert_eq!(clamp_pct(24.5), 25);
165/// assert_eq!(clamp_pct(-3.0), 0);
166/// assert_eq!(clamp_pct(140.0), 100);
167/// assert_eq!(clamp_pct(f64::NAN), 0);
168/// ```
169pub fn clamp_pct(v: f64) -> u16 {
170    if v.is_nan() {
171        0
172    } else {
173        v.round().clamp(0.0, 100.0) as u16
174    }
175}
176
177pub fn local_time_hm(when: DateTime<Utc>) -> String {
178    when.with_timezone(&Local).format("%H:%M").to_string()
179}
180
181pub fn local_time_hms(when: DateTime<Utc>) -> String {
182    when.with_timezone(&Local).format("%H:%M:%S").to_string()
183}
184
185pub fn local_date_hm(when: DateTime<Utc>) -> String {
186    when.with_timezone(&Local)
187        .format("%b %-d %H:%M")
188        .to_string()
189}
190
191/// Compact count for placeholders and tooltips that have to stay on one line.
192pub fn reset_credits(credits: &ResetCredits) -> String {
193    let noun = if credits.available == 1 {
194        "reset"
195    } else {
196        "resets"
197    };
198    format!("{} {noun} available", credits.available)
199}
200
201/// One row per banked reset, soonest expiry first. This is what the panel,
202/// TUI, and tooltip list — a single "2 available · next expires Oct 4" line
203/// hides that two credits can lapse hours apart on the same day.
204pub fn reset_credit_lines(credits: &ResetCredits, now: DateTime<Utc>) -> Vec<String> {
205    let mut items = credits.credits.clone();
206    items.sort_by_key(|credit| credit.expires_at);
207    let mut lines: Vec<String> = items
208        .iter()
209        .map(|credit| reset_credit_line(credit, now))
210        .collect();
211    if lines.is_empty() {
212        // A stated zero still gets a row: an empty block reads as missing
213        // data rather than "you hold none".
214        lines.push(if credits.available > 0 {
215            reset_credits(credits)
216        } else {
217            "None available".to_string()
218        });
219    }
220    lines
221}
222
223fn reset_credit_line(credit: &ResetCredit, now: DateTime<Utc>) -> String {
224    let expiry = match credit.expires_at {
225        Some(expires) if expires <= now => format!("expired {}", local_date_hm(expires)),
226        Some(expires) => format!(
227            "expires {} ({})",
228            local_date_hm(expires),
229            crate::countdown::format(Some(expires), now)
230        ),
231        None => "no expiry reported".into(),
232    };
233    match credit
234        .title
235        .as_deref()
236        .map(str::trim)
237        .filter(|title| !title.is_empty())
238    {
239        Some(title) => format!("{title} · {expiry}"),
240        None => capitalize(&expiry),
241    }
242}
243
244/// The spending-page pair for one Cursor credit grant: `$21.00/$25.00
245/// remaining`, plus the expiry when the grant has one.
246///
247/// Money goes through [`crate::usage::fmt_minor`] so the sign and the dollar
248/// symbol match every other USD figure. The date is the local clock time, the
249/// same way a banked reset is written.
250pub fn cursor_credit_line(
251    remaining_cents: i64,
252    total_cents: i64,
253    expires_at: Option<DateTime<Utc>>,
254    now: DateTime<Utc>,
255) -> String {
256    let pair = format!(
257        "{}/{}",
258        crate::usage::fmt_minor(remaining_cents, 2, Some("USD")),
259        crate::usage::fmt_minor(total_cents, 2, Some("USD"))
260    );
261    let balance = format!("{pair} remaining");
262    match credit_expiry_clause(expires_at, now) {
263        Some(clause) => format!("{balance} · {clause}"),
264        None => balance,
265    }
266}
267
268/// The grant as the same meter On-Demand uses: remaining dollars beside a bar
269/// of how much of the grant is already spent, with that spend in the caption.
270///
271/// `(used percent, remaining dollars, caption)`. The percent is half-up, the
272/// same rounding as a usage bar, and it stays within 0–100 because a grant
273/// cannot be spent past its own total.
274pub fn cursor_credit_meter(
275    remaining_cents: i64,
276    total_cents: i64,
277    expires_at: Option<DateTime<Utc>>,
278    now: DateTime<Utc>,
279) -> (u16, String, String) {
280    let total = total_cents.max(0);
281    let remaining = remaining_cents.max(0).min(total);
282    let spent = total.saturating_sub(remaining);
283    let pct = if total <= 0 {
284        0
285    } else {
286        let rounded = (i128::from(spent) * 100 + i128::from(total) / 2) / i128::from(total);
287        u16::try_from(rounded).unwrap_or(100).min(100)
288    };
289    let mut caption = format!(
290        "{} of {} used ({pct}%)",
291        crate::usage::fmt_minor(spent, 2, Some("USD")),
292        crate::usage::fmt_minor(total, 2, Some("USD"))
293    );
294    if let Some(clause) = credit_expiry_clause(expires_at, now) {
295        caption.push_str(" · ");
296        caption.push_str(&clause);
297    }
298    (
299        pct,
300        crate::usage::fmt_minor(remaining, 2, Some("USD")),
301        caption,
302    )
303}
304
305fn credit_expiry_clause(expires_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> Option<String> {
306    match expires_at {
307        Some(expires) if expires <= now => Some(format!("expired {}", local_date_hm(expires))),
308        Some(expires) => Some(format!(
309            "expires {} ({})",
310            local_date_hm(expires),
311            crate::countdown::format(Some(expires), now)
312        )),
313        None => None,
314    }
315}
316
317/// The title Cursor puts on the grant.
318///
319/// The spending card is "Credits". The IDE banner says "Use your $N free
320/// credits", or names a scoped product ("Cursor Grok 4.6 Credit", "Cloud
321/// Agent Credits", "Bugbot Credit"). `display_name` is that product title
322/// when it says "credit". A server label that does not ("Power user grant")
323/// and a `grant_type` such as "promo" are not the words on those screens, so
324/// the row says "Credits".
325pub fn cursor_credit_label(display_name: &str) -> String {
326    let name = display_name.trim();
327    if name.to_lowercase().contains("credit") {
328        name.to_string()
329    } else {
330        "Credits".to_string()
331    }
332}
333
334pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
335    match cache_age {
336        Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
337        None => "—".to_string(),
338    }
339}
340
341/// Substitute every `{key}` in `template` with `values[key]`. Unknown keys
342/// are left as-is.
343///
344/// This is a single-pass scan; an O(N) implementation that does no
345/// re-substitution. (Avoids the bash pitfall where replacement text
346/// containing `{foo}` would get further substituted.)
347pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
348    let mut out = String::with_capacity(template.len());
349    let mut rest = template;
350    while !rest.is_empty() {
351        match rest.find('{') {
352            None => {
353                out.push_str(rest);
354                break;
355            }
356            Some(open) => {
357                // Copy everything up to the '{'.
358                out.push_str(&rest[..open]);
359                let after_open = &rest[open + 1..];
360                if let Some(close) = after_open.find('}') {
361                    let key = &after_open[..close];
362                    if let Some(val) = values.get(key) {
363                        out.push_str(val);
364                        rest = &after_open[close + 1..];
365                        continue;
366                    }
367                }
368                // Unmatched or unknown — keep the '{' literal and continue.
369                out.push('{');
370                rest = after_open;
371            }
372        }
373    }
374    out
375}
376
377/// Convenience: build a placeholder map from `(&str, impl Into<String>)` pairs.
378pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
379where
380    I: IntoIterator<Item = (&'static str, V)>,
381    V: Into<String>,
382{
383    pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
391        placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
392    }
393
394    /// The doc example above is not a CI gate — `.github/workflows/ci.yml` runs
395    /// `cargo test --all-targets`, which skips doctests — so the rule every
396    /// gauge in the app clamps through is asserted here as well.
397    #[test]
398    fn a_grant_is_titled_the_way_cursor_titles_it() {
399        assert_eq!(cursor_credit_label(""), "Credits");
400        assert_eq!(cursor_credit_label("Power user grant"), "Credits");
401        assert_eq!(cursor_credit_label("promo"), "Credits");
402        assert_eq!(
403            cursor_credit_label("Cursor Grok 4.6 Credit"),
404            "Cursor Grok 4.6 Credit"
405        );
406        assert_eq!(
407            cursor_credit_label("Cloud Agent Credits"),
408            "Cloud Agent Credits"
409        );
410    }
411
412    #[test]
413    fn a_spent_grant_stays_a_full_meter() {
414        let now = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
415        let (pct, value, caption) = cursor_credit_meter(0, 2500, None, now);
416        assert_eq!(pct, 100);
417        assert_eq!(value, "$0.00");
418        assert_eq!(caption, "$25.00 of $25.00 used (100%)");
419    }
420
421    #[test]
422    fn a_percentage_is_rounded_held_to_the_meter_and_nan_safe() {
423        assert_eq!(clamp_pct(0.0), 0);
424        assert_eq!(clamp_pct(100.0), 100);
425        assert_eq!(clamp_pct(24.5), 25);
426        assert_eq!(clamp_pct(24.4), 24);
427        // Out of range in either direction stops at the end of the meter
428        // rather than drawing past it or wrapping.
429        assert_eq!(clamp_pct(-0.4), 0);
430        assert_eq!(clamp_pct(-9_999.0), 0);
431        assert_eq!(clamp_pct(140.0), 100);
432        assert_eq!(clamp_pct(f64::INFINITY), 100);
433        assert_eq!(clamp_pct(f64::NEG_INFINITY), 0);
434        // A denominator off the wire can be zero, and `0.0 / 0.0` is NaN. That
435        // is "nothing to draw", not a panic and not a cast to garbage.
436        assert_eq!(clamp_pct(f64::NAN), 0);
437    }
438
439    fn offer(title: Option<&str>, expires: &str) -> ResetCredit {
440        ResetCredit {
441            title: title.map(str::to_string),
442            expires_at: Some(expires.parse().unwrap()),
443        }
444    }
445
446    #[test]
447    fn a_reset_inventory_lists_each_credit_soonest_first() {
448        let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
449        let credits = ResetCredits {
450            available: 2,
451            credits: vec![
452                offer(Some("Full reset (Weekly + 5 hr)"), "2026-08-01T00:00:00Z"),
453                offer(Some("Full reset (Weekly + 5 hr)"), "2026-07-17T00:00:00Z"),
454            ],
455        };
456        assert_eq!(reset_credits(&credits), "2 resets available");
457        let lines = reset_credit_lines(&credits, now);
458        assert_eq!(lines.len(), 2, "{lines:?}");
459        assert!(lines[0].contains("Full reset (Weekly + 5 hr)"), "{lines:?}");
460        assert!(lines[0].contains("(13d 0h)"), "{lines:?}");
461        assert!(lines[1].contains("(28d 0h)"), "{lines:?}");
462    }
463
464    #[test]
465    fn a_lapsed_deadline_reads_as_expired_rather_than_now() {
466        let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
467        let lines = reset_credit_lines(
468            &ResetCredits {
469                available: 1,
470                credits: vec![offer(None, "2026-07-01T00:00:00Z")],
471            },
472            now,
473        );
474        assert_eq!(lines.len(), 1);
475        assert!(lines[0].starts_with("Expired "), "{lines:?}");
476        assert!(!lines[0].contains("(now)"), "{lines:?}");
477    }
478
479    #[test]
480    fn a_count_without_detail_still_renders_the_inventory() {
481        let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
482        assert_eq!(
483            reset_credit_lines(
484                &ResetCredits {
485                    available: 2,
486                    credits: vec![]
487                },
488                now
489            ),
490            vec!["2 resets available"]
491        );
492    }
493
494    #[test]
495    fn single_substitution() {
496        let v = pm(&[("session_pct", "42")]);
497        assert_eq!(substitute("{session_pct}%", &v), "42%");
498    }
499
500    #[test]
501    fn usd_keeps_the_sign_outside_the_symbol() {
502        assert_eq!(usd(74.5), "$74.50");
503        assert_eq!(usd(0.0), "$0.00");
504        assert_eq!(usd(-5.71), "-$5.71");
505        // A negative zero off the wire is not a debt, and must not print as
506        // one — `format!("{:.2}")` alone would render it "$-0.00".
507        assert_eq!(usd(-0.0), "$0.00");
508        // Neither is a debt too small to show a cent.
509        assert_eq!(usd(-0.001), "$0.00");
510        // One that does round to a cent keeps its sign.
511        assert_eq!(usd(-0.006), "-$0.01");
512    }
513
514    #[test]
515    fn money_places_the_sign_ahead_of_every_currency() {
516        assert_eq!(money(20.0, "CNY"), "¥20.00");
517        assert_eq!(money(-20.0, "CNY"), "-¥20.00");
518        // A currency trails its code only when this table has no symbol for
519        // it; the sign still leads either way.
520        assert_eq!(money(3.5, "SEK"), "3.50 SEK");
521        assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
522        // usd() is the same policy, not a second one.
523        assert_eq!(money(-5.71, "USD"), usd(-5.71));
524        assert_eq!(money(-0.0, "CNY"), "¥0.00");
525    }
526
527    /// `money` and `usage::fmt_minor` compute different numbers — f64 at two
528    /// decimals versus integer minor units at the currency's own scale — but a
529    /// euro must look like a euro in both. They disagreed once: `money` had no
530    /// EUR/GBP/BRL/JPY entry and trailed the code while `fmt_minor` printed the
531    /// symbol, so the same currency read two ways in two panels.
532    #[test]
533    fn the_two_money_formatters_agree_on_every_symbol() {
534        for (code, symbol) in [
535            ("USD", "$"),
536            ("BRL", "R$"),
537            ("EUR", "€"),
538            ("GBP", "£"),
539            ("JPY", "¥"),
540            ("CNY", "¥"),
541        ] {
542            assert_eq!(money(3.5, code), format!("{symbol}3.50"), "money {code}");
543            assert_eq!(
544                crate::usage::fmt_minor(350, 2, Some(code)),
545                format!("{symbol}3.50"),
546                "fmt_minor {code}"
547            );
548        }
549        // And they agree that an unlisted code trails instead of guessing.
550        assert_eq!(money(3.5, "SEK"), "3.50 SEK");
551        assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
552        // fmt_minor keeps its own scale: JPY has no minor unit.
553        assert_eq!(crate::usage::fmt_minor(350, 0, Some("JPY")), "¥350");
554    }
555
556    #[test]
557    fn multiple_substitutions() {
558        let v = pm(&[("a", "1"), ("b", "2")]);
559        assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
560    }
561
562    #[test]
563    fn tokens_compact_every_allowance_scale() {
564        assert_eq!(tokens(0), "0");
565        assert_eq!(tokens(999), "999");
566        assert_eq!(tokens(1_000), "1K");
567        assert_eq!(tokens(12_400), "12.4K");
568        assert_eq!(tokens(812_400_000), "812.4M");
569        assert_eq!(tokens(2_500_000_000), "2.5B");
570        assert_eq!(tokens(625_000_000), "625M");
571        assert_eq!(tokens(-2_500), "-2.5K");
572    }
573
574    #[test]
575    fn unknown_placeholder_passes_through() {
576        let v = pm(&[("a", "1")]);
577        assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
578    }
579
580    #[test]
581    fn no_re_substitution_in_replacement_text() {
582        // Replacement text containing {a} must NOT be re-expanded.
583        let v = pm(&[("a", "{a}"), ("b", "X")]);
584        assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
585    }
586
587    #[test]
588    fn empty_template() {
589        let v = pm(&[("a", "1")]);
590        assert_eq!(substitute("", &v), "");
591    }
592
593    #[test]
594    fn template_without_braces() {
595        let v = pm(&[("a", "1")]);
596        assert_eq!(substitute("hello world", &v), "hello world");
597    }
598
599    #[test]
600    fn unmatched_open_brace_is_literal() {
601        let v = pm(&[("a", "1")]);
602        assert_eq!(substitute("{a {x", &v), "{a {x");
603    }
604
605    #[test]
606    fn placeholders_with_underscores_and_digits() {
607        let v = pm(&[("session_pct_2", "x")]);
608        assert_eq!(substitute("{session_pct_2}", &v), "x");
609    }
610
611    #[test]
612    fn utf8_around_braces() {
613        let v = pm(&[("x", "→")]);
614        assert_eq!(substitute("α{x}β", &v), "α→β");
615    }
616}