1use 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#[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}", Self::Download => "\u{2193}", }
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}", "\u{2582}", "\u{2583}", "\u{2584}", "\u{2585}", "\u{2586}", "\u{2587}", "\u{2588}", ]
98 } else {
99 [".", ":", "-", "=", "+", "*", "#", "@"] };
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#[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#[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 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 pub fn upload(ctx: OutputContext, label: impl Into<String>, quiet: bool) -> Self {
185 Self::new(ctx, TransferDirection::Upload, label, quiet)
186 }
187
188 pub fn download(ctx: OutputContext, label: impl Into<String>, quiet: bool) -> Self {
190 Self::new(ctx, TransferDirection::Download, label, quiet)
191 }
192
193 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 pub fn set_current_file(&mut self, path: impl Into<String>) {
211 self.current_file = Some(path.into());
212 }
213
214 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 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 #[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 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 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 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 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); 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); }
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); }
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}