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::usage::{ResetCredit, ResetCredits};
17
18/// A monetary amount, with the sign outside the symbol. `format!("${v:.2}")`
19/// puts it inside — `$-5.71` — which reads as a typo rather than as debt, and
20/// several providers let a balance go negative: OpenRouter overruns its
21/// credits, Moonshot carries an explicit `cash_balance` debt.
22///
23/// The sign is decided from the *rounded* magnitude, so neither a negative
24/// zero off the wire nor a sub-cent debt can produce the nonsense `-$0.00`.
25///
26/// This is the one place that decides what money looks like. Every renderer
27/// goes through it so a debt cannot be spelled two ways in two panels.
28pub fn money(v: f64, currency: &str) -> String {
29    let magnitude = format!("{:.2}", v.abs());
30    let sign = if v < 0.0 && magnitude != "0.00" {
31        "-"
32    } else {
33        ""
34    };
35    with_currency(sign, &magnitude, Some(currency))
36}
37
38/// Attach a currency to an already-formatted magnitude and sign.
39///
40/// The one table that decides which currencies get a symbol. [`money`] works in
41/// `f64` at two decimals and [`crate::usage::fmt_minor`] works in integer minor
42/// units at the currency's own scale — two different numbers, but they must not
43/// disagree about what a euro looks like. They did: `money` rendered EUR as
44/// `3.50 EUR` while `fmt_minor` rendered the same currency as `€3.50`.
45///
46/// `None` means a payload that predates any currency field; those were always
47/// USD. A code with no symbol here trails the code instead of guessing one,
48/// which is still truthful — rendering R$ 141.57 as "$141.57" is a claim about
49/// the wrong currency, the same class of defect as a fabricated number.
50pub fn with_currency(sign: &str, number: &str, currency: Option<&str>) -> String {
51    match currency {
52        None | Some("USD") => format!("{sign}${number}"),
53        Some("BRL") => format!("{sign}R${number}"),
54        Some("EUR") => format!("{sign}€{number}"),
55        Some("GBP") => format!("{sign}£{number}"),
56        Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
57        Some(other) => format!("{sign}{number} {other}"),
58    }
59}
60
61/// Upper-case the first character, leaving the rest alone.
62///
63/// Vendor plans arrive lower-cased (`"pro"`, `"max"`, `"glm coding pro"`) and
64/// every one of them wants the same title-ish label. `char::to_uppercase` can
65/// yield more than one char, so this is not `s[..1].to_uppercase() + &s[1..]`.
66pub fn capitalize(s: &str) -> String {
67    let mut chars = s.chars();
68    match chars.next() {
69        Some(first) => {
70            let mut out = String::with_capacity(s.len());
71            out.extend(first.to_uppercase());
72            out.push_str(chars.as_str());
73            out
74        }
75        None => String::new(),
76    }
77}
78
79/// [`money`] for the providers that only ever bill in dollars.
80pub fn usd(v: f64) -> String {
81    money(v, "USD")
82}
83
84/// A raw percentage as a whole number a meter can draw: rounded, held to
85/// 0–100, and NaN-safe.
86///
87/// A meter whose denominator came off the wire can be handed a NaN
88/// (`0.0 / 0.0`) or an out-of-range ratio, and each caller inventing its own
89/// guard is how two gauges end up disagreeing about what "100%" means.
90///
91/// **A shared helper, not a chokepoint.** Every float percentage that is
92/// rounded and held to 0–100 routes through here, and an `i32` caller widens
93/// the result with `i32::from`. Nothing enforces that. Some percentages follow
94/// a different rule on purpose and do not come here: a whole-number percent
95/// that a parser already rounded is clamped as an integer before it is cast
96/// into `Metric.pct`, and a few vendors round without clamping, or reject an
97/// out-of-range value instead of holding it. Prefer this for new callers.
98///
99/// # Examples
100///
101/// ```
102/// use ai_usagebar::format::clamp_pct;
103///
104/// assert_eq!(clamp_pct(24.5), 25);
105/// assert_eq!(clamp_pct(-3.0), 0);
106/// assert_eq!(clamp_pct(140.0), 100);
107/// assert_eq!(clamp_pct(f64::NAN), 0);
108/// ```
109pub fn clamp_pct(v: f64) -> u16 {
110    if v.is_nan() {
111        0
112    } else {
113        v.round().clamp(0.0, 100.0) as u16
114    }
115}
116
117pub fn local_time_hm(when: DateTime<Utc>) -> String {
118    when.with_timezone(&Local).format("%H:%M").to_string()
119}
120
121pub fn local_time_hms(when: DateTime<Utc>) -> String {
122    when.with_timezone(&Local).format("%H:%M:%S").to_string()
123}
124
125pub fn local_date_hm(when: DateTime<Utc>) -> String {
126    when.with_timezone(&Local)
127        .format("%b %-d %H:%M")
128        .to_string()
129}
130
131/// Compact count for placeholders and tooltips that have to stay on one line.
132pub fn reset_credits(credits: &ResetCredits) -> String {
133    let noun = if credits.available == 1 {
134        "reset"
135    } else {
136        "resets"
137    };
138    format!("{} {noun} available", credits.available)
139}
140
141/// One row per banked reset, soonest expiry first. This is what the panel,
142/// TUI, and tooltip list — a single "2 available · next expires Oct 4" line
143/// hides that two credits can lapse hours apart on the same day.
144pub fn reset_credit_lines(credits: &ResetCredits, now: DateTime<Utc>) -> Vec<String> {
145    let mut items = credits.credits.clone();
146    items.sort_by_key(|credit| credit.expires_at);
147    let mut lines: Vec<String> = items
148        .iter()
149        .map(|credit| reset_credit_line(credit, now))
150        .collect();
151    if lines.is_empty() && credits.available > 0 {
152        lines.push(reset_credits(credits));
153    }
154    lines
155}
156
157fn reset_credit_line(credit: &ResetCredit, now: DateTime<Utc>) -> String {
158    let expiry = match credit.expires_at {
159        Some(expires) if expires <= now => format!("expired {}", local_date_hm(expires)),
160        Some(expires) => format!(
161            "expires {} ({})",
162            local_date_hm(expires),
163            crate::countdown::format(Some(expires), now)
164        ),
165        None => "no expiry reported".into(),
166    };
167    match credit
168        .title
169        .as_deref()
170        .map(str::trim)
171        .filter(|title| !title.is_empty())
172    {
173        Some(title) => format!("{title} · {expiry}"),
174        None => capitalize(&expiry),
175    }
176}
177
178pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
179    match cache_age {
180        Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
181        None => "—".to_string(),
182    }
183}
184
185/// Substitute every `{key}` in `template` with `values[key]`. Unknown keys
186/// are left as-is.
187///
188/// This is a single-pass scan; an O(N) implementation that does no
189/// re-substitution. (Avoids the bash pitfall where replacement text
190/// containing `{foo}` would get further substituted.)
191pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
192    let mut out = String::with_capacity(template.len());
193    let mut rest = template;
194    while !rest.is_empty() {
195        match rest.find('{') {
196            None => {
197                out.push_str(rest);
198                break;
199            }
200            Some(open) => {
201                // Copy everything up to the '{'.
202                out.push_str(&rest[..open]);
203                let after_open = &rest[open + 1..];
204                if let Some(close) = after_open.find('}') {
205                    let key = &after_open[..close];
206                    if let Some(val) = values.get(key) {
207                        out.push_str(val);
208                        rest = &after_open[close + 1..];
209                        continue;
210                    }
211                }
212                // Unmatched or unknown — keep the '{' literal and continue.
213                out.push('{');
214                rest = after_open;
215            }
216        }
217    }
218    out
219}
220
221/// Convenience: build a placeholder map from `(&str, impl Into<String>)` pairs.
222pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
223where
224    I: IntoIterator<Item = (&'static str, V)>,
225    V: Into<String>,
226{
227    pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
235        placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
236    }
237
238    /// The doc example above is not a CI gate — `.github/workflows/ci.yml` runs
239    /// `cargo test --all-targets`, which skips doctests — so the rule every
240    /// gauge in the app clamps through is asserted here as well.
241    #[test]
242    fn a_percentage_is_rounded_held_to_the_meter_and_nan_safe() {
243        assert_eq!(clamp_pct(0.0), 0);
244        assert_eq!(clamp_pct(100.0), 100);
245        assert_eq!(clamp_pct(24.5), 25);
246        assert_eq!(clamp_pct(24.4), 24);
247        // Out of range in either direction stops at the end of the meter
248        // rather than drawing past it or wrapping.
249        assert_eq!(clamp_pct(-0.4), 0);
250        assert_eq!(clamp_pct(-9_999.0), 0);
251        assert_eq!(clamp_pct(140.0), 100);
252        assert_eq!(clamp_pct(f64::INFINITY), 100);
253        assert_eq!(clamp_pct(f64::NEG_INFINITY), 0);
254        // A denominator off the wire can be zero, and `0.0 / 0.0` is NaN. That
255        // is "nothing to draw", not a panic and not a cast to garbage.
256        assert_eq!(clamp_pct(f64::NAN), 0);
257    }
258
259    fn offer(title: Option<&str>, expires: &str) -> ResetCredit {
260        ResetCredit {
261            title: title.map(str::to_string),
262            expires_at: Some(expires.parse().unwrap()),
263        }
264    }
265
266    #[test]
267    fn a_reset_inventory_lists_each_credit_soonest_first() {
268        let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
269        let credits = ResetCredits {
270            available: 2,
271            credits: vec![
272                offer(Some("Full reset (Weekly + 5 hr)"), "2026-08-01T00:00:00Z"),
273                offer(Some("Full reset (Weekly + 5 hr)"), "2026-07-17T00:00:00Z"),
274            ],
275        };
276        assert_eq!(reset_credits(&credits), "2 resets available");
277        let lines = reset_credit_lines(&credits, now);
278        assert_eq!(lines.len(), 2, "{lines:?}");
279        assert!(lines[0].contains("Full reset (Weekly + 5 hr)"), "{lines:?}");
280        assert!(lines[0].contains("(13d 0h)"), "{lines:?}");
281        assert!(lines[1].contains("(28d 0h)"), "{lines:?}");
282    }
283
284    #[test]
285    fn a_lapsed_deadline_reads_as_expired_rather_than_now() {
286        let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
287        let lines = reset_credit_lines(
288            &ResetCredits {
289                available: 1,
290                credits: vec![offer(None, "2026-07-01T00:00:00Z")],
291            },
292            now,
293        );
294        assert_eq!(lines.len(), 1);
295        assert!(lines[0].starts_with("Expired "), "{lines:?}");
296        assert!(!lines[0].contains("(now)"), "{lines:?}");
297    }
298
299    #[test]
300    fn a_count_without_detail_still_renders_the_inventory() {
301        let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
302        assert_eq!(
303            reset_credit_lines(
304                &ResetCredits {
305                    available: 2,
306                    credits: vec![]
307                },
308                now
309            ),
310            vec!["2 resets available"]
311        );
312    }
313
314    #[test]
315    fn single_substitution() {
316        let v = pm(&[("session_pct", "42")]);
317        assert_eq!(substitute("{session_pct}%", &v), "42%");
318    }
319
320    #[test]
321    fn usd_keeps_the_sign_outside_the_symbol() {
322        assert_eq!(usd(74.5), "$74.50");
323        assert_eq!(usd(0.0), "$0.00");
324        assert_eq!(usd(-5.71), "-$5.71");
325        // A negative zero off the wire is not a debt, and must not print as
326        // one — `format!("{:.2}")` alone would render it "$-0.00".
327        assert_eq!(usd(-0.0), "$0.00");
328        // Neither is a debt too small to show a cent.
329        assert_eq!(usd(-0.001), "$0.00");
330        // One that does round to a cent keeps its sign.
331        assert_eq!(usd(-0.006), "-$0.01");
332    }
333
334    #[test]
335    fn money_places_the_sign_ahead_of_every_currency() {
336        assert_eq!(money(20.0, "CNY"), "¥20.00");
337        assert_eq!(money(-20.0, "CNY"), "-¥20.00");
338        // A currency trails its code only when this table has no symbol for
339        // it; the sign still leads either way.
340        assert_eq!(money(3.5, "SEK"), "3.50 SEK");
341        assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
342        // usd() is the same policy, not a second one.
343        assert_eq!(money(-5.71, "USD"), usd(-5.71));
344        assert_eq!(money(-0.0, "CNY"), "¥0.00");
345    }
346
347    /// `money` and `usage::fmt_minor` compute different numbers — f64 at two
348    /// decimals versus integer minor units at the currency's own scale — but a
349    /// euro must look like a euro in both. They disagreed once: `money` had no
350    /// EUR/GBP/BRL/JPY entry and trailed the code while `fmt_minor` printed the
351    /// symbol, so the same currency read two ways in two panels.
352    #[test]
353    fn the_two_money_formatters_agree_on_every_symbol() {
354        for (code, symbol) in [
355            ("USD", "$"),
356            ("BRL", "R$"),
357            ("EUR", "€"),
358            ("GBP", "£"),
359            ("JPY", "¥"),
360            ("CNY", "¥"),
361        ] {
362            assert_eq!(money(3.5, code), format!("{symbol}3.50"), "money {code}");
363            assert_eq!(
364                crate::usage::fmt_minor(350, 2, Some(code)),
365                format!("{symbol}3.50"),
366                "fmt_minor {code}"
367            );
368        }
369        // And they agree that an unlisted code trails instead of guessing.
370        assert_eq!(money(3.5, "SEK"), "3.50 SEK");
371        assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
372        // fmt_minor keeps its own scale: JPY has no minor unit.
373        assert_eq!(crate::usage::fmt_minor(350, 0, Some("JPY")), "¥350");
374    }
375
376    #[test]
377    fn multiple_substitutions() {
378        let v = pm(&[("a", "1"), ("b", "2")]);
379        assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
380    }
381
382    #[test]
383    fn unknown_placeholder_passes_through() {
384        let v = pm(&[("a", "1")]);
385        assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
386    }
387
388    #[test]
389    fn no_re_substitution_in_replacement_text() {
390        // Replacement text containing {a} must NOT be re-expanded.
391        let v = pm(&[("a", "{a}"), ("b", "X")]);
392        assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
393    }
394
395    #[test]
396    fn empty_template() {
397        let v = pm(&[("a", "1")]);
398        assert_eq!(substitute("", &v), "");
399    }
400
401    #[test]
402    fn template_without_braces() {
403        let v = pm(&[("a", "1")]);
404        assert_eq!(substitute("hello world", &v), "hello world");
405    }
406
407    #[test]
408    fn unmatched_open_brace_is_literal() {
409        let v = pm(&[("a", "1")]);
410        assert_eq!(substitute("{a {x", &v), "{a {x");
411    }
412
413    #[test]
414    fn placeholders_with_underscores_and_digits() {
415        let v = pm(&[("session_pct_2", "x")]);
416        assert_eq!(substitute("{session_pct_2}", &v), "x");
417    }
418
419    #[test]
420    fn utf8_around_braces() {
421        let v = pm(&[("x", "→")]);
422        assert_eq!(substitute("α{x}β", &v), "α→β");
423    }
424}