use std::collections::HashMap;
use std::time::Duration;
use chrono::{DateTime, Local, Utc};
use crate::usage::{ResetCredit, ResetCredits};
pub fn money(v: f64, currency: &str) -> String {
let magnitude = format!("{:.2}", v.abs());
let sign = if v < 0.0 && magnitude != "0.00" {
"-"
} else {
""
};
with_currency(sign, &magnitude, Some(currency))
}
pub fn with_currency(sign: &str, number: &str, currency: Option<&str>) -> String {
match currency {
None | Some("USD") => format!("{sign}${number}"),
Some("BRL") => format!("{sign}R${number}"),
Some("EUR") => format!("{sign}€{number}"),
Some("GBP") => format!("{sign}£{number}"),
Some("JPY") | Some("CNY") => format!("{sign}¥{number}"),
Some(other) => format!("{sign}{number} {other}"),
}
}
pub fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
Some(first) => {
let mut out = String::with_capacity(s.len());
out.extend(first.to_uppercase());
out.push_str(chars.as_str());
out
}
None => String::new(),
}
}
pub fn usd(v: f64) -> String {
money(v, "USD")
}
pub fn local_time_hm(when: DateTime<Utc>) -> String {
when.with_timezone(&Local).format("%H:%M").to_string()
}
pub fn local_time_hms(when: DateTime<Utc>) -> String {
when.with_timezone(&Local).format("%H:%M:%S").to_string()
}
pub fn local_date_hm(when: DateTime<Utc>) -> String {
when.with_timezone(&Local)
.format("%b %-d %H:%M")
.to_string()
}
pub fn reset_credits(credits: &ResetCredits) -> String {
let noun = if credits.available == 1 {
"reset"
} else {
"resets"
};
format!("{} {noun} available", credits.available)
}
pub fn reset_credit_lines(credits: &ResetCredits, now: DateTime<Utc>) -> Vec<String> {
let mut items = credits.credits.clone();
items.sort_by_key(|credit| credit.expires_at);
let mut lines: Vec<String> = items
.iter()
.map(|credit| reset_credit_line(credit, now))
.collect();
if lines.is_empty() && credits.available > 0 {
lines.push(reset_credits(credits));
}
lines
}
fn reset_credit_line(credit: &ResetCredit, now: DateTime<Utc>) -> String {
let expiry = match credit.expires_at {
Some(expires) if expires <= now => format!("expired {}", local_date_hm(expires)),
Some(expires) => format!(
"expires {} ({})",
local_date_hm(expires),
crate::countdown::format(Some(expires), now)
),
None => "no expiry reported".into(),
};
match credit
.title
.as_deref()
.map(str::trim)
.filter(|title| !title.is_empty())
{
Some(title) => format!("{title} · {expiry}"),
None => capitalize(&expiry),
}
}
pub fn updated_at_hm(now: DateTime<Utc>, cache_age: Option<Duration>) -> String {
match cache_age {
Some(age) => local_time_hm(now - chrono::Duration::from_std(age).unwrap_or_default()),
None => "—".to_string(),
}
}
pub fn substitute(template: &str, values: &HashMap<&str, String>) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while !rest.is_empty() {
match rest.find('{') {
None => {
out.push_str(rest);
break;
}
Some(open) => {
out.push_str(&rest[..open]);
let after_open = &rest[open + 1..];
if let Some(close) = after_open.find('}') {
let key = &after_open[..close];
if let Some(val) = values.get(key) {
out.push_str(val);
rest = &after_open[close + 1..];
continue;
}
}
out.push('{');
rest = after_open;
}
}
}
out
}
pub fn placeholders<I, V>(pairs: I) -> HashMap<&'static str, String>
where
I: IntoIterator<Item = (&'static str, V)>,
V: Into<String>,
{
pairs.into_iter().map(|(k, v)| (k, v.into())).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn pm(pairs: &[(&'static str, &str)]) -> HashMap<&'static str, String> {
placeholders(pairs.iter().map(|(k, v)| (*k, v.to_string())))
}
fn offer(title: Option<&str>, expires: &str) -> ResetCredit {
ResetCredit {
title: title.map(str::to_string),
expires_at: Some(expires.parse().unwrap()),
}
}
#[test]
fn a_reset_inventory_lists_each_credit_soonest_first() {
let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let credits = ResetCredits {
available: 2,
credits: vec![
offer(Some("Full reset (Weekly + 5 hr)"), "2026-08-01T00:00:00Z"),
offer(Some("Full reset (Weekly + 5 hr)"), "2026-07-17T00:00:00Z"),
],
};
assert_eq!(reset_credits(&credits), "2 resets available");
let lines = reset_credit_lines(&credits, now);
assert_eq!(lines.len(), 2, "{lines:?}");
assert!(lines[0].contains("Full reset (Weekly + 5 hr)"), "{lines:?}");
assert!(lines[0].contains("(13d 0h)"), "{lines:?}");
assert!(lines[1].contains("(28d 0h)"), "{lines:?}");
}
#[test]
fn a_lapsed_deadline_reads_as_expired_rather_than_now() {
let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
let lines = reset_credit_lines(
&ResetCredits {
available: 1,
credits: vec![offer(None, "2026-07-01T00:00:00Z")],
},
now,
);
assert_eq!(lines.len(), 1);
assert!(lines[0].starts_with("Expired "), "{lines:?}");
assert!(!lines[0].contains("(now)"), "{lines:?}");
}
#[test]
fn a_count_without_detail_still_renders_the_inventory() {
let now = "2026-07-04T00:00:00Z".parse::<DateTime<Utc>>().unwrap();
assert_eq!(
reset_credit_lines(
&ResetCredits {
available: 2,
credits: vec![]
},
now
),
vec!["2 resets available"]
);
}
#[test]
fn single_substitution() {
let v = pm(&[("session_pct", "42")]);
assert_eq!(substitute("{session_pct}%", &v), "42%");
}
#[test]
fn usd_keeps_the_sign_outside_the_symbol() {
assert_eq!(usd(74.5), "$74.50");
assert_eq!(usd(0.0), "$0.00");
assert_eq!(usd(-5.71), "-$5.71");
assert_eq!(usd(-0.0), "$0.00");
assert_eq!(usd(-0.001), "$0.00");
assert_eq!(usd(-0.006), "-$0.01");
}
#[test]
fn money_places_the_sign_ahead_of_every_currency() {
assert_eq!(money(20.0, "CNY"), "¥20.00");
assert_eq!(money(-20.0, "CNY"), "-¥20.00");
assert_eq!(money(3.5, "SEK"), "3.50 SEK");
assert_eq!(money(-3.5, "SEK"), "-3.50 SEK");
assert_eq!(money(-5.71, "USD"), usd(-5.71));
assert_eq!(money(-0.0, "CNY"), "¥0.00");
}
#[test]
fn the_two_money_formatters_agree_on_every_symbol() {
for (code, symbol) in [
("USD", "$"),
("BRL", "R$"),
("EUR", "€"),
("GBP", "£"),
("JPY", "¥"),
("CNY", "¥"),
] {
assert_eq!(money(3.5, code), format!("{symbol}3.50"), "money {code}");
assert_eq!(
crate::usage::fmt_minor(350, 2, Some(code)),
format!("{symbol}3.50"),
"fmt_minor {code}"
);
}
assert_eq!(money(3.5, "SEK"), "3.50 SEK");
assert_eq!(crate::usage::fmt_minor(350, 2, Some("SEK")), "3.50 SEK");
assert_eq!(crate::usage::fmt_minor(350, 0, Some("JPY")), "¥350");
}
#[test]
fn multiple_substitutions() {
let v = pm(&[("a", "1"), ("b", "2")]);
assert_eq!(substitute("{a}-{b}-{a}", &v), "1-2-1");
}
#[test]
fn unknown_placeholder_passes_through() {
let v = pm(&[("a", "1")]);
assert_eq!(substitute("{a} {unknown}", &v), "1 {unknown}");
}
#[test]
fn no_re_substitution_in_replacement_text() {
let v = pm(&[("a", "{a}"), ("b", "X")]);
assert_eq!(substitute("{b}{a}{b}", &v), "X{a}X");
}
#[test]
fn empty_template() {
let v = pm(&[("a", "1")]);
assert_eq!(substitute("", &v), "");
}
#[test]
fn template_without_braces() {
let v = pm(&[("a", "1")]);
assert_eq!(substitute("hello world", &v), "hello world");
}
#[test]
fn unmatched_open_brace_is_literal() {
let v = pm(&[("a", "1")]);
assert_eq!(substitute("{a {x", &v), "{a {x");
}
#[test]
fn placeholders_with_underscores_and_digits() {
let v = pm(&[("session_pct_2", "x")]);
assert_eq!(substitute("{session_pct_2}", &v), "x");
}
#[test]
fn utf8_around_braces() {
let v = pm(&[("x", "→")]);
assert_eq!(substitute("α{x}β", &v), "α→β");
}
}