Skip to main content

rget/
ui.rs

1//! Terminal rendering (PRD §19).
2//!
3//! This is the only module allowed to know what a terminal is. It consumes
4//! [`Event`]s and samples [`Stats`]; it never talks to the engine.
5//!
6//! Human progress goes to **stderr** so it cannot contaminate piped data, and
7//! `--json` events go to **stdout** so they can be piped into `jq`. When stderr
8//! is not a TTY we emit plain periodic lines with no cursor control at all, so
9//! `rget URL 2> log.txt` produces a readable log rather than escape soup.
10
11use 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    /// Live, redrawing block.
23    Interactive,
24    /// Periodic one-line updates, no escape codes.
25    Plain,
26    /// One JSON object per line on stdout.
27    Json,
28    /// Errors and the final result only.
29    Quiet,
30}
31
32impl Mode {
33    /// Pick a mode from the flags and the environment. Explicit flags win;
34    /// otherwise interactivity decides (PRD §19).
35    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        // Debug logging and a redrawing block fight over the same cursor, so
43        // when logs are on we degrade to plain output on purpose.
44        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
53/// How wide to draw, clamped to something sane for very wide or unknown
54/// terminals.
55fn 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    /// Lines currently occupied by the live block, so we know how far to move
72    /// the cursor back up.
73    drawn_lines: usize,
74    last_note: Option<String>,
75    verifying: Option<(String, u64, u64)>,
76    finished: bool,
77    verbose: bool,
78}
79
80/// Drive the display until the event channel closes.
81pub 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    // Plain mode prints a line every few seconds instead of redrawing.
101    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"); // hide cursor
106    }
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"); // show cursor
121        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                    // The live block carries the filename on its first line, so
166                    // only the non-redrawing modes need it announced here.
167                    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                    // A mismatch is the loudest thing this tool can say, so it
261                    // gets the full-width red treatment and both digests.
262                    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            // Byte-level and range-level events drive the sampled display
338            // rather than printing anything of their own.
339            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        // Once verification starts the transfer is over; a further progress
357        // line here would print "100% ETA 0s" underneath "Verifying...".
358        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        // Cyan rather than green: verification is a different phase, and the
435        // colour change is what tells you the download itself is done.
436        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    /// Redraw the block in place: move up over what we drew last time, clearing
446    /// each line as we go. Never scrolls, so the terminal stays quiet (PRD §4).
447    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        // If this frame is shorter than the last, wipe the leftovers.
456        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    /// Drop the live block so a permanent message can be printed under it.
467    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
499/// Build the progress block.
500///
501/// Pure — it takes the [`Style`] rather than deciding on one, so tests can
502/// render both a plain version (to assert content) and a styled version (to
503/// assert colour) without needing a terminal.
504pub 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    // Name, with a marker that gives the eye somewhere to land.
514    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    // The bar carries the colour weight: bright where done, receding where not.
522    // The percentage is padded to the width of "100.0%" so the block's right
523    // edge does not twitch every time the number gains a digit.
524    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    // Size, speed and ETA — the three numbers people actually watch.
537    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    // Connection detail: diagnostic rather than headline, so it reads dimmer.
566    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
594/// Join `(plain, styled)` segments with dim separators, starting a new line
595/// whenever the plain text would run past `width`.
596///
597/// The plain half exists purely so we can measure: ANSI escapes have length but
598/// occupy no columns, so measuring the styled string would wrap far too early.
599fn 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
639/// Shorten a name to fit, keeping the extension visible — the tail of a
640/// filename is usually the informative part.
641fn 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    /// Styling off — what a pipe, a log file or `NO_COLOR` sees.
676    fn plain() -> Style {
677        Style::new(false)
678    }
679
680    /// The bar line, wherever it happens to sit in the block.
681    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; // NO_COLOR is set in this environment; nothing to assert.
704        }
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        // Every sequence we open must be closed, or the colour bleeds into the
709        // user's shell prompt after we exit.
710        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        // A bar that changes width as it fills makes the whole block jitter.
747        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        // --json wins over --quiet: a script asked for machine output.
791        assert_eq!(Mode::detect(true, true, false), Mode::Json);
792        assert_eq!(Mode::detect(false, true, false), Mode::Quiet);
793        // Verbose logging forces plain output so logs and the block do not
794        // fight over the cursor.
795        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        // NO_COLOR may be set in the test environment; either way, no panic and
811        // the text survives.
812        assert!(styled.green("ok").contains("ok"));
813    }
814}