Skip to main content

rch_common/ui/progress/
transfer.rs

1//! TransferProgress - rsync progress visualization.
2//!
3//! Parses `rsync --info=progress2` output and renders a compact progress line.
4
5use crate::ui::{Icons, OutputContext, ProgressContext};
6use std::time::{Duration, Instant};
7
8#[cfg(all(feature = "rich-ui", unix))]
9use crate::ui::RchTheme;
10#[cfg(all(feature = "rich-ui", unix))]
11use rich_rust::prelude::{BarStyle, ProgressBar, Style};
12
13const DEFAULT_BYTES_BAR_WIDTH: usize = 18;
14const DEFAULT_FILES_BAR_WIDTH: usize = 10;
15const SPEED_SAMPLE_WINDOW: usize = 10;
16const MIN_PERCENT_FOR_TOTAL: u8 = 1;
17
18/// Transfer direction for progress display.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum TransferDirection {
21    Upload,
22    Download,
23}
24
25impl TransferDirection {
26    fn arrow(self, ctx: OutputContext) -> &'static str {
27        if ctx.supports_unicode() {
28            match self {
29                Self::Upload => "\u{2191}",   // ↑
30                Self::Download => "\u{2193}", // ↓
31            }
32        } else {
33            match self {
34                Self::Upload => "^",
35                Self::Download => "v",
36            }
37        }
38    }
39
40    fn label(self) -> &'static str {
41        match self {
42            Self::Upload => "Syncing",
43            Self::Download => "Fetching",
44        }
45    }
46}
47
48#[derive(Debug, Clone, Copy)]
49struct ProgressSample {
50    bytes: u64,
51    percent: Option<u8>,
52    speed_bps: Option<f64>,
53    eta: Option<Duration>,
54    files_done: Option<u32>,
55    files_total: Option<u32>,
56}
57
58#[derive(Debug, Default)]
59struct SpeedSmoother {
60    samples: Vec<f64>,
61}
62
63impl SpeedSmoother {
64    fn push(&mut self, value: f64) {
65        if value <= 0.0 {
66            return;
67        }
68        self.samples.push(value);
69        if self.samples.len() > SPEED_SAMPLE_WINDOW {
70            self.samples.remove(0);
71        }
72    }
73
74    fn average(&self) -> Option<f64> {
75        if self.samples.is_empty() {
76            return None;
77        }
78        let sum: f64 = self.samples.iter().sum();
79        Some(sum / self.samples.len() as f64)
80    }
81
82    fn sparkline(&self, ctx: OutputContext) -> String {
83        if self.samples.is_empty() {
84            return String::new();
85        }
86
87        let levels: [&str; 8] = if ctx.supports_unicode() {
88            [
89                "\u{2581}", // ▁
90                "\u{2582}", // ▂
91                "\u{2583}", // ▃
92                "\u{2584}", // ▄
93                "\u{2585}", // ▅
94                "\u{2586}", // ▆
95                "\u{2587}", // ▇
96                "\u{2588}", // █
97            ]
98        } else {
99            [".", ":", "-", "=", "+", "*", "#", "@"] // ASCII fallback
100        };
101
102        let max = self
103            .samples
104            .iter()
105            .cloned()
106            .fold(0.0_f64, f64::max)
107            .max(1.0);
108
109        let mut out = String::new();
110        for sample in &self.samples {
111            let ratio = (sample / max).clamp(0.0, 1.0);
112            let idx = (ratio * (levels.len() as f64 - 1.0)).round() as usize;
113            out.push_str(levels[idx]);
114        }
115        out
116    }
117}
118
119/// Progress display for rsync transfers.
120#[derive(Debug)]
121pub struct TransferProgress {
122    ctx: OutputContext,
123    direction: TransferDirection,
124    label: String,
125    enabled: bool,
126    progress: Option<ProgressContext>,
127    start: Instant,
128    bytes_transferred: u64,
129    bytes_total: Option<u64>,
130    percent: Option<u8>,
131    files_transferred: u32,
132    files_total: Option<u32>,
133    current_file: Option<String>,
134    speed: SpeedSmoother,
135    eta: Option<Duration>,
136    compression_ratio: Option<f64>,
137}
138
139/// Snapshot of transfer progress stats.
140#[derive(Debug, Clone, Copy, Default)]
141pub struct TransferStats {
142    pub bytes_transferred: u64,
143    pub bytes_total: Option<u64>,
144    pub files_transferred: u32,
145    pub files_total: Option<u32>,
146    pub percent: Option<u8>,
147}
148
149impl TransferProgress {
150    /// Create a new transfer progress display.
151    pub fn new(
152        ctx: OutputContext,
153        direction: TransferDirection,
154        label: impl Into<String>,
155        quiet: bool,
156    ) -> Self {
157        let enabled = !quiet && !ctx.is_machine();
158        let progress = if enabled && matches!(ctx, OutputContext::Interactive) {
159            Some(ProgressContext::new(ctx))
160        } else {
161            None
162        };
163
164        Self {
165            ctx,
166            direction,
167            label: label.into(),
168            enabled,
169            progress,
170            start: Instant::now(),
171            bytes_transferred: 0,
172            bytes_total: None,
173            percent: None,
174            files_transferred: 0,
175            files_total: None,
176            current_file: None,
177            speed: SpeedSmoother::default(),
178            eta: None,
179            compression_ratio: None,
180        }
181    }
182
183    /// Convenience constructor for uploads.
184    pub fn upload(ctx: OutputContext, label: impl Into<String>, quiet: bool) -> Self {
185        Self::new(ctx, TransferDirection::Upload, label, quiet)
186    }
187
188    /// Convenience constructor for downloads.
189    pub fn download(ctx: OutputContext, label: impl Into<String>, quiet: bool) -> Self {
190        Self::new(ctx, TransferDirection::Download, label, quiet)
191    }
192
193    /// Update progress from a raw rsync output line.
194    pub fn update_from_line(&mut self, line: &str) {
195        let trimmed = line.trim();
196        if trimmed.is_empty() {
197            return;
198        }
199
200        if let Some(sample) = parse_progress_line(trimmed) {
201            self.apply_sample(sample);
202        } else {
203            self.set_current_file(trimmed.to_string());
204        }
205
206        self.render();
207    }
208
209    /// Set the current file being transferred.
210    pub fn set_current_file(&mut self, path: impl Into<String>) {
211        self.current_file = Some(path.into());
212    }
213
214    /// Set compression ratio (e.g. 3.2 for 3.2:1).
215    pub fn set_compression_ratio(&mut self, ratio: f64) {
216        if ratio.is_finite() && ratio > 0.0 {
217            self.compression_ratio = Some(ratio);
218        }
219    }
220
221    /// Apply a final summary from an external source.
222    ///
223    /// Useful when progress lines are unavailable (e.g., mock transport).
224    pub fn apply_summary(&mut self, bytes_transferred: u64, files_transferred: u32) {
225        if bytes_transferred > 0 {
226            self.bytes_transferred = bytes_transferred;
227        }
228        if files_transferred > 0 {
229            self.files_transferred = files_transferred;
230        }
231    }
232
233    /// Snapshot current transfer stats for callers.
234    #[must_use]
235    pub fn stats(&self) -> TransferStats {
236        TransferStats {
237            bytes_transferred: self.bytes_transferred,
238            bytes_total: self.bytes_total,
239            files_transferred: self.files_transferred,
240            files_total: self.files_total,
241            percent: self.percent,
242        }
243    }
244
245    /// Finish the progress display and print a summary line.
246    pub fn finish(&mut self) {
247        if let Some(progress) = &self.progress {
248            progress.clear();
249        }
250
251        if !self.enabled {
252            return;
253        }
254
255        let duration = self.start.elapsed();
256        let avg_speed = if duration.as_secs_f64() > 0.0 {
257            self.bytes_transferred as f64 / duration.as_secs_f64()
258        } else {
259            0.0
260        };
261
262        let icon = Icons::check(self.ctx);
263        let files = self.files_transferred;
264        let bytes = format_bytes(self.bytes_transferred);
265        let speed = format_speed(avg_speed);
266        let duration_str = format_duration(duration);
267        let ratio = self
268            .compression_ratio
269            .map(|r| format!(" {r:.1}:1 compression"))
270            .unwrap_or_default();
271
272        eprintln!("{icon} Synced {files} files ({bytes}) in {duration_str} ({speed} avg{ratio})");
273    }
274
275    /// Finish with a failure message.
276    pub fn finish_error(&mut self, message: &str) {
277        if let Some(progress) = &self.progress {
278            progress.clear();
279        }
280
281        if !self.enabled {
282            return;
283        }
284
285        let icon = Icons::cross(self.ctx);
286        let duration = self.start.elapsed();
287        let duration_str = format_duration(duration);
288        eprintln!("{icon} Transfer failed after {duration_str}: {message}");
289    }
290
291    fn apply_sample(&mut self, sample: ProgressSample) {
292        self.bytes_transferred = sample.bytes;
293        self.percent = sample.percent;
294
295        if self.bytes_total.is_none()
296            && let Some(percent) = sample.percent
297            && percent >= MIN_PERCENT_FOR_TOTAL
298        {
299            let total = (self.bytes_transferred as f64 / (percent as f64 / 100.0)).round();
300            if total.is_finite() && total > 0.0 {
301                self.bytes_total = Some(total as u64);
302            }
303        }
304
305        if let Some(total) = sample.files_total {
306            self.files_total = Some(total);
307        }
308        if let Some(done) = sample.files_done {
309            self.files_transferred = done;
310        }
311
312        if let Some(speed) = sample.speed_bps {
313            self.speed.push(speed);
314        }
315
316        if let Some(eta) = sample.eta {
317            self.eta = Some(eta);
318        }
319    }
320
321    fn render(&mut self) {
322        if !self.enabled {
323            return;
324        }
325
326        let arrow = self.direction.arrow(self.ctx);
327        let label = if self.label.is_empty() {
328            self.direction.label()
329        } else {
330            self.label.as_str()
331        };
332
333        let bytes_bar = render_bar(
334            self.ctx,
335            self.bytes_transferred,
336            self.bytes_total,
337            self.percent.map(|p| f64::from(p) / 100.0),
338            DEFAULT_BYTES_BAR_WIDTH,
339        );
340
341        let files_percent = match (self.files_transferred, self.files_total) {
342            (_, Some(total)) if total > 0 => Some(self.files_transferred as f64 / total as f64),
343            _ => None,
344        };
345
346        let files_bar = render_bar(
347            self.ctx,
348            u64::from(self.files_transferred),
349            self.files_total.map(u64::from),
350            files_percent,
351            DEFAULT_FILES_BAR_WIDTH,
352        );
353
354        let bytes_total = self
355            .bytes_total
356            .map(format_bytes)
357            .unwrap_or_else(|| "?".to_string());
358        let bytes_done = format_bytes(self.bytes_transferred);
359
360        let files_total = self
361            .files_total
362            .map(|total| total.to_string())
363            .unwrap_or_else(|| "?".to_string());
364
365        let avg_speed = self.speed.average().unwrap_or(0.0);
366        let speed = format_speed(avg_speed);
367        let sparkline = self.speed.sparkline(self.ctx);
368
369        let eta = if let (Some(total), Some(speed_bps)) = (self.bytes_total, self.speed.average()) {
370            if speed_bps > 0.0 && total > self.bytes_transferred {
371                let remaining = (total - self.bytes_transferred) as f64;
372                Some(Duration::from_secs_f64(remaining / speed_bps))
373            } else {
374                self.eta
375            }
376        } else {
377            self.eta
378        };
379
380        let eta_str = eta.map(format_duration).unwrap_or_else(|| "--".to_string());
381
382        let ratio = self
383            .compression_ratio
384            .map(|r| format!("{r:.1}:1"))
385            .unwrap_or_else(|| "--".to_string());
386
387        let current_file = self
388            .current_file
389            .as_ref()
390            .map(|path| truncate_middle(path, 36))
391            .unwrap_or_else(|| "--".to_string());
392
393        let mut line = format!(
394            "{arrow} {label} {bytes_bar} {bytes_done}/{bytes_total} {speed} {sparkline} ETA {eta_str} ratio {ratio} | {files_bar} {files_transferred}/{files_total} files | {current_file}",
395            files_transferred = self.files_transferred
396        );
397
398        line = line.trim_end().to_string();
399
400        if let Some(progress) = &mut self.progress {
401            progress.render(&line);
402        }
403    }
404}
405
406fn parse_progress_line(line: &str) -> Option<ProgressSample> {
407    let tokens: Vec<&str> = line.split_whitespace().collect();
408    if tokens.len() < 4 {
409        return None;
410    }
411
412    if !tokens[1].ends_with('%') || !tokens[2].contains("/s") {
413        return None;
414    }
415
416    let bytes = parse_size(tokens[0])?;
417    let percent = tokens[1].trim_end_matches('%').parse::<u8>().ok();
418    let speed_bps = parse_speed(tokens[2]);
419    let eta = parse_eta(tokens[3]);
420
421    let details = if tokens.len() > 4 {
422        Some(tokens[4..].join(" "))
423    } else {
424        None
425    };
426
427    let (files_done, files_total) = details
428        .as_deref()
429        .and_then(parse_details)
430        .unwrap_or((None, None));
431
432    Some(ProgressSample {
433        bytes,
434        percent,
435        speed_bps,
436        eta,
437        files_done,
438        files_total,
439    })
440}
441
442fn parse_details(details: &str) -> Option<(Option<u32>, Option<u32>)> {
443    let cleaned = details.trim().trim_start_matches('(').trim_end_matches(')');
444
445    let mut files_done = None;
446    let mut files_total = None;
447
448    for part in cleaned.split(',') {
449        let part = part.trim();
450        // `to-chk=` is rsync 3.1+ (`--info=progress2`); `to-check=` is the
451        // pre-3.1 / openrsync `--progress` spelling (issue #66).
452        if let Some(rest) = part
453            .strip_prefix("to-chk=")
454            .or_else(|| part.strip_prefix("to-check="))
455        {
456            let mut iter = rest.split('/');
457            let remaining = iter.next()?.trim().parse::<u32>().ok()?;
458            let total = iter.next()?.trim().parse::<u32>().ok()?;
459            files_total = Some(total);
460            files_done = Some(total.saturating_sub(remaining));
461        }
462    }
463
464    if files_done.is_none() && files_total.is_none() {
465        None
466    } else {
467        Some((files_done, files_total))
468    }
469}
470
471fn parse_speed(token: &str) -> Option<f64> {
472    let trimmed = token.trim();
473    let value = trimmed.trim_end_matches("/s");
474    parse_size_f64(value)
475}
476
477fn parse_eta(token: &str) -> Option<Duration> {
478    let parts: Vec<&str> = token.trim().split(':').collect();
479    if parts.len() < 2 {
480        return None;
481    }
482
483    let mut nums = Vec::new();
484    for part in parts {
485        nums.push(part.parse::<u64>().ok()?);
486    }
487
488    let (hours, minutes, seconds) = match nums.len() {
489        2 => (0, nums[0], nums[1]),
490        3 => (nums[0], nums[1], nums[2]),
491        _ => return None,
492    };
493
494    Some(Duration::from_secs(hours * 3600 + minutes * 60 + seconds))
495}
496
497fn parse_size(input: &str) -> Option<u64> {
498    parse_size_f64(input).map(|value| value.round() as u64)
499}
500
501fn parse_size_f64(input: &str) -> Option<f64> {
502    let mut num = String::new();
503    let mut unit = String::new();
504
505    for ch in input.trim().chars() {
506        if ch.is_ascii_digit() || ch == '.' {
507            num.push(ch);
508        } else if ch == ',' {
509            continue;
510        } else if !ch.is_whitespace() {
511            unit.push(ch);
512        }
513    }
514
515    if num.is_empty() {
516        return None;
517    }
518
519    let value = num.parse::<f64>().ok()?;
520    let unit = unit.to_ascii_lowercase();
521
522    let multiplier = match unit.as_str() {
523        "" | "b" => 1.0,
524        "k" | "kb" => 1024.0,
525        "m" | "mb" => 1024.0 * 1024.0,
526        "g" | "gb" => 1024.0 * 1024.0 * 1024.0,
527        "t" | "tb" => 1024.0_f64.powi(4),
528        "p" | "pb" => 1024.0_f64.powi(5),
529        "e" | "eb" => 1024.0_f64.powi(6),
530        _ => 1.0,
531    };
532
533    Some(value * multiplier)
534}
535
536#[cfg(all(feature = "rich-ui", unix))]
537fn render_bar(
538    ctx: OutputContext,
539    current: u64,
540    total: Option<u64>,
541    percent: Option<f64>,
542    width: usize,
543) -> String {
544    let mut bar = if let Some(total) = total {
545        ProgressBar::with_total(total)
546    } else {
547        ProgressBar::new()
548    };
549
550    let bar_style = if ctx.supports_unicode() {
551        BarStyle::Block
552    } else {
553        BarStyle::Ascii
554    };
555
556    let completed_style = Style::new()
557        .color_str(RchTheme::SECONDARY)
558        .unwrap_or_default();
559    let remaining_style = Style::new().color_str("bright_black").unwrap_or_default();
560
561    bar = bar
562        .width(width)
563        .bar_style(bar_style)
564        .completed_style(completed_style)
565        .remaining_style(remaining_style)
566        .show_percentage(false)
567        .show_eta(false)
568        .show_speed(false)
569        .show_elapsed(false);
570
571    if total.is_some() {
572        bar.update(current);
573    } else if let Some(percent) = percent {
574        bar.set_progress(percent);
575    }
576
577    bar.render_plain(width + 2).trim_end().to_string()
578}
579
580#[cfg(not(all(feature = "rich-ui", unix)))]
581fn render_bar(
582    ctx: OutputContext,
583    _current: u64,
584    _total: Option<u64>,
585    percent: Option<f64>,
586    width: usize,
587) -> String {
588    let progress = percent.unwrap_or(0.0).clamp(0.0, 1.0);
589    let filled = (progress * width as f64).round() as usize;
590    let empty = width.saturating_sub(filled);
591    let filled_char = Icons::progress_filled(ctx);
592    let empty_char = Icons::progress_empty(ctx);
593
594    let mut bar = String::from("[");
595    bar.push_str(&filled_char.repeat(filled));
596    bar.push_str(&empty_char.repeat(empty));
597    bar.push(']');
598    bar
599}
600
601fn format_bytes(bytes: u64) -> String {
602    let units = ["B", "KB", "MB", "GB", "TB", "PB"];
603    let mut value = bytes as f64;
604    let mut unit = 0;
605    while value >= 1024.0 && unit < units.len() - 1 {
606        value /= 1024.0;
607        unit += 1;
608    }
609    if unit == 0 {
610        format!("{bytes} B")
611    } else {
612        format!("{value:.1} {}", units[unit])
613    }
614}
615
616fn format_speed(bytes_per_sec: f64) -> String {
617    if bytes_per_sec <= 0.0 {
618        return "--/s".to_string();
619    }
620    let formatted = format_bytes(bytes_per_sec.round() as u64);
621    format!("{formatted}/s")
622}
623
624fn format_duration(duration: Duration) -> String {
625    let total_secs = duration.as_secs();
626    if total_secs < 60 {
627        format!("{:.1}s", duration.as_secs_f64())
628    } else if total_secs < 3600 {
629        let mins = total_secs / 60;
630        let secs = total_secs % 60;
631        format!("{mins}:{secs:02}")
632    } else {
633        let hours = total_secs / 3600;
634        let mins = (total_secs % 3600) / 60;
635        let secs = total_secs % 60;
636        format!("{hours}:{mins:02}:{secs:02}")
637    }
638}
639
640fn truncate_middle(value: &str, max_len: usize) -> String {
641    let len = value.chars().count();
642    if len <= max_len {
643        return value.to_string();
644    }
645    if max_len <= 3 {
646        return value.chars().take(max_len).collect();
647    }
648
649    let head = (max_len - 3) / 2;
650    let tail = max_len - 3 - head;
651    let start: String = value.chars().take(head).collect();
652    let end: String = value
653        .chars()
654        .rev()
655        .take(tail)
656        .collect::<String>()
657        .chars()
658        .rev()
659        .collect();
660    format!("{start}...{end}")
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    #[test]
668    fn parse_progress_line_with_details() {
669        let line = "9.53G  21%  317.26MB/s    0:00:28 (xfr#83063, to-chk=443926/538653)";
670        let sample = parse_progress_line(line).expect("parse");
671        assert_eq!(sample.percent, Some(21));
672        assert_eq!(sample.files_total, Some(538_653));
673        assert!(sample.bytes > 0);
674        assert!(sample.speed_bps.unwrap_or(0.0) > 0.0);
675        assert_eq!(sample.eta, Some(Duration::from_secs(28)));
676    }
677
678    #[test]
679    fn parse_progress_line_numeric_bytes() {
680        let line = "1234567  12%  1.23MB/s  0:01:23 (xfr#5, to-chk=10/20)";
681        let sample = parse_progress_line(line).expect("parse");
682        assert_eq!(sample.bytes, 1_234_567);
683        assert_eq!(sample.percent, Some(12));
684        assert_eq!(sample.files_total, Some(20));
685        assert_eq!(sample.files_done, Some(10));
686    }
687
688    #[test]
689    fn parse_progress_line_openrsync_per_file_shape() {
690        // Captured from stock macOS openrsync `--progress` (issue #66): the
691        // pre-3.1 spelling uses `xfer#` / `to-check=` and an `HH:MM:SS` ETA.
692        let line = "11 100% 381.83KB/s 00:00:00 (xfer#1, to-check=1/4)";
693        let sample = parse_progress_line(line).expect("parse");
694        assert_eq!(sample.bytes, 11);
695        assert_eq!(sample.percent, Some(100));
696        assert_eq!(sample.files_total, Some(4));
697        assert_eq!(sample.files_done, Some(3));
698    }
699
700    #[test]
701    fn truncate_middle_shortens() {
702        let value = "path/to/very/long/file.rs";
703        let truncated = truncate_middle(value, 12);
704        assert!(truncated.len() <= 12);
705        assert!(truncated.contains("..."));
706    }
707
708    #[test]
709    fn truncate_middle_no_change_when_short() {
710        let value = "short.rs";
711        let truncated = truncate_middle(value, 20);
712        assert_eq!(truncated, value);
713    }
714
715    #[test]
716    fn truncate_middle_exact_length() {
717        let value = "exactly_12c";
718        let truncated = truncate_middle(value, 11);
719        assert_eq!(truncated.len(), 11);
720    }
721
722    #[test]
723    fn transfer_direction_label() {
724        assert_eq!(TransferDirection::Upload.label(), "Syncing");
725        assert_eq!(TransferDirection::Download.label(), "Fetching");
726    }
727
728    #[test]
729    fn transfer_direction_arrow_plain() {
730        // Plain context doesn't support rich output, so gets ASCII arrows
731        let ctx = OutputContext::plain();
732        assert_eq!(TransferDirection::Upload.arrow(ctx), "^");
733        assert_eq!(TransferDirection::Download.arrow(ctx), "v");
734    }
735
736    #[test]
737    fn speed_smoother_empty() {
738        let smoother = SpeedSmoother::default();
739        assert!(smoother.average().is_none());
740    }
741
742    #[test]
743    fn speed_smoother_single_value() {
744        let mut smoother = SpeedSmoother::default();
745        smoother.push(100.0);
746        assert_eq!(smoother.average(), Some(100.0));
747    }
748
749    #[test]
750    fn speed_smoother_ignores_zero() {
751        let mut smoother = SpeedSmoother::default();
752        smoother.push(0.0);
753        assert!(smoother.average().is_none());
754    }
755
756    #[test]
757    fn speed_smoother_ignores_negative() {
758        let mut smoother = SpeedSmoother::default();
759        smoother.push(-50.0);
760        assert!(smoother.average().is_none());
761    }
762
763    #[test]
764    fn speed_smoother_computes_average() {
765        let mut smoother = SpeedSmoother::default();
766        smoother.push(100.0);
767        smoother.push(200.0);
768        smoother.push(300.0);
769        assert_eq!(smoother.average(), Some(200.0));
770    }
771
772    #[test]
773    fn speed_smoother_sparkline_empty() {
774        let smoother = SpeedSmoother::default();
775        let ctx = OutputContext::plain();
776        assert!(smoother.sparkline(ctx).is_empty());
777    }
778
779    #[test]
780    fn speed_smoother_sparkline_plain() {
781        let mut smoother = SpeedSmoother::default();
782        smoother.push(10.0);
783        smoother.push(50.0);
784        smoother.push(100.0);
785        let ctx = OutputContext::plain();
786        // Plain context still generates sparkline with ASCII fallback
787        let sparkline = smoother.sparkline(ctx);
788        assert!(!sparkline.is_empty());
789    }
790
791    #[test]
792    fn parse_progress_line_minimal() {
793        let line = "1024  50%  1.0MB/s  0:00:10";
794        let sample = parse_progress_line(line).expect("parse");
795        assert_eq!(sample.bytes, 1024);
796        assert_eq!(sample.percent, Some(50));
797    }
798
799    #[test]
800    fn parse_progress_line_kilobytes() {
801        let line = "1.5K  10%  500.0KB/s  0:00:05";
802        let sample = parse_progress_line(line).expect("parse");
803        assert_eq!(sample.bytes, 1536); // 1.5 * 1024
804        assert_eq!(sample.percent, Some(10));
805    }
806
807    #[test]
808    fn parse_progress_line_megabytes() {
809        let line = "2.5M  25%  10.0MB/s  0:00:30";
810        let sample = parse_progress_line(line).expect("parse");
811        assert_eq!(sample.bytes, 2_621_440); // 2.5 * 1024 * 1024
812    }
813
814    #[test]
815    fn parse_progress_line_gigabytes() {
816        let line = "1.0G  75%  100.0MB/s  0:00:10";
817        let sample = parse_progress_line(line).expect("parse");
818        assert_eq!(sample.bytes, 1_073_741_824); // 1.0 * 1024^3
819    }
820
821    #[test]
822    fn parse_progress_line_invalid() {
823        let line = "not valid progress";
824        assert!(parse_progress_line(line).is_none());
825    }
826
827    #[test]
828    fn parse_progress_line_empty() {
829        assert!(parse_progress_line("").is_none());
830    }
831}