Skip to main content

rigtop_core/
format.rs

1//! Compact human-readable formatting for narrow terminal cells.
2
3use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
4
5/// Formats a byte count with a binary unit, e.g. `"1.5G"`, `"512K"`, `"0B"`.
6///
7/// Uses at most 5 characters so that columns stay aligned.
8pub fn bytes(value: u64) -> String {
9    const UNITS: &[&str] = &["B", "K", "M", "G", "T", "P"];
10    let mut v = value as f64;
11    let mut unit = 0;
12    while v >= 1024.0 && unit < UNITS.len() - 1 {
13        v /= 1024.0;
14        unit += 1;
15    }
16    if unit == 0 {
17        format!("{value}B")
18    } else if v < 10.0 {
19        format!("{v:.1}{}", UNITS[unit])
20    } else {
21        format!("{v:.0}{}", UNITS[unit])
22    }
23}
24
25/// Formats a throughput, e.g. `"3.4M/s"`.
26pub fn rate(bytes_per_sec: f64) -> String {
27    format!("{}/s", bytes(bytes_per_sec.max(0.0) as u64))
28}
29
30/// Formats a time span in hours and minutes: `"3h10m"`, `"45m"`.
31pub fn hours_minutes(secs: u64) -> String {
32    let minutes = secs / 60;
33    if minutes >= 60 {
34        format!("{}h{:02}m", minutes / 60, minutes % 60)
35    } else {
36        format!("{minutes}m")
37    }
38}
39
40/// Formats a request time: `"0.3ms"`, `"12ms"`, `"1.4s"`.
41pub fn latency(ms: f32) -> String {
42    if ms >= 1000.0 {
43        format!("{:.1}s", ms / 1000.0)
44    } else if ms >= 10.0 {
45        format!("{ms:.0}ms")
46    } else {
47        format!("{ms:.1}ms")
48    }
49}
50
51/// Formats a duration in seconds as `"3d04h"`, `"5h12m"` or `"12:34"`.
52pub fn duration(secs: u64) -> String {
53    let days = secs / 86_400;
54    let hours = (secs % 86_400) / 3_600;
55    let minutes = (secs % 3_600) / 60;
56    if days > 0 {
57        format!("{days}d{hours:02}h")
58    } else if hours > 0 {
59        format!("{hours}h{minutes:02}m")
60    } else {
61        format!("{minutes:02}:{:02}", secs % 60)
62    }
63}
64
65/// Formats how long ago something happened, e.g. `"45s ago"`, `"8m ago"`, `"3d ago"`.
66pub fn age(secs: u64) -> String {
67    match secs {
68        0..10 => "just now".to_owned(),
69        10..120 => format!("{secs}s ago"),
70        120..7_200 => format!("{}m ago", secs / 60),
71        7_200..172_800 => format!("{}h ago", secs / 3_600),
72        _ => format!("{}d ago", secs / 86_400),
73    }
74}
75
76/// Formats a link speed in Mbit/s, e.g. `"10G"`, `"2.5G"`, `"100M"`.
77pub fn link_speed(mbps: f64) -> String {
78    if mbps >= 1000.0 {
79        let gbps = mbps / 1000.0;
80        if gbps.fract().abs() < 0.05 {
81            format!("{gbps:.0}G")
82        } else {
83            format!("{gbps:.1}G")
84        }
85    } else {
86        format!("{mbps:.0}M")
87    }
88}
89
90/// Truncates `text` to at most `width` characters, marking the cut with `…`.
91///
92/// Widths are terminal columns: CJK characters such as "イーサネット" count
93/// as two.
94pub fn truncate(text: &str, width: usize) -> String {
95    if text.width() <= width {
96        return text.to_owned();
97    }
98    if width == 0 {
99        return String::new();
100    }
101    // Keep characters while they fit, leaving one column for the ellipsis.
102    let mut out = String::new();
103    let mut used = 0;
104    for ch in text.chars() {
105        let w = ch.width().unwrap_or(0);
106        if used + w > width - 1 {
107            break;
108        }
109        out.push(ch);
110        used += w;
111    }
112    out.push('…');
113    out
114}
115
116/// Truncates `text` to `width` terminal columns and pads it with spaces to
117/// exactly that width (`format!("{:<N}")` counts characters, not columns).
118pub fn pad(text: &str, width: usize) -> String {
119    let mut out = truncate(text, width);
120    let used = out.width();
121    out.extend(std::iter::repeat_n(' ', width.saturating_sub(used)));
122    out
123}
124
125/// A percentage right-aligned in 4 columns (`" 12%"`), or `" --%"` when it
126/// is unknown (e.g. Intel GPU utilization).
127pub fn percent(value: Option<f32>) -> String {
128    value.map_or_else(|| " --%".to_owned(), |v| format!("{v:>3.0}%"))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn percent_or_unknown() {
137        assert_eq!(percent(Some(7.4)), "  7%");
138        assert_eq!(percent(Some(100.0)), "100%");
139        assert_eq!(percent(None), " --%");
140    }
141    use unicode_width::UnicodeWidthStr;
142
143    #[test]
144    fn bytes_uses_binary_units_and_short_output() {
145        assert_eq!(bytes(0), "0B");
146        assert_eq!(bytes(1023), "1023B");
147        assert_eq!(bytes(1536), "1.5K");
148        assert_eq!(bytes(20 * 1024 * 1024), "20M");
149        assert_eq!(bytes(6_000_067_395_584), "5.5T");
150    }
151
152    #[test]
153    fn rate_appends_per_second() {
154        assert_eq!(rate(3_560_916.6), "3.4M/s");
155        assert_eq!(rate(-1.0), "0B/s");
156    }
157
158    #[test]
159    fn hours_and_minutes() {
160        assert_eq!(hours_minutes(59), "0m");
161        assert_eq!(hours_minutes(45 * 60), "45m");
162        assert_eq!(hours_minutes(3 * 3600 + 10 * 60 + 30), "3h10m");
163    }
164
165    #[test]
166    fn latency_keeps_three_digits() {
167        assert_eq!(latency(0.04), "0.0ms");
168        assert_eq!(latency(2.345), "2.3ms");
169        assert_eq!(latency(12.6), "13ms");
170        assert_eq!(latency(1420.0), "1.4s");
171    }
172
173    #[test]
174    fn duration_picks_the_largest_units() {
175        assert_eq!(duration(75), "01:15");
176        assert_eq!(duration(3 * 3600 + 5 * 60), "3h05m");
177        assert_eq!(duration(2 * 86_400 + 4 * 3600), "2d04h");
178    }
179
180    #[test]
181    fn age_picks_a_readable_unit() {
182        assert_eq!(age(3), "just now");
183        assert_eq!(age(45), "45s ago");
184        assert_eq!(age(8 * 60), "8m ago");
185        assert_eq!(age(5 * 3600), "5h ago");
186        assert_eq!(age(3 * 86_400), "3d ago");
187    }
188
189    #[test]
190    fn link_speed_uses_g_and_m() {
191        assert_eq!(link_speed(10_000.0), "10G");
192        assert_eq!(link_speed(2_500.0), "2.5G");
193        assert_eq!(link_speed(1_000.0), "1G");
194        assert_eq!(link_speed(576.4), "576M");
195    }
196
197    #[test]
198    fn wide_characters_count_as_two_columns() {
199        // "イーサネット 3" is 7 characters but 14 columns.
200        assert_eq!(truncate("イーサネット 3", 14), "イーサネット 3");
201        assert_eq!(truncate("イーサネット 3", 8), "イーサ…");
202        assert_eq!(pad("イーサネット 3", 16), "イーサネット 3  ");
203        assert_eq!(pad("enp9s0", 8), "enp9s0  ");
204        assert_eq!(pad("イーサネット", 5).width(), 5);
205    }
206
207    #[test]
208    fn truncate_marks_the_cut() {
209        assert_eq!(truncate("rigtop", 10), "rigtop");
210        assert_eq!(truncate("rigtop-gui", 6), "rigto…");
211        assert_eq!(truncate("abc", 0), "");
212    }
213}