1use std::collections::HashMap;
12use std::time::Duration;
13
14use chrono::{DateTime, Local, Utc};
15
16use crate::usage::{ResetCredit, ResetCredits};
17
18pub 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
38pub 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
61pub 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
79pub fn usd(v: f64) -> String {
81 money(v, "USD")
82}
83
84pub 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
131pub 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
141pub 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
185pub 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 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 out.push('{');
214 rest = after_open;
215 }
216 }
217 }
218 out
219}
220
221pub 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 #[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 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 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 assert_eq!(usd(-0.0), "$0.00");
328 assert_eq!(usd(-0.001), "$0.00");
330 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 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
341 assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
342 assert_eq!(money(-5.71, "USD"), usd(-5.71));
344 assert_eq!(money(-0.0, "CNY"), "¥0.00");
345 }
346
347 #[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 assert_eq!(money(3.5, "SEK"), "3.50 SEK");
371 assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
372 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 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}