Skip to main content

batuta_common/
fmt.rs

1//! Formatting utilities for bytes, percentages, durations, and numbers.
2//!
3//! Consolidates byte-formatting implementations previously duplicated across
4//! pmat, trueno-viz, aprender, and trueno.
5
6// Byte formatting intentionally casts u64 -> f64 for display purposes.
7// Precision loss beyond 2^52 is acceptable for human-readable output.
8#![allow(clippy::cast_precision_loss)]
9
10// =============================================================================
11// BYTE FORMATTING
12// =============================================================================
13
14/// Format bytes using SI units (powers of 1000).
15///
16/// Uses adaptive precision: 3+ digits show no decimal, 2 digits show 1 decimal,
17/// 1 digit shows 2 decimals.
18///
19/// # Examples
20/// ```
21/// use batuta_common::fmt::format_bytes_si;
22/// assert_eq!(format_bytes_si(0), "0B");
23/// assert_eq!(format_bytes_si(500), "500B");
24/// assert_eq!(format_bytes_si(1500), "1.50K");
25/// assert_eq!(format_bytes_si(1_500_000), "1.50M");
26/// assert_eq!(format_bytes_si(1_500_000_000), "1.50G");
27/// ```
28#[must_use]
29pub fn format_bytes_si(bytes: u64) -> String {
30    const UNITS: &[&str] = &["B", "K", "M", "G", "T", "P", "E"];
31    const THRESHOLD: f64 = 1000.0;
32
33    if bytes == 0 {
34        return "0B".to_string();
35    }
36
37    let mut value = bytes as f64;
38    let mut unit_idx = 0;
39
40    while value >= THRESHOLD && unit_idx < UNITS.len() - 1 {
41        value /= THRESHOLD;
42        unit_idx += 1;
43    }
44
45    if unit_idx == 0 {
46        format!("{bytes}B")
47    } else if value >= 100.0 {
48        format!("{value:.0}{}", UNITS[unit_idx])
49    } else if value >= 10.0 {
50        format!("{value:.1}{}", UNITS[unit_idx])
51    } else {
52        format!("{value:.2}{}", UNITS[unit_idx])
53    }
54}
55
56/// Format bytes using IEC units (powers of 1024).
57///
58/// # Examples
59/// ```
60/// use batuta_common::fmt::format_bytes_iec;
61/// assert_eq!(format_bytes_iec(0), "0B");
62/// assert_eq!(format_bytes_iec(1024), "1.00KiB");
63/// assert_eq!(format_bytes_iec(1536), "1.50KiB");
64/// ```
65#[must_use]
66pub fn format_bytes_iec(bytes: u64) -> String {
67    const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
68    const THRESHOLD: f64 = 1024.0;
69
70    if bytes == 0 {
71        return "0B".to_string();
72    }
73
74    let mut value = bytes as f64;
75    let mut unit_idx = 0;
76
77    while value >= THRESHOLD && unit_idx < UNITS.len() - 1 {
78        value /= THRESHOLD;
79        unit_idx += 1;
80    }
81
82    if unit_idx == 0 {
83        format!("{bytes}B")
84    } else {
85        format!("{value:.2}{}", UNITS[unit_idx])
86    }
87}
88
89/// Format bytes in human-readable form (IEC-style with short units).
90///
91/// Uses "KB", "MB", "GB" labels (1024-based) with 1 decimal place.
92/// This is the most common format for CLI output.
93///
94/// # Examples
95/// ```
96/// use batuta_common::fmt::format_bytes;
97/// assert_eq!(format_bytes(0), "0 B");
98/// assert_eq!(format_bytes(1023), "1023 B");
99/// assert_eq!(format_bytes(1024), "1.0 KB");
100/// assert_eq!(format_bytes(1_048_576), "1.0 MB");
101/// ```
102#[must_use]
103pub fn format_bytes(bytes: u64) -> String {
104    const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
105    let mut size = bytes as f64;
106    let mut unit_index = 0;
107
108    while size >= 1024.0 && unit_index < UNITS.len() - 1 {
109        size /= 1024.0;
110        unit_index += 1;
111    }
112
113    if unit_index == 0 {
114        format!("{bytes} {}", UNITS[unit_index])
115    } else {
116        format!("{size:.1} {}", UNITS[unit_index])
117    }
118}
119
120/// Format bytes compactly with short IEC-based units and no spaces.
121///
122/// Uses 1024-based thresholds with single-letter units (K, M, G, T)
123/// and 1 decimal place. Values below 1024 are shown as fractional K.
124/// Intended for tight UI columns like disk panels.
125///
126/// # Examples
127/// ```
128/// use batuta_common::fmt::format_bytes_compact;
129/// assert_eq!(format_bytes_compact(1_073_741_824), "1.0G");
130/// assert_eq!(format_bytes_compact(1_048_576), "1.0M");
131/// assert_eq!(format_bytes_compact(1024), "1.0K");
132/// assert_eq!(format_bytes_compact(512), "0.5K");
133/// ```
134#[must_use]
135pub fn format_bytes_compact(bytes: u64) -> String {
136    const KB: u64 = 1024;
137    const MB: u64 = KB * 1024;
138    const GB: u64 = MB * 1024;
139    const TB: u64 = GB * 1024;
140
141    if bytes >= TB {
142        format!("{:.1}T", bytes as f64 / TB as f64)
143    } else if bytes >= GB {
144        format!("{:.1}G", bytes as f64 / GB as f64)
145    } else if bytes >= MB {
146        format!("{:.1}M", bytes as f64 / MB as f64)
147    } else {
148        format!("{:.1}K", bytes as f64 / KB as f64)
149    }
150}
151
152/// Format bytes with variable precision and full unit labels.
153///
154/// Uses 1024-based thresholds. TB and GB use 2 decimal places for
155/// precision; MB and KB use 1 decimal place. Values below 1024 show
156/// the raw count with " B" suffix. Intended for transfer totals
157/// and `PCIe` bandwidth displays.
158///
159/// # Examples
160/// ```
161/// use batuta_common::fmt::format_bytes_full;
162/// assert_eq!(format_bytes_full(1_099_511_627_776), "1.00 TB");
163/// assert_eq!(format_bytes_full(1_073_741_824), "1.00 GB");
164/// assert_eq!(format_bytes_full(1_048_576), "1.0 MB");
165/// assert_eq!(format_bytes_full(1024), "1.0 KB");
166/// assert_eq!(format_bytes_full(500), "500 B");
167/// ```
168#[must_use]
169pub fn format_bytes_full(bytes: u64) -> String {
170    const KB: u64 = 1024;
171    const MB: u64 = KB * 1024;
172    const GB: u64 = MB * 1024;
173    const TB: u64 = GB * 1024;
174
175    if bytes >= TB {
176        format!("{:.2} TB", bytes as f64 / TB as f64)
177    } else if bytes >= GB {
178        format!("{:.2} GB", bytes as f64 / GB as f64)
179    } else if bytes >= MB {
180        format!("{:.1} MB", bytes as f64 / MB as f64)
181    } else if bytes >= KB {
182        format!("{:.1} KB", bytes as f64 / KB as f64)
183    } else {
184        format!("{bytes} B")
185    }
186}
187
188/// Format bytes per second as a rate string.
189///
190/// # Examples
191/// ```
192/// use batuta_common::fmt::format_bytes_rate;
193/// assert_eq!(format_bytes_rate(1500.0), "1.50K/s");
194/// ```
195#[must_use]
196#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
197pub fn format_bytes_rate(bytes_per_sec: f64) -> String {
198    format!("{}/s", format_bytes_si(bytes_per_sec as u64))
199}
200
201// =============================================================================
202// PERCENTAGE FORMATTING
203// =============================================================================
204
205/// Format a percentage value (0.0 to 100.0) with 1 decimal place.
206///
207/// # Examples
208/// ```
209/// use batuta_common::fmt::format_percent;
210/// assert_eq!(format_percent(45.3), "45.3%");
211/// assert_eq!(format_percent(100.0), "100.0%");
212/// ```
213#[must_use]
214pub fn format_percent(value: f64) -> String {
215    format!("{value:.1}%")
216}
217
218/// Format a percentage clamped to 0-100 range.
219///
220/// # Examples
221/// ```
222/// use batuta_common::fmt::format_percent_clamped;
223/// assert_eq!(format_percent_clamped(150.0), "100.0%");
224/// assert_eq!(format_percent_clamped(-10.0), "0.0%");
225/// ```
226#[must_use]
227pub fn format_percent_clamped(value: f64) -> String {
228    format_percent(value.clamp(0.0, 100.0))
229}
230
231/// Format a percentage with fixed decimal places.
232///
233/// # Examples
234/// ```
235/// use batuta_common::fmt::format_percent_fixed;
236/// assert_eq!(format_percent_fixed(45.333, 2), "45.33%");
237/// ```
238#[must_use]
239pub fn format_percent_fixed(value: f64, decimals: usize) -> String {
240    format!("{value:.decimals$}%")
241}
242
243/// Compute and format a percentage from a fraction.
244///
245/// # Examples
246/// ```
247/// use batuta_common::fmt::usage_percent;
248/// assert_eq!(usage_percent(50, 200), "25.0%");
249/// assert_eq!(usage_percent(0, 0), "0.0%");
250/// ```
251#[must_use]
252pub fn usage_percent(used: u64, total: u64) -> String {
253    if total == 0 {
254        return "0.0%".to_string();
255    }
256    format_percent(used as f64 / total as f64 * 100.0)
257}
258
259// =============================================================================
260// DURATION FORMATTING
261// =============================================================================
262
263/// Format a duration in seconds to human-readable form.
264///
265/// # Examples
266/// ```
267/// use batuta_common::fmt::format_duration;
268/// assert_eq!(format_duration(45), "45s");
269/// assert_eq!(format_duration(125), "2m 5s");
270/// assert_eq!(format_duration(3725), "1h 2m");
271/// assert_eq!(format_duration(90061), "1d 1h");
272/// ```
273#[must_use]
274pub fn format_duration(seconds: u64) -> String {
275    const MINUTE: u64 = 60;
276    const HOUR: u64 = 60 * MINUTE;
277    const DAY: u64 = 24 * HOUR;
278
279    if seconds < MINUTE {
280        format!("{seconds}s")
281    } else if seconds < HOUR {
282        let mins = seconds / MINUTE;
283        let secs = seconds % MINUTE;
284        if secs == 0 {
285            format!("{mins}m")
286        } else {
287            format!("{mins}m {secs}s")
288        }
289    } else if seconds < DAY {
290        let hours = seconds / HOUR;
291        let mins = (seconds % HOUR) / MINUTE;
292        if mins == 0 {
293            format!("{hours}h")
294        } else {
295            format!("{hours}h {mins}m")
296        }
297    } else {
298        let days = seconds / DAY;
299        let hours = (seconds % DAY) / HOUR;
300        if hours == 0 {
301            format!("{days}d")
302        } else {
303            format!("{days}d {hours}h")
304        }
305    }
306}
307
308/// Format a duration compactly (always fixed-width, 6 chars).
309///
310/// # Examples
311/// ```
312/// use batuta_common::fmt::format_duration_compact;
313/// assert_eq!(format_duration_compact(45), "   45s");
314/// assert_eq!(format_duration_compact(3725), " 1h02m");
315/// ```
316#[must_use]
317pub fn format_duration_compact(seconds: u64) -> String {
318    const MINUTE: u64 = 60;
319    const HOUR: u64 = 60 * MINUTE;
320    const DAY: u64 = 24 * HOUR;
321
322    if seconds < MINUTE {
323        format!("{seconds:>5}s")
324    } else if seconds < HOUR {
325        let mins = seconds / MINUTE;
326        let secs = seconds % MINUTE;
327        format!("{mins:>2}m{secs:02}s")
328    } else if seconds < DAY {
329        let hours = seconds / HOUR;
330        let mins = (seconds % HOUR) / MINUTE;
331        format!("{hours:>2}h{mins:02}m")
332    } else {
333        let days = seconds / DAY;
334        let hours = (seconds % DAY) / HOUR;
335        format!("{days:>2}d{hours:02}h")
336    }
337}
338
339// =============================================================================
340// NUMBER FORMATTING
341// =============================================================================
342
343/// Format a number with thousands separators.
344///
345/// # Examples
346/// ```
347/// use batuta_common::fmt::format_number;
348/// assert_eq!(format_number(1234567), "1,234,567");
349/// assert_eq!(format_number(999), "999");
350/// assert_eq!(format_number(0), "0");
351/// ```
352#[must_use]
353pub fn format_number(n: u64) -> String {
354    let s = n.to_string();
355    let mut result = String::with_capacity(s.len() + s.len() / 3);
356    let chars: Vec<char> = s.chars().collect();
357
358    for (i, c) in chars.iter().enumerate() {
359        if i > 0 && (chars.len() - i).is_multiple_of(3) {
360            result.push(',');
361        }
362        result.push(*c);
363    }
364
365    result
366}
367
368/// Format frequency in MHz to human-readable GHz/MHz.
369///
370/// # Examples
371/// ```
372/// use batuta_common::fmt::format_freq_mhz;
373/// assert_eq!(format_freq_mhz(3200), "3.2GHz");
374/// assert_eq!(format_freq_mhz(800), "800MHz");
375/// ```
376#[must_use]
377pub fn format_freq_mhz(mhz: u32) -> String {
378    if mhz >= 1000 {
379        format!("{:.1}GHz", f64::from(mhz) / 1000.0)
380    } else {
381        format!("{mhz}MHz")
382    }
383}
384
385// =============================================================================
386// TESTS
387// =============================================================================
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    #[test]
394    fn test_format_bytes_si_zero() {
395        assert_eq!(format_bytes_si(0), "0B");
396    }
397
398    #[test]
399    fn test_format_bytes_si_small() {
400        assert_eq!(format_bytes_si(1), "1B");
401        assert_eq!(format_bytes_si(999), "999B");
402    }
403
404    #[test]
405    fn test_format_bytes_si_kilobytes() {
406        assert_eq!(format_bytes_si(1000), "1.00K");
407        assert_eq!(format_bytes_si(1500), "1.50K");
408        assert_eq!(format_bytes_si(10_000), "10.0K");
409        assert_eq!(format_bytes_si(100_000), "100K");
410    }
411
412    #[test]
413    fn test_format_bytes_si_megabytes() {
414        assert_eq!(format_bytes_si(1_000_000), "1.00M");
415        assert_eq!(format_bytes_si(1_500_000), "1.50M");
416    }
417
418    #[test]
419    fn test_format_bytes_si_gigabytes() {
420        assert_eq!(format_bytes_si(1_000_000_000), "1.00G");
421        assert_eq!(format_bytes_si(1_500_000_000), "1.50G");
422    }
423
424    #[test]
425    fn test_format_bytes_si_terabytes() {
426        assert_eq!(format_bytes_si(1_000_000_000_000), "1.00T");
427    }
428
429    #[test]
430    fn test_format_bytes_iec_zero() {
431        assert_eq!(format_bytes_iec(0), "0B");
432    }
433
434    #[test]
435    fn test_format_bytes_iec_kib() {
436        assert_eq!(format_bytes_iec(1024), "1.00KiB");
437        assert_eq!(format_bytes_iec(1536), "1.50KiB");
438    }
439
440    #[test]
441    fn test_format_bytes_iec_mib() {
442        assert_eq!(format_bytes_iec(1_048_576), "1.00MiB");
443    }
444
445    #[test]
446    fn test_format_bytes_human() {
447        assert_eq!(format_bytes(0), "0 B");
448        assert_eq!(format_bytes(1023), "1023 B");
449        assert_eq!(format_bytes(1024), "1.0 KB");
450        assert_eq!(format_bytes(1_048_576), "1.0 MB");
451        assert_eq!(format_bytes(1_073_741_824), "1.0 GB");
452    }
453
454    #[test]
455    fn test_format_bytes_compact() {
456        assert_eq!(format_bytes_compact(0), "0.0K");
457        assert_eq!(format_bytes_compact(512), "0.5K");
458        assert_eq!(format_bytes_compact(1024), "1.0K");
459        assert_eq!(format_bytes_compact(1_048_576), "1.0M");
460        assert_eq!(format_bytes_compact(1_073_741_824), "1.0G");
461        assert_eq!(format_bytes_compact(1_099_511_627_776), "1.0T");
462    }
463
464    #[test]
465    fn test_format_bytes_full() {
466        assert_eq!(format_bytes_full(500), "500 B");
467        assert_eq!(format_bytes_full(1024), "1.0 KB");
468        assert_eq!(format_bytes_full(1_048_576), "1.0 MB");
469        assert_eq!(format_bytes_full(1_073_741_824), "1.00 GB");
470        assert_eq!(format_bytes_full(1_099_511_627_776), "1.00 TB");
471    }
472
473    #[test]
474    fn test_format_bytes_rate() {
475        assert_eq!(format_bytes_rate(1500.0), "1.50K/s");
476        assert_eq!(format_bytes_rate(0.0), "0B/s");
477    }
478
479    #[test]
480    fn test_format_percent() {
481        assert_eq!(format_percent(45.3), "45.3%");
482        assert_eq!(format_percent(0.0), "0.0%");
483        assert_eq!(format_percent(100.0), "100.0%");
484    }
485
486    #[test]
487    fn test_format_percent_clamped() {
488        assert_eq!(format_percent_clamped(150.0), "100.0%");
489        assert_eq!(format_percent_clamped(-10.0), "0.0%");
490        assert_eq!(format_percent_clamped(50.0), "50.0%");
491    }
492
493    #[test]
494    fn test_format_percent_fixed() {
495        assert_eq!(format_percent_fixed(45.333, 2), "45.33%");
496        assert_eq!(format_percent_fixed(5.0, 0), "5%");
497    }
498
499    #[test]
500    fn test_usage_percent() {
501        assert_eq!(usage_percent(50, 200), "25.0%");
502        assert_eq!(usage_percent(100, 100), "100.0%");
503        assert_eq!(usage_percent(0, 0), "0.0%");
504        assert_eq!(usage_percent(0, 100), "0.0%");
505    }
506
507    #[test]
508    fn test_format_duration_seconds() {
509        assert_eq!(format_duration(0), "0s");
510        assert_eq!(format_duration(45), "45s");
511        assert_eq!(format_duration(59), "59s");
512    }
513
514    #[test]
515    fn test_format_duration_minutes() {
516        assert_eq!(format_duration(60), "1m");
517        assert_eq!(format_duration(125), "2m 5s");
518        assert_eq!(format_duration(3599), "59m 59s");
519    }
520
521    #[test]
522    fn test_format_duration_hours() {
523        assert_eq!(format_duration(3600), "1h");
524        assert_eq!(format_duration(3725), "1h 2m");
525        assert_eq!(format_duration(86399), "23h 59m");
526    }
527
528    #[test]
529    fn test_format_duration_days() {
530        assert_eq!(format_duration(86400), "1d");
531        assert_eq!(format_duration(90061), "1d 1h");
532    }
533
534    #[test]
535    fn test_format_duration_compact() {
536        assert_eq!(format_duration_compact(45), "   45s");
537        assert_eq!(format_duration_compact(125), " 2m05s");
538        assert_eq!(format_duration_compact(3725), " 1h02m");
539        assert_eq!(format_duration_compact(90061), " 1d01h");
540    }
541
542    #[test]
543    fn test_format_number() {
544        assert_eq!(format_number(0), "0");
545        assert_eq!(format_number(999), "999");
546        assert_eq!(format_number(1000), "1,000");
547        assert_eq!(format_number(1_234_567), "1,234,567");
548    }
549
550    #[test]
551    fn test_format_freq_mhz() {
552        assert_eq!(format_freq_mhz(800), "800MHz");
553        assert_eq!(format_freq_mhz(3200), "3.2GHz");
554        assert_eq!(format_freq_mhz(1000), "1.0GHz");
555    }
556}