Skip to main content

hya_core/
fmt.rs

1// Copyright (C) 2026 Javad Rajabzadeh
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Human-readable sizes, rates and durations, in the spellings the front-ends
5//! share: `1.50 KiB` / `12.3 MiB` for the terminal, `121.66 MB` and
6//! `1.027 MB/sec` for the download list, `3m02s` and `3 min 2 sec` for an ETA.
7
8const IEC: [&str; 7] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
9
10/// `512 B`, `1.50 KiB`, `12.3 MiB`: two decimals below ten, one above.
11///
12/// The ladder runs to EiB so no input can widen an aligned column: `u64::MAX`
13/// in TiB would be fifteen characters.
14pub fn bytes(n: u64) -> String {
15    let mut v = n as f64;
16    let mut i = 0;
17    while v >= 1024.0 && i < IEC.len() - 1 {
18        v /= 1024.0;
19        i += 1;
20    }
21    if i == 0 {
22        format!("{n} B")
23    } else if v < 10.0 {
24        format!("{v:.2} {}", IEC[i])
25    } else {
26        format!("{v:.1} {}", IEC[i])
27    }
28}
29
30/// `121.66 MB` with a fixed number of decimals: 1024-based, labelled `KB`,
31/// `MB`, `GB`, and stopping at `GB`. Below one KiB the count is printed as is.
32pub fn bytes_fixed(n: u64, decimals: usize) -> String {
33    const KB: f64 = 1024.0;
34    const MB: f64 = KB * 1024.0;
35    const GB: f64 = MB * 1024.0;
36    let b = n as f64;
37    if b >= GB {
38        format!("{:.decimals$} GB", b / GB)
39    } else if b >= MB {
40        format!("{:.decimals$} MB", b / MB)
41    } else if b >= KB {
42        format!("{:.decimals$} KB", b / KB)
43    } else {
44        format!("{n} B")
45    }
46}
47
48/// `1.50 KiB/s`: [`bytes`] with a per-second suffix.
49pub fn rate(bytes_per_s: f64) -> String {
50    format!("{}/s", bytes(bytes_per_s.max(0.0) as u64))
51}
52
53/// `1.027 MB/sec`, `200.666 KB/sec`: 1024-based, `KB` up to one MiB and `MB`
54/// above it. Empty below one byte per second, so an idle transfer shows no
55/// figure at all.
56pub fn rate_fixed(bytes_per_s: f64, decimals: usize) -> String {
57    const KB: f64 = 1024.0;
58    const MB: f64 = KB * 1024.0;
59    if bytes_per_s >= MB {
60        format!("{:.decimals$} MB/sec", bytes_per_s / MB)
61    } else if bytes_per_s >= 1.0 {
62        format!("{:.decimals$} KB/sec", bytes_per_s / KB)
63    } else {
64        String::new()
65    }
66}
67
68/// `45.0s`, `3m02s`, `1h12m`; `?` when `secs` is not finite, which is how a
69/// caller says it has no estimate yet.
70pub fn duration(secs: f64) -> String {
71    if !secs.is_finite() {
72        return "?".into();
73    }
74    let s = secs.max(0.0);
75    if s < 60.0 {
76        format!("{s:.1}s")
77    } else if s < 3600.0 {
78        format!("{}m{:02}s", (s / 60.0) as u64, (s % 60.0) as u64)
79    } else {
80        format!(
81            "{}h{:02}m",
82            (s / 3600.0) as u64,
83            ((s % 3600.0) / 60.0) as u64
84        )
85    }
86}
87
88/// The unit labels [`duration_long_with`] spells a duration in.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub struct DurationUnits<'a> {
91    pub hour: &'a str,
92    pub minute: &'a str,
93    pub second: &'a str,
94}
95
96impl DurationUnits<'static> {
97    /// `hr`, `min`, `sec`.
98    pub const ENGLISH: Self = DurationUnits {
99        hour: "hr",
100        minute: "min",
101        second: "sec",
102    };
103}
104
105/// `45 sec`, `3 min 2 sec`, `1 hr 12 min`.
106pub fn duration_long(secs: u64) -> String {
107    duration_long_with(secs, DurationUnits::ENGLISH)
108}
109
110/// [`duration_long`] with the caller's own unit labels, for a localised UI.
111///
112/// Two units at most, the larger first: seconds are dropped once the estimate
113/// is an hour or more.
114pub fn duration_long_with(secs: u64, units: DurationUnits<'_>) -> String {
115    if secs >= 3600 {
116        format!(
117            "{} {} {} {}",
118            secs / 3600,
119            units.hour,
120            (secs % 3600) / 60,
121            units.minute
122        )
123    } else if secs >= 60 {
124        format!(
125            "{} {} {} {}",
126            secs / 60,
127            units.minute,
128            secs % 60,
129            units.second
130        )
131    } else {
132        format!("{secs} {}", units.second)
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139
140    const MIB: u64 = 1024 * 1024;
141    const GIB: u64 = MIB * 1024;
142
143    #[test]
144    fn iec_bytes_across_every_boundary() {
145        for (n, want) in [
146            (0, "0 B"),
147            (1, "1 B"),
148            (1023, "1023 B"),
149            (1024, "1.00 KiB"),
150            (1536, "1.50 KiB"),
151            (10 * 1024, "10.0 KiB"),
152            (MIB - 1, "1024.0 KiB"),
153            (MIB, "1.00 MiB"),
154            (GIB * 3 / 2, "1.50 GiB"),
155            (GIB * 1024 * 12, "12.0 TiB"),
156            (u64::MAX, "16.0 EiB"),
157        ] {
158            assert_eq!(bytes(n), want, "{n}");
159        }
160    }
161
162    #[test]
163    fn fixed_bytes_use_kb_mb_gb_labels_and_stop_at_gb() {
164        for (n, decimals, want) in [
165            (0, 2, "0 B"),
166            (1023, 2, "1023 B"),
167            (1024, 2, "1.00 KB"),
168            (MIB - 1, 2, "1024.00 KB"),
169            (MIB, 3, "1.000 MB"),
170            (127_565_824, 2, "121.66 MB"),
171            (GIB, 2, "1.00 GB"),
172            (u64::MAX, 1, "17179869184.0 GB"),
173        ] {
174            assert_eq!(bytes_fixed(n, decimals), want, "{n}");
175        }
176    }
177
178    #[test]
179    fn rates_carry_their_suffix() {
180        assert_eq!(rate(0.0), "0 B/s");
181        assert_eq!(rate(-5.0), "0 B/s");
182        assert_eq!(rate(1536.0), "1.50 KiB/s");
183        assert_eq!(rate(12.0 * MIB as f64), "12.0 MiB/s");
184    }
185
186    #[test]
187    fn fixed_rates_switch_unit_at_one_mib_and_hide_an_idle_transfer() {
188        assert_eq!(rate_fixed(0.0, 3), "");
189        assert_eq!(rate_fixed(0.999, 3), "");
190        assert_eq!(rate_fixed(1.0, 3), "0.001 KB/sec");
191        assert_eq!(rate_fixed(205_482.0, 3), "200.666 KB/sec");
192        assert_eq!(rate_fixed(MIB as f64 - 1.0, 3), "1023.999 KB/sec");
193        assert_eq!(rate_fixed(1_076_887.0, 3), "1.027 MB/sec");
194    }
195
196    #[test]
197    fn short_durations_keep_two_fields() {
198        for (secs, want) in [
199            (0.0, "0.0s"),
200            (-3.0, "0.0s"),
201            (45.25, "45.2s"),
202            (59.99, "60.0s"),
203            (60.0, "1m00s"),
204            (182.0, "3m02s"),
205            (3599.0, "59m59s"),
206            (3600.0, "1h00m"),
207            (4320.0, "1h12m"),
208            (f64::NAN, "?"),
209            (f64::INFINITY, "?"),
210        ] {
211            assert_eq!(duration(secs), want, "{secs}");
212        }
213    }
214
215    #[test]
216    fn long_durations_spell_their_units() {
217        for (secs, want) in [
218            (0, "0 sec"),
219            (45, "45 sec"),
220            (60, "1 min 0 sec"),
221            (182, "3 min 2 sec"),
222            (3599, "59 min 59 sec"),
223            (3600, "1 hr 0 min"),
224            (4320, "1 hr 12 min"),
225            (u64::MAX, "5124095576030431 hr 0 min"),
226        ] {
227            assert_eq!(duration_long(secs), want, "{secs}");
228        }
229    }
230
231    #[test]
232    fn a_localised_ui_supplies_its_own_labels() {
233        let units = DurationUnits {
234            hour: "Std",
235            minute: "Min",
236            second: "Sek",
237        };
238        assert_eq!(duration_long_with(4320, units), "1 Std 12 Min");
239        assert_eq!(duration_long_with(182, units), "3 Min 2 Sek");
240        assert_eq!(duration_long_with(7, units), "7 Sek");
241    }
242}