Skip to main content

rget/
fmt.rs

1//! Human-facing formatting and the minimal ANSI styling we need.
2
3use std::time::Duration;
4
5const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
6
7/// `12.40 GiB`
8pub fn bytes(n: u64) -> String {
9    let mut value = n as f64;
10    let mut unit = 0;
11    while value >= 1024.0 && unit < UNITS.len() - 1 {
12        value /= 1024.0;
13        unit += 1;
14    }
15    if unit == 0 {
16        format!("{n} B")
17    } else if value >= 100.0 {
18        format!("{value:.0} {}", UNITS[unit])
19    } else {
20        format!("{value:.2} {}", UNITS[unit])
21    }
22}
23
24/// `84.7 MiB/s`
25pub fn rate(bytes_per_sec: f64) -> String {
26    if !bytes_per_sec.is_finite() || bytes_per_sec <= 0.0 {
27        return "--".to_string();
28    }
29    let mut value = bytes_per_sec;
30    let mut unit = 0;
31    while value >= 1024.0 && unit < UNITS.len() - 1 {
32        value /= 1024.0;
33        unit += 1;
34    }
35    format!("{value:.1} {}/s", UNITS[unit])
36}
37
38/// `2m 31s`, `1h 04m`, `812ms`
39pub fn duration(d: Duration) -> String {
40    let secs = d.as_secs();
41    match secs {
42        // Sub-second precision matters for retry delays, but a bare "0ms"
43        // reads as broken; an elapsed or remaining time of zero is "0s".
44        0 if d.subsec_millis() == 0 => "0s".to_string(),
45        0 => format!("{}ms", d.subsec_millis()),
46        1..=59 => format!("{secs}s"),
47        60..=3599 => format!("{}m {:02}s", secs / 60, secs % 60),
48        _ => format!("{}h {:02}m", secs / 3600, (secs % 3600) / 60),
49    }
50}
51
52/// ETA, or `--` when we have no basis for an estimate.
53pub fn eta(remaining: u64, bytes_per_sec: f64) -> String {
54    if !bytes_per_sec.is_finite() || bytes_per_sec < 1.0 {
55        return "--".to_string();
56    }
57    duration(Duration::from_secs_f64(remaining as f64 / bytes_per_sec))
58}
59
60/// `55.0%`
61pub fn percent(done: u64, total: Option<u64>) -> String {
62    match total {
63        Some(t) if t > 0 => format!("{:.1}%", (done as f64 / t as f64) * 100.0),
64        _ => "--".to_string(),
65    }
66}
67
68/// Eighth-width blocks, so the bar creeps forward smoothly instead of jumping a
69/// whole cell at a time — the difference between a bar that looks alive and one
70/// that looks stuck.
71const PARTIALS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
72
73/// The filled and empty halves of a progress bar, separate so the caller can
74/// colour them independently. Together they are always exactly `width` cells.
75pub fn bar_parts(done: u64, total: Option<u64>, width: usize) -> (String, String) {
76    let frac = match total {
77        Some(t) if t > 0 => (done as f64 / t as f64).clamp(0.0, 1.0),
78        _ => 0.0,
79    };
80    let exact = frac * width as f64;
81    let whole = (exact.floor() as usize).min(width);
82
83    let mut filled = "█".repeat(whole);
84    let mut used = whole;
85    let remainder = exact - whole as f64;
86    if used < width && remainder > 0.02 {
87        let idx = ((remainder * 8.0).round() as usize).clamp(1, 8) - 1;
88        filled.push(PARTIALS[idx]);
89        used += 1;
90    }
91    (filled, "░".repeat(width - used))
92}
93
94pub fn bar(done: u64, total: Option<u64>, width: usize) -> String {
95    let (filled, empty) = bar_parts(done, total, width);
96    format!("{filled}{empty}")
97}
98
99/// Truncate a URL for display so a signed CDN URL does not wrap the terminal.
100/// Also drops the query string, which is where credentials usually hide.
101pub fn short_url(raw: &str) -> String {
102    let no_query = raw.split(['?', '#']).next().unwrap_or(raw);
103    if no_query.len() <= 72 {
104        return no_query.to_string();
105    }
106    let tail = &no_query[no_query.len() - 40..];
107    format!("{}…{}", &no_query[..30], tail)
108}
109
110// -- styling ---------------------------------------------------------------
111
112/// ANSI styling, globally disabled when output is not a terminal or when
113/// `NO_COLOR` is set.
114#[derive(Clone, Copy, Debug)]
115pub struct Style {
116    enabled: bool,
117}
118
119impl Style {
120    pub fn new(enabled: bool) -> Self {
121        Self {
122            // `NO_COLOR` is a promise, not a suggestion: https://no-color.org.
123            enabled: enabled && std::env::var_os("NO_COLOR").is_none(),
124        }
125    }
126
127    /// Styling for anything printed to stdout — `list`, `info`, `config`.
128    pub fn stdout() -> Self {
129        Self::new(std::io::IsTerminal::is_terminal(&std::io::stdout()))
130    }
131
132    /// Styling for progress and messages, which go to stderr.
133    pub fn stderr() -> Self {
134        Self::new(std::io::IsTerminal::is_terminal(&std::io::stderr()))
135    }
136
137    pub fn is_enabled(&self) -> bool {
138        self.enabled
139    }
140
141    fn wrap(&self, code: &str, text: &str) -> String {
142        if self.enabled {
143            format!("\x1b[{code}m{text}\x1b[0m")
144        } else {
145            text.to_string()
146        }
147    }
148
149    pub fn bold(&self, t: &str) -> String {
150        self.wrap("1", t)
151    }
152    pub fn dim(&self, t: &str) -> String {
153        self.wrap("2", t)
154    }
155    pub fn green(&self, t: &str) -> String {
156        self.wrap("32", t)
157    }
158    pub fn red(&self, t: &str) -> String {
159        self.wrap("31", t)
160    }
161    pub fn yellow(&self, t: &str) -> String {
162        self.wrap("33", t)
163    }
164    pub fn cyan(&self, t: &str) -> String {
165        self.wrap("36", t)
166    }
167    pub fn blue(&self, t: &str) -> String {
168        self.wrap("34", t)
169    }
170    pub fn magenta(&self, t: &str) -> String {
171        self.wrap("35", t)
172    }
173    pub fn bright_green(&self, t: &str) -> String {
174        self.wrap("92", t)
175    }
176    pub fn bright_cyan(&self, t: &str) -> String {
177        self.wrap("96", t)
178    }
179    pub fn bold_green(&self, t: &str) -> String {
180        self.wrap("1;32", t)
181    }
182    pub fn bold_red(&self, t: &str) -> String {
183        self.wrap("1;31", t)
184    }
185    pub fn bold_cyan(&self, t: &str) -> String {
186        self.wrap("1;36", t)
187    }
188
189    /// A `·` separator, always dim so it recedes behind the values it divides.
190    pub fn sep(&self) -> String {
191        self.dim(" · ")
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn formats_bytes() {
201        assert_eq!(bytes(0), "0 B");
202        assert_eq!(bytes(512), "512 B");
203        assert_eq!(bytes(1536), "1.50 KiB");
204        assert_eq!(bytes(13314398618), "12.40 GiB");
205        // Three significant figures would be noise past 100.
206        assert_eq!(bytes(200 * 1024 * 1024), "200 MiB");
207    }
208
209    #[test]
210    fn formats_duration() {
211        assert_eq!(duration(Duration::ZERO), "0s");
212        assert_eq!(duration(Duration::from_millis(812)), "812ms");
213        assert_eq!(duration(Duration::from_secs(45)), "45s");
214        assert_eq!(duration(Duration::from_secs(151)), "2m 31s");
215        assert_eq!(duration(Duration::from_secs(3900)), "1h 05m");
216    }
217
218    #[test]
219    fn eta_needs_a_basis() {
220        assert_eq!(eta(1000, 0.0), "--");
221        assert_eq!(eta(1000, f64::NAN), "--");
222        assert_eq!(eta(1024, 1024.0), "1s");
223    }
224
225    #[test]
226    fn bar_is_width_stable() {
227        assert_eq!(bar(0, Some(10), 4).chars().count(), 4);
228        assert_eq!(bar(10, Some(10), 4).chars().count(), 4);
229        assert_eq!(bar(5, None, 4).chars().count(), 4);
230    }
231
232    #[test]
233    fn short_url_drops_query() {
234        assert_eq!(
235            short_url("https://x.example/file.iso?token=secret"),
236            "https://x.example/file.iso"
237        );
238    }
239}