diff --git a/.gitignore b/.gitignore index eb5a316..6936990 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ -target +/target +**/*.rs.bk +Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index 6ddf89c..2e03145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -279,7 +279,6 @@ dependencies = [ "filetime", "glob", "indicatif", - "thiserror", "walkdir", ] @@ -318,26 +317,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "unicode-ident" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index 53fbf19..226187d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,11 +7,10 @@ license = "MIT" authors = ["Alexander Pieck "] [dependencies] -indicatif = "0.17.11" # Removed non-existent feature -anyhow = "1.0" -walkdir = "2.4" -glob = "0.3" -clap = { version = "4.4", features = ["derive"] } -colored = "2.1" -filetime = "0.2" # For preserving timestamps -thiserror = "1.0" # For better error handling +anyhow = "1.0.75" +clap = { version = "4.4.6", features = ["derive"] } +colored = "2.0.4" +filetime = "0.2.22" +glob = "0.3.1" +indicatif = "0.17.7" +walkdir = "2.4.0" diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29 diff --git a/src/main.rs b/src/main.rs index d8e0aaf..f52537f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,11 @@ use std::fs; -use std::io::{Read, Write}; +use std::io::{Read, Write, stdout, Stdout}; use std::path::{Path, PathBuf}; use std::collections::VecDeque; +use std::time::{Instant, Duration}; use colored::Colorize; use indicatif::{ProgressBar, ProgressStyle}; -use anyhow::Result; +use anyhow::{Result, Context}; use walkdir::WalkDir; use glob::glob; use clap::Parser; @@ -34,56 +35,183 @@ struct Args { preserve: bool, } +struct DisplayManager { + history: VecDeque, + max_history: usize, + current_file: String, + stdout: Stdout, +} + +impl DisplayManager { + fn new(max_history: usize) -> Self { + // Initialize terminal + print!("\x1B[?25l"); // Hide cursor + println!(); // Initial newline for the display area + + Self { + history: VecDeque::with_capacity(max_history), + max_history, + current_file: String::new(), + stdout: stdout(), + } + } + + fn set_current_file(&mut self, file: &str) { + self.current_file = format!("{}: {}", "Copying".bold().yellow(), file); + self.update_display(); + } + + fn add_completed_file(&mut self, file: &str) { + if self.history.len() >= self.max_history { + self.history.pop_front(); + } + self.history.push_back(format!("{}: {}", "Copied".green(), file)); + self.update_display(); + } + + fn add_error(&mut self, file: &str, error: &str) { + if self.history.len() >= self.max_history { + self.history.pop_front(); + } + self.history.push_back(format!("{}: {} - {}", "Error".red(), file, error)); + self.update_display(); + } + + fn update_display(&mut self) { + // Move cursor to beginning of display area + print!("\x1B[7A\x1B[0J"); // Move up 7 lines and clear to end of screen + + // Print current file line (progress bar will be rendered separately) + println!("{}", self.current_file); + + // Print history lines + for _ in 0..self.max_history { + println!(); + } + + // Print summary line (progress bar will be rendered separately) + println!(); + + // Move cursor back to start of display area + print!("\x1B[7A"); + + // Move past the current file line to where history should begin + print!("\x1B[1B"); + + // Update history items + for (i, item) in self.history.iter().enumerate() { + print!("\x1B[{};0H{}\x1B[K", i + 2, item); + } + + // Fill any remaining empty history lines + for i in self.history.len()..self.max_history { + print!("\x1B[{};0H\x1B[K", i + 2); + } + + // Ensure stdout is flushed + let _ = self.stdout.flush(); + } + + fn cleanup(&mut self) { + print!("\x1B[?25h"); // Show cursor + let _ = self.stdout.flush(); + } +} + +fn format_speed(bytes_per_sec: f64) -> String { + if bytes_per_sec < 1024.0 { + format!("{:.1}B/s", bytes_per_sec) + } else if bytes_per_sec < 1024.0 * 1024.0 { + format!("{:.1}KB/s", bytes_per_sec / 1024.0) + } else if bytes_per_sec < 1024.0 * 1024.0 * 1024.0 { + format!("{:.1}MB/s", bytes_per_sec / (1024.0 * 1024.0)) + } else { + format!("{:.1}GB/s", bytes_per_sec / (1024.0 * 1024.0 * 1024.0)) + } +} + +fn format_size(bytes: u64) -> String { + if bytes < 1024 { + format!("{}B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1}KB", bytes as f64 / 1024.0) + } else if bytes < 1024 * 1024 * 1024 { + format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) + } else { + format!("{:.1}GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0)) + } +} + fn create_file_progress_bar() -> ProgressBar { - ProgressBar::new(0).with_style( - ProgressStyle::with_template("{msg}\n[{bar:40.cyan/blue}] {bytes}/{total_bytes} ({eta})") + let pb = ProgressBar::new(0); + pb.set_style( + ProgressStyle::with_template("{msg}\n[{bar:40.cyan/blue}] {percent}% ({bytes}/{total_bytes} @ {binary_bytes_per_sec} - ETA: {eta})") .unwrap() - .progress_chars("##-"), - ) + .progress_chars("█░-"), + ); + pb } fn create_summary_progress_bar() -> ProgressBar { - ProgressBar::new(0).with_style( - ProgressStyle::with_template("\n[{bar:40.green/yellow}] {pos}/{len} files") + let pb = ProgressBar::new(0); + pb.set_style( + ProgressStyle::with_template("[{bar:40.green/yellow}] {pos}/{len} files") .unwrap() - .progress_chars("##-"), - ) + .progress_chars("█░-"), + ); + pb } fn collect_files(sources: &[String], recursive: bool) -> Result> { let mut all_files = Vec::new(); + for src_pattern in sources { if src_pattern.contains('*') { for entry in glob(src_pattern)? { let path = entry?; - if path.is_dir() && !recursive { - eprintln!("{}: Skipping directory (use -r to copy)", path.display().to_string().yellow()); - continue; + if path.is_dir() { + if recursive { + // Add directory itself for creation + all_files.push(path.clone()); + } else { + eprintln!("{}: Skipping directory (use -r to copy recursively)", + path.display().to_string().yellow()); + continue; + } + } else { + all_files.push(path); } - all_files.push(path); } } else { let path = PathBuf::from(src_pattern); if !path.exists() { - return Err(anyhow::anyhow!("Source file not found: {}", path.display())); + return Err(anyhow::anyhow!("Source path not found: {}", path.display())); } - if path.is_dir() && !recursive { - eprintln!("{}: Skipping directory (use -r to copy)", path.display().to_string().yellow()); - continue; + + if path.is_dir() { + if recursive { + // Add directory itself for creation + all_files.push(path.clone()); + } else { + eprintln!("{}: Skipping directory (use -r to copy recursively)", + path.display().to_string().yellow()); + continue; + } + } else { + all_files.push(path); } - all_files.push(path); } } + // If using recursive flag, collect all files under directories if recursive { let mut dir_files = Vec::new(); for path in &all_files { if path.is_dir() { - for entry in WalkDir::new(path) { + for entry in WalkDir::new(path).min_depth(1) { match entry { - Ok(e) if e.file_type().is_file() => dir_files.push(e.into_path()), + Ok(e) => dir_files.push(e.into_path()), Err(e) => eprintln!("{}: {}", "Warning".yellow(), e), - _ => continue, } } } @@ -98,94 +226,170 @@ fn collect_files(sources: &[String], recursive: bool) -> Result> { Ok(all_files) } -fn copy_file_with_progress(src: &Path, dst: &Path, pb: &ProgressBar, preserve: bool) -> Result<()> { - let mut src_file = fs::File::open(src)?; - let file_size = src_file.metadata()?.len(); - pb.set_length(file_size); - - let mut dst_file = fs::File::create(dst)?; - let mut buffer = vec![0u8; 64 * 1024]; - let mut total_copied = 0; - - loop { - let bytes_read = src_file.read(&mut buffer)?; - if bytes_read == 0 { - break; +fn ensure_directory_exists(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + if !parent.exists() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; } - dst_file.write_all(&buffer[..bytes_read])?; + } + Ok(()) +} + +fn copy_file_with_progress( + src: &Path, + dst: &Path, + pb: &ProgressBar, + display: &mut DisplayManager, + preserve: bool, +) -> Result<()> { + ensure_directory_exists(dst)?; + + let mut src_file = fs::File::open(src) + .with_context(|| format!("Failed to open source file: {}", src.display()))?; + + let file_size = src_file.metadata() + .with_context(|| format!("Failed to read metadata for: {}", src.display()))?.len(); + + pb.set_length(file_size); + display.set_current_file(&src.display().to_string()); + + let dst_file = fs::File::create(dst) + .with_context(|| format!("Failed to create destination file: {}", dst.display()))?; + + let mut dst_file = dst_file; + let mut buffer = vec![0u8; 64 * 1024]; // 64KB buffer + let mut total_copied = 0; + let start_time = Instant::now(); + + loop { + let bytes_read = match src_file.read(&mut buffer) { + Ok(0) => break, // End of file + Ok(n) => n, + Err(e) => return Err(anyhow::anyhow!("Failed to read from {}: {}", src.display(), e)), + }; + + match dst_file.write_all(&buffer[..bytes_read]) { + Ok(_) => {}, + Err(e) => return Err(anyhow::anyhow!("Failed to write to {}: {}", dst.display(), e)), + } + total_copied += bytes_read as u64; pb.set_position(total_copied); } if preserve { - let metadata = src_file.metadata()?; - filetime::set_file_times( - dst, - FileTime::from_last_access_time(&metadata), - FileTime::from_last_modification_time(&metadata), - )?; + if let Ok(metadata) = fs::metadata(src) { + let _ = filetime::set_file_times( + dst, + FileTime::from_last_access_time(&metadata), + FileTime::from_last_modification_time(&metadata), + ); + + // Try to preserve permissions on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let permissions = metadata.permissions(); + let _ = fs::set_permissions(dst, permissions); + } + } } + display.add_completed_file(&src.display().to_string()); Ok(()) } +fn compute_dest_path(src: &Path, src_base: Option<&Path>, dest: &Path) -> PathBuf { + if let Some(base) = src_base { + if let Ok(relative) = src.strip_prefix(base) { + return dest.join(relative); + } + } + + if dest.is_dir() { + dest.join(src.file_name().unwrap_or_else(|| src.as_os_str())) + } else { + dest.to_path_buf() + } +} + fn main() -> Result<()> { let args = Args::parse(); + // Reserve space for the display (7 lines) + for _ in 0..7 { + println!(); + } + + let mut display = DisplayManager::new(args.max_history); let file_pb = create_file_progress_bar(); let summary_pb = create_summary_progress_bar(); - let all_files = collect_files(&args.sources, args.recursive)?; + let all_paths = collect_files(&args.sources, args.recursive)?; + + // Filter to get just files for progress tracking + let all_files: Vec<_> = all_paths.iter() + .filter(|p| p.is_file()) + .collect(); + let total_files = all_files.len(); summary_pb.set_length(total_files as u64); - - let mut history = VecDeque::with_capacity(args.max_history); + let mut success_count = 0; - - for src in &all_files { - let dst = if PathBuf::from(&args.dest).is_dir() { - PathBuf::from(&args.dest).join( - src.file_name() - .unwrap_or_else(|| src.as_os_str()) - ) + let dest_path = PathBuf::from(&args.dest); + + // If we're copying multiple files, destination must be a directory + if all_files.len() > 1 && !dest_path.is_dir() && !all_paths.iter().all(|p| p.is_dir()) { + if !dest_path.exists() { + // Create directory if it doesn't exist + fs::create_dir_all(&dest_path) + .with_context(|| format!("Destination must be a directory when copying multiple files: {}", dest_path.display()))?; } else { - PathBuf::from(&args.dest) - }; + return Err(anyhow::anyhow!("Destination must be a directory when copying multiple files: {}", dest_path.display())); + } + } - file_pb.reset(); - file_pb.set_message(format!("{}: {}", "Copying".bold().yellow(), src.display())); - - match copy_file_with_progress(src, &dst, &file_pb, args.preserve) { - Ok(_) => { - success_count += 1; - if history.len() >= args.max_history { - history.pop_front(); - } - history.push_back(format!("{}: {}", "Copied".green(), src.display())); - - println!("\x1B[2J\x1B[H"); // Clear screen - println!("{}", file_pb.message()); - for item in &history { - println!("{}", item); - } - println!("{}", summary_pb.message()); - } - Err(e) => { - eprintln!("{}: {}", "Error".red(), e); + // Process directories first to ensure they exist + for src in all_paths.iter().filter(|p| p.is_dir()) { + let dst = compute_dest_path(src, None, &dest_path); + if !dst.exists() { + if let Err(e) = fs::create_dir_all(&dst) { + display.add_error(&src.display().to_string(), &format!("Failed to create directory: {}", e)); } } + } + + // Now copy all files + for src in &all_files { + let dst = compute_dest_path(src, None, &dest_path); + + file_pb.reset(); + + match copy_file_with_progress(src, &dst, &file_pb, &mut display, args.preserve) { + Ok(_) => { + success_count += 1; + } + Err(e) => { + display.add_error(&src.display().to_string(), &e.to_string()); + } + } + summary_pb.inc(1); } file_pb.finish_and_clear(); summary_pb.finish_with_message( - format!("{} ({} successful, {} errors)", + format!("{} ({} successful, {} failed)", "Copy complete".bold().green(), success_count.to_string().green(), (total_files - success_count).to_string().red() ) ); - + + // Clean up terminal state + display.cleanup(); + if success_count != total_files { std::process::exit(1); }