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        if let Some(rest) = part.strip_prefix("to-chk=") {
451            let mut iter = rest.split('/');
452            let remaining = iter.next()?.trim().parse::<u32>().ok()?;
453            let total = iter.next()?.trim().parse::<u32>().ok()?;
454            files_total = Some(total);
455            files_done = Some(total.saturating_sub(remaining));
456        }
457    }
458
459    if files_done.is_none() && files_total.is_none() {
460        None
461    } else {
462        Some((files_done, files_total))
463    }
464}
465
466fn parse_speed(token: &str) -> Option<f64> {
467    let trimmed = token.trim();
468    let value = trimmed.trim_end_matches("/s");
469    parse_size_f64(value)
470}
471
472fn parse_eta(token: &str) -> Option<Duration> {
473    let parts: Vec<&str> = token.trim().split(':').collect();
474    if parts.len() < 2 {
475        return None;
476    }
477
478    let mut nums = Vec::new();
479    for part in parts {
480        nums.push(part.parse::<u64>().ok()?);
481    }
482
483    let (hours, minutes, seconds) = match nums.len() {
484        2 => (0, nums[0], nums[1]),
485        3 => (nums[0], nums[1], nums[2]),
486        _ => return None,
487    };
488
489    Some(Duration::from_secs(hours * 3600 + minutes * 60 + seconds))
490}
491
492fn parse_size(input: &str) -> Option<u64> {
493    parse_size_f64(input).map(|value| value.round() as u64)
494}
495
496fn parse_size_f64(input: &str) -> Option<f64> {
497    let mut num = String::new();
498    let mut unit = String::new();
499
500    for ch in input.trim().chars() {
501        if ch.is_ascii_digit() || ch == '.' {
502            num.push(ch);
503        } else if ch == ',' {
504            continue;
505        } else if !ch.is_whitespace() {
506            unit.push(ch);
507        }
508    }
509
510    if num.is_empty() {
511        return None;
512    }
513
514    let value = num.parse::<f64>().ok()?;
515    let unit = unit.to_ascii_lowercase();
516
517    let multiplier = match unit.as_str() {
518        "" | "b" => 1.0,
519        "k" | "kb" => 1024.0,
520        "m" | "mb" => 1024.0 * 1024.0,
521        "g" | "gb" => 1024.0 * 1024.0 * 1024.0,
522        "t" | "tb" => 1024.0_f64.powi(4),
523        "p" | "pb" => 1024.0_f64.powi(5),
524        "e" | "eb" => 1024.0_f64.powi(6),
525        _ => 1.0,
526    };
527
528    Some(value * multiplier)
529}
530
531#[cfg(all(feature = "rich-ui", unix))]
532fn render_bar(
533    ctx: OutputContext,
534    current: u64,
535    total: Option<u64>,
536    percent: Option<f64>,
537    width: usize,
538) -> String {
539    let mut bar = if let Some(total) = total {
540        ProgressBar::with_total(total)
541    } else {
542        ProgressBar::new()
543    };
544
545    let bar_style = if ctx.supports_unicode() {
546        BarStyle::Block
547    } else {
548        BarStyle::Ascii
549    };
550
551    let completed_style = Style::new()
552        .color_str(RchTheme::SECONDARY)
553        .unwrap_or_default();
554    let remaining_style = Style::new().color_str("bright_black").unwrap_or_default();
555
556    bar = bar
557        .width(width)
558        .bar_style(bar_style)
559        .completed_style(completed_style)
560        .remaining_style(remaining_style)
561        .show_percentage(false)
562        .show_eta(false)
563        .show_speed(false)
564        .show_elapsed(false);
565
566    if total.is_some() {
567        bar.update(current);
568    } else if let Some(percent) = percent {
569        bar.set_progress(percent);
570    }
571
572    bar.render_plain(width + 2).trim_end().to_string()
573}
574
575#[cfg(not(all(feature = "rich-ui", unix)))]
576fn render_bar(
577    ctx: OutputContext,
578    _current: u64,
579    _total: Option<u64>,
580    percent: Option<f64>,
581    width: usize,
582) -> String {
583    let progress = percent.unwrap_or(0.0).clamp(0.0, 1.0);
584    let filled = (progress * width as f64).round() as usize;
585    let empty = width.saturating_sub(filled);
586    let filled_char = Icons::progress_filled(ctx);
587    let empty_char = Icons::progress_empty(ctx);
588
589    let mut bar = String::from("[");
590    bar.push_str(&filled_char.repeat(filled));
591    bar.push_str(&empty_char.repeat(empty));
592    bar.push(']');
593    bar
594}
595
596fn format_bytes(bytes: u64) -> String {
597    let units = ["B", "KB", "MB", "GB", "TB", "PB"];
598    let mut value = bytes as f64;
599    let mut unit = 0;
600    while value >= 1024.0 && unit < units.len() - 1 {
601        value /= 1024.0;
602        unit += 1;
603    }
604    if unit == 0 {
605        format!("{bytes} B")
606    } else {
607        format!("{value:.1} {}", units[unit])
608    }
609}
610
611fn format_speed(bytes_per_sec: f64) -> String {
612    if bytes_per_sec <= 0.0 {
613        return "--/s".to_string();
614    }
615    let formatted = format_bytes(bytes_per_sec.round() as u64);
616    format!("{formatted}/s")
617}
618
619fn format_duration(duration: Duration) -> String {
620    let total_secs = duration.as_secs();
621    if total_secs < 60 {
622        format!("{:.1}s", duration.as_secs_f64())
623    } else if total_secs < 3600 {
624        let mins = total_secs / 60;
625        let secs = total_secs % 60;
626        format!("{mins}:{secs:02}")
627    } else {
628        let hours = total_secs / 3600;
629        let mins = (total_secs % 3600) / 60;
630        let secs = total_secs % 60;
631        format!("{hours}:{mins:02}:{secs:02}")
632    }
633}
634
635fn truncate_middle(value: &str, max_len: usize) -> String {
636    let len = value.chars().count();
637    if len <= max_len {
638        return value.to_string();
639    }
640    if max_len <= 3 {
641        return value.chars().take(max_len).collect();
642    }
643
644    let head = (max_len - 3) / 2;
645    let tail = max_len - 3 - head;
646    let start: String = value.chars().take(head).collect();
647    let end: String = value
648        .chars()
649        .rev()
650        .take(tail)
651        .collect::<String>()
652        .chars()
653        .rev()
654        .collect();
655    format!("{start}...{end}")
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661
662    #[test]
663    fn parse_progress_line_with_details() {
664        let line = "9.53G  21%  317.26MB/s    0:00:28 (xfr#83063, to-chk=443926/538653)";
665        let sample = parse_progress_line(line).expect("parse");
666        assert_eq!(sample.percent, Some(21));
667        assert_eq!(sample.files_total, Some(538_653));
668        assert!(sample.bytes > 0);
669        assert!(sample.speed_bps.unwrap_or(0.0) > 0.0);
670        assert_eq!(sample.eta, Some(Duration::from_secs(28)));
671    }
672
673    #[test]
674    fn parse_progress_line_numeric_bytes() {
675        let line = "1234567  12%  1.23MB/s  0:01:23 (xfr#5, to-chk=10/20)";
676        let sample = parse_progress_line(line).expect("parse");
677        assert_eq!(sample.bytes, 1_234_567);
678        assert_eq!(sample.percent, Some(12));
679        assert_eq!(sample.files_total, Some(20));
680        assert_eq!(sample.files_done, Some(10));
681    }
682
683    #[test]
684    fn truncate_middle_shortens() {
685        let value = "path/to/very/long/file.rs";
686        let truncated = truncate_middle(value, 12);
687        assert!(truncated.len() <= 12);
688        assert!(truncated.contains("..."));
689    }
690
691    #[test]
692    fn truncate_middle_no_change_when_short() {
693        let value = "short.rs";
694        let truncated = truncate_middle(value, 20);
695        assert_eq!(truncated, value);
696    }
697
698    #[test]
699    fn truncate_middle_exact_length() {
700        let value = "exactly_12c";
701        let truncated = truncate_middle(value, 11);
702        assert_eq!(truncated.len(), 11);
703    }
704
705    #[test]
706    fn transfer_direction_label() {
707        assert_eq!(TransferDirection::Upload.label(), "Syncing");
708        assert_eq!(TransferDirection::Download.label(), "Fetching");
709    }
710
711    #[test]
712    fn transfer_direction_arrow_plain() {
713        // Plain context doesn't support rich output, so gets ASCII arrows
714        let ctx = OutputContext::plain();
715        assert_eq!(TransferDirection::Upload.arrow(ctx), "^");
716        assert_eq!(TransferDirection::Download.arrow(ctx), "v");
717    }
718
719    #[test]
720    fn speed_smoother_empty() {
721        let smoother = SpeedSmoother::default();
722        assert!(smoother.average().is_none());
723    }
724
725    #[test]
726    fn speed_smoother_single_value() {
727        let mut smoother = SpeedSmoother::default();
728        smoother.push(100.0);
729        assert_eq!(smoother.average(), Some(100.0));
730    }
731
732    #[test]
733    fn speed_smoother_ignores_zero() {
734        let mut smoother = SpeedSmoother::default();
735        smoother.push(0.0);
736        assert!(smoother.average().is_none());
737    }
738
739    #[test]
740    fn speed_smoother_ignores_negative() {
741        let mut smoother = SpeedSmoother::default();
742        smoother.push(-50.0);
743        assert!(smoother.average().is_none());
744    }
745
746    #[test]
747    fn speed_smoother_computes_average() {
748        let mut smoother = SpeedSmoother::default();
749        smoother.push(100.0);
750        smoother.push(200.0);
751        smoother.push(300.0);
752        assert_eq!(smoother.average(), Some(200.0));
753    }
754
755    #[test]
756    fn speed_smoother_sparkline_empty() {
757        let smoother = SpeedSmoother::default();
758        let ctx = OutputContext::plain();
759        assert!(smoother.sparkline(ctx).is_empty());
760    }
761
762    #[test]
763    fn speed_smoother_sparkline_plain() {
764        let mut smoother = SpeedSmoother::default();
765        smoother.push(10.0);
766        smoother.push(50.0);
767        smoother.push(100.0);
768        let ctx = OutputContext::plain();
769        // Plain context still generates sparkline with ASCII fallback
770        let sparkline = smoother.sparkline(ctx);
771        assert!(!sparkline.is_empty());
772    }
773
774    #[test]
775    fn parse_progress_line_minimal() {
776        let line = "1024  50%  1.0MB/s  0:00:10";
777        let sample = parse_progress_line(line).expect("parse");
778        assert_eq!(sample.bytes, 1024);
779        assert_eq!(sample.percent, Some(50));
780    }
781
782    #[test]
783    fn parse_progress_line_kilobytes() {
784        let line = "1.5K  10%  500.0KB/s  0:00:05";
785        let sample = parse_progress_line(line).expect("parse");
786        assert_eq!(sample.bytes, 1536); // 1.5 * 1024
787        assert_eq!(sample.percent, Some(10));
788    }
789
790    #[test]
791    fn parse_progress_line_megabytes() {
792        let line = "2.5M  25%  10.0MB/s  0:00:30";
793        let sample = parse_progress_line(line).expect("parse");
794        assert_eq!(sample.bytes, 2_621_440); // 2.5 * 1024 * 1024
795    }
796
797    #[test]
798    fn parse_progress_line_gigabytes() {
799        let line = "1.0G  75%  100.0MB/s  0:00:10";
800        let sample = parse_progress_line(line).expect("parse");
801        assert_eq!(sample.bytes, 1_073_741_824); // 1.0 * 1024^3
802    }
803
804    #[test]
805    fn parse_progress_line_invalid() {
806        let line = "not valid progress";
807        assert!(parse_progress_line(line).is_none());
808    }
809
810    #[test]
811    fn parse_progress_line_empty() {
812        assert!(parse_progress_line("").is_none());
813    }
814}