diff --git a/Cargo.lock b/Cargo.lock index 655d467..3a66d5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -194,6 +194,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.15.7" @@ -478,6 +488,7 @@ name = "ia-get" version = "0.1.2" dependencies = [ "clap", + "colored", "ctrlc", "futures", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index ce84de9..7141d41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ thiserror = "1.0" url = "2.4.1" clap = { version = "4.0", features = ["derive"] } ctrlc = "3.4" +colored = "2.1.0" [profile.release] strip = true # Automatically strip symbols from the binary. diff --git a/src/downloader.rs b/src/downloader.rs index d942f43..4800296 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -8,15 +8,17 @@ use std::sync::Arc; use reqwest::header::{HeaderMap, HeaderValue}; use reqwest::Client; +use colored::*; // Add this line use crate::Result; -use crate::utils::{create_progress_bar, format_duration, format_size, format_transfer_rate}; +use crate::error::IaGetError; // Import IaGetError for explicit error conversion +use crate::utils::{create_progress_bar, format_duration, format_size, format_transfer_rate}; // Import utility functions /// Buffer size for file operations (8KB) const BUFFER_SIZE: usize = 8192; -/// File size threshold for showing hash progress bar (16MB) -const LARGE_FILE_THRESHOLD: u64 = 2 * 1024 * 1024; +/// File size threshold for showing hash progress bar (2MB) +const LARGE_FILE_THRESHOLD: u64 = 2 * 1024 * 1024; /// Sets up signal handling for graceful shutdown on Ctrl+C /// @@ -28,7 +30,7 @@ fn setup_signal_handler() -> Arc { ctrlc::set_handler(move || { r.store(false, Ordering::SeqCst); - println!("\nReceived Ctrl+C, finishing current operation..."); + println!("\n{} Received Ctrl+C, finishing current operation...", "✘".red().bold()); }).expect("Error setting Ctrl+C handler"); running @@ -45,11 +47,8 @@ fn calculate_md5(file_path: &str, running: &Arc) -> Result { let mut buffer = [0; BUFFER_SIZE]; let pb = if is_large_file { - let progress_bar = create_progress_bar(file_size, "╰╼ Hashing ", Some("cyan/blue"), false); - Some(progress_bar) - } else { - None - }; + Some(create_progress_bar(file_size, &format!("{} {} ", "╰╼".cyan().dimmed(), "Verifying".white()), Some("blue/blue"), false)) + } else { None }; let mut bytes_processed: u64 = 0; @@ -77,9 +76,7 @@ fn calculate_md5(file_path: &str, running: &Arc) -> Result { } } - if let Some(progress_bar) = pb { - progress_bar.finish_and_clear(); - } + if let Some(progress_bar) = pb.as_ref() { progress_bar.finish_and_clear(); } let hash = context.compute(); Ok(format!("{:x}", hash)) @@ -101,7 +98,7 @@ fn check_existing_file(file_path: &str, expected_md5: Option<&str>, running: &Ar if e.to_string().contains("interrupted by signal") { return Err(e); } - println!("╰╼ Failed to calculate MD5 hash: {}", e); + println!("{} {} to calculate MD5 hash: {}", "╰╼".cyan().dimmed(), "Failed".red().bold(), e); return Ok(Some(false)); } }; @@ -142,35 +139,31 @@ async fn download_file_content( running: &Arc, is_resuming: bool ) -> Result { - let download_action = if is_resuming { "╰╼ Resuming " } else { "╰╼ Downloading " }; + let download_action = if is_resuming { + format!("{} {} ", "╰╼".cyan().dimmed(), "Resuming".white()) + } else { + format!("{} {} ", "╰╼".cyan().dimmed(), "Downloading".white()) + }; - let mut response = if file_size > 0 { - // Resume download with range request - let range_header = format!("bytes={}-", file_size); - let mut headers = HeaderMap::new(); - headers.insert( - reqwest::header::RANGE, - HeaderValue::from_str(&range_header).map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Invalid header value: {}", e) - ) - })? - ); - + let mut headers = HeaderMap::new(); + if file_size > 0 { + // Use IaGetError::Network for header parsing errors + headers.insert(reqwest::header::RANGE, HeaderValue::from_str(&format!("bytes={}-", file_size)).map_err(|e| IaGetError::Network(format!("Invalid range header value: {}", e)))?); + } + + let mut response = if file_size > 0 && is_resuming { // Ensure headers are only used for resume client.get(url).headers(headers).send().await? } else { - // Fresh download client.get(url).send().await? }; let content_length = response.content_length().unwrap_or(0); - let total_expected_size = content_length + file_size; + let total_expected_size = if is_resuming { content_length + file_size } else { content_length }; let pb = create_progress_bar( - total_expected_size, - download_action, - None, + total_expected_size, + &download_action, + Some("green/green"), // Color for download bar true ); @@ -201,90 +194,55 @@ async fn download_file_content( let elapsed = start_time.elapsed(); let elapsed_secs = elapsed.as_secs_f64(); - - let transfer_rate = if elapsed_secs > 0.0 { + let transfer_rate_val = if elapsed_secs > 0.0 { downloaded_bytes as f64 / elapsed_secs - } else { - 0.0 - }; - - let (rate, unit) = format_transfer_rate(transfer_rate); - + } else { 0.0 }; + + let (rate, unit) = format_transfer_rate(transfer_rate_val); + pb.finish_and_clear(); - - if downloaded_bytes > 0 { - println!("├╼ Downloaded ⤵️ {} in {} ({:.2} {}/s)", - format_size(downloaded_bytes), - format_duration(elapsed), - rate, - unit); - } + println!( + "{} {} {} {} in {} ({:.2} {}/s)", + "├╼".cyan().dimmed(), + "Downloaded".white(), + "↓".green().bold(), + format_size(downloaded_bytes).bold(), + format_duration(elapsed).bold(), + rate, + unit + ); Ok(total_bytes) } /// Verify a downloaded file's hash against an expected value -fn verify_downloaded_file(file_path: &str, expected_md5: Option<&str>, running: &Arc) -> Result { - let local_md5 = match calculate_md5(file_path, running) { - Ok(hash) => hash, - Err(e) => { - println!("╰╼ Failed to calculate MD5 hash: {}", e); - return Ok(false); - } - }; - - match expected_md5 { - Some(expected) => { - let matches = local_md5 == expected; - if !matches { - println!("╰╼ Hash ❌"); - } else { - println!("╰╼ Hash ✅"); - } - Ok(matches) - }, - None => { - println!("╰╼ No MD5: ⚠️"); - Ok(true) - }, +fn verify_downloaded_file(file_path: &str, expected_md5: Option<&str>, running: &Arc) -> Result { + if expected_md5.is_none() { + println!("{} {}", "-".dimmed(), "No MD5 hash provided for verification.".dimmed()); + return Ok(true); // No hash to check against, consider it verified } -} - -/// Download a file from archive.org with resume capability -/// -/// This function handles signal setup internally and manages graceful shutdown -/// during download operations. -pub async fn download_file( - client: &Client, - url: &str, - file_path: &str, - expected_md5: Option<&str>, -) -> Result<()> { - // Set up signal handling for this download session - let running = setup_signal_handler(); - - println!(" "); - println!("📦️ Filename {}", file_path); - - if let Some(is_valid) = check_existing_file(file_path, expected_md5, &running)? { - if is_valid { - println!("╰╼ Downloaded ✅"); - return Ok(()); - } else { - println!("├╼ Partial 🔄"); - } + let expected_md5_str = expected_md5.unwrap(); + let local_md5 = calculate_md5(file_path, running)?; + if local_md5 == expected_md5_str { + println!( + "{} {} {} {}", + "╰╼".cyan().dimmed(), + "Hash".white(), + "✔".green().bold(), + format!("({})", local_md5).dimmed() + ); + Ok(true) + } else { + println!( + "{} {} {} ({}) Expected ({})", + "╰╼".cyan().dimmed(), + "Hash".white(), + "✘".red().bold(), + local_md5.red(), + expected_md5_str.dimmed() + ); + Ok(false) } - - ensure_parent_directories(file_path)?; - - let mut file = prepare_file_for_download(file_path)?; - - let file_size = file.metadata()?.len(); - let is_resuming = file_size > 0; - download_file_content(client, url, file_size, &mut file, &running, is_resuming).await?; - verify_downloaded_file(file_path, expected_md5, &running)?; - - Ok(()) } /// Download multiple files with shared signal handling @@ -305,20 +263,32 @@ where for (index, (url, file_path, expected_md5)) in files.into_iter().enumerate() { // Check if we should stop due to signal if !running.load(Ordering::SeqCst) { - println!("\nDownload interrupted. Run the command again to resume remaining files."); + println!("\n{} Download interrupted. Run the command again to resume remaining files.", "✘".red().bold()); break; } println!(" "); - println!("📦️ Filename {}", file_path); - println!("├╼ Count {} of {}", index + 1, total_files); + println!( + "{} {} {}", + "▣".bright_cyan().bold(), + "Filename".white(), + file_path.bold() + ); + println!( + "{} {} {} {} of {}", + "├╼".cyan().dimmed(), + "Count".white(), + "#".blue().bold(), + (index + 1).to_string().bold(), + total_files.to_string().bold() + ); if let Some(is_valid) = check_existing_file(&file_path, expected_md5.as_deref(), &running)? { if is_valid { - println!("╰╼ Downloaded ✅"); + println!("{} {} {}", "╰╼".cyan().dimmed(), "Downloaded".white(), "✔".green().bold()); continue; } else { - println!("├╼ Partial 🔄"); + println!("{} {} {}", "├╼".cyan().dimmed(), "Partial".white(), "▲".yellow().bold()); } } diff --git a/src/main.rs b/src/main.rs index bb4b236..afaf863 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ use ia_get::archive_metadata::{XmlFiles, parse_xml_files}; use indicatif::ProgressStyle; use reqwest::Client; use clap::Parser; +use colored::*; // Add this line /// Checks if a URL is accessible by sending a HEAD request async fn is_url_accessible(url: &str, client: &Client) -> Result<()> { @@ -41,7 +42,7 @@ fn get_xml_url(original_url: &str) -> String { // The identifier is the last segment of the trimmed URL // This expect is considered safe because get_xml_url is only called after // validate_archive_url has confirmed the URL structure. - let identifier = trimmed_url.split('/').last() + let identifier = trimmed_url.rsplit('/').next() // Changed from split().last() to address clippy warning .expect("Validated URL should have a valid identifier segment after validation"); // The base URL for download is "https://archive.org/download/{identifier}" @@ -64,22 +65,30 @@ fn get_xml_url(original_url: &str) -> String { /// # Returns /// Tuple of (XmlFiles, base_url) for download processing async fn fetch_xml_metadata( - details_url: &str, - client: &Client, - spinner: &indicatif::ProgressBar + details_url: &str, + client: &Client, + spinner: &indicatif::ProgressBar, ) -> Result<(XmlFiles, reqwest::Url)> { // Generate XML URL let xml_url = get_xml_url(details_url); - spinner.set_message(format!("Accessing XML metadata: {}", xml_url)); + spinner.set_message(format!( + "{} Accessing XML metadata: {}", + "⚙".blue(), + xml_url.bold() + )); // Check XML URL accessibility if let Err(e) = is_url_accessible(&xml_url, client).await { - spinner.finish_with_message(format!("🔴 XML metadata not accessible: {}", xml_url)); + spinner.finish_with_message(format!( + "{} XML metadata not accessible: {}", + "✘".red().bold(), + xml_url.bold() + )); return Err(e); // Propagate the error } - spinner.set_message("Parsing archive metadata... 👀"); - + spinner.set_message(format!("{} {}", "⚙".blue(), "Parsing archive metadata...".bold())); + // Parse base URL and fetch XML content let base_url = reqwest::Url::parse(&xml_url)?; let response = client.get(&xml_url).send().await?; @@ -109,7 +118,7 @@ struct Cli { #[tokio::main] async fn main() -> std::result::Result<(), Box> { let cli = Cli::parse(); - + // Create a single client instance for all requests let client = Client::builder() .user_agent(USER_AGENT) @@ -117,28 +126,38 @@ async fn main() -> std::result::Result<(), Box> { .build()?; // Start a single spinner for the entire initialization process - let spinner = create_spinner(&format!("Processing archive.org URL: {}", cli.url)); - + let spinner = create_spinner(&format!("Processing archive.org URL: {}", cli.url.bold())); + // Validate URL format using consolidated function if let Err(e) = validate_archive_url(&cli.url) { - spinner.finish_with_message(format!("❌ {}", e)); + spinner.finish_with_message(format!("{} {}", "✘".red().bold(), e)); return Err(e.into()); } // Check URL accessibility if let Err(e) = is_url_accessible(&cli.url, &client).await { - spinner.finish_with_message(format!("🔴 Archive.org URL not accessible: {}", cli.url)); + spinner.finish_with_message(format!( + "{} Archive.org URL not accessible: {}", + "✘".red().bold(), + cli.url.bold() + )); return Err(e.into()); // Propagate error } // Fetch and parse XML metadata in one operation let (files, base_url) = fetch_xml_metadata(&cli.url, &client, &spinner).await?; - // Successfully finished initialization - replace with green tick + // Successfully finished initialization spinner.set_style( ProgressStyle::default_spinner() - .template(&format!("✅ Ready to download {} files from archive.org ✨", files.files.len())) - .expect("Failed to set completion style") + .template(&format!( + "{} {} to download {} files from archive.org {}", + "✔".green().bold(), + "Ready".bold(), + files.files.len().to_string().bold(), + "★".yellow() + )) + .expect("Failed to set completion style"), ); spinner.finish(); diff --git a/src/utils.rs b/src/utils.rs index 13aadfc..ddaeceb 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -5,6 +5,7 @@ use regex::Regex; use std::sync::LazyLock; use crate::constants::URL_PATTERN; use crate::{Result, IaGetError}; +use colored::*; // Add this line /// Spinner tick interval in milliseconds pub const SPINNER_TICK_INTERVAL: u64 = 100; @@ -57,11 +58,18 @@ pub fn validate_archive_url(url: &str) -> Result<()> { pub fn create_progress_bar(total: u64, action: &str, color: Option<&str>, with_eta: bool) -> ProgressBar { let pb = ProgressBar::new(total); let color_str = color.unwrap_or("green/green"); + + let styled_action = if action.contains("├╼") || action.contains("╰╼") { + action.replace("├╼", &"├╼".cyan().dimmed().to_string()) + .replace("╰╼", &"╰╼".cyan().dimmed().to_string()) + } else { + action.to_string() + }; let template = if with_eta { - format!("{action}{{elapsed_precise}} {{bar:40.{color_str}}} {{bytes}}/{{total_bytes}} (ETA: {{eta}})") + format!("{}{{elapsed_precise}} {{bar:40.{}}} {{bytes}}/{{total_bytes}} (ETA: {{eta}})", styled_action, color_str) } else { - format!("{action}{{elapsed_precise}} {{bar:40.{color_str}}} {{bytes}}/{{total_bytes}}") + format!("{}{{elapsed_precise}} {{bar:40.{}}} {{bytes}}/{{total_bytes}}", styled_action, color_str) }; pb.set_style( @@ -86,7 +94,7 @@ pub fn create_spinner(message: &str) -> ProgressBar { spinner.set_style( ProgressStyle::default_spinner() .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏") - .template(&format!("{{spinner}} {message}")) + .template(&format!("{} {}", "{spinner}".yellow().bold(), message)) .expect("Failed to set spinner style") ); spinner.enable_steady_tick(std::time::Duration::from_millis(SPINNER_TICK_INTERVAL));