Skip to main content

vct_core/utils/
format.rs

1use chrono::{Local, NaiveDate};
2use std::sync::RwLock;
3
4/// Formats an integer with comma thousands-separators.
5///
6/// Accepts any [`itoa::Integer`], so both signed and unsigned widths work.
7/// Uses `itoa` to render the digits into a stack buffer, then inserts commas
8/// every three digits from the right — no intermediate `String` allocation
9/// for the conversion itself.
10///
11/// # Examples
12///
13/// ```
14/// use vct_core::utils::format_number;
15///
16/// assert_eq!(format_number(0), "0");
17/// assert_eq!(format_number(1234), "1,234");
18/// assert_eq!(format_number(1_234_567), "1,234,567");
19/// ```
20pub fn format_number<T>(n: T) -> String
21where
22    T: itoa::Integer,
23{
24    // Use itoa for fast conversion (no allocations, direct to buffer)
25    let mut buf = itoa::Buffer::new();
26    let s = buf.format(n);
27
28    if s.len() <= 3 {
29        return s.to_string();
30    }
31
32    let mut result = String::with_capacity(s.len() + (s.len() - 1) / 3);
33    let remainder = s.len() % 3;
34
35    // Handle first group (which might be 1, 2, or 3 digits)
36    if remainder > 0 {
37        result.push_str(&s[..remainder]);
38    }
39
40    // Handle remaining groups of 3 (direct byte operations for speed)
41    for (i, chunk) in s.as_bytes()[remainder..].chunks_exact(3).enumerate() {
42        // Add comma before each group (including first if remainder > 0)
43        if remainder > 0 || i > 0 {
44            result.push(',');
45        }
46        // SAFETY: chunks_exact(3) guarantees valid UTF-8 ASCII digits
47        unsafe {
48            result.push_str(std::str::from_utf8_unchecked(chunk));
49        }
50    }
51
52    result
53}
54
55/// Formats an integer into a compact human string with a K/M/B/T suffix.
56///
57/// Targets a dense dashboard where column width is scarce: values under 1000
58/// render verbatim, otherwise the largest fitting unit is used with 3
59/// significant figures (2 / 1 / 0 decimals as the scaled value crosses 10 and
60/// 100), so the result stays within ~5 characters. Rounding that would reach
61/// 1000 within a unit is promoted to the next unit, so `999_950` renders as
62/// `"1.00M"` rather than `"1000K"`. Negative values keep a leading `-`.
63///
64/// Only the interactive TUI uses this; the static table, text, and JSON paths
65/// keep [`format_number`] so their numbers stay exact.
66///
67/// # Examples
68///
69/// ```
70/// use vct_core::utils::format_compact;
71///
72/// assert_eq!(format_compact(999), "999");
73/// assert_eq!(format_compact(1_000), "1.00K");
74/// assert_eq!(format_compact(1_234_567), "1.23M");
75/// assert_eq!(format_compact(999_950), "1.00M");
76/// ```
77pub fn format_compact(n: i64) -> String {
78    // Descending so the first match is the largest fitting unit.
79    const UNITS: [(f64, char); 4] = [(1e12, 'T'), (1e9, 'B'), (1e6, 'M'), (1e3, 'K')];
80
81    let abs = n.unsigned_abs() as f64;
82    if abs < 1000.0 {
83        // No suffix needed; render the integer verbatim (keeps the sign).
84        return n.to_string();
85    }
86
87    // Decimals by magnitude, with a tiny epsilon so e.g. 9.999 classifies as
88    // the 1-decimal bucket and renders "10.0K" instead of "10.00K".
89    let decimals = |v: f64| -> usize {
90        if v < 9.995 {
91            2
92        } else if v < 99.95 {
93            1
94        } else {
95            0
96        }
97    };
98
99    let mut idx = UNITS
100        .iter()
101        .position(|&(threshold, _)| abs >= threshold)
102        .unwrap_or(UNITS.len() - 1);
103    let mut scaled = abs / UNITS[idx].0;
104
105    // Rounding can push the scaled value to 1000 (e.g. 999_950 -> "1000K");
106    // promote one unit up so it reads "1.00M".
107    let dec = decimals(scaled);
108    let factor = 10f64.powi(dec as i32);
109    if (scaled * factor).round() / factor >= 1000.0 && idx > 0 {
110        idx -= 1;
111        scaled = abs / UNITS[idx].0;
112    }
113
114    let dec = decimals(scaled);
115    let sign = if n < 0 { "-" } else { "" };
116    format!("{sign}{scaled:.dec$}{suffix}", suffix = UNITS[idx].1)
117}
118
119/// Formats a USD amount as `"$1,234.56"` (comma-grouped dollars, two decimals).
120///
121/// Unlike [`format_compact`], money is never abbreviated to K/M/B — that would
122/// drop the cents — so long totals are kept readable with thousands separators
123/// instead. Negative amounts render as `"-$1.23"`.
124///
125/// # Examples
126///
127/// ```
128/// use vct_core::utils::format_cost;
129///
130/// assert_eq!(format_cost(0.0), "$0.00");
131/// assert_eq!(format_cost(1234.5), "$1,234.50");
132/// ```
133pub fn format_cost(cost: f64) -> String {
134    let cents = (cost.abs() * 100.0).round() as i64;
135    let sign = if cost < 0.0 && cents != 0 { "-" } else { "" };
136    format!("{sign}${}.{:02}", format_number(cents / 100), cents % 100)
137}
138
139/// Formats a USD amount for the dense interactive dashboard, abbreviating large
140/// values with a K/M/B/T suffix.
141///
142/// Amounts under `$1000` keep full cents via [`format_cost`] (`$74.18`); larger
143/// amounts drop to [`format_compact`]'s 3-significant-figure form prefixed with
144/// `$` (`$8.63K`, `$14.2K`). This mirrors how the TUI already abbreviates token
145/// counts — only the interactive view uses it, while the static table, text, and
146/// JSON paths keep [`format_cost`] so their amounts stay exact to the cent.
147///
148/// # Examples
149///
150/// ```
151/// use vct_core::utils::format_cost_compact;
152///
153/// assert_eq!(format_cost_compact(74.18), "$74.18");
154/// assert_eq!(format_cost_compact(4114.28), "$4.11K");
155/// assert_eq!(format_cost_compact(10021.35), "$10.0K");
156/// assert_eq!(format_cost_compact(-1500.0), "-$1.50K");
157/// ```
158pub fn format_cost_compact(cost: f64) -> String {
159    if cost.abs() < 1000.0 {
160        return format_cost(cost);
161    }
162    let sign = if cost < 0.0 { "-" } else { "" };
163    format!("{sign}${}", format_compact(cost.abs().round() as i64))
164}
165
166// Cache for current date (updated once per day)
167static DATE_CACHE: RwLock<Option<(NaiveDate, String)>> = RwLock::new(None);
168
169/// Returns today's local date as a `YYYY-MM-DD` string.
170///
171/// The formatted string is cached behind an `RwLock` and only recomputed
172/// when the local calendar day changes, so repeated calls within the same
173/// day (e.g. tagging every aggregated row) avoid re-running `strftime`. A
174/// poisoned lock degrades gracefully: the value is recomputed without
175/// touching the cache rather than panicking.
176pub fn get_current_date() -> String {
177    let today = Local::now().date_naive();
178
179    // Fast path: read lock to check if cache is valid
180    {
181        if let Ok(cache) = DATE_CACHE.read()
182            && let Some((cached_date, cached_string)) = cache.as_ref()
183            && *cached_date == today
184        {
185            return cached_string.clone();
186        }
187    }
188
189    // Slow path: write lock to update cache
190    let date_string = today.format("%Y-%m-%d").to_string();
191    if let Ok(mut cache) = DATE_CACHE.write() {
192        *cache = Some((today, date_string.clone()));
193    }
194
195    date_string
196}
197
198/// Formats the time remaining until `reset_unix` as a compact human string.
199///
200/// Returns "now" when the reset is in the past or under a minute away
201/// (clamped at 0), otherwise the two most significant units: "13m", "2h13m",
202/// or "4d2h".
203///
204/// # Examples
205///
206/// ```
207/// use vct_core::utils::format_duration_until;
208///
209/// assert_eq!(format_duration_until(100, 100), "now");
210/// assert_eq!(format_duration_until(100 + 13 * 60, 100), "13m");
211/// assert_eq!(format_duration_until(100 + 2 * 3600 + 13 * 60, 100), "2h13m");
212/// assert_eq!(format_duration_until(100 + 4 * 86400 + 2 * 3600, 100), "4d2h");
213/// ```
214pub fn format_duration_until(reset_unix: i64, now_unix: i64) -> String {
215    let secs = (reset_unix - now_unix).max(0);
216    let days = secs / 86400;
217    let hours = (secs % 86400) / 3600;
218    let minutes = (secs % 3600) / 60;
219    if days > 0 {
220        format!("{days}d{hours}h")
221    } else if hours > 0 {
222        format!("{hours}h{minutes}m")
223    } else if minutes > 0 {
224        format!("{minutes}m")
225    } else {
226        "now".to_string()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_format_number() {
236        assert_eq!(format_number(0), "0");
237        assert_eq!(format_number(123), "123");
238        assert_eq!(format_number(1234), "1,234");
239        assert_eq!(format_number(1234567), "1,234,567");
240        assert_eq!(format_number(1234567890), "1,234,567,890");
241    }
242
243    #[test]
244    fn test_format_number_edge_cases() {
245        // Single digit
246        assert_eq!(format_number(1), "1");
247        assert_eq!(format_number(9), "9");
248
249        // Two digits
250        assert_eq!(format_number(10), "10");
251        assert_eq!(format_number(99), "99");
252
253        // Three digits (boundary - no comma needed)
254        assert_eq!(format_number(100), "100");
255        assert_eq!(format_number(999), "999");
256
257        // Four digits (boundary - first comma appears)
258        assert_eq!(format_number(1000), "1,000");
259        assert_eq!(format_number(9999), "9,999");
260
261        // Exact multiples of 1000
262        assert_eq!(format_number(10000), "10,000");
263        assert_eq!(format_number(100000), "100,000");
264        assert_eq!(format_number(1000000), "1,000,000");
265
266        // Large numbers
267        assert_eq!(format_number(12345678901_i64), "12,345,678,901");
268        assert_eq!(format_number(999999999999_i64), "999,999,999,999");
269    }
270
271    #[test]
272    fn test_format_number_with_large_integers() {
273        assert_eq!(format_number(12345_i64), "12,345");
274        assert_eq!(format_number(999_i32), "999");
275    }
276
277    #[test]
278    fn test_format_compact() {
279        // Below 1000: verbatim, no suffix.
280        assert_eq!(format_compact(0), "0");
281        assert_eq!(format_compact(42), "42");
282        assert_eq!(format_compact(999), "999");
283
284        // K / M / B / T with 3 significant figures.
285        assert_eq!(format_compact(1_000), "1.00K");
286        assert_eq!(format_compact(1_234), "1.23K");
287        assert_eq!(format_compact(12_345), "12.3K");
288        assert_eq!(format_compact(123_456), "123K");
289        assert_eq!(format_compact(1_234_567), "1.23M");
290        assert_eq!(format_compact(1_230_000_000), "1.23B");
291        assert_eq!(format_compact(2_000_000_000_000), "2.00T");
292    }
293
294    #[test]
295    fn test_format_compact_rounding_promotion() {
296        // Rounding that reaches 1000 within a unit promotes to the next unit.
297        // 999_499 rounds to 999K; 999_500 rounds up and promotes to 1.00M.
298        assert_eq!(format_compact(999_499), "999K");
299        assert_eq!(format_compact(999_500), "1.00M");
300        assert_eq!(format_compact(999_999), "1.00M");
301        assert_eq!(format_compact(1_000_000), "1.00M");
302        // 9.999K classifies into the 1-decimal bucket, not "10.00K".
303        assert_eq!(format_compact(9_999), "10.0K");
304    }
305
306    #[test]
307    fn test_format_compact_negative() {
308        assert_eq!(format_compact(-42), "-42");
309        assert_eq!(format_compact(-1_234), "-1.23K");
310        assert_eq!(format_compact(-1_234_567), "-1.23M");
311    }
312
313    #[test]
314    fn test_format_cost() {
315        assert_eq!(format_cost(0.0), "$0.00");
316        assert_eq!(format_cost(1.5), "$1.50");
317        assert_eq!(format_cost(1234.56), "$1,234.56");
318        assert_eq!(format_cost(1_234_567.891), "$1,234,567.89");
319        assert_eq!(format_cost(-5.5), "-$5.50");
320    }
321
322    #[test]
323    fn test_format_cost_compact() {
324        // Under $1000: full cents via format_cost.
325        assert_eq!(format_cost_compact(0.0), "$0.00");
326        assert_eq!(format_cost_compact(2.42), "$2.42");
327        assert_eq!(format_cost_compact(74.18), "$74.18");
328        assert_eq!(format_cost_compact(999.99), "$999.99");
329
330        // $1000 and up: K/M/B/T with 3 significant figures.
331        assert_eq!(format_cost_compact(1000.0), "$1.00K");
332        assert_eq!(format_cost_compact(4114.28), "$4.11K");
333        assert_eq!(format_cost_compact(10021.35), "$10.0K");
334        assert_eq!(format_cost_compact(14212.22), "$14.2K");
335        assert_eq!(format_cost_compact(8_631_270.0), "$8.63M");
336
337        // Negative (credit) amounts keep a leading minus.
338        assert_eq!(format_cost_compact(-50.0), "-$50.00");
339        assert_eq!(format_cost_compact(-1500.0), "-$1.50K");
340    }
341
342    #[test]
343    fn test_get_current_date() {
344        let date = get_current_date();
345        assert_eq!(date.len(), 10); // YYYY-MM-DD format
346        assert!(date.contains('-'));
347    }
348}