/ app / src / progress.rs
progress.rs
  1  use anyhow::{Context, Result};
  2  use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
  3  use std::collections::HashSet; // Import HashSet
  4  use std::os::unix::fs::MetadataExt; // Import for ino() and dev()
  5  use std::path::{Path, PathBuf};
  6  use std::sync::Arc;
  7  use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
  8  use std::time::{Duration, Instant};
  9  
 10  /// Tracks progress during file transfers
 11  pub struct TransferProgress {
 12      total_files: Arc<AtomicUsize>,
 13      current_file: Arc<AtomicUsize>,
 14      total_bytes: Arc<AtomicU64>,
 15      bytes_transferred: Arc<AtomicU64>,
 16      uncompressed_bytes_processed: Arc<AtomicU64>,
 17      start_time: Instant,
 18      current_filename: Arc<std::sync::Mutex<String>>,
 19      compression_ratio: Arc<std::sync::Mutex<Option<f64>>>,
 20  }
 21  
 22  /// Manages the visual progress display
 23  pub struct ProgressDisplay {
 24      multi_progress: MultiProgress,
 25      pub main_bar: ProgressBar,
 26      pub status_bar: ProgressBar,
 27  }
 28  
 29  impl TransferProgress {
 30      /// Create a new progress tracker
 31      pub fn new() -> Self {
 32          Self {
 33              total_files: Arc::new(AtomicUsize::new(0)),
 34              current_file: Arc::new(AtomicUsize::new(0)),
 35              total_bytes: Arc::new(AtomicU64::new(0)),
 36              bytes_transferred: Arc::new(AtomicU64::new(0)),
 37              uncompressed_bytes_processed: Arc::new(AtomicU64::new(0)),
 38              start_time: Instant::now(),
 39              current_filename: Arc::new(std::sync::Mutex::new(String::new())),
 40              compression_ratio: Arc::new(std::sync::Mutex::new(None)),
 41          }
 42      }
 43  
 44      /// Calculate total size and file count for multiple source paths,
 45      /// correctly handling hard links to avoid double-counting.
 46      pub fn calculate_multiple_sources_info(&self, sources: &[PathBuf]) -> Result<(usize, u64)> {
 47          let mut total_files = 0;
 48          let mut total_bytes = 0;
 49          // A set to store (device_id, inode_id) tuples we've already seen
 50          let mut seen_inodes = HashSet::new();
 51  
 52          for source in sources {
 53              if source.is_file() {
 54                  let metadata = source.metadata().context("Failed to get file metadata")?;
 55                  let inode_key = (metadata.dev(), metadata.ino());
 56  
 57                  // Only count if we haven't seen this inode before
 58                  if seen_inodes.insert(inode_key) {
 59                      total_files += 1;
 60                      total_bytes += metadata.len();
 61                  }
 62              } else if source.is_dir() {
 63                  // Pass the tracking set into the recursive walker
 64                  self.walk_directory(source, &mut total_files, &mut total_bytes, &mut seen_inodes)?;
 65              }
 66          }
 67  
 68          self.total_files.store(total_files, Ordering::Relaxed);
 69          self.total_bytes.store(total_bytes, Ordering::Relaxed);
 70  
 71          Ok((total_files, total_bytes))
 72      }
 73  
 74      /// Recursively walks a directory to calculate size and file count,
 75      /// using a HashSet to avoid counting data from hard links more than once.
 76      fn walk_directory(
 77          &self,
 78          dir: &Path,
 79          files: &mut usize,
 80          bytes: &mut u64,
 81          seen_inodes: &mut HashSet<(u64, u64)>,
 82      ) -> Result<()> {
 83          for entry in std::fs::read_dir(dir).context("Failed to read directory")? {
 84              let entry = entry.context("Failed to read directory entry")?;
 85              let path = entry.path();
 86  
 87              // We use `symlink_metadata` to avoid following symlinks, but get metadata.
 88              let metadata = entry.metadata().context("Failed to get file metadata")?;
 89  
 90              if metadata.is_file() {
 91                  let inode_key = (metadata.dev(), metadata.ino());
 92  
 93                  // Only count the file and its size if it's a new inode.
 94                  if seen_inodes.insert(inode_key) {
 95                      *files += 1;
 96                      *bytes += metadata.len();
 97                  }
 98              } else if metadata.is_dir() {
 99                  // Recurse into subdirectory, passing the set along.
100                  self.walk_directory(&path, files, bytes, seen_inodes)?;
101              }
102          }
103          Ok(())
104      }
105  
106      /// Update current file being processed
107      pub fn set_current_file(&self, filename: &str) {
108          let mut current = self.current_filename.lock().unwrap();
109          *current = filename.to_string();
110          self.current_file.fetch_add(1, Ordering::Relaxed);
111      }
112  
113      /// Update bytes transferred (compressed bytes to the network)
114      pub fn add_bytes_transferred(&self, bytes: u64) {
115          self.bytes_transferred.fetch_add(bytes, Ordering::Relaxed);
116      }
117  
118      /// Update uncompressed bytes processed (original bytes from disk)
119      pub fn add_uncompressed_bytes_processed(&self, bytes: u64) {
120          self.uncompressed_bytes_processed
121              .fetch_add(bytes, Ordering::Relaxed);
122      }
123  
124      /// Set compression ratio
125      pub fn set_compression_ratio(&self, ratio: f64) {
126          let mut ratio_lock = self.compression_ratio.lock().unwrap();
127          *ratio_lock = Some(ratio);
128      }
129  
130      /// Get current progress values
131      pub fn get_progress(&self) -> ProgressSnapshot {
132          let current_file_idx = self.current_file.load(Ordering::Relaxed);
133          let total_files = self.total_files.load(Ordering::Relaxed);
134          let bytes_transferred = self.bytes_transferred.load(Ordering::Relaxed);
135          let total_bytes = self.total_bytes.load(Ordering::Relaxed);
136          let uncompressed_bytes_processed =
137              self.uncompressed_bytes_processed.load(Ordering::Relaxed);
138          let elapsed = self.start_time.elapsed();
139  
140          let current_filename = self.current_filename.lock().unwrap().clone();
141          let compression_ratio = *self.compression_ratio.lock().unwrap();
142  
143          ProgressSnapshot {
144              current_file: current_file_idx,
145              total_files,
146              bytes_transferred,
147              total_bytes,
148              uncompressed_bytes_processed,
149              elapsed,
150              current_filename,
151              compression_ratio,
152          }
153      }
154  }
155  
156  impl ProgressDisplay {
157      /// Create a new progress display
158      pub fn new() -> Result<Self> {
159          let multi_progress = MultiProgress::new();
160  
161          // Enhanced main progress bar with better formatting
162          let main_style = ProgressStyle::with_template(
163              "{spinner:.green} [{elapsed_precise}] {bar:40.cyan/blue} {percent:>3}% {bytes:>9}/{total_bytes:>9} {msg}",
164          )?
165          .progress_chars("█▉▊▋▌▍▎▏  ");
166  
167          // Enhanced status bar with colors and better formatting
168          let status_style = ProgressStyle::with_template("{msg}")?;
169  
170          let main_bar = multi_progress.add(ProgressBar::new(0));
171          main_bar.set_style(main_style);
172  
173          let status_bar = multi_progress.add(ProgressBar::new(0));
174          status_bar.set_style(status_style);
175          status_bar.set_length(1); // Indeterminate
176  
177          Ok(Self {
178              multi_progress,
179              main_bar,
180              status_bar,
181          })
182      }
183  
184      /// Initialize the progress display with source info
185      pub fn initialize(&self, files: usize, bytes: u64, source_name: String) {
186          self.main_bar.set_length(bytes);
187  
188          let formatted_bytes = format_bytes(bytes as f64);
189          let header_msg = format!("Transfer: {} ({}, {})", source_name, files, formatted_bytes);
190          self.main_bar.set_message(header_msg);
191          self.status_bar.set_message("Preparing transfer...");
192      }
193  
194      /// Update the progress display
195      pub fn update(&self, progress: &ProgressSnapshot) {
196          // NOTE: The position is now set in main.rs to use the correct value
197          // self.main_bar.set_position(progress.bytes_transferred);
198  
199          // Calculate speed and ETA based on actual bytes transferred for network speed
200          let speed = if progress.elapsed.as_secs() > 0 {
201              progress.bytes_transferred as f64 / progress.elapsed.as_secs_f64()
202          } else {
203              0.0
204          };
205  
206          // Calculate ETA based on uncompressed bytes remaining
207          let remaining_bytes = progress
208              .total_bytes
209              .saturating_sub(progress.uncompressed_bytes_processed);
210          let eta_seconds = if speed > 0.0 && progress.bytes_transferred > 0 {
211              // Estimate remaining time based on current compression ratio
212              let estimated_total_compressed = (progress.bytes_transferred as f64
213                  / progress.uncompressed_bytes_processed as f64)
214                  * progress.total_bytes as f64;
215              let remaining_compressed_bytes =
216                  estimated_total_compressed - progress.bytes_transferred as f64;
217              remaining_compressed_bytes / speed
218          } else {
219              0.0
220          };
221  
222          let speed_str = format_bytes(speed);
223          let eta_str = format_duration(eta_seconds);
224  
225          let mut status_msg = format!("Speed: {}/s | ETA: {}", speed_str, eta_str);
226  
227          if let Some(ratio) = progress.compression_ratio {
228              status_msg.push_str(&format!(" | Compression: {:.1}%", ratio * 100.0));
229          }
230  
231          self.status_bar.set_message(status_msg);
232      }
233  
234      /// Complete the progress display
235      pub fn finish(&self, progress: &ProgressSnapshot) {
236          let total_time = format_duration(progress.elapsed.as_secs_f64());
237          // Use bytes_transferred for average speed calculation
238          let avg_speed =
239              format_bytes(progress.bytes_transferred as f64 / progress.elapsed.as_secs_f64());
240          let total_bytes_str = format_bytes(progress.bytes_transferred as f64);
241  
242          let completion_msg = format!("Transfer complete: {} in {}", total_bytes_str, total_time);
243  
244          let summary_msg = format!(
245              "Average speed: {}/s | Total time: {}",
246              avg_speed, total_time
247          );
248  
249          self.main_bar.finish_with_message(completion_msg);
250          self.status_bar.finish_with_message(summary_msg);
251      }
252  
253      /// Handle error cleanup
254      pub fn error(&self, error: &str) {
255          self.main_bar.finish_with_message("Transfer failed");
256          let message = format!("Error: {}", error);
257          self.status_bar.finish_with_message(message);
258      }
259  }
260  
261  /// Snapshot of current progress
262  #[derive(Debug, Clone)]
263  pub struct ProgressSnapshot {
264      pub current_file: usize,
265      pub total_files: usize,
266      pub bytes_transferred: u64,
267      pub total_bytes: u64,
268      pub uncompressed_bytes_processed: u64,
269      pub elapsed: Duration,
270      pub current_filename: String,
271      pub compression_ratio: Option<f64>,
272  }
273  
274  /// Format bytes into human-readable format with color codes
275  fn format_bytes(bytes: f64) -> String {
276      const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
277      let mut size = bytes;
278      let mut unit_index = 0;
279  
280      while size >= 1024.0 && unit_index < UNITS.len() - 1 {
281          size /= 1024.0;
282          unit_index += 1;
283      }
284  
285      if unit_index == 0 {
286          format!("{:.0} {}", size, UNITS[unit_index])
287      } else if size >= 100.0 {
288          format!("{:.0} {}", size, UNITS[unit_index])
289      } else {
290          format!("{:.1} {}", size, UNITS[unit_index])
291      }
292  }
293  
294  /// Format duration into human-readable format
295  fn format_duration(seconds: f64) -> String {
296      if seconds.is_nan() || seconds.is_infinite() || seconds < 0.0 {
297          return "n/a".to_string();
298      }
299      if seconds < 60.0 {
300          format!("{:.0}s", seconds)
301      } else if seconds < 3600.0 {
302          let minutes = (seconds / 60.0).floor();
303          let secs = (seconds % 60.0).round();
304          if secs == 0.0 {
305              format!("{:.0}m", minutes)
306          } else {
307              format!("{:.0}m {:.0}s", minutes, secs)
308          }
309      } else {
310          let hours = (seconds / 3600.0).floor();
311          let minutes = ((seconds % 3600.0) / 60.0).round();
312          if minutes == 0.0 {
313              format!("{:.0}h", hours)
314          } else {
315              format!("{:.0}h {:.0}m", hours, minutes)
316          }
317      }
318  }