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
16/// A monetary amount, with the sign outside the symbol. `format!("${v:.2}")`
17/// puts it inside — `$-5.71` — which reads as a typo rather than as debt, and
18/// several providers let a balance go negative: OpenRouter overruns its
19/// credits, Moonshot carries an explicit `cash_balance` debt.
20///
21/// The sign is decided from the *rounded* magnitude, so neither a negative
22/// zero off the wire nor a sub-cent debt can produce the nonsense `-$0.00`.
23///
24/// This is the one place that decides what money looks like. Every renderer
25/// goes through it so a debt cannot be spelled two ways in two panels.
26pub fn money(v: f64, currency: &str) -> String {
27    let magnitude = format!("{:.2}", v.abs());
28    let sign = if v < 0.0 && magnitude != "0.00" {
29        "-"
30    } else {
31        ""
32    };
33    with_currency(sign, &magnitude, Some(currency))
34}
35
36/// Attach a currency to an already-formatted magnitude and sign.
37///
38/// The one table that decides which currencies get a symbol. [`money`] works in
39/// `f64` at two decimals and [`crate::usage::fmt_minor`] works in integer minor
40/// units at the currency's own scale — two different numbers, but they must not
41/// disagree about what a euro looks like. They did: `money` rendered EUR as
42/// `3.50 EUR` while `fmt_minor` rendered the same currency as `€3.50`.
43///
44/// `None` means a payload that predates any currency field; those were always
45/// USD. A code with no symbol here trails the code instead of guessing one,
46/// which is still truthful — rendering R$ 141.57 as "$141.57" is a claim about
47/// the wrong currency, the same class of defect as a fabricated number.
48pub fn with_currency(sign: &str, number: &str, currency: Option<&str>) -> String {
49    match currency {
50        None | Some("USD") => format!("{sign}${number}"),
51        Some("BRL") => format!("{sign}R${number}"),
52        Some("EUR") => format!("{sign}€{number}"),
53        Some("GBP") => format!("{sign}£{number}"),
54        Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
55        Some(other) => format!("{sign}{number} {other}"),
56    }
57}
58
59/// Upper-case the first character, leaving the rest alone.
60///
61/// Vendor plans arrive lower-cased (`"pro"`, `"max"`, `"glm coding pro"`) and
62/// every one of them wants the same title-ish label. `char::to_uppercase` can
63/// yield more than one char, so this is not `s[..1].to_uppercase() + &s[1..]`.
64pub fn capitalize(s: &str) -> String {
65    let mut chars = s.chars();
66    match chars.next() {
67        Some(first) => {
68            let mut out = String::with_capacity(s.len());
69            out.extend(first.to_uppercase());
70            out.push_str(chars.as_str());
71            out
72        }
73        None => String::new(),
74    }
75}
76
77/// [`money`] for the providers that only ever bill in dollars.
78pub fn usd(v: f64) -> String {
79    money(v, "USD")
80}
81
82pub fn local_time_hm(when: DateTime<Utc>) -> String {
83    when.with_timezone(&Local).format("%H:%M").to_string()
84}
85
86pub fn local_time_hms(when: DateTime<Utc>) -> String {
87    when.with_timezone(&Local).format("%H:%M:%S").to_string()
88}
89
90pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
91    match cache_age {
92        Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
93        None => "—".to_string(),
94    }
95}
96
97/// Substitute every `{key}` in `template` with `values[key]`. Unknown keys
98/// are left as-is.
99///
100/// This is a single-pass scan; an O(N) implementation that does no
101/// re-substitution. (Avoids the bash pitfall where replacement text
102/// containing `{foo}` would get further substituted.)
103pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
104    let mut out = String::with_capacity(template.len());
105    let mut rest = template;
106    while !rest.is_empty() {
107        match rest.find('{') {
108            None => {
109                out.push_str(rest);
110                break;
111            }
112            Some(open) => {
113                // Copy everything up to the '{'.
114                out.push_str(&rest[..open]);
115                let after_open = &rest[open + 1..];
116                if let Some(close) = after_open.find('}') {
117                    let key = &after_open[..close];
118                    if let Some(val) = values.get(key) {
119                        out.push_str(val);
120                        rest = &after_open[close + 1..];
121                        continue;
122                    }
123                }
124                // Unmatched or unknown — keep the '{' literal and continue.
125                out.push('{');
126                rest = after_open;
127            }
128        }
129    }
130    out
131}
132
133/// Convenience: build a placeholder map from `(&str, impl Into<String>)` pairs.
134pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
135where
136    I: IntoIterator<Item = (&'static str, V)>,
137    V: Into<String>,
138{
139    pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
147        placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
148    }
149
150    #[test]
151    fn single_substitution() {
152        let v = pm(&[("session_pct", "42")]);
153        assert_eq!(substitute("{session_pct}%", &v), "42%");
154    }
155
156    #[test]
157    fn usd_keeps_the_sign_outside_the_symbol() {
158        assert_eq!(usd(74.5), "$74.50");
159        assert_eq!(usd(0.0), "$0.00");
160        assert_eq!(usd(-5.71), "-$5.71");
161        // A negative zero off the wire is not a debt, and must not print as
162        // one — `format!("{:.2}")` alone would render it "$-0.00".
163        assert_eq!(usd(-0.0), "$0.00");
164        // Neither is a debt too small to show a cent.
165        assert_eq!(usd(-0.001), "$0.00");
166        // One that does round to a cent keeps its sign.
167        assert_eq!(usd(-0.006), "-$0.01");
168    }
169
170    #[test]
171    fn money_places_the_sign_ahead_of_every_currency() {
172        assert_eq!(money(20.0, "CNY"), "¥20.00");
173        assert_eq!(money(-20.0, "CNY"), "-¥20.00");
174        // A currency trails its code only when this table has no symbol for
175        // it; the sign still leads either way.
176        assert_eq!(money(3.5, "SEK"), "3.50 SEK");
177        assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
178        // usd() is the same policy, not a second one.
179        assert_eq!(money(-5.71, "USD"), usd(-5.71));
180        assert_eq!(money(-0.0, "CNY"), "¥0.00");
181    }
182
183    /// `money` and `usage::fmt_minor` compute different numbers — f64 at two
184    /// decimals versus integer minor units at the currency's own scale — but a
185    /// euro must look like a euro in both. They disagreed once: `money` had no
186    /// EUR/GBP/BRL/JPY entry and trailed the code while `fmt_minor` printed the
187    /// symbol, so the same currency read two ways in two panels.
188    #[test]
189    fn the_two_money_formatters_agree_on_every_symbol() {
190        for (code, symbol) in [
191            ("USD", "$"),
192            ("BRL", "R$"),
193            ("EUR", "€"),
194            ("GBP", "£"),
195            ("JPY", "¥"),
196            ("CNY", "¥"),
197        ] {
198            assert_eq!(money(3.5, code), format!("{symbol}3.50"), "money {code}");
199            assert_eq!(
200                crate::usage::fmt_minor(350, 2, Some(code)),
201                format!("{symbol}3.50"),
202                "fmt_minor {code}"
203            );
204        }
205        // And they agree that an unlisted code trails instead of guessing.
206        assert_eq!(money(3.5, "SEK"), "3.50 SEK");
207        assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
208        // fmt_minor keeps its own scale: JPY has no minor unit.
209        assert_eq!(crate::usage::fmt_minor(350, 0, Some("JPY")), "¥350");
210    }
211
212    #[test]
213    fn multiple_substitutions() {
214        let v = pm(&[("a", "1"), ("b", "2")]);
215        assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
216    }
217
218    #[test]
219    fn unknown_placeholder_passes_through() {
220        let v = pm(&[("a", "1")]);
221        assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
222    }
223
224    #[test]
225    fn no_re_substitution_in_replacement_text() {
226        // Replacement text containing {a} must NOT be re-expanded.
227        let v = pm(&[("a", "{a}"), ("b", "X")]);
228        assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
229    }
230
231    #[test]
232    fn empty_template() {
233        let v = pm(&[("a", "1")]);
234        assert_eq!(substitute("", &v), "");
235    }
236
237    #[test]
238    fn template_without_braces() {
239        let v = pm(&[("a", "1")]);
240        assert_eq!(substitute("hello world", &v), "hello world");
241    }
242
243    #[test]
244    fn unmatched_open_brace_is_literal() {
245        let v = pm(&[("a", "1")]);
246        assert_eq!(substitute("{a {x", &v), "{a {x");
247    }
248
249    #[test]
250    fn placeholders_with_underscores_and_digits() {
251        let v = pm(&[("session_pct_2", "x")]);
252        assert_eq!(substitute("{session_pct_2}", &v), "x");
253    }
254
255    #[test]
256    fn utf8_around_braces() {
257        let v = pm(&[("x", "→")]);
258        assert_eq!(substitute("α{x}β", &v), "α→β");
259    }
260}