From 03ac7bd2eda874b3394ac54e4cdd9de592fcf322 Mon Sep 17 00:00:00 2001 From: Abhinav A <71514966+abhixdd@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:24:35 +0530 Subject: [PATCH 1/4] feat: add support for releases download --- Cargo.lock | 160 ++++++++++++++++ Cargo.toml | 6 + README.md | 25 +++ package.json | 3 +- src/bin/ghg.rs | 6 + src/cli.rs | 343 +++++++++++++++++++++++++++++++++ src/github.rs | 30 +++ src/lib.rs | 2 + src/main.rs | 265 +------------------------- src/release.rs | 501 +++++++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1076 insertions(+), 265 deletions(-) create mode 100644 src/bin/ghg.rs create mode 100644 src/cli.rs create mode 100644 src/release.rs diff --git a/Cargo.lock b/Cargo.lock index 2002843..dd0fe2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,6 +79,15 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -247,6 +256,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + [[package]] name = "crossterm" version = "0.27.0" @@ -281,6 +296,17 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "dirs" version = "6.0.0" @@ -344,6 +370,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fancy-regex" version = "0.16.2" @@ -355,6 +391,17 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -465,17 +512,22 @@ dependencies = [ "clap", "crossterm", "dirs", + "flate2", "ratatui", + "regex", "reqwest", "rusty-hook", "serde", "serde_json", "syntect", + "tar", "thiserror 1.0.69", "tokio", "toml 1.1.0+spec-1.1.0", "url", "urlencoding", + "xz2", + "zip", ] [[package]] @@ -786,6 +838,7 @@ checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ "bitflags 2.10.0", "libc", + "redox_syscall", ] [[package]] @@ -794,6 +847,12 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -824,6 +883,17 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "memchr" version = "2.7.6" @@ -946,6 +1016,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plist" version = "1.8.0" @@ -1040,6 +1116,18 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1112,6 +1200,19 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.21.12" @@ -1455,6 +1556,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2106,6 +2218,25 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + [[package]] name = "yaml-rust" version = "0.4.5" @@ -2191,3 +2322,32 @@ dependencies = [ "quote", "syn 2.0.111", ] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.12.1", + "memchr", + "thiserror 2.0.17", + "zopfli", +] + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index 81f3b56..0528f9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "ghgrab" version = "1.2.0" edition = "2021" +default-run = "ghgrab" authors = ["abhixdd"] description = "A TUI-based tool to download specific files or folders from GitHub repositories" license = "MIT" @@ -24,6 +25,11 @@ thiserror = "1.0" syntect = { version = "5.3.0", default-features = false, features = ["parsing", "default-syntaxes", "default-themes", "default-fancy"] } urlencoding = "2.1" toml = "1.1.0" +regex = "1.11" +zip = { version = "2.2", default-features = false, features = ["deflate"] } +tar = "0.4" +flate2 = "1.0" +xz2 = "0.1" [dev-dependencies] rusty-hook = "0.11.2" diff --git a/README.md b/README.md index 4b1bc6a..7d36f19 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ - **File Preview**: Preview source code and text files directly in the TUI. - **Handles the big stuff**: Built-in support for GitHub LFS (Large File Storage). - **Batch mode**: Select a bunch of files and folders to download them all at once. +- **Release downloads**: Grab GitHub release artifacts with OS/architecture-aware selection. --- @@ -84,6 +85,30 @@ ghgrab https://github.com/rust-lang/rust --cwd --no-folder You can also type a repository keyword on the home screen (for example `ratatui`) and press `Enter` to open repository search mode. +### Release Downloads + +You can also download GitHub release assets directly with the user-facing `release` command or its short alias `rel`. + +```bash +# Download the best matching artifact for your OS and architecture +ghgrab release sharkdp/bat + +# Same command with the short alias and short binary name +ghg rel sharkdp/bat + +# Pick a release tag explicitly +ghgrab rel sharkdp/bat --tag v0.25.0 + +# Match assets with a regex +ghgrab rel sharkdp/bat --asset-regex "x86_64.*linux.*tar.gz" + +# Extract an archive after download +ghgrab rel sharkdp/bat --extract + +# Install the selected file or extracted binary into a target directory +ghgrab rel sharkdp/bat --extract --bin-path ~/.local/bin +``` + ### CLI Flags | Flag | Description | diff --git a/package.json b/package.json index 52d8a01..b1e3bcd 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "version": "1.2.0", "description": "A terminal-based tool for downloading specific files and folders from GitHub repositories", "bin": { - "ghgrab": "bin/ghgrab.js" + "ghgrab": "bin/ghgrab.js", + "ghg": "bin/ghgrab.js" }, "scripts": { "postinstall": "node scripts/install.js" diff --git a/src/bin/ghg.rs b/src/bin/ghg.rs new file mode 100644 index 0000000..7cec804 --- /dev/null +++ b/src/bin/ghg.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +#[tokio::main] +async fn main() -> Result<()> { + ghgrab::cli::run().await +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..69ac435 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,343 @@ +use crate::agent::{self, AgentEnvelope}; +use crate::config::Config; +use crate::release::{self, FileTypePreference, ReleaseRequest}; +use crate::ui; +use anyhow::Result; +use clap::{Parser, Subcommand, ValueEnum}; + +const GHGRAB_GITHUB_TOKEN_ENV: &str = "GHGRAB_GITHUB_TOKEN"; +const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN"; + +#[derive(Parser)] +#[command(name = "ghgrab", version, about)] +pub struct Cli { + #[command(subcommand)] + command: Option, + + url: Option, + + #[arg(long, help = "Download files to current directory")] + cwd: bool, + + #[arg(long, help = "Download files directly into target without repo folder")] + no_folder: bool, + + #[arg(long, help = "One-time GitHub token (not stored)")] + token: Option, +} + +#[derive(Subcommand)] +enum Commands { + Config { + #[command(subcommand)] + action: ConfigCommand, + }, + Agent { + #[command(subcommand)] + action: AgentCommand, + }, + #[command(alias = "rel")] + Release { + repo: String, + #[arg(long, help = "Download a specific release tag")] + tag: Option, + #[arg(long, help = "Allow selecting prereleases when tag is not specified")] + prerelease: bool, + #[arg(long, help = "Regex for matching a specific release asset")] + asset_regex: Option, + #[arg(long, help = "Override detected operating system")] + os: Option, + #[arg(long, help = "Override detected architecture")] + arch: Option, + #[arg(long, value_enum, default_value_t = ReleaseFileType::Any, help = "Preferred artifact type")] + file_type: ReleaseFileType, + #[arg(long, help = "Extract archive assets after download")] + extract: bool, + #[arg(long, help = "Custom output directory for this run")] + out: Option, + #[arg(long, help = "Install the selected binary into the provided directory")] + bin_path: Option, + #[arg(long, help = "Download files to current directory")] + cwd: bool, + #[arg(long, help = "One-time GitHub token for this run")] + token: Option, + }, +} + +#[derive(Subcommand)] +enum ConfigCommand { + Set { + #[command(subcommand)] + target: SetTarget, + }, + Unset { + #[command(subcommand)] + target: UnsetTarget, + }, + List, +} + +#[derive(Subcommand)] +enum SetTarget { + Token { value: String }, + Path { value: String }, +} + +#[derive(Subcommand)] +enum UnsetTarget { + Token, + Path, +} + +#[derive(Subcommand)] +enum AgentCommand { + Tree { + url: String, + #[arg(long, help = "One-time GitHub token for this run")] + token: Option, + }, + Download { + url: String, + #[arg(help = "Repo paths to download")] + paths: Vec, + #[arg(long, help = "Download the entire repository")] + repo: bool, + #[arg(long, help = "Download a specific subtree path")] + subtree: Option, + #[arg(long, help = "Download files to current directory")] + cwd: bool, + #[arg(long, help = "Download files directly into target without repo folder")] + no_folder: bool, + #[arg(long, help = "Custom output directory for this run")] + out: Option, + #[arg(long, help = "One-time GitHub token for this run")] + token: Option, + }, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] +enum ReleaseFileType { + Any, + Archive, + Binary, +} + +impl From for FileTypePreference { + fn from(value: ReleaseFileType) -> Self { + match value { + ReleaseFileType::Any => FileTypePreference::Any, + ReleaseFileType::Archive => FileTypePreference::Archive, + ReleaseFileType::Binary => FileTypePreference::Binary, + } + } +} + +pub async fn run() -> Result<()> { + let cli = Cli::parse(); + let default_config = Config::load().unwrap_or_default(); + + match cli.command { + Some(Commands::Config { action }) => match action { + ConfigCommand::Set { target } => match target { + SetTarget::Token { value } => { + let mut config = Config::load()?; + config.github_token = Some(value); + config.save()?; + println!("✅ GitHub token saved successfully!"); + } + SetTarget::Path { value } => { + if let Err(e) = Config::validate_path(&value) { + eprintln!("❌ Invalid path: {}", e); + } else { + let mut config = Config::load()?; + config.download_path = Some(value); + config.save()?; + println!("✅ Download path saved successfully!"); + } + } + }, + ConfigCommand::Unset { target } => match target { + UnsetTarget::Token => { + let mut config = Config::load()?; + config.github_token = None; + config.save()?; + println!("✅ GitHub token removed successfully!"); + } + UnsetTarget::Path => { + let mut config = Config::load()?; + config.download_path = None; + config.save()?; + println!("✅ Download path removed successfully!"); + } + }, + ConfigCommand::List => { + let config = default_config; + if let Some(token) = &config.github_token { + let masked = if token.len() > 8 { + format!("{}...{}", &token[..4], &token[token.len() - 4..]) + } else { + "********".to_string() + }; + println!("GitHub Token: {}", masked); + } else { + println!("GitHub Token: Not set"); + } + + if let Some(path) = &config.download_path { + println!("Download Path: {}", path); + } else { + println!("Download Path: Not set (using default Downloads folder)"); + } + } + }, + Some(Commands::Agent { action }) => match action { + AgentCommand::Tree { url, token } => { + let token = resolve_github_token(token, default_config.github_token.clone()); + let result = agent::fetch_tree(&url, token).await; + print_agent_json("tree", result)?; + } + AgentCommand::Download { + url, + paths, + repo, + subtree, + cwd, + no_folder, + out, + token, + } => { + let token = resolve_github_token(token, default_config.github_token.clone()); + let out = out.or(default_config.download_path.clone()); + let selected_paths = build_download_request(paths, repo, subtree); + let result = match selected_paths { + Ok(selected_paths) => { + agent::download_paths(&url, token, &selected_paths, out, cwd, no_folder) + .await + } + Err(error) => Err(error), + }; + print_agent_json("download", result)?; + } + }, + Some(Commands::Release { + repo, + tag, + prerelease, + asset_regex, + os, + arch, + file_type, + extract, + out, + bin_path, + cwd, + token, + }) => { + let token = resolve_github_token(token, default_config.github_token.clone()); + let result = release::download_release(ReleaseRequest { + repo, + tag, + include_prerelease: prerelease, + asset_regex, + os, + arch, + file_type: file_type.into(), + extract, + output_path: out.or(default_config.download_path.clone()), + cwd, + bin_path, + token, + }) + .await?; + + println!("Downloaded release asset: {}", result.asset_name); + println!("Release tag: {}", result.tag); + println!("Saved to: {}", result.download_path); + if let Some(installed_binary) = result.installed_binary { + println!("Installed binary: {}", installed_binary); + } + } + None => { + let url = cli.url; + let download_path = default_config.download_path.clone(); + let token = resolve_github_token(cli.token, default_config.github_token.clone()); + let initial_icon_mode = default_config.icon_mode.unwrap_or(ui::IconMode::Emoji); + + let final_icon_mode = ui::run_tui( + url, + token, + download_path, + cli.cwd, + cli.no_folder, + initial_icon_mode, + ) + .await?; + if final_icon_mode != initial_icon_mode { + let mut config = Config::load().unwrap_or_default(); + config.icon_mode = Some(final_icon_mode); + let _ = config.save(); + } + } + } + + Ok(()) +} + +fn resolve_github_token(cli_token: Option, config_token: Option) -> Option { + normalize_token(cli_token) + .or_else(resolve_github_token_from_env) + .or_else(|| normalize_token(config_token)) +} + +fn resolve_github_token_from_env() -> Option { + [GHGRAB_GITHUB_TOKEN_ENV, GITHUB_TOKEN_ENV] + .into_iter() + .find_map(|key| std::env::var(key).ok()) + .and_then(|token| normalize_token(Some(token))) +} + +fn normalize_token(token: Option) -> Option { + token.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn build_download_request( + paths: Vec, + repo: bool, + subtree: Option, +) -> Result> { + if repo && (!paths.is_empty() || subtree.is_some()) { + anyhow::bail!("--repo cannot be combined with paths or --subtree"); + } + + if repo { + return Ok(Vec::new()); + } + + if let Some(subtree) = subtree { + if !paths.is_empty() { + anyhow::bail!("--subtree cannot be combined with positional paths"); + } + return Ok(vec![subtree]); + } + + Ok(paths) +} + +fn print_agent_json(command: &str, result: anyhow::Result) -> Result<()> { + let payload = match result { + Ok(data) => AgentEnvelope::success(command, data), + Err(error) => { + AgentEnvelope::::error(command, agent::classify_error(&error), error.to_string()) + } + }; + + println!("{}", serde_json::to_string_pretty(&payload)?); + Ok(()) +} diff --git a/src/github.rs b/src/github.rs index 9032c39..8e33799 100644 --- a/src/github.rs +++ b/src/github.rs @@ -210,6 +210,22 @@ pub struct SearchItem { pub pushed_at: String, } +#[derive(Debug, serde::Deserialize, Clone)] +pub struct GitHubRelease { + pub tag_name: String, + pub draft: bool, + pub prerelease: bool, + pub assets: Vec, +} + +#[derive(Debug, serde::Deserialize, Clone)] +pub struct GitHubReleaseAsset { + pub name: String, + pub browser_download_url: String, + pub content_type: Option, + pub size: u64, +} + #[derive(Debug, serde::Deserialize)] pub struct GitTreeEntry { pub path: String, @@ -374,6 +390,20 @@ impl GitHubClient { Ok(result.items) } + pub async fn fetch_releases( + &self, + owner: &str, + repo: &str, + ) -> std::result::Result, GitHubError> { + let url = format!("https://api.github.com/repos/{}/{}/releases", owner, repo); + let response = self.request(reqwest::Method::GET, &url, None).await?; + let releases: Vec = response + .json() + .await + .map_err(|e| GitHubError::ApiError(e.to_string()))?; + Ok(releases) + } + // Fetch raw content pub async fn fetch_raw_content(&self, url: &str) -> Result { let response = self.request(reqwest::Method::GET, url, None).await?; diff --git a/src/lib.rs b/src/lib.rs index 04fb02d..781330d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,7 @@ pub mod agent; +pub mod cli; pub mod config; pub mod download; pub mod github; +pub mod release; pub mod ui; diff --git a/src/main.rs b/src/main.rs index bcb88ae..7cec804 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,269 +1,6 @@ use anyhow::Result; -use clap::{Parser, Subcommand}; -use ghgrab::agent::{self, AgentEnvelope}; -use ghgrab::config::Config; - -use ghgrab::ui; - -const GHGRAB_GITHUB_TOKEN_ENV: &str = "GHGRAB_GITHUB_TOKEN"; -const GITHUB_TOKEN_ENV: &str = "GITHUB_TOKEN"; - -#[derive(Parser)] -#[command(name = "ghgrab", version, about)] -struct Cli { - #[command(subcommand)] - command: Option, - - url: Option, - - #[arg(long, help = "Download files to current directory")] - cwd: bool, - - #[arg(long, help = "Download files directly into target without repo folder")] - no_folder: bool, - - #[arg(long, help = "One-time GitHub token (not stored)")] - token: Option, -} - -#[derive(Subcommand)] -enum Commands { - Config { - #[command(subcommand)] - action: ConfigCommand, - }, - Agent { - #[command(subcommand)] - action: AgentCommand, - }, -} - -#[derive(Subcommand)] -enum ConfigCommand { - Set { - #[command(subcommand)] - target: SetTarget, - }, - - Unset { - #[command(subcommand)] - target: UnsetTarget, - }, - - List, -} - -#[derive(Subcommand)] -enum SetTarget { - Token { value: String }, - - Path { value: String }, -} - -#[derive(Subcommand)] -enum UnsetTarget { - Token, - - Path, -} - -#[derive(Subcommand)] -enum AgentCommand { - Tree { - url: String, - #[arg(long, help = "One-time GitHub token for this run")] - token: Option, - }, - Download { - url: String, - #[arg(help = "Repo paths to download")] - paths: Vec, - #[arg(long, help = "Download the entire repository")] - repo: bool, - #[arg(long, help = "Download a specific subtree path")] - subtree: Option, - #[arg(long, help = "Download files to current directory")] - cwd: bool, - #[arg(long, help = "Download files directly into target without repo folder")] - no_folder: bool, - #[arg(long, help = "Custom output directory for this run")] - out: Option, - #[arg(long, help = "One-time GitHub token for this run")] - token: Option, - }, -} #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse(); - let default_config = Config::load().unwrap_or_default(); - - match cli.command { - Some(Commands::Config { action }) => match action { - ConfigCommand::Set { target } => match target { - SetTarget::Token { value } => { - let mut config = Config::load()?; - config.github_token = Some(value); - config.save()?; - println!("✅ GitHub token saved successfully!"); - } - SetTarget::Path { value } => { - if let Err(e) = Config::validate_path(&value) { - eprintln!("❌ Invalid path: {}", e); - } else { - let mut config = Config::load()?; - config.download_path = Some(value); - config.save()?; - println!("✅ Download path saved successfully!"); - } - } - }, - ConfigCommand::Unset { target } => match target { - UnsetTarget::Token => { - let mut config = Config::load()?; - config.github_token = None; - config.save()?; - println!("✅ GitHub token removed successfully!"); - } - UnsetTarget::Path => { - let mut config = Config::load()?; - config.download_path = None; - config.save()?; - println!("✅ Download path removed successfully!"); - } - }, - ConfigCommand::List => { - let config = default_config; - if let Some(token) = &config.github_token { - let masked = if token.len() > 8 { - format!("{}...{}", &token[..4], &token[token.len() - 4..]) - } else { - "********".to_string() - }; - println!("GitHub Token: {}", masked); - } else { - println!("GitHub Token: Not set"); - } - - if let Some(path) = &config.download_path { - println!("Download Path: {}", path); - } else { - println!("Download Path: Not set (using default Downloads folder)"); - } - } - }, - Some(Commands::Agent { action }) => match action { - AgentCommand::Tree { url, token } => { - let token = resolve_github_token(token, default_config.github_token.clone()); - let result = agent::fetch_tree(&url, token).await; - print_agent_json("tree", result)?; - } - AgentCommand::Download { - url, - paths, - repo, - subtree, - cwd, - no_folder, - out, - token, - } => { - let token = resolve_github_token(token, default_config.github_token.clone()); - let out = out.or(default_config.download_path.clone()); - let selected_paths = build_download_request(paths, repo, subtree); - let result = match selected_paths { - Ok(selected_paths) => { - agent::download_paths(&url, token, &selected_paths, out, cwd, no_folder) - .await - } - Err(error) => Err(error), - }; - print_agent_json("download", result)?; - } - }, - None => { - let url = cli.url; - - let download_path = default_config.download_path.clone(); - - let token = resolve_github_token(cli.token, default_config.github_token.clone()); - let initial_icon_mode = default_config.icon_mode.unwrap_or(ui::IconMode::Emoji); - - let final_icon_mode = ui::run_tui( - url, - token, - download_path, - cli.cwd, - cli.no_folder, - initial_icon_mode, - ) - .await?; - if final_icon_mode != initial_icon_mode { - let mut config = Config::load().unwrap_or_default(); - config.icon_mode = Some(final_icon_mode); - let _ = config.save(); - } - } - } - - Ok(()) -} - -fn resolve_github_token(cli_token: Option, config_token: Option) -> Option { - normalize_token(cli_token) - .or_else(resolve_github_token_from_env) - .or_else(|| normalize_token(config_token)) -} - -fn resolve_github_token_from_env() -> Option { - [GHGRAB_GITHUB_TOKEN_ENV, GITHUB_TOKEN_ENV] - .into_iter() - .find_map(|key| std::env::var(key).ok()) - .and_then(|token| normalize_token(Some(token))) -} - -fn normalize_token(token: Option) -> Option { - token.and_then(|value| { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - }) -} - -fn build_download_request( - paths: Vec, - repo: bool, - subtree: Option, -) -> Result> { - if repo && (!paths.is_empty() || subtree.is_some()) { - anyhow::bail!("--repo cannot be combined with paths or --subtree"); - } - - if repo { - return Ok(Vec::new()); - } - - if let Some(subtree) = subtree { - if !paths.is_empty() { - anyhow::bail!("--subtree cannot be combined with positional paths"); - } - return Ok(vec![subtree]); - } - - Ok(paths) -} - -fn print_agent_json(command: &str, result: anyhow::Result) -> Result<()> { - let payload = match result { - Ok(data) => AgentEnvelope::success(command, data), - Err(error) => { - AgentEnvelope::::error(command, agent::classify_error(&error), error.to_string()) - } - }; - - println!("{}", serde_json::to_string_pretty(&payload)?); - Ok(()) + ghgrab::cli::run().await } diff --git a/src/release.rs b/src/release.rs new file mode 100644 index 0000000..e3de272 --- /dev/null +++ b/src/release.rs @@ -0,0 +1,501 @@ +use crate::github::{GitHubClient, GitHubRelease, GitHubReleaseAsset}; +use anyhow::{anyhow, bail, Context, Result}; +use regex::Regex; +use std::fs; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use tar::Archive; +use url::Url; +use xz2::read::XzDecoder; +use zip::ZipArchive; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FileTypePreference { + Any, + Archive, + Binary, +} + +#[derive(Debug, Clone)] +pub struct ReleaseRequest { + pub repo: String, + pub tag: Option, + pub include_prerelease: bool, + pub asset_regex: Option, + pub os: Option, + pub arch: Option, + pub file_type: FileTypePreference, + pub extract: bool, + pub output_path: Option, + pub cwd: bool, + pub bin_path: Option, + pub token: Option, +} + +#[derive(Debug, Clone)] +pub struct ReleaseDownloadResult { + pub owner: String, + pub repo: String, + pub tag: String, + pub asset_name: String, + pub download_path: String, + pub installed_binary: Option, + pub extracted: bool, +} + +#[derive(Debug, Clone)] +pub struct ParsedRepo { + pub owner: String, + pub repo: String, +} + +pub async fn download_release(request: ReleaseRequest) -> Result { + let parsed = parse_repo_reference(&request.repo)?; + let client = GitHubClient::new(request.token.clone())?; + let releases = client + .fetch_releases(&parsed.owner, &parsed.repo) + .await + .context("Failed to fetch releases")?; + + let release = select_release( + &releases, + request.tag.as_deref(), + request.include_prerelease, + )?; + let asset = select_asset( + &release.assets, + &parsed.repo, + request.asset_regex.as_deref(), + request.os.as_deref(), + request.arch.as_deref(), + request.file_type, + )?; + + let base_dir = resolve_base_dir(request.output_path.clone(), request.cwd)?; + fs::create_dir_all(&base_dir)?; + + let download_path = base_dir.join(&asset.name); + let bytes = client + .fetch_bytes(&asset.browser_download_url) + .await + .with_context(|| format!("Failed to download asset '{}'", asset.name))?; + fs::write(&download_path, bytes.as_slice()) + .with_context(|| format!("Failed to write asset to '{}'", download_path.display()))?; + + let mut installed_binary = None; + let extracted = request.extract && is_archive(&asset.name); + + if extracted { + extract_archive(&download_path, &base_dir)?; + if let Some(bin_dir) = request.bin_path.as_deref() { + let installed = install_best_binary(&base_dir, &PathBuf::from(bin_dir), &parsed.repo)?; + installed_binary = Some(installed.display().to_string()); + } + } else if let Some(bin_dir) = request.bin_path.as_deref() { + let target = install_file(&download_path, &PathBuf::from(bin_dir))?; + installed_binary = Some(target.display().to_string()); + } + + Ok(ReleaseDownloadResult { + owner: parsed.owner, + repo: parsed.repo, + tag: release.tag_name.clone(), + asset_name: asset.name.clone(), + download_path: download_path.display().to_string(), + installed_binary, + extracted, + }) +} + +pub fn parse_repo_reference(value: &str) -> Result { + if value.starts_with("https://") || value.starts_with("http://") { + let url = Url::parse(value).context("Invalid repository URL")?; + if url.host_str() != Some("github.com") { + bail!("Repository URL must point to github.com"); + } + + let parts: Vec<_> = url + .path_segments() + .ok_or_else(|| anyhow!("Invalid repository URL path"))? + .filter(|segment| !segment.is_empty()) + .collect(); + if parts.len() < 2 { + bail!("Repository URL must include owner and repository"); + } + + return Ok(ParsedRepo { + owner: parts[0].to_string(), + repo: parts[1].trim_end_matches(".git").to_string(), + }); + } + + let mut parts = value.split('/'); + let owner = parts.next().unwrap_or_default().trim(); + let repo = parts.next().unwrap_or_default().trim(); + if owner.is_empty() || repo.is_empty() || parts.next().is_some() { + bail!("Repository must be in the form owner/repo or a GitHub URL"); + } + + Ok(ParsedRepo { + owner: owner.to_string(), + repo: repo.to_string(), + }) +} + +fn select_release<'a>( + releases: &'a [GitHubRelease], + tag: Option<&str>, + include_prerelease: bool, +) -> Result<&'a GitHubRelease> { + if releases.is_empty() { + bail!("No releases found for this repository"); + } + + if let Some(tag) = tag { + return releases + .iter() + .find(|release| release.tag_name == tag) + .ok_or_else(|| anyhow!("Release tag '{}' was not found", tag)); + } + + releases + .iter() + .find(|release| !release.draft && (include_prerelease || !release.prerelease)) + .ok_or_else(|| anyhow!("No matching release found")) +} + +fn select_asset<'a>( + assets: &'a [GitHubReleaseAsset], + repo: &str, + asset_regex: Option<&str>, + os: Option<&str>, + arch: Option<&str>, + file_type: FileTypePreference, +) -> Result<&'a GitHubReleaseAsset> { + if assets.is_empty() { + bail!("The selected release has no downloadable assets"); + } + + let regex = asset_regex + .map(Regex::new) + .transpose() + .context("Invalid asset regex")?; + let detected_os = normalize_os(os.unwrap_or(std::env::consts::OS)); + let detected_arch = normalize_arch(arch.unwrap_or(std::env::consts::ARCH)); + + assets + .iter() + .filter(|asset| !looks_like_auxiliary(&asset.name)) + .filter(|asset| regex.as_ref().is_none_or(|re| re.is_match(&asset.name))) + .max_by_key(|asset| score_asset(&asset.name, repo, detected_os, detected_arch, file_type)) + .filter(|asset| score_asset(&asset.name, repo, detected_os, detected_arch, file_type) > 0) + .ok_or_else(|| anyhow!("No release asset matched the requested criteria")) +} + +fn normalize_os(value: &str) -> &'static str { + match value.to_ascii_lowercase().as_str() { + "windows" | "win32" | "win64" | "pc-windows-msvc" => "windows", + "macos" | "darwin" | "osx" | "apple-darwin" => "darwin", + _ => "linux", + } +} + +fn normalize_arch(value: &str) -> &'static str { + match value.to_ascii_lowercase().as_str() { + "x86_64" | "amd64" => "amd64", + "aarch64" | "arm64" => "arm64", + "x86" | "i386" | "i686" => "386", + other if other.starts_with("armv7") => "armv7", + _ => "amd64", + } +} + +fn score_asset( + name: &str, + repo: &str, + detected_os: &str, + detected_arch: &str, + file_type: FileTypePreference, +) -> i32 { + let lower = name.to_ascii_lowercase(); + let mut score = 0; + + if lower.contains(&repo.to_ascii_lowercase()) { + score += 5; + } + if matches_os(&lower, detected_os) { + score += 40; + } else { + score -= 40; + } + if matches_arch(&lower, detected_arch) { + score += 30; + } else { + score -= 30; + } + if matches_file_type(&lower, file_type) { + score += 15; + } + if detected_os == "windows" && lower.ends_with(".exe") { + score += 20; + } + if detected_os != "windows" && !lower.ends_with(".exe") { + score += 10; + } + if is_archive(&lower) { + score += 5; + } + + score +} + +fn matches_os(name: &str, detected_os: &str) -> bool { + match detected_os { + "windows" => ["windows", "win32", "win64", "pc-windows"] + .iter() + .any(|t| name.contains(t)), + "darwin" => ["darwin", "macos", "apple-darwin", "osx"] + .iter() + .any(|t| name.contains(t)), + _ => ["linux", "unknown-linux", "gnu", "musl"] + .iter() + .any(|t| name.contains(t)), + } +} + +fn matches_arch(name: &str, detected_arch: &str) -> bool { + match detected_arch { + "arm64" => ["arm64", "aarch64"].iter().any(|t| name.contains(t)), + "386" => ["386", "i386", "i686", "x86"] + .iter() + .any(|t| name.contains(t)), + "armv7" => ["armv7", "armv7l"].iter().any(|t| name.contains(t)), + _ => ["amd64", "x86_64", "x64"].iter().any(|t| name.contains(t)), + } +} + +fn matches_file_type(name: &str, file_type: FileTypePreference) -> bool { + match file_type { + FileTypePreference::Any => true, + FileTypePreference::Archive => is_archive(name), + FileTypePreference::Binary => !is_archive(name), + } +} + +fn looks_like_auxiliary(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + [ + ".sha256", + ".sha512", + ".sig", + ".asc", + "checksums", + "checksum", + "sbom", + ] + .iter() + .any(|part| lower.contains(part)) +} + +fn is_archive(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.ends_with(".zip") + || lower.ends_with(".tar.gz") + || lower.ends_with(".tgz") + || lower.ends_with(".tar.xz") +} + +fn resolve_base_dir(output_path: Option, cwd: bool) -> Result { + if cwd { + std::env::current_dir().map_err(Into::into) + } else if let Some(path) = output_path { + Ok(PathBuf::from(path)) + } else { + dirs::download_dir() + .or_else(|| dirs::home_dir().map(|h| h.join("Downloads"))) + .ok_or_else(|| anyhow!("Could not find User Downloads directory")) + } +} + +fn extract_archive(archive_path: &Path, destination: &Path) -> Result<()> { + let bytes = fs::read(archive_path)?; + let name = archive_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + + if name.ends_with(".zip") { + let cursor = Cursor::new(bytes); + let mut archive = ZipArchive::new(cursor).context("Failed to read zip archive")?; + for index in 0..archive.len() { + let mut file = archive + .by_index(index) + .context("Failed to read zip entry")?; + let out_path = destination.join(file.mangled_name()); + if file.is_dir() { + fs::create_dir_all(&out_path)?; + continue; + } + if let Some(parent) = out_path.parent() { + fs::create_dir_all(parent)?; + } + let mut out_file = fs::File::create(&out_path)?; + std::io::copy(&mut file, &mut out_file)?; + } + return Ok(()); + } + + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + let cursor = Cursor::new(bytes); + let decoder = flate2::read::GzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + archive.unpack(destination)?; + return Ok(()); + } + + if name.ends_with(".tar.xz") { + let cursor = Cursor::new(bytes); + let decoder = XzDecoder::new(cursor); + let mut archive = Archive::new(decoder); + archive.unpack(destination)?; + return Ok(()); + } + + bail!("Unsupported archive type for '{}'", archive_path.display()) +} + +fn install_best_binary(extract_root: &Path, bin_dir: &Path, repo: &str) -> Result { + let mut candidates = Vec::new(); + collect_files(extract_root, &mut candidates)?; + let selected = candidates + .into_iter() + .filter(|path| is_probable_binary(path)) + .max_by_key(|path| binary_score(path, repo)) + .ok_or_else(|| anyhow!("No installable binary found after extraction"))?; + + install_file(&selected, bin_dir) +} + +fn install_file(source: &Path, bin_dir: &Path) -> Result { + fs::create_dir_all(bin_dir)?; + let target = bin_dir.join( + source + .file_name() + .ok_or_else(|| anyhow!("Could not determine target filename"))?, + ); + fs::copy(source, &target).with_context(|| { + format!( + "Failed to copy '{}' to '{}'", + source.display(), + target.display() + ) + })?; + #[cfg(not(windows))] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&target)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&target, perms)?; + } + Ok(target) +} + +fn collect_files(root: &Path, out: &mut Vec) -> Result<()> { + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_files(&path, out)?; + } else { + out.push(path); + } + } + Ok(()) +} + +fn is_probable_binary(path: &Path) -> bool { + let name = path + .file_name() + .and_then(|file| file.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + if name.is_empty() { + return false; + } + if ["readme", "license", "changelog"] + .iter() + .any(|p| name.starts_with(p)) + { + return false; + } + if cfg!(windows) { + return name.ends_with(".exe"); + } + !name.contains(".so") && !name.contains(".dylib") +} + +fn binary_score(path: &Path, repo: &str) -> i32 { + let name = path + .file_name() + .and_then(|file| file.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let mut score = 0; + if name == repo.to_ascii_lowercase() || name.starts_with(&repo.to_ascii_lowercase()) { + score += 50; + } + if cfg!(windows) && name.ends_with(".exe") { + score += 20; + } + score - path.components().count() as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_owner_repo() { + let parsed = parse_repo_reference("owner/repo").unwrap(); + assert_eq!(parsed.owner, "owner"); + assert_eq!(parsed.repo, "repo"); + } + + #[test] + fn parses_github_url() { + let parsed = parse_repo_reference("https://github.com/owner/repo").unwrap(); + assert_eq!(parsed.owner, "owner"); + assert_eq!(parsed.repo, "repo"); + } + + #[test] + fn prefers_matching_asset() { + let assets = vec![ + GitHubReleaseAsset { + name: "tool_darwin_arm64.tar.gz".to_string(), + browser_download_url: "https://example.com/a".to_string(), + content_type: None, + size: 10, + }, + GitHubReleaseAsset { + name: "tool_linux_x86_64.tar.gz".to_string(), + browser_download_url: "https://example.com/b".to_string(), + content_type: None, + size: 10, + }, + ]; + + let selected = select_asset( + &assets, + "tool", + None, + Some("linux"), + Some("amd64"), + FileTypePreference::Archive, + ) + .unwrap(); + + assert_eq!(selected.name, "tool_linux_x86_64.tar.gz"); + } +} From 7af3bc36dc68d632d16abf52df3b9c52f34857e7 Mon Sep 17 00:00:00 2001 From: Abhinav A <71514966+abhixdd@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:38:14 +0530 Subject: [PATCH 2/4] chore: update docs --- README.md | 2 + src/cli.rs | 14 ++++-- src/release.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 142 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 7d36f19..67a3b6e 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ You can also type a repository keyword on the home screen (for example `ratatui` You can also download GitHub release assets directly with the user-facing `release` command or its short alias `rel`. +When multiple close asset matches are available, ghgrab shows an interactive picker in the terminal. Type the asset number to continue, or `q` to quit. + ```bash # Download the best matching artifact for your OS and architecture ghgrab release sharkdp/bat diff --git a/src/cli.rs b/src/cli.rs index 69ac435..cb052d6 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ use crate::agent::{self, AgentEnvelope}; use crate::config::Config; -use crate::release::{self, FileTypePreference, ReleaseRequest}; +use crate::release::{self, FileTypePreference, ReleaseRequest, ReleaseSelectionCancelled}; use crate::ui; use anyhow::Result; use clap::{Parser, Subcommand, ValueEnum}; @@ -234,7 +234,7 @@ pub async fn run() -> Result<()> { token, }) => { let token = resolve_github_token(token, default_config.github_token.clone()); - let result = release::download_release(ReleaseRequest { + let result = match release::download_release(ReleaseRequest { repo, tag, include_prerelease: prerelease, @@ -248,7 +248,15 @@ pub async fn run() -> Result<()> { bin_path, token, }) - .await?; + .await + { + Ok(result) => result, + Err(error) if error.downcast_ref::().is_some() => { + println!("Cancelled."); + return Ok(()); + } + Err(error) => return Err(error), + }; println!("Downloaded release asset: {}", result.asset_name); println!("Release tag: {}", result.tag); diff --git a/src/release.rs b/src/release.rs index e3de272..43285ad 100644 --- a/src/release.rs +++ b/src/release.rs @@ -3,6 +3,7 @@ use anyhow::{anyhow, bail, Context, Result}; use regex::Regex; use std::fs; use std::io::Cursor; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use tar::Archive; use url::Url; @@ -49,6 +50,10 @@ pub struct ParsedRepo { pub repo: String, } +#[derive(Debug, thiserror::Error)] +#[error("Release selection cancelled")] +pub struct ReleaseSelectionCancelled; + pub async fn download_release(request: ReleaseRequest) -> Result { let parsed = parse_repo_reference(&request.repo)?; let client = GitHubClient::new(request.token.clone())?; @@ -182,14 +187,103 @@ fn select_asset<'a>( .context("Invalid asset regex")?; let detected_os = normalize_os(os.unwrap_or(std::env::consts::OS)); let detected_arch = normalize_arch(arch.unwrap_or(std::env::consts::ARCH)); - - assets + let candidates: Vec<_> = assets .iter() .filter(|asset| !looks_like_auxiliary(&asset.name)) .filter(|asset| regex.as_ref().is_none_or(|re| re.is_match(&asset.name))) - .max_by_key(|asset| score_asset(&asset.name, repo, detected_os, detected_arch, file_type)) - .filter(|asset| score_asset(&asset.name, repo, detected_os, detected_arch, file_type) > 0) - .ok_or_else(|| anyhow!("No release asset matched the requested criteria")) + .map(|asset| { + ( + asset, + score_asset(&asset.name, repo, detected_os, detected_arch, file_type), + ) + }) + .filter(|(_, score)| *score > 0) + .collect(); + + let mut candidates = candidates; + candidates.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name.cmp(&b.0.name))); + + if candidates.is_empty() { + bail!("No release asset matched the requested criteria"); + } + + if candidates.len() == 1 { + return Ok(candidates[0].0); + } + + if asset_regex.is_some() || has_clear_best_match(&candidates) { + return Ok(candidates[0].0); + } + + prompt_for_asset_selection(&candidates) +} + +fn has_clear_best_match(candidates: &[(&GitHubReleaseAsset, i32)]) -> bool { + if candidates.len() < 2 { + return true; + } + candidates[0].1 >= candidates[1].1 + 20 +} + +fn prompt_for_asset_selection<'a>( + candidates: &[(&'a GitHubReleaseAsset, i32)], +) -> Result<&'a GitHubReleaseAsset> { + println!("Multiple release assets matched. Select one:"); + for (index, (asset, _)) in candidates.iter().enumerate() { + println!( + " {}. {} ({})", + index + 1, + asset.name, + format_size(asset.size) + ); + } + println!("Type q and press Enter to cancel."); + + loop { + print!("Enter a number [1-{}]: ", candidates.len()); + io::stdout().flush().context("Failed to flush stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("Failed to read selection")?; + + let trimmed = input.trim(); + if trimmed.eq_ignore_ascii_case("q") { + return Err(ReleaseSelectionCancelled.into()); + } + + let Ok(choice) = trimmed.parse::() else { + eprintln!("Invalid selection '{}'. Enter a number.", trimmed); + continue; + }; + + if (1..=candidates.len()).contains(&choice) { + return Ok(candidates[choice - 1].0); + } + + eprintln!( + "Selection out of range. Choose between 1 and {}.", + candidates.len() + ); + } +} + +fn format_size(size: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let size_f = size as f64; + + if size_f >= GB { + format!("{:.1} GB", size_f / GB) + } else if size_f >= MB { + format!("{:.1} MB", size_f / MB) + } else if size_f >= KB { + format!("{:.1} KB", size_f / KB) + } else { + format!("{} B", size) + } } fn normalize_os(value: &str) -> &'static str { @@ -498,4 +592,34 @@ mod tests { assert_eq!(selected.name, "tool_linux_x86_64.tar.gz"); } + + #[test] + fn filters_auxiliary_assets() { + let assets = vec![ + GitHubReleaseAsset { + name: "tool_checksums.txt".to_string(), + browser_download_url: "https://example.com/a".to_string(), + content_type: None, + size: 10, + }, + GitHubReleaseAsset { + name: "tool_linux_x86_64.tar.gz".to_string(), + browser_download_url: "https://example.com/b".to_string(), + content_type: None, + size: 10, + }, + ]; + + let selected = select_asset( + &assets, + "tool", + Some("linux"), + Some("linux"), + Some("amd64"), + FileTypePreference::Archive, + ) + .unwrap(); + + assert_eq!(selected.name, "tool_linux_x86_64.tar.gz"); + } } From 4e72ab1e39d272a4d16e95ac2923fe57a69135fb Mon Sep 17 00:00:00 2001 From: Abhinav A <71514966+abhixdd@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:46:39 +0530 Subject: [PATCH 3/4] chore: udpate docs --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 67a3b6e..de64d2f 100644 --- a/README.md +++ b/README.md @@ -85,19 +85,63 @@ ghgrab https://github.com/rust-lang/rust --cwd --no-folder You can also type a repository keyword on the home screen (for example `ratatui`) and press `Enter` to open repository search mode. +### CLI Flags + +| Flag | Description | +| ----------------- | -------------------------------------------------------------------- | +| `--cwd` | Forces download to the current working directory. | +| `--no-folder` | Downloads files directly without creating a subfolder for the repo. | +| `--token ` | Use a specific GitHub token for this run (doesn't save to settings). | + ### Release Downloads You can also download GitHub release assets directly with the user-facing `release` command or its short alias `rel`. -When multiple close asset matches are available, ghgrab shows an interactive picker in the terminal. Type the asset number to continue, or `q` to quit. +Basic examples: ```bash # Download the best matching artifact for your OS and architecture -ghgrab release sharkdp/bat +ghgrab rel sharkdp/bat + +# Extract an archive after download +ghgrab rel sharkdp/bat --extract + +# Download a specific release tag +ghgrab rel sharkdp/bat --tag v0.25.0 + +# Match a specific asset by regex +ghgrab rel sharkdp/bat --asset-regex "x86_64.*windows.*zip" + +# Download into a custom directory +ghgrab rel sharkdp/bat --extract --out ./tmp/bat + +# Install the selected file or extracted binary into a target directory +ghgrab rel sharkdp/bat --extract --bin-path ~/.local/bin +``` + +Basic release flags: + +| Flag | Description | +| ---- | ----------- | +| `--tag ` | Download a specific release tag. | +| `--asset-regex ` | Match a specific release asset by regex. | +| `--extract` | Extract archive assets after download. | +| `--out ` | Download into a custom output directory. | +| `--bin-path ` | Copy the selected file or extracted binary into the provided directory. | +| `--os ` | Override detected operating system. | +| `--arch ` | Override detected architecture. | -# Same command with the short alias and short binary name -ghg rel sharkdp/bat +
+Show release download usage, flags, and examples +How selection works: + +- If there is one clear best asset match for your OS and architecture, ghgrab downloads it directly. +- If multiple close matches exist, ghgrab shows an interactive picker in the terminal. +- Type the asset number and press `Enter` to continue. +- Type `q` and press `Enter` to cancel the picker. + +```bash # Pick a release tag explicitly ghgrab rel sharkdp/bat --tag v0.25.0 @@ -109,15 +153,50 @@ ghgrab rel sharkdp/bat --extract # Install the selected file or extracted binary into a target directory ghgrab rel sharkdp/bat --extract --bin-path ~/.local/bin + +# Download to a custom directory +ghgrab rel sharkdp/bat --extract --out ./tmp/bat + +# Force Windows x64 asset selection +ghgrab rel BurntSushi/ripgrep --os windows --arch amd64 + +# Allow prereleases when selecting the latest release +ghgrab rel starship/starship --prerelease ``` -### CLI Flags +### Release Flags -| Flag | Description | -| ----------------- | -------------------------------------------------------------------- | -| `--cwd` | Forces download to the current working directory. | -| `--no-folder` | Downloads files directly without creating a subfolder for the repo. | -| `--token ` | Use a specific GitHub token for this run (doesn't save to settings). | +| Flag | Description | +| ---- | ----------- | +| `--tag ` | Download a specific release tag instead of the latest matching release. | +| `--prerelease` | Allow prereleases when `--tag` is not provided. | +| `--asset-regex ` | Match a specific release asset by regex. Useful for forcing one artifact and skipping the picker. | +| `--os ` | Override detected operating system for asset selection. | +| `--arch ` | Override detected architecture for asset selection. | +| `--file-type ` | Prefer `any`, `archive`, or `binary` assets. | +| `--extract` | Extract archive assets after download. Supports `.zip`, `.tar.gz`, `.tgz`, and `.tar.xz`. | +| `--out ` | Download into a custom output directory. | +| `--bin-path ` | Copy the selected file or extracted binary into the provided directory. | +| `--cwd` | Download into the current working directory. | +| `--token ` | Use a one-time GitHub token for this run without saving it. | + +### Release Examples + +```bash +# Download a specific ripgrep release for Windows x64 +ghgrab rel BurntSushi/ripgrep --tag 15.1.0 --os windows --arch amd64 + +# Use a regex to choose one exact asset +ghgrab rel sharkdp/bat --asset-regex "x86_64.*windows.*zip" + +# Install an extracted binary into your local bin directory +ghgrab rel sharkdp/bat --extract --bin-path ~/.local/bin + +# Use the long command form +ghgrab release sharkdp/bat +``` + +
### Environment Variables From 6f60e8045334a1ca3d1243b7b33977bd35810b1e Mon Sep 17 00:00:00 2001 From: Abhinav A <71514966+abhixdd@users.noreply.github.com> Date: Fri, 3 Apr 2026 14:04:12 +0530 Subject: [PATCH 4/4] chore: add tests --- src/release.rs | 98 ++++++------------------------- tests/release_test.rs | 131 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 80 deletions(-) create mode 100644 tests/release_test.rs diff --git a/src/release.rs b/src/release.rs index 43285ad..6423eff 100644 --- a/src/release.rs +++ b/src/release.rs @@ -147,6 +147,21 @@ pub fn parse_repo_reference(value: &str) -> Result { }) } +pub fn select_asset_name_for_request( + assets: &[GitHubReleaseAsset], + repo: &str, + asset_regex: Option<&str>, + os: Option<&str>, + arch: Option<&str>, + file_type: FileTypePreference, +) -> Result { + Ok( + select_asset(assets, repo, asset_regex, os, arch, file_type)? + .name + .clone(), + ) +} + fn select_release<'a>( releases: &'a [GitHubRelease], tag: Option<&str>, @@ -328,7 +343,9 @@ fn score_asset( score -= 30; } if matches_file_type(&lower, file_type) { - score += 15; + score += 25; + } else if !matches!(file_type, FileTypePreference::Any) { + score -= 25; } if detected_os == "windows" && lower.ends_with(".exe") { score += 20; @@ -544,82 +561,3 @@ fn binary_score(path: &Path, repo: &str) -> i32 { } score - path.components().count() as i32 } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_owner_repo() { - let parsed = parse_repo_reference("owner/repo").unwrap(); - assert_eq!(parsed.owner, "owner"); - assert_eq!(parsed.repo, "repo"); - } - - #[test] - fn parses_github_url() { - let parsed = parse_repo_reference("https://github.com/owner/repo").unwrap(); - assert_eq!(parsed.owner, "owner"); - assert_eq!(parsed.repo, "repo"); - } - - #[test] - fn prefers_matching_asset() { - let assets = vec![ - GitHubReleaseAsset { - name: "tool_darwin_arm64.tar.gz".to_string(), - browser_download_url: "https://example.com/a".to_string(), - content_type: None, - size: 10, - }, - GitHubReleaseAsset { - name: "tool_linux_x86_64.tar.gz".to_string(), - browser_download_url: "https://example.com/b".to_string(), - content_type: None, - size: 10, - }, - ]; - - let selected = select_asset( - &assets, - "tool", - None, - Some("linux"), - Some("amd64"), - FileTypePreference::Archive, - ) - .unwrap(); - - assert_eq!(selected.name, "tool_linux_x86_64.tar.gz"); - } - - #[test] - fn filters_auxiliary_assets() { - let assets = vec![ - GitHubReleaseAsset { - name: "tool_checksums.txt".to_string(), - browser_download_url: "https://example.com/a".to_string(), - content_type: None, - size: 10, - }, - GitHubReleaseAsset { - name: "tool_linux_x86_64.tar.gz".to_string(), - browser_download_url: "https://example.com/b".to_string(), - content_type: None, - size: 10, - }, - ]; - - let selected = select_asset( - &assets, - "tool", - Some("linux"), - Some("linux"), - Some("amd64"), - FileTypePreference::Archive, - ) - .unwrap(); - - assert_eq!(selected.name, "tool_linux_x86_64.tar.gz"); - } -} diff --git a/tests/release_test.rs b/tests/release_test.rs new file mode 100644 index 0000000..7605213 --- /dev/null +++ b/tests/release_test.rs @@ -0,0 +1,131 @@ +use ghgrab::github::GitHubReleaseAsset; +use ghgrab::release::{parse_repo_reference, select_asset_name_for_request, FileTypePreference}; + +fn asset(name: &str) -> GitHubReleaseAsset { + GitHubReleaseAsset { + name: name.to_string(), + browser_download_url: format!("https://example.com/{}", name), + content_type: None, + size: 10, + } +} + +#[test] +fn parses_owner_repo_reference() { + let parsed = parse_repo_reference("owner/repo").unwrap(); + assert_eq!(parsed.owner, "owner"); + assert_eq!(parsed.repo, "repo"); +} + +#[test] +fn parses_github_url_reference() { + let parsed = parse_repo_reference("https://github.com/owner/repo").unwrap(); + assert_eq!(parsed.owner, "owner"); + assert_eq!(parsed.repo, "repo"); +} + +#[test] +fn rejects_invalid_repo_reference() { + let error = parse_repo_reference("not-valid").unwrap_err().to_string(); + assert!(error.contains("owner/repo")); +} + +#[test] +fn selects_best_matching_asset_for_os_and_arch() { + let assets = vec![ + asset("tool_darwin_arm64.tar.gz"), + asset("tool_linux_x86_64.tar.gz"), + ]; + + let selected = select_asset_name_for_request( + &assets, + "tool", + None, + Some("linux"), + Some("amd64"), + FileTypePreference::Archive, + ) + .unwrap(); + + assert_eq!(selected, "tool_linux_x86_64.tar.gz"); +} + +#[test] +fn filters_auxiliary_assets() { + let assets = vec![ + asset("tool_checksums.txt"), + asset("tool_linux_x86_64.tar.gz"), + ]; + + let selected = select_asset_name_for_request( + &assets, + "tool", + Some("linux"), + Some("linux"), + Some("amd64"), + FileTypePreference::Archive, + ) + .unwrap(); + + assert_eq!(selected, "tool_linux_x86_64.tar.gz"); +} + +#[test] +fn regex_can_force_specific_asset() { + let assets = vec![ + asset("tool_windows_amd64.zip"), + asset("tool_linux_x86_64.tar.gz"), + ]; + + let selected = select_asset_name_for_request( + &assets, + "tool", + Some("windows"), + Some("linux"), + Some("amd64"), + FileTypePreference::Any, + ) + .unwrap(); + + assert_eq!(selected, "tool_windows_amd64.zip"); +} + +#[test] +fn arch_override_prefers_arm64_asset() { + let assets = vec![ + asset("tool_linux_x86_64.tar.gz"), + asset("tool_linux_aarch64.tar.gz"), + ]; + + let selected = select_asset_name_for_request( + &assets, + "tool", + None, + Some("linux"), + Some("arm64"), + FileTypePreference::Archive, + ) + .unwrap(); + + assert_eq!(selected, "tool_linux_aarch64.tar.gz"); +} + +#[test] +fn file_type_binary_prefers_non_archive() { + let assets = vec![ + asset("tool_windows_amd64.zip"), + asset("tool_windows_amd64.exe"), + ]; + + let selected = select_asset_name_for_request( + &assets, + "tool", + None, + Some("windows"), + Some("amd64"), + FileTypePreference::Binary, + ) + .unwrap(); + + assert_eq!(selected, "tool_windows_amd64.exe"); +}