1use std::io::{self, Write};
2
3pub struct ProgressBar {
20 total: u64,
21 current: u64,
22 width: usize,
23 pub speed: u64,
24 start_time: std::time::Instant,
25}
26
27impl ProgressBar {
28 pub fn new(total: u64) -> Self {
29 let width = terminal_width().clamp(20, 60);
30 Self {
31 total,
32 current: 0,
33 width,
34 speed: 0,
35 start_time: std::time::Instant::now(),
36 }
37 }
38
39 pub fn update(&mut self, current: u64) {
40 self.current = current.min(self.total);
41 let elapsed = self.start_time.elapsed().as_secs_f64();
42 if elapsed > 0.0 {
43 self.speed = (self.current as f64 / elapsed) as u64;
44 }
45 }
46
47 pub fn finish(&mut self) {
48 self.current = self.total;
49 self.render(true);
50 println!();
51 }
52
53 pub fn render(&self, force: bool) {
54 if !force && !is_tty() {
55 return;
56 }
57 let percent = if self.total > 0 {
58 (self.current as f64 / self.total as f64 * 100.0).min(100.0)
59 } else {
60 0.0
61 };
62
63 let filled = (percent / 100.0 * self.width as f64) as usize;
64 let empty = self.width.saturating_sub(filled);
65
66 let bar: String = "=".repeat(filled) + &" ".repeat(empty);
67
68 let speed_str = format_speed(self.speed);
69 let downloaded = format_size(self.current);
70 let total_str = format_size(self.total);
71 let eta = self.eta();
72
73 print!(
74 "\r[{}] {:.0}% ({}/{}) {} ETA: {}",
75 bar, percent, downloaded, total_str, speed_str, eta
76 );
77 let _ = io::stdout().flush();
78 }
79
80 pub fn render_summary(&self) -> String {
81 format!(
82 "{}% ({}/{}) {}",
83 if self.total > 0 {
84 (self.current as f64 / self.total as f64 * 100.0) as i32
85 } else {
86 0
87 },
88 format_size(self.current),
89 format_size(self.total),
90 format_speed(self.speed)
91 )
92 }
93
94 fn eta(&self) -> String {
95 if self.speed == 0 || self.current >= self.total {
96 return "--:--".to_string();
97 }
98 let remaining = self.total.saturating_sub(self.current);
99 let secs = remaining as f64 / self.speed as f64;
100 format_duration(secs)
101 }
102
103 pub fn set_total(&mut self, total: u64) {
104 self.total = total;
105 }
106 pub fn current(&self) -> u64 {
107 self.current
108 }
109 pub fn total(&self) -> u64 {
110 self.total
111 }
112 pub fn is_complete(&self) -> bool {
113 self.current >= self.total
114 }
115}
116
117pub struct MultiProgress {
123 bars: Vec<ProgressBar>,
124 labels: Vec<String>,
125 total_speed: u64,
126}
127
128impl MultiProgress {
129 pub fn new() -> Self {
130 Self {
131 bars: Vec::new(),
132 labels: Vec::new(),
133 total_speed: 0,
134 }
135 }
136
137 pub fn add(&mut self, label: impl Into<String>, total: u64) -> usize {
138 let idx = self.bars.len();
139 self.labels.push(label.into());
140 self.bars.push(ProgressBar::new(total));
141 idx
142 }
143
144 pub fn update(&mut self, idx: usize, current: u64) {
145 if idx < self.bars.len() {
146 self.bars[idx].update(current);
147 self.total_speed = self.bars.iter().map(|b| b.speed).sum();
148 }
149 }
150
151 pub fn render(&self, force: bool) {
152 if !force && !is_tty() {
153 return;
154 }
155 for (i, (bar, label)) in self.bars.iter().zip(self.labels.iter()).enumerate() {
156 print!("[#{} ", i + 1);
157 print!("{}", label);
158 print!("] ");
159 bar.render(force);
160 println!();
161 }
162 println!("Total: {}", format_speed(self.total_speed));
163 }
164
165 pub fn finish_all(&mut self) {
166 for bar in &mut self.bars {
167 bar.finish();
168 }
169 }
170
171 pub fn len(&self) -> usize {
172 self.bars.len()
173 }
174 pub fn is_empty(&self) -> bool {
175 self.bars.is_empty()
176 }
177}
178
179impl Default for MultiProgress {
180 fn default() -> Self {
181 Self::new()
182 }
183}
184
185pub struct StatusPanel {
194 quiet: bool,
195 last_update: std::time::Instant,
196 update_interval_ms: u64,
197}
198
199impl StatusPanel {
200 pub fn new(quiet: bool) -> Self {
201 Self {
202 quiet,
203 last_update: std::time::Instant::now(),
204 update_interval_ms: 500,
205 }
206 }
207
208 pub fn should_update(&self) -> bool {
209 if self.quiet {
210 return false;
211 }
212 self.last_update.elapsed().as_millis() as u64 >= self.update_interval_ms
213 }
214
215 pub fn touch(&mut self) {
216 self.last_update = std::time::Instant::now();
217 }
218
219 pub fn print_download_status(&self, gid: u64, status: &str, progress: &str) {
220 if self.quiet {
221 return;
222 }
223 println!("[#{} {}] {}", gid, status, progress);
224 }
225
226 pub fn print_complete(&self, gid: u64, filename: &str, size: &str) {
227 if self.quiet {
228 return;
229 }
230 use colored::Colorize;
231 println!(
232 "[#{} {}] {} - {} ({})",
233 gid,
234 "DONE".green().bold(),
235 filename.green(),
236 size.white(),
237 format_size_str(size)
238 );
239 }
240
241 pub fn print_error(&self, gid: u64, error: &str) {
242 use colored::Colorize;
243 eprintln!("[#{} {}] {}", gid, "ERR".red().bold(), error.red());
244 }
245
246 pub fn print_summary(&self, total_files: u64, total_size: u64, elapsed_secs: f64) {
247 use colored::Colorize;
248 if self.quiet {
249 return;
250 }
251 println!();
252 println!("{}", "Download summary:".yellow());
253 println!(" Total files: {}", total_files.to_string().white());
254 println!(" Total size: {}", format_size(total_size).white());
255 println!(
256 " Total time: {}",
257 format_duration(elapsed_secs).white()
258 );
259 println!(
260 " Average speed: {}/s",
261 format_size((total_size as f64 / elapsed_secs.max(1.0)) as u64).white()
262 );
263 }
264}
265
266pub fn terminal_width() -> usize {
267 crossterm::terminal::size()
268 .map(|(w, _)| w as usize)
269 .unwrap_or(80)
270 .saturating_sub(10)
271}
272
273pub fn is_tty() -> bool {
274 atty::is(atty::Stream::Stdout)
275}
276
277fn format_size(bytes: u64) -> String {
278 const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"];
279 let mut size = bytes as f64;
280 let mut unit_idx = 0;
281 while size >= 1024.0 && unit_idx < UNITS.len() - 1 {
282 size /= 1024.0;
283 unit_idx += 1;
284 }
285 if unit_idx == 0 {
286 format!("{}{}", bytes, UNITS[0])
287 } else {
288 format!("{:.1} {}", size, UNITS[unit_idx])
289 }
290}
291
292pub fn format_size_str(s: &str) -> String {
293 if let Ok(bytes) = s.parse::<u64>() {
294 format_size(bytes)
295 } else {
296 s.to_string()
297 }
298}
299
300fn format_speed(bytes_per_sec: u64) -> String {
301 if bytes_per_sec == 0 {
302 return "0 B/s".to_string();
303 }
304 format!("{}/s", format_size(bytes_per_sec))
305}
306
307pub fn format_duration(secs: f64) -> String {
308 if secs.is_nan() || secs < 0.0 {
309 return "--:--".to_string();
310 }
311 let total_secs = secs as u64;
312 let hours = total_secs / 3600;
313 let mins = (total_secs % 3600) / 60;
314 let secs_rem = total_secs % 60;
315 if hours > 0 {
316 format!("{:02}:{:02}:{:02}", hours, mins, secs_rem)
317 } else {
318 format!("{:02}:{:02}", mins, secs_rem)
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 #[test]
327 fn test_progress_bar_creation() {
328 let pb = ProgressBar::new(1024);
329 assert_eq!(pb.total(), 1024);
330 assert_eq!(pb.current(), 0);
331 assert!(!pb.is_complete());
332 }
333
334 #[test]
335 fn test_progress_bar_update() {
336 let mut pb = ProgressBar::new(100);
337 pb.update(50);
338 assert_eq!(pb.current(), 50);
339 assert!(!pb.is_complete());
340
341 pb.update(100);
342 assert!(pb.is_complete());
343 }
344
345 #[test]
346 fn test_progress_bar_finish() {
347 let mut pb = ProgressBar::new(200);
348 pb.finish();
349 assert!(pb.is_complete());
350 assert_eq!(pb.current(), 200);
351 }
352
353 #[test]
354 fn test_progress_bar_render_summary() {
355 let mut pb = ProgressBar::new(1000);
356 pb.update(500);
357 let summary = pb.render_summary();
358 assert!(summary.contains("50"));
359 }
360
361 #[test]
362 fn test_multi_progress_add_and_update() {
363 let mut mp = MultiProgress::new();
364 mp.add("file1.zip".to_string(), 1000);
365 mp.add("file2.iso".to_string(), 2000);
366 assert_eq!(mp.len(), 2);
367 mp.update(0, 500);
368 mp.update(1, 1500);
369 assert_eq!(mp.bars[0].current(), 500);
370 assert_eq!(mp.bars[1].current(), 1500);
371 }
372
373 #[test]
374 fn test_multi_progress_default() {
375 let mp = MultiProgress::default();
376 assert!(mp.is_empty());
377 }
378
379 #[test]
380 fn test_format_size_bytes() {
381 assert_eq!(format_size(0), "0B");
382 assert_eq!(format_size(512), "512B");
383 assert_eq!(format_size(1024), "1.0 KiB");
384 assert_eq!(format_size(1536), "1.5 KiB");
385 assert_eq!(format_size(1048576), "1.0 MiB");
386 assert_eq!(format_size(1073741824), "1.0 GiB");
387 }
388
389 #[test]
390 fn test_format_speed() {
391 assert_eq!(format_speed(0), "0 B/s");
392 assert!(format_speed(1024).contains("KiB/s"));
393 assert!(format_speed(1048576).contains("MiB/s"));
394 }
395
396 #[test]
397 fn test_format_duration() {
398 assert_eq!(format_duration(0.0), "00:00");
399 assert_eq!(format_duration(65.0), "01:05");
400 assert_eq!(format_duration(3661.0), "01:01:01");
401 assert_eq!(format_duration(-1.0), "--:--");
402 }
403
404 #[test]
405 fn test_terminal_width_positive() {
406 let w = terminal_width();
407 assert!(w > 0);
408 }
409
410 #[test]
411 fn test_status_panel_quiet_mode() {
412 let panel = StatusPanel::new(true);
413 assert!(!panel.should_update());
414 }
415
416 #[test]
417 fn test_status_panel_verbose_mode() {
418 let panel = StatusPanel::new(false);
419 assert!(
420 !panel.should_update(),
421 "immediately after creation, interval not yet elapsed"
422 );
423 }
424
425 #[test]
426 fn test_set_total_updates_total() {
427 let mut pb = ProgressBar::new(100);
428 pb.set_total(999);
429 assert_eq!(pb.total(), 999);
430 }
431}