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
16pub fn local_time_hm(when: DateTime<Utc>) -> String {
17    when.with_timezone(&Local).format("%H:%M").to_string()
18}
19
20pub fn local_time_hms(when: DateTime<Utc>) -> String {
21    when.with_timezone(&Local).format("%H:%M:%S").to_string()
22}
23
24pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
25    match cache_age {
26        Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
27        None => "—".to_string(),
28    }
29}
30
31pub fn updated_at_hms(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
32    match cache_age {
33        Some(age) => local_time_hms(now - chrono::Duration::from_std(age).unwrap_or_default()),
34        None => "—".to_string(),
35    }
36}
37
38/// Substitute every `{key}` in `template` with `values[key]`. Unknown keys
39/// are left as-is.
40///
41/// This is a single-pass scan; an O(N) implementation that does no
42/// re-substitution. (Avoids the bash pitfall where replacement text
43/// containing `{foo}` would get further substituted.)
44pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
45    let mut out = String::with_capacity(template.len());
46    let mut rest = template;
47    while !rest.is_empty() {
48        match rest.find('{') {
49            None => {
50                out.push_str(rest);
51                break;
52            }
53            Some(open) => {
54                // Copy everything up to the '{'.
55                out.push_str(&rest[..open]);
56                let after_open = &rest[open + 1..];
57                if let Some(close) = after_open.find('}') {
58                    let key = &after_open[..close];
59                    if let Some(val) = values.get(key) {
60                        out.push_str(val);
61                        rest = &after_open[close + 1..];
62                        continue;
63                    }
64                }
65                // Unmatched or unknown — keep the '{' literal and continue.
66                out.push('{');
67                rest = after_open;
68            }
69        }
70    }
71    out
72}
73
74/// Convenience: build a placeholder map from `(&str, impl Into<String>)` pairs.
75pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
76where
77    I: IntoIterator<Item = (&'static str, V)>,
78    V: Into<String>,
79{
80    pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
88        placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
89    }
90
91    #[test]
92    fn single_substitution() {
93        let v = pm(&[("session_pct", "42")]);
94        assert_eq!(substitute("{session_pct}%", &v), "42%");
95    }
96
97    #[test]
98    fn multiple_substitutions() {
99        let v = pm(&[("a", "1"), ("b", "2")]);
100        assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
101    }
102
103    #[test]
104    fn unknown_placeholder_passes_through() {
105        let v = pm(&[("a", "1")]);
106        assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
107    }
108
109    #[test]
110    fn no_re_substitution_in_replacement_text() {
111        // Replacement text containing {a} must NOT be re-expanded.
112        let v = pm(&[("a", "{a}"), ("b", "X")]);
113        assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
114    }
115
116    #[test]
117    fn empty_template() {
118        let v = pm(&[("a", "1")]);
119        assert_eq!(substitute("", &v), "");
120    }
121
122    #[test]
123    fn template_without_braces() {
124        let v = pm(&[("a", "1")]);
125        assert_eq!(substitute("hello world", &v), "hello world");
126    }
127
128    #[test]
129    fn unmatched_open_brace_is_literal() {
130        let v = pm(&[("a", "1")]);
131        assert_eq!(substitute("{a {x", &v), "{a {x");
132    }
133
134    #[test]
135    fn placeholders_with_underscores_and_digits() {
136        let v = pm(&[("session_pct_2", "x")]);
137        assert_eq!(substitute("{session_pct_2}", &v), "x");
138    }
139
140    #[test]
141    fn utf8_around_braces() {
142        let v = pm(&[("x", "→")]);
143        assert_eq!(substitute("α{x}β", &v), "α→β");
144    }
145}