1use std::collections::HashMap;
12use std::time::Duration;
13
14use chrono::{DateTime, Local, Utc};
15
16pub 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
36pub 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
59pub 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
77pub 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
97pub 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 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 out.push('{');
126 rest = after_open;
127 }
128 }
129 }
130 out
131}
132
133pub 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 assert_eq!(usd(-0.0), "$0.00");
164 assert_eq!(usd(-0.001), "$0.00");
166 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 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
177 assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
178 assert_eq!(money(-5.71, "USD"), usd(-5.71));
180 assert_eq!(money(-0.0, "CNY"), "¥0.00");
181 }
182
183 #[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 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
207 assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
208 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 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}