Skip to main content

gglib_core/download/
format.rs

1//! Display formatting for download rates and durations.
2//!
3//! Shared by every Rust renderer (the CLI progress bars in `gglib-download`)
4//! and mirrored exactly by `formatRate` / `formatDuration` in
5//! `src/utils/format.ts` so the CLI, the Tauri GUI and the web UI all render the
6//! same number the same way.
7//!
8//! # Units
9//!
10//! **Rates are decimal** — `1 MB/s` is 1,000,000 bytes per second. This matches
11//! Activity Monitor, `nettop`, `iftop` and every ISP, which is what users
12//! compare a download speed against.
13//!
14//! **Sizes stay binary** (`MiB`, `GiB`) because that is the convention for
15//! model files on disk, and are rendered by `indicatif`'s `HumanBytes`, which
16//! already labels them correctly. Do not use this module for sizes.
17
18/// Placeholder rendered when a value is not yet known.
19///
20/// An unknown rate is deliberately not `0`: zero is a real reading that means
21/// "stalled", and conflating the two is what produced `ETA: 0s` on a download
22/// that was progressing perfectly well.
23pub const UNKNOWN: &str = "—";
24
25const KB: f64 = 1_000.0;
26const MB: f64 = 1_000_000.0;
27const GB: f64 = 1_000_000_000.0;
28
29/// Format a transfer rate in decimal units, e.g. `118.4 MB/s`.
30///
31/// Returns [`UNKNOWN`] for `None` and for values that are negative or not
32/// finite.
33#[must_use]
34pub fn format_rate(bps: Option<f64>) -> String {
35    let Some(bps) = bps.filter(|v| v.is_finite() && *v >= 0.0) else {
36        return UNKNOWN.to_string();
37    };
38
39    if bps >= GB {
40        format!("{:.2} GB/s", bps / GB)
41    } else if bps >= MB {
42        format!("{:.1} MB/s", bps / MB)
43    } else if bps >= KB {
44        format!("{:.0} kB/s", bps / KB)
45    } else {
46        format!("{bps:.0} B/s")
47    }
48}
49
50/// Format a duration in seconds as `45s`, `3m 20s` or `1h 04m`.
51///
52/// Returns [`UNKNOWN`] for `None` and for values that are negative or not
53/// finite. Sub-second values round up to `1s` so a live countdown never
54/// displays `0s` while work is still in flight.
55#[must_use]
56pub fn format_duration(seconds: Option<f64>) -> String {
57    let Some(seconds) = seconds.filter(|v| v.is_finite() && *v >= 0.0) else {
58        return UNKNOWN.to_string();
59    };
60
61    // Saturate rather than wrap on absurd inputs (a near-zero rate can produce
62    // an ETA of centuries before the average settles).
63    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
64    let total = seconds.ceil().min(359_999.0) as u64;
65
66    let (hours, minutes, secs) = (total / 3600, (total % 3600) / 60, total % 60);
67
68    if hours > 0 {
69        format!("{hours}h {minutes:02}m")
70    } else if minutes > 0 {
71        format!("{minutes}m {secs:02}s")
72    } else {
73        format!("{}s", total.max(1))
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn unknown_rate_renders_a_placeholder() {
83        assert_eq!(format_rate(None), UNKNOWN);
84        assert_eq!(format_rate(Some(f64::NAN)), UNKNOWN);
85        assert_eq!(format_rate(Some(f64::INFINITY)), UNKNOWN);
86        assert_eq!(format_rate(Some(-1.0)), UNKNOWN);
87    }
88
89    #[test]
90    fn rates_use_decimal_units() {
91        assert_eq!(format_rate(Some(0.0)), "0 B/s");
92        assert_eq!(format_rate(Some(999.0)), "999 B/s");
93        assert_eq!(format_rate(Some(1_000.0)), "1 kB/s");
94        assert_eq!(format_rate(Some(1_500_000.0)), "1.5 MB/s");
95        assert_eq!(format_rate(Some(118_400_000.0)), "118.4 MB/s");
96        assert_eq!(format_rate(Some(2_500_000_000.0)), "2.50 GB/s");
97    }
98
99    #[test]
100    fn a_megabyte_per_second_is_a_million_bytes() {
101        // The whole point of choosing decimal: this must agree with what a
102        // system network monitor reports for the same transfer.
103        assert_eq!(format_rate(Some(1_048_576.0)), "1.0 MB/s");
104        assert_eq!(format_rate(Some(1_000_000.0)), "1.0 MB/s");
105    }
106
107    #[test]
108    fn unknown_duration_renders_a_placeholder() {
109        assert_eq!(format_duration(None), UNKNOWN);
110        assert_eq!(format_duration(Some(f64::NAN)), UNKNOWN);
111        assert_eq!(format_duration(Some(-5.0)), UNKNOWN);
112    }
113
114    #[test]
115    fn durations_scale_by_magnitude() {
116        assert_eq!(format_duration(Some(0.0)), "1s");
117        assert_eq!(format_duration(Some(45.0)), "45s");
118        assert_eq!(format_duration(Some(59.4)), "1m 00s");
119        assert_eq!(format_duration(Some(200.0)), "3m 20s");
120        assert_eq!(format_duration(Some(3_600.0)), "1h 00m");
121        assert_eq!(format_duration(Some(3_845.0)), "1h 04m");
122    }
123
124    #[test]
125    fn absurd_durations_saturate_instead_of_wrapping() {
126        assert_eq!(format_duration(Some(1e18)), "99h 59m");
127    }
128}