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}