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    match currency {
34        "USD" => format!("{sign}${magnitude}"),
35        "CNY" => format!("{sign}¥{magnitude}"),
36        // Unknown currencies trail their code instead of guessing a symbol.
37        _ => format!("{sign}{magnitude} {currency}"),
38    }
39}
40
41/// [`money`] for the providers that only ever bill in dollars.
42pub fn usd(v: f64) -> String {
43    money(v, "USD")
44}
45
46pub fn local_time_hm(when: DateTime<Utc>) -> String {
47    when.with_timezone(&Local).format("%H:%M").to_string()
48}
49
50pub fn local_time_hms(when: DateTime<Utc>) -> String {
51    when.with_timezone(&Local).format("%H:%M:%S").to_string()
52}
53
54pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
55    match cache_age {
56        Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
57        None => "—".to_string(),
58    }
59}
60
61pub fn updated_at_hms(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
62    match cache_age {
63        Some(age) => local_time_hms(now - chrono::Duration::from_std(age).unwrap_or_default()),
64        None => "—".to_string(),
65    }
66}
67
68/// Substitute every `{key}` in `template` with `values[key]`. Unknown keys
69/// are left as-is.
70///
71/// This is a single-pass scan; an O(N) implementation that does no
72/// re-substitution. (Avoids the bash pitfall where replacement text
73/// containing `{foo}` would get further substituted.)
74pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
75    let mut out = String::with_capacity(template.len());
76    let mut rest = template;
77    while !rest.is_empty() {
78        match rest.find('{') {
79            None => {
80                out.push_str(rest);
81                break;
82            }
83            Some(open) => {
84                // Copy everything up to the '{'.
85                out.push_str(&rest[..open]);
86                let after_open = &rest[open + 1..];
87                if let Some(close) = after_open.find('}') {
88                    let key = &after_open[..close];
89                    if let Some(val) = values.get(key) {
90                        out.push_str(val);
91                        rest = &after_open[close + 1..];
92                        continue;
93                    }
94                }
95                // Unmatched or unknown — keep the '{' literal and continue.
96                out.push('{');
97                rest = after_open;
98            }
99        }
100    }
101    out
102}
103
104/// Convenience: build a placeholder map from `(&str, impl Into<String>)` pairs.
105pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
106where
107    I: IntoIterator<Item = (&'static str, V)>,
108    V: Into<String>,
109{
110    pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
118        placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
119    }
120
121    #[test]
122    fn single_substitution() {
123        let v = pm(&[("session_pct", "42")]);
124        assert_eq!(substitute("{session_pct}%", &v), "42%");
125    }
126
127    #[test]
128    fn usd_keeps_the_sign_outside_the_symbol() {
129        assert_eq!(usd(74.5), "$74.50");
130        assert_eq!(usd(0.0), "$0.00");
131        assert_eq!(usd(-5.71), "-$5.71");
132        // A negative zero off the wire is not a debt, and must not print as
133        // one — `format!("{:.2}")` alone would render it "$-0.00".
134        assert_eq!(usd(-0.0), "$0.00");
135        // Neither is a debt too small to show a cent.
136        assert_eq!(usd(-0.001), "$0.00");
137        // One that does round to a cent keeps its sign.
138        assert_eq!(usd(-0.006), "-$0.01");
139    }
140
141    #[test]
142    fn money_places_the_sign_ahead_of_every_currency() {
143        assert_eq!(money(20.0, "CNY"), "¥20.00");
144        assert_eq!(money(-20.0, "CNY"), "-¥20.00");
145        // An unknown currency trails its code rather than guessing a symbol,
146        // and the sign still leads.
147        assert_eq!(money(3.5, "EUR"), "3.50 EUR");
148        assert_eq!(money(-3.5, "EUR"), "-3.50 EUR");
149        // usd() is the same policy, not a second one.
150        assert_eq!(money(-5.71, "USD"), usd(-5.71));
151        assert_eq!(money(-0.0, "CNY"), "¥0.00");
152    }
153
154    #[test]
155    fn multiple_substitutions() {
156        let v = pm(&[("a", "1"), ("b", "2")]);
157        assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
158    }
159
160    #[test]
161    fn unknown_placeholder_passes_through() {
162        let v = pm(&[("a", "1")]);
163        assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
164    }
165
166    #[test]
167    fn no_re_substitution_in_replacement_text() {
168        // Replacement text containing {a} must NOT be re-expanded.
169        let v = pm(&[("a", "{a}"), ("b", "X")]);
170        assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
171    }
172
173    #[test]
174    fn empty_template() {
175        let v = pm(&[("a", "1")]);
176        assert_eq!(substitute("", &v), "");
177    }
178
179    #[test]
180    fn template_without_braces() {
181        let v = pm(&[("a", "1")]);
182        assert_eq!(substitute("hello world", &v), "hello world");
183    }
184
185    #[test]
186    fn unmatched_open_brace_is_literal() {
187        let v = pm(&[("a", "1")]);
188        assert_eq!(substitute("{a {x", &v), "{a {x");
189    }
190
191    #[test]
192    fn placeholders_with_underscores_and_digits() {
193        let v = pm(&[("session_pct_2", "x")]);
194        assert_eq!(substitute("{session_pct_2}", &v), "x");
195    }
196
197    #[test]
198    fn utf8_around_braces() {
199        let v = pm(&[("x", "→")]);
200        assert_eq!(substitute("α{x}β", &v), "α→β");
201    }
202}