Skip to main content

tokenburn_core/utils/
fmt.rs

1//! Small number-formatting helpers shared by the front-ends.
2
3/// `1234567` → `"1,234,567"`.
4pub fn thousands(n: u64) -> String {
5    let s = n.to_string();
6    let mut out = String::with_capacity(s.len() + s.len() / 3);
7    for (i, ch) in s.chars().enumerate() {
8        if i > 0 && (s.len() - i).is_multiple_of(3) {
9            out.push(',');
10        }
11        out.push(ch);
12    }
13    out
14}
15
16/// `1_234_567` → `"1.23M"`, `12_345` → `"12.3k"`, small numbers unchanged.
17pub fn compact(n: u64) -> String {
18    let f = n as f64;
19    if n >= 1_000_000_000 {
20        format!("{:.2}B", f / 1e9)
21    } else if n >= 1_000_000 {
22        format!("{:.2}M", f / 1e6)
23    } else if n >= 10_000 {
24        format!("{:.1}k", f / 1e3)
25    } else {
26        n.to_string()
27    }
28}
29
30/// `1.5` → `"$1.50"`: cents, so small costs like `$0.04` stay readable
31/// (the JSON output keeps the full precision).
32pub fn usd(v: f64) -> String {
33    format!("${v:.2}")
34}
35
36/// A share as a whole percentage: `0.486` of the total → `"49%"`.
37pub fn percent(fraction: f64) -> String {
38    format!("{:.0}%", fraction * 100.0)
39}
40
41/// Tokens for an axis: `20M`, `2.5M`, `15k`, `800` — no trailing zeros.
42pub fn axis_tokens(v: f64) -> String {
43    let (scaled, unit) = match v {
44        v if v >= 1e9 => (v / 1e9, "B"),
45        v if v >= 1e6 => (v / 1e6, "M"),
46        v if v >= 1e3 => (v / 1e3, "k"),
47        v => (v, ""),
48    };
49    let s = format!("{scaled:.1}");
50    format!("{}{unit}", s.strip_suffix(".0").unwrap_or(&s))
51}
52
53/// A short x-axis label: drop the year (`2026-05-10` → `05-10`, `2026-05-10 13:00` → `13:00`).
54pub fn short_label(label: &str) -> String {
55    match label.len() {
56        16 => label[11..].to_string(),
57        10 => label[5..].to_string(),
58        _ => label.to_string(),
59    }
60}
61
62/// `pi:Users-me-Projects-tokenburn` → `pi:tokenburn` (last path-ish segment).
63pub fn short_name(full: &str) -> String {
64    match full.split_once(':') {
65        Some((tool, rest)) => {
66            let last = rest
67                .rsplit(['-', '/', '\\'])
68                .find(|s| !s.is_empty())
69                .unwrap_or(rest);
70            format!("{tool}:{last}")
71        }
72        None => full.to_string(),
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn thousands_groups() {
82        assert_eq!(thousands(0), "0");
83        assert_eq!(thousands(999), "999");
84        assert_eq!(thousands(1000), "1,000");
85        assert_eq!(thousands(1_234_567), "1,234,567");
86    }
87
88    #[test]
89    fn compact_scales() {
90        assert_eq!(compact(999), "999");
91        assert_eq!(compact(12_345), "12.3k");
92        assert_eq!(compact(1_500_000), "1.50M");
93        assert_eq!(compact(2_000_000_000), "2.00B");
94    }
95
96    #[test]
97    fn usd_four_decimals() {
98        assert_eq!(usd(1.5), "$1.50");
99        assert_eq!(usd(0.0), "$0.00");
100        assert_eq!(usd(0.04), "$0.04", "small costs stay readable");
101        assert_eq!(usd(12.34), "$12.34");
102        assert_eq!(usd(165.9051), "$165.91");
103        assert_eq!(usd(1234.56), "$1234.56");
104    }
105
106    #[test]
107    fn percentages_are_whole_numbers() {
108        assert_eq!(percent(0.486), "49%");
109        assert_eq!(percent(1.0), "100%");
110        assert_eq!(percent(0.0), "0%");
111        assert_eq!(percent(0.004), "0%");
112    }
113
114    #[test]
115    fn axis_tokens_have_no_trailing_zeros() {
116        assert_eq!(axis_tokens(20_000_000.0), "20M");
117        assert_eq!(axis_tokens(2_500_000.0), "2.5M");
118        assert_eq!(axis_tokens(15_000.0), "15k");
119        assert_eq!(axis_tokens(800.0), "800");
120        assert_eq!(axis_tokens(0.0), "0");
121        assert_eq!(axis_tokens(3_000_000_000.0), "3B");
122    }
123
124    #[test]
125    fn x_labels_drop_the_year() {
126        assert_eq!(short_label("2026-05-10"), "05-10");
127        assert_eq!(short_label("2026-05-10 13:00"), "13:00");
128        assert_eq!(short_label("2026-05"), "2026-05");
129    }
130
131    #[test]
132    fn short_names_keep_the_tool_and_the_last_path_segment() {
133        assert_eq!(short_name("pi:Users-me-Projects-tokenburn"), "pi:tokenburn");
134        assert_eq!(short_name("zed:claude-sonnet"), "zed:sonnet");
135        assert_eq!(short_name("plain"), "plain");
136    }
137}