1use std::time::Duration;
4
5const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
6
7pub 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
24pub 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
38pub fn duration(d: Duration) -> String {
40 let secs = d.as_secs();
41 match secs {
42 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
52pub 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
60pub 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
68const PARTIALS: [char; 8] = ['▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
72
73pub 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
99pub 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#[derive(Clone, Copy, Debug)]
115pub struct Style {
116 enabled: bool,
117}
118
119impl Style {
120 pub fn new(enabled: bool) -> Self {
121 Self {
122 enabled: enabled && std::env::var_os("NO_COLOR").is_none(),
124 }
125 }
126
127 pub fn stdout() -> Self {
129 Self::new(std::io::IsTerminal::is_terminal(&std::io::stdout()))
130 }
131
132 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 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 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}