diff --git a/src/cli.rs b/src/cli.rs index b1b8181..cbeadcf 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -88,6 +88,10 @@ pub struct Cli { #[arg(long = "cache")] pub cache: bool, + /// Download a specific file from a repository which requires a token. + #[arg(short = 'g', long = "get-file")] + pub get_file: Option, + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] pub verbose: u8, } diff --git a/src/git.rs b/src/git.rs index c177198..87a8edf 100644 --- a/src/git.rs +++ b/src/git.rs @@ -139,6 +139,52 @@ pub fn construct_clone_url(owner: &str, repo: &str, token: Option<&str>) -> Stri } } +pub fn get_raw_file_url(plain_download_url: &str) -> Result { + // Expected format: https://github.com/{owner}/{repo}/blob/{ref}/{path} + // Convert to: https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path} + + let url = plain_download_url.trim(); + + // Basic validation + if !url.starts_with("https://github.com/") && !url.starts_with("http://github.com/") { + return Err(GhrError::InvalidUrl { + url: "URL must be a GitHub URL starting with https://github.com/".to_string(), + }); + } + + // Remove protocol + let without_protocol = url + .trim_start_matches("https://") + .trim_start_matches("http://"); + + // Split into parts: github.com/{owner}/{repo}/blob/{ref}/{path} + let parts: Vec<&str> = without_protocol.split('/').collect(); + + if parts.len() < 5 { + return Err(GhrError::InvalidUrl { + url: format!("Invalid GitHub URL format. Expected: https://github.com/{{owner}}/{{repo}}/blob/{{ref}}/{{path}}, got: {}", url) + }); + } + + // Verify it's a blob URL + if parts[3] != "blob" { + return Err(GhrError::InvalidUrl { + url: format!("URL must contain '/blob/' segment. Expected format: https://github.com/{{owner}}/{{repo}}/blob/{{ref}}/{{path}}") + }); + } + + let owner = parts[1]; + let repo = parts[2]; + let ref_name = parts[4]; + let path = parts[5..].join("/"); + + // Construct raw URL: https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path} + Ok(format!( + "https://raw.githubusercontent.com/{}/{}/{}/{}", + owner, repo, ref_name, path + )) +} + /// Execute git clone command pub async fn execute_git_clone( clone_url: &str, diff --git a/src/main.rs b/src/main.rs index 74ae7d3..4c76aa9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,6 +16,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use jlogger_tracing::{jdebug, jerror, jinfo, JloggerBuilder, LevelFilter, LogTimeFormat}; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT}; use reqwest::Client; +use std::io::{self, Write}; use std::path::PathBuf; use std::sync::Arc; use tokio::fs; @@ -27,9 +28,9 @@ async fn main() -> Result<()> { let cli = Cli::parse(); // Validate that either --repo, --search, or --clone is provided - if cli.repo.is_none() && cli.search.is_none() && cli.clone.is_none() { + if cli.repo.is_none() && cli.search.is_none() && cli.clone.is_none() && cli.get_file.is_none() { return Err(GhrError::MissingArgument( - "Either --repo, --search, or --clone must be provided. Use --help for more information." + "Either --repo, --search, --get-file or --clone must be provided. Use --help for more information." .to_string(), )); } @@ -201,6 +202,97 @@ async fn main() -> Result<()> { return Ok(()); } + // Download file. + if let Some(f) = cli.get_file.as_deref() { + let download_url = git::get_raw_file_url(f)?; + jinfo!("Downloading file from URL: {}", download_url); + + // Determine output path + let output_path = + if let Some(directory) = &cli.directory { + PathBuf::from(directory).join(PathBuf::from(f).file_name().ok_or_else(|| { + GhrError::Generic("Cannot extract filename from URL".to_string()) + })?) + } else { + PathBuf::from(PathBuf::from(f).file_name().ok_or_else(|| { + GhrError::Generic("Cannot extract filename from URL".to_string()) + })?) + }; + + // Check if file exists and prompt user for confirmation + if output_path.exists() { + print!( + "File '{}' already exists. Overwrite? [y/N]: ", + output_path.display() + ); + io::stdout().flush().unwrap(); + + let mut response = String::new(); + io::stdin() + .read_line(&mut response) + .map_err(|e| GhrError::Generic(format!("Failed to read user input: {}", e)))?; + + let response = response.trim().to_lowercase(); + if response != "y" && response != "yes" { + jinfo!("Download cancelled by user"); + return Ok(()); + } + } + + let client = Arc::new(client); + let multi_progress = Arc::new(MultiProgress::new()); + + let response = client + .get(&download_url) + .header(ACCEPT, constants::headers::ACCEPT_OCTET_STREAM) + .send() + .await + .map_err(GhrError::Network)?; + + let status = response.status(); + if !status.is_success() { + return Err(GhrError::GitHubApi(format!("HTTP {} for '{}'", status, f))); + } + + // Get content length for accurate progress bar + let total_size = response.content_length().unwrap_or(0); + + // Create progress bar with actual file size + let pb = multi_progress.add(ProgressBar::new(total_size)); + pb.set_style( + ProgressStyle::default_bar() + .template("{msg}\n{spinner:.green} [{elapsed_precise}] [{wide_bar:.cyan/blue}] {bytes}/{total_bytes} ({eta})") + .unwrap() + .progress_chars("#>-"), + ); + pb.set_message(format!( + "Downloading: {}", + output_path.file_name().unwrap().to_string_lossy() + )); + + let mut downloaded: u64 = 0; + let mut bytes_vec = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(GhrError::Network)?; + downloaded += chunk.len() as u64; + bytes_vec.extend_from_slice(&chunk); + pb.set_position(downloaded); + } + pb.finish_with_message(format!( + "Complete: {}", + output_path.file_name().unwrap().to_string_lossy() + )); + + fs::write(&output_path, &bytes_vec) + .await + .map_err(GhrError::Io)?; + + jinfo!("File saved to: {}", output_path.display()); + + return Ok(()); + } + if let Some(download) = cli.download.as_deref() { let repo = cli.repo.as_deref().ok_or_else(|| { GhrError::MissingArgument("--repo is required for download mode".to_string())