1use std::io::{IsTerminal, Write};
12use std::sync::Arc;
13use std::time::Duration;
14
15use tokio::sync::mpsc::UnboundedReceiver;
16
17use crate::fmt::{self, Style};
18use crate::progress::{Event, NoteLevel, Snapshot, SpeedMeter, Stats};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Mode {
22 Interactive,
24 Plain,
26 Json,
28 Quiet,
30}
31
32impl Mode {
33 pub fn detect(json: bool, quiet: bool, verbose: bool) -> Mode {
36 if json {
37 return Mode::Json;
38 }
39 if quiet {
40 return Mode::Quiet;
41 }
42 let logging = verbose || std::env::var_os("RUST_LOG").is_some();
45 if std::io::stderr().is_terminal() && !logging {
46 Mode::Interactive
47 } else {
48 Mode::Plain
49 }
50 }
51}
52
53fn terminal_width() -> usize {
56 terminal_size::terminal_size()
57 .map(|(terminal_size::Width(w), _)| w as usize)
58 .unwrap_or(80)
59 .clamp(40, 120)
60}
61
62struct Ui {
63 mode: Mode,
64 style: Style,
65 stats: Arc<Stats>,
66 filename: String,
67 total_size: Option<u64>,
68 resumed_bytes: u64,
69 connections: usize,
70 meter: Option<SpeedMeter>,
71 drawn_lines: usize,
74 last_note: Option<String>,
75 verifying: Option<(String, u64, u64)>,
76 finished: bool,
77 verbose: bool,
78}
79
80pub async fn run(mode: Mode, stats: Arc<Stats>, mut rx: UnboundedReceiver<Event>, verbose: bool) {
82 let mut ui = Ui {
83 mode,
84 style: Style::new(mode == Mode::Interactive),
85 stats,
86 filename: String::new(),
87 total_size: None,
88 resumed_bytes: 0,
89 connections: 0,
90 meter: None,
91 drawn_lines: 0,
92 last_note: None,
93 verifying: None,
94 finished: false,
95 verbose,
96 };
97
98 let mut ticker = tokio::time::interval(Duration::from_millis(100));
99 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
100 let mut plain_ticker = tokio::time::interval(Duration::from_secs(5));
102 plain_ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
103
104 if ui.mode == Mode::Interactive {
105 let _ = write!(std::io::stderr(), "\x1b[?25l"); }
107
108 loop {
109 tokio::select! {
110 event = rx.recv() => match event {
111 Some(event) => ui.handle(event),
112 None => break,
113 },
114 _ = ticker.tick(), if ui.mode == Mode::Interactive => ui.tick(),
115 _ = plain_ticker.tick(), if ui.mode == Mode::Plain => ui.tick(),
116 }
117 }
118
119 if ui.mode == Mode::Interactive {
120 let _ = write!(std::io::stderr(), "\x1b[?25h"); let _ = std::io::stderr().flush();
122 }
123}
124
125impl Ui {
126 fn handle(&mut self, event: Event) {
127 if self.mode == Mode::Json {
128 self.emit_json(&event);
129 return;
130 }
131
132 match event {
133 Event::DownloadStarted {
134 ref filename,
135 total_size,
136 resumed_bytes,
137 connections,
138 parallel,
139 ref id,
140 ..
141 } => {
142 self.filename = filename.clone();
143 self.total_size = total_size;
144 self.resumed_bytes = resumed_bytes;
145 self.connections = connections;
146 self.meter = Some(SpeedMeter::new(Duration::from_secs(3), resumed_bytes));
147
148 if self.mode != Mode::Quiet {
149 if resumed_bytes > 0 {
150 self.line(format!(
151 "{} {} {}",
152 self.style.bright_cyan("⟳"),
153 self.style.dim("Resuming"),
154 self.style.bold(filename)
155 ));
156 self.line(format!(
157 " {} {}",
158 self.style.bold(&fmt::bytes(resumed_bytes)),
159 self.style.dim(&match total_size {
160 Some(t) => format!("of {} already downloaded", fmt::bytes(t)),
161 None => "already downloaded".to_string(),
162 })
163 ));
164 }
165 if resumed_bytes == 0 && self.mode == Mode::Plain {
168 self.line(self.style.bold(filename));
169 }
170 if self.verbose {
171 self.line(format!(
172 " {} {}",
173 self.style.dim("id"),
174 self.style.dim(&format!(
175 "{id} · {} connection(s) · {}",
176 if parallel { connections } else { 1 },
177 if parallel {
178 "parallel ranges"
179 } else {
180 "sequential"
181 }
182 ))
183 ));
184 }
185 }
186 }
187 Event::RetryScheduled {
188 attempt,
189 delay_ms,
190 ref reason,
191 ..
192 } => {
193 let msg = format!(
194 "{}. Retrying in {}... attempt {attempt}",
195 reason,
196 fmt::duration(Duration::from_millis(delay_ms))
197 );
198 if self.mode == Mode::Interactive {
199 self.last_note = Some(self.style.yellow(&msg));
200 } else if self.mode != Mode::Quiet {
201 self.line(msg);
202 }
203 }
204 Event::Note { level, ref message } => {
205 let styled = match level {
206 NoteLevel::Info => self.style.dim(&format!(" {message}")),
207 NoteLevel::Warn => self.style.yellow(&format!(" warning: {message}")),
208 NoteLevel::Error => self.style.red(&format!(" error: {message}")),
209 };
210 if level == NoteLevel::Info && (self.mode == Mode::Quiet || !self.verbose) {
211 return;
212 }
213 if self.mode == Mode::Quiet && level != NoteLevel::Error {
214 return;
215 }
216 self.line(styled);
217 }
218 Event::VerificationStarted {
219 ref algorithm,
220 total_size,
221 } => {
222 self.clear_block();
223 self.verifying = Some((algorithm.clone(), 0, total_size));
224 if self.mode != Mode::Quiet {
225 self.line(format!(
226 " {} {} {}",
227 self.style.bright_cyan("⋯"),
228 self.style.dim("Verifying"),
229 self.style.bold(&label_for(algorithm))
230 ));
231 }
232 }
233 Event::VerificationProgress { bytes, total_size } => {
234 if let Some((algo, _, _)) = &self.verifying {
235 let algo = algo.clone();
236 self.verifying = Some((algo, bytes, total_size));
237 }
238 if self.mode == Mode::Interactive {
239 self.draw_verification();
240 }
241 }
242 Event::VerificationCompleted {
243 ref algorithm,
244 ok,
245 ref expected,
246 ref actual,
247 } => {
248 self.clear_block();
249 self.verifying = None;
250 if ok {
251 if self.mode != Mode::Quiet {
252 self.line(format!(
253 " {} {} {}",
254 self.style.bold_green("✓"),
255 self.style.dim(&label_for(algorithm)),
256 self.style.green("verified")
257 ));
258 }
259 } else {
260 self.line(format!(
263 " {} {} {}",
264 self.style.bold_red("✗"),
265 self.style.bold(&label_for(algorithm)),
266 self.style.red("MISMATCH")
267 ));
268 self.line(format!(
269 " {} {}",
270 self.style.dim("expected"),
271 self.style.green(expected.as_deref().unwrap_or("?"))
272 ));
273 self.line(format!(
274 " {} {}",
275 self.style.dim("actual "),
276 self.style.red(actual)
277 ));
278 }
279 }
280 Event::DownloadCompleted {
281 downloaded,
282 elapsed_ms,
283 average_bps,
284 } => {
285 self.finished = true;
286 self.clear_block();
287 if self.mode == Mode::Quiet {
288 return;
289 }
290 self.line(format!(
291 " {} {}",
292 self.style.bold_green("✓"),
293 self.style.bold(&self.filename)
294 ));
295 self.line(format!(
296 " {}{}{} {}{}{} {}",
297 self.style.bold(&fmt::bytes(downloaded)),
298 self.style.sep(),
299 self.style.dim("in"),
300 self.style
301 .cyan(&fmt::duration(Duration::from_millis(elapsed_ms))),
302 self.style.sep(),
303 self.style.green(&fmt::rate(average_bps as f64)),
304 self.style.dim("average"),
305 ));
306 }
307 Event::DownloadPaused {
308 downloaded,
309 total_size,
310 } => {
311 self.finished = true;
312 self.clear_block();
313 self.line(format!(
314 " {} {}",
315 self.style.yellow("⏸"),
316 self.style.bold("Download paused")
317 ));
318 self.line(format!(
319 " {} {}",
320 self.style.bold(&fmt::bytes(downloaded)),
321 self.style.dim(&match total_size {
322 Some(total) => format!("of {} downloaded", fmt::bytes(total)),
323 None => "downloaded".to_string(),
324 })
325 ));
326 self.line(self.style.dim(" Run the same command again to resume."));
327 }
328 Event::DownloadFailed { ref error } => {
329 self.finished = true;
330 self.clear_block();
331 self.line(format!(
332 " {} {}",
333 self.style.bold_red("✗"),
334 self.style.red(error)
335 ));
336 }
337 Event::BytesWritten { .. }
340 | Event::RangeStarted { .. }
341 | Event::RangeCompleted { .. }
342 | Event::RangeSplit { .. }
343 | Event::Checkpointed { .. } => {}
344 }
345 }
346
347 fn emit_json(&self, event: &Event) {
348 let mut out = std::io::stdout().lock();
349 if let Ok(line) = serde_json::to_string(event) {
350 let _ = writeln!(out, "{line}");
351 let _ = out.flush();
352 }
353 }
354
355 fn tick(&mut self) {
356 if self.finished || self.meter.is_none() || self.verifying.is_some() {
359 return;
360 }
361 let downloaded = self.stats.downloaded();
362 if let Some(meter) = &mut self.meter {
363 meter.record(downloaded);
364 }
365 match self.mode {
366 Mode::Interactive if self.verifying.is_none() => self.draw_block(),
367 Mode::Plain => {
368 let snap = self.snapshot();
369 let _ = writeln!(
370 std::io::stderr(),
371 "{}: {} / {} ({}) at {} ETA {}",
372 snap.filename,
373 fmt::bytes(snap.downloaded),
374 snap.total_size
375 .map(fmt::bytes)
376 .unwrap_or_else(|| "unknown".into()),
377 fmt::percent(snap.downloaded, snap.total_size),
378 fmt::rate(snap.bps),
379 snap.eta_secs
380 .map(|s| fmt::duration(Duration::from_secs(s)))
381 .unwrap_or_else(|| "--".into()),
382 );
383 }
384 _ => {}
385 }
386 }
387
388 fn snapshot(&self) -> Snapshot {
389 let downloaded = self.stats.downloaded();
390 let (complete, total_ranges) = self.stats.ranges();
391 let (bps, smoothed, elapsed) = match &self.meter {
392 Some(m) => (m.rolling_bps(), m.smoothed_bps(), m.elapsed()),
393 None => (0.0, 0.0, Duration::ZERO),
394 };
395 let eta_secs = match (self.total_size, smoothed) {
396 (Some(total), s) if s >= 1.0 => {
397 Some((total.saturating_sub(downloaded) as f64 / s) as u64)
398 }
399 _ => None,
400 };
401 Snapshot {
402 filename: self.filename.clone(),
403 downloaded,
404 total_size: self.total_size,
405 bps,
406 smoothed_bps: smoothed,
407 eta_secs,
408 elapsed_ms: elapsed.as_millis() as u64,
409 active_connections: self.stats.active_connections(),
410 ranges_complete: complete,
411 ranges_total: total_ranges,
412 retries: self.stats.retries(),
413 }
414 }
415
416 fn draw_block(&mut self) {
417 let width = terminal_width();
418 let lines = render_block(
419 &self.snapshot(),
420 self.last_note.as_deref(),
421 width,
422 &self.style,
423 );
424 self.paint(&lines);
425 }
426
427 fn draw_verification(&mut self) {
428 let Some((_, bytes, total)) = self.verifying.clone() else {
429 return;
430 };
431 let width = terminal_width();
432 let bar_width = width.saturating_sub(12).clamp(12, 44);
433 let (filled, empty) = fmt::bar_parts(bytes, Some(total), bar_width);
434 let lines = vec![format!(
437 " {}{} {}",
438 self.style.bright_cyan(&filled),
439 self.style.dim(&empty),
440 self.style.bold_cyan(&fmt::percent(bytes, Some(total)))
441 )];
442 self.paint(&lines);
443 }
444
445 fn paint(&mut self, lines: &[String]) {
448 let mut out = std::io::stderr().lock();
449 if self.drawn_lines > 0 {
450 let _ = write!(out, "\x1b[{}A", self.drawn_lines);
451 }
452 for line in lines {
453 let _ = writeln!(out, "\x1b[2K{line}");
454 }
455 for _ in lines.len()..self.drawn_lines {
457 let _ = writeln!(out, "\x1b[2K");
458 }
459 if self.drawn_lines > lines.len() {
460 let _ = write!(out, "\x1b[{}A", self.drawn_lines - lines.len());
461 }
462 let _ = out.flush();
463 self.drawn_lines = lines.len();
464 }
465
466 fn clear_block(&mut self) {
468 if self.mode != Mode::Interactive || self.drawn_lines == 0 {
469 return;
470 }
471 let mut out = std::io::stderr().lock();
472 let _ = write!(out, "\x1b[{}A", self.drawn_lines);
473 for _ in 0..self.drawn_lines {
474 let _ = writeln!(out, "\x1b[2K");
475 }
476 let _ = write!(out, "\x1b[{}A", self.drawn_lines);
477 let _ = out.flush();
478 self.drawn_lines = 0;
479 }
480
481 fn line(&mut self, text: impl AsRef<str>) {
482 if self.mode == Mode::Json {
483 return;
484 }
485 self.clear_block();
486 let _ = writeln!(std::io::stderr(), "{}", text.as_ref());
487 }
488}
489
490fn label_for(algorithm: &str) -> String {
491 match algorithm {
492 "sha256" => "SHA-256".into(),
493 "sha512" => "SHA-512".into(),
494 "blake3" => "BLAKE3".into(),
495 other => other.to_uppercase(),
496 }
497}
498
499pub fn render_block(
505 snap: &Snapshot,
506 note: Option<&str>,
507 width: usize,
508 style: &Style,
509) -> Vec<String> {
510 const INDENT: &str = " ";
511 let mut lines = Vec::with_capacity(6);
512
513 let name = truncate_display(&snap.filename, width.saturating_sub(4));
515 lines.push(format!(
516 "{INDENT}{} {}",
517 style.bright_cyan("↓"),
518 style.bold(&name)
519 ));
520
521 let pct = format!("{:>6}", fmt::percent(snap.downloaded, snap.total_size));
525 let bar_width = width
526 .saturating_sub(INDENT.len() + 2 + pct.len())
527 .clamp(8, 44);
528 let (filled, empty) = fmt::bar_parts(snap.downloaded, snap.total_size, bar_width);
529 lines.push(format!(
530 "{INDENT}{}{} {}",
531 style.bright_green(&filled),
532 style.dim(&empty),
533 style.bold_green(&pct)
534 ));
535
536 let total = snap
538 .total_size
539 .map(fmt::bytes)
540 .unwrap_or_else(|| "unknown".to_string());
541 let downloaded = fmt::bytes(snap.downloaded);
542 let rate = fmt::rate(snap.bps);
543 let eta = snap
544 .eta_secs
545 .map(|s| fmt::duration(Duration::from_secs(s)))
546 .unwrap_or_else(|| "--".to_string());
547
548 let headline = [
549 (
550 format!("{downloaded} / {total}"),
551 format!(
552 "{}{}",
553 style.bold(&downloaded),
554 style.dim(&format!(" / {total}"))
555 ),
556 ),
557 (rate.clone(), style.green(&rate)),
558 (
559 format!("ETA {eta}"),
560 format!("{} {}", style.dim("ETA"), style.cyan(&eta)),
561 ),
562 ];
563 lines.extend(join_wrapped(&headline, INDENT, width, style));
564
565 let conns = snap.active_connections.to_string();
567 let chunks = format!("{}/{}", snap.ranges_complete, snap.ranges_total);
568 let mut detail = vec![
569 (
570 format!("{conns} connections"),
571 format!("{} {}", style.blue(&conns), style.dim("connections")),
572 ),
573 (
574 format!("{chunks} chunks"),
575 format!("{} {}", style.magenta(&chunks), style.dim("chunks")),
576 ),
577 ];
578 if snap.retries > 0 {
579 let retries = snap.retries.to_string();
580 detail.push((
581 format!("{retries} retries"),
582 format!("{} {}", style.yellow(&retries), style.dim("retries")),
583 ));
584 }
585 lines.extend(join_wrapped(&detail, INDENT, width, style));
586
587 if let Some(note) = note {
588 lines.push(format!("{INDENT}{note}"));
589 }
590
591 lines
592}
593
594fn join_wrapped(
600 segments: &[(String, String)],
601 indent: &str,
602 width: usize,
603 style: &Style,
604) -> Vec<String> {
605 const SEP: &str = " · ";
606 let indent_cols = indent.chars().count();
607
608 let mut lines = Vec::new();
609 let mut plain = String::new();
610 let mut styled = String::new();
611
612 for (segment_plain, segment_styled) in segments {
613 let segment_cols = segment_plain.chars().count();
614 let would_be = if plain.is_empty() {
615 indent_cols + segment_cols
616 } else {
617 indent_cols + plain.chars().count() + SEP.chars().count() + segment_cols
618 };
619
620 if !plain.is_empty() && would_be > width {
621 lines.push(format!("{indent}{styled}"));
622 plain.clear();
623 styled.clear();
624 }
625 if !plain.is_empty() {
626 plain.push_str(SEP);
627 styled.push_str(&style.sep());
628 }
629 plain.push_str(segment_plain);
630 styled.push_str(segment_styled);
631 }
632
633 if !plain.is_empty() {
634 lines.push(format!("{indent}{styled}"));
635 }
636 lines
637}
638
639fn truncate_display(name: &str, max_cols: usize) -> String {
642 let cols = name.chars().count();
643 if cols <= max_cols || max_cols < 4 {
644 return name.to_string();
645 }
646 let keep = max_cols - 1;
647 let head: String = name.chars().take(keep / 2).collect();
648 let tail: String = name
649 .chars()
650 .skip(cols - (keep - keep / 2))
651 .collect::<String>();
652 format!("{head}…{tail}")
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 fn snap() -> Snapshot {
660 Snapshot {
661 filename: "linux.iso".into(),
662 downloaded: 7_324_070_215,
663 total_size: Some(13_314_398_618),
664 bps: 88_800_000.0,
665 smoothed_bps: 88_000_000.0,
666 eta_secs: Some(67),
667 elapsed_ms: 151_000,
668 active_connections: 8,
669 ranges_complete: 14,
670 ranges_total: 24,
671 retries: 0,
672 }
673 }
674
675 fn plain() -> Style {
677 Style::new(false)
678 }
679
680 fn bar_line(lines: &[String]) -> String {
682 lines
683 .iter()
684 .find(|l| l.contains('█') || l.contains('░'))
685 .expect("the block should contain a progress bar")
686 .clone()
687 }
688
689 #[test]
690 fn plain_style_emits_no_escape_codes() {
691 for line in render_block(&snap(), Some("a note"), 80, &plain()) {
692 assert!(
693 !line.contains('\x1b'),
694 "disabled styling must stay disabled: {line}"
695 );
696 }
697 }
698
699 #[test]
700 fn styled_block_is_actually_coloured() {
701 let style = Style::new(true);
702 if !style.is_enabled() {
703 return; }
705 let lines = render_block(&snap(), None, 80, &style);
706 let text = lines.join("\n");
707 assert!(text.contains('\x1b'), "expected colour, got: {text:?}");
708 assert_eq!(
711 text.matches("\x1b[").count(),
712 text.matches("\x1b[0m").count() * 2,
713 "unbalanced colour codes: {text:?}"
714 );
715 }
716
717 #[test]
718 fn block_shows_the_prd_fields() {
719 let lines = render_block(&snap(), None, 80, &plain());
720 let text = lines.join("\n");
721 assert!(text.contains("linux.iso"), "{text}");
722 assert!(text.contains("6.82 GiB"), "{text}");
723 assert!(text.contains("12.40 GiB"), "{text}");
724 assert!(text.contains("55.0%"), "{text}");
725 assert!(text.contains("84.7 MiB/s"), "{text}");
726 assert!(text.contains("ETA 1m 07s"), "{text}");
727 assert!(text.contains("8 connections"), "{text}");
728 assert!(text.contains("14/24 chunks"), "{text}");
729 }
730
731 #[test]
732 fn block_is_stable_width() {
733 for width in [40usize, 60, 80, 200] {
734 let lines = render_block(&snap(), None, width, &plain());
735 for line in &lines {
736 assert!(
737 line.chars().count() <= width,
738 "line overflows at width {width}: {line}"
739 );
740 }
741 }
742 }
743
744 #[test]
745 fn bar_keeps_its_width_as_progress_changes() {
746 let mut widths = std::collections::HashSet::new();
748 for done in [
749 0u64,
750 1,
751 5_000,
752 7_324_070_215,
753 13_314_398_617,
754 13_314_398_618,
755 ] {
756 let mut s = snap();
757 s.downloaded = done;
758 widths.insert(
759 bar_line(&render_block(&s, None, 80, &plain()))
760 .chars()
761 .count(),
762 );
763 }
764 assert_eq!(widths.len(), 1, "bar width jitters: {widths:?}");
765 }
766
767 #[test]
768 fn unknown_total_degrades_gracefully() {
769 let mut s = snap();
770 s.total_size = None;
771 s.eta_secs = None;
772 let text = render_block(&s, None, 80, &plain()).join("\n");
773 assert!(text.contains("unknown"), "{text}");
774 assert!(text.contains("ETA --"), "{text}");
775 }
776
777 #[test]
778 fn retries_and_notes_surface() {
779 let mut s = snap();
780 s.retries = 3;
781 let lines = render_block(&s, Some("Connection lost. Retrying in 2s..."), 80, &plain());
782 let text = lines.join("\n");
783 assert!(text.contains("3 retries"), "{text}");
784 assert!(text.contains("Retrying in 2s"), "{text}");
785 }
786
787 #[test]
788 fn mode_detection_respects_flags() {
789 assert_eq!(Mode::detect(true, false, false), Mode::Json);
790 assert_eq!(Mode::detect(true, true, false), Mode::Json);
792 assert_eq!(Mode::detect(false, true, false), Mode::Quiet);
793 assert_eq!(Mode::detect(false, false, true), Mode::Plain);
796 }
797
798 #[test]
799 fn algorithm_labels() {
800 assert_eq!(label_for("sha256"), "SHA-256");
801 assert_eq!(label_for("blake3"), "BLAKE3");
802 assert_eq!(label_for("weird"), "WEIRD");
803 }
804
805 #[test]
806 fn style_can_be_disabled() {
807 let plain = Style::new(false);
808 assert_eq!(plain.green("ok"), "ok");
809 let styled = Style::new(true);
810 assert!(styled.green("ok").contains("ok"));
813 }
814}