From 709723dd55b9334fbbd564d4b926b1b0aa3e878a Mon Sep 17 00:00:00 2001 From: Seimizu Joukan Date: Thu, 15 Jan 2026 11:54:19 +0900 Subject: [PATCH 1/2] Add -g option to download a raw file from the repository. --- src/cli.rs | 4 ++++ src/git.rs | 6 ++++++ src/main.rs | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 2 deletions(-) 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..b275a0b 100644 --- a/src/git.rs +++ b/src/git.rs @@ -139,6 +139,12 @@ pub fn construct_clone_url(owner: &str, repo: &str, token: Option<&str>) -> Stri } } +pub fn get_raw_file_url(plain_download_url: &str) -> String { + plain_download_url + .replace("blob", "refs/heads") + .replace("github.com", "raw.githubusercontent.com") +} + /// 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..74d6d60 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,9 +27,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 +201,61 @@ 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); + + let client = Arc::new(client); + let multi_progress = Arc::new(MultiProgress::new()); + + // Create progress bar for this asset + let pb = multi_progress.add(ProgressBar::new(100)); + 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: {}", f)); + + 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() { + pb.finish_with_message(format!("Failed: {} (HTTP {})", f, status)); + return Err(GhrError::GitHubApi(format!("HTTP {} for '{}'", status, f))); + } + + 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: {}", f)); + + let output_path = if let Some(directory) = &cli.directory { + PathBuf::from(directory).join(PathBuf::from(f).file_name().unwrap()) + } else { + PathBuf::from(PathBuf::from(f).file_name().unwrap()) + }; + + fs::write(&output_path, &bytes_vec) + .await + .map_err(GhrError::Io)?; + + 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()) From 811886895a92fd1d2062ab6a88bcc54a536004cc Mon Sep 17 00:00:00 2001 From: Seimizu Joukan Date: Fri, 16 Jan 2026 14:37:15 +0900 Subject: [PATCH 2/2] Improve robustness and UX of raw file download feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace fragile string-based URL conversion with proper parsing and validation to prevent edge cases. Add accurate progress tracking using Content-Length, file overwrite warnings and confirmation, and better error messages to improve user experience and reliability. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- src/git.rs | 48 +++++++++++++++++++++++++++++++--- src/main.rs | 75 +++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/src/git.rs b/src/git.rs index b275a0b..87a8edf 100644 --- a/src/git.rs +++ b/src/git.rs @@ -139,10 +139,50 @@ pub fn construct_clone_url(owner: &str, repo: &str, token: Option<&str>) -> Stri } } -pub fn get_raw_file_url(plain_download_url: &str) -> String { - plain_download_url - .replace("blob", "refs/heads") - .replace("github.com", "raw.githubusercontent.com") +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 diff --git a/src/main.rs b/src/main.rs index 74d6d60..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; @@ -203,22 +204,44 @@ async fn main() -> Result<()> { // Download file. if let Some(f) = cli.get_file.as_deref() { - let download_url = git::get_raw_file_url(f); + 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()); - // Create progress bar for this asset - let pb = multi_progress.add(ProgressBar::new(100)); - 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: {}", f)); - let response = client .get(&download_url) .header(ACCEPT, constants::headers::ACCEPT_OCTET_STREAM) @@ -228,10 +251,25 @@ async fn main() -> Result<()> { let status = response.status(); if !status.is_success() { - pb.finish_with_message(format!("Failed: {} (HTTP {})", f, status)); 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(); @@ -241,18 +279,17 @@ async fn main() -> Result<()> { bytes_vec.extend_from_slice(&chunk); pb.set_position(downloaded); } - pb.finish_with_message(format!("Complete: {}", f)); - - let output_path = if let Some(directory) = &cli.directory { - PathBuf::from(directory).join(PathBuf::from(f).file_name().unwrap()) - } else { - PathBuf::from(PathBuf::from(f).file_name().unwrap()) - }; + 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(()); }