diff --git a/Cargo.lock b/Cargo.lock index bf5a129..020c0e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,6 +94,16 @@ version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.18.1" @@ -428,15 +438,31 @@ dependencies = [ "clap", "dirs", "futures", + "globset", "indicatif", "jlogger-tracing", + "regex", "reqwest", "serde", "serde_json", + "thiserror", "tokio", "urlencoding", ] +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.3.26" @@ -1036,6 +1062,18 @@ dependencies = [ "thiserror", ] +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.13" diff --git a/Cargo.toml b/Cargo.toml index 681c323..7b7e2e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,9 @@ chrono = "0.4.42" futures = "0.3" indicatif = "0.17" urlencoding = "2.1" +thiserror = "1.0" +regex = "1.10" +globset = "0.4" [package.metadata.deb] maintainer = "Seimizu Joukan " diff --git a/README.md b/README.md index 5d3feb4..74189d7 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,15 @@ A fast, simple command-line tool for fetching and downloading GitHub release ass - 🚀 **Fast and lightweight** - Single binary written in Rust - 🔐 **Multiple authentication methods** - Token, token file, or `.netrc` support - 🔒 **Private repository support** - Works seamlessly with private repos using proper authentication -- 🎯 **Asset filtering** - Download only the assets you need with comma-separated filters +- 🎯 **Advanced asset filtering** - Glob patterns, regex, and exclusion filters - 📦 **Latest release shorthand** - Use `-d latest` to always get the newest release - 📁 **Custom output directory** - Save downloads to any directory - 📊 **Release information** - View detailed info about releases before downloading - 🔄 **Cross-platform** - Works on Linux, macOS, and Windows +- 🏢 **GitHub Enterprise support** - Custom API base URLs for enterprise instances +- 💾 **Response caching** - Optional caching to reduce API calls +- 🎭 **Dry-run mode** - Preview operations without executing them +- 📋 **JSON output** - Machine-readable output for scripting ## Installation @@ -48,10 +52,15 @@ ghr [OPTIONS] --repo | Token File | `-T` | `--token-file ` | Path to file containing GitHub token | | Clone | `-c` | `--clone ` | Clone repository with optional branch/tag/commit | | Download | `-d` | `--download ` | Download specific version (or "latest") | -| Filter | `-f` | `--filter ` | Filter assets by comma-separated patterns | +| Filter | `-f` | `--filter ` | Filter assets by patterns (glob/regex/exclude) | | Info | `-i` | `--info ` | Show info about specific versions (comma-separated) | -| Number | `-n` | `--num ` | Number of releases to list (default: 1) | +| Search | `-s` | `--search ` | Search for repositories | +| Number | `-n` | `--num ` | Number of releases to list (default: 10) | | Concurrency | `-j` | `--concurrency ` | Maximum number of concurrent downloads (default: 5) | +| Dry-run | | `--dry-run` | Preview operations without executing them | +| Format | | `--format ` | Output format: table (default) or json | +| API URL | | `--api-url ` | GitHub API base URL (for GitHub Enterprise) | +| Cache | | `--cache` | Enable response caching (24 hour TTL) | | Verbose | `-v` | `--verbose` | Increase verbosity (-v, -vv for more detail) | ### Positional Arguments @@ -96,15 +105,49 @@ ghr -r owner/repo -d v1.2.3 ./releases ### Download with Filtering +The filter system supports multiple pattern types: + +#### Substring Matching (Simple) ```bash -# Download only Linux amd64 packages +# Download assets containing "linux" AND "amd64" ghr -r owner/repo -d latest -f "linux,amd64" +``` +#### Glob Patterns (Wildcards) +```bash # Download only .deb files -ghr -r owner/repo -d v1.0.0 -f ".deb" +ghr -r owner/repo -d latest -f "*.deb" + +# Download .tar.gz files +ghr -r owner/repo -d latest -f "*.tar.gz" + +# Download files starting with "app-" +ghr -r owner/repo -d latest -f "app-*" +``` + +#### Regex Patterns (Advanced) +```bash +# Download linux packages for amd64 architecture +ghr -r owner/repo -d latest -f "linux-.*-amd64" + +# Download versioned releases +ghr -r owner/repo -d latest -f "app-v[0-9]+\\..*" +``` + +#### Exclusion Patterns +```bash +# Download everything except Windows binaries +ghr -r owner/repo -d latest -f "!windows" -# Multiple filters (downloads assets containing any of these) -ghr -r owner/repo -d latest -f "linux,darwin,windows" +# Download .deb files but not test packages +ghr -r owner/repo -d latest -f "*.deb,!test" +``` + +#### Combined Patterns +```bash +# Multiple filters work with AND logic +ghr -r owner/repo -d latest -f "*.deb,!test,linux" +# Downloads: .deb files, excluding test packages, containing "linux" ``` ### Clone Repository @@ -181,6 +224,80 @@ ghr -s "/kubernetes" -n 10 **Note**: Use `-n` flag to control number of results (default: 10) +### Dry-Run Mode + +Preview what will be downloaded or cloned without executing: + +```bash +# Preview download without downloading +ghr -r owner/repo -d latest --dry-run + +# Output shows: +# - List of assets that would be downloaded +# - Size of each asset +# - Total download size +# - Destination directory + +# Preview clone operation +ghr -c owner/repo:main --dry-run + +# Output shows: +# - Repository to be cloned +# - Branch/tag/commit to checkout +# - Target directory +``` + +### JSON Output + +Get machine-readable output for scripting and automation: + +```bash +# List releases in JSON format +ghr -r owner/repo --format json + +# Search repositories in JSON format +ghr -s "rust-lang/" --format json -n 5 + +# Parse with jq +ghr -r owner/repo --format json | jq '.[0].tag_name' +ghr -r owner/repo --format json | jq -r '.[] | .assets[].name' +``` + +### Response Caching + +Enable caching to reduce API calls and improve performance: + +```bash +# Enable caching (24 hour TTL) +ghr -r owner/repo --cache + +# Subsequent calls use cached data +ghr -r owner/repo --cache # Fast! Uses cache + +# Works with all API operations +ghr -s "microsoft/" --cache -n 10 +ghr -r owner/repo -i v1.0.0 --cache +``` + +**Benefits:** +- Reduces API rate limit usage +- Faster response times for repeated queries +- Cache stored in `~/.cache/ghr/` (or platform equivalent) +- Automatic expiration after 24 hours + +### GitHub Enterprise Support + +Use with GitHub Enterprise instances: + +```bash +# Specify custom API URL +ghr --api-url https://github.enterprise.com/api -r owner/repo -d latest + +# Works with all operations +ghr --api-url https://ghe.company.com/api -s "team/" -n 10 +ghr --api-url https://ghe.company.com/api -c owner/repo +``` + ### Private Repository Access ```bash @@ -206,7 +323,14 @@ ghr -r owner/private-repo -d latest ```yaml - name: Download release asset run: | - ghr -r owner/repo -d latest -f "linux,amd64" ./bin + # Use advanced filtering and caching + ghr -r owner/repo -d latest -f "*.deb,!test" --cache ./bin + +- name: Check for new releases + run: | + # Use JSON output for scripting + LATEST=$(ghr -r owner/repo --format json | jq -r '.[0].tag_name') + echo "Latest version: $LATEST" ``` #### GitLab CI @@ -214,13 +338,16 @@ ghr -r owner/private-repo -d latest ```yaml download_release: script: - - ghr -r owner/repo -d v1.0.0 -t $GITHUB_TOKEN ./artifacts + # Use dry-run first, then download + - ghr -r owner/repo -d v1.0.0 -t $GITHUB_TOKEN --dry-run + - ghr -r owner/repo -d v1.0.0 -t $GITHUB_TOKEN -f "linux-.*-amd64" ./artifacts ``` #### Jenkins ```groovy -sh 'ghr -r owner/repo -d latest -T /var/jenkins/.github_token ./bin' +// Use GitHub Enterprise and caching +sh 'ghr --api-url https://ghe.company.com/api -r owner/repo -d latest --cache -T /var/jenkins/.github_token ./bin' ``` ## Authentication @@ -299,7 +426,11 @@ ghr -r owner/repo -d latest -vv ```bash #!/bin/bash -ghr -r mycompany/app -d latest -f "linux,amd64" /tmp +# Preview first with dry-run +ghr -r mycompany/app -d latest -f "*.deb,!test" --dry-run + +# Then download with advanced filtering +ghr -r mycompany/app -d latest -f "*.deb,!test,linux" /tmp sudo dpkg -i /tmp/app_*_amd64.deb ``` @@ -317,11 +448,16 @@ done ```bash #!/bin/bash current_version="v1.2.3" -latest=$(ghr -r owner/repo -n 1 2>&1 | grep "Tag:" | awk '{print $2}') + +# Use JSON output with caching for efficiency +latest=$(ghr -r owner/repo --format json --cache | jq -r '.[0].tag_name') if [ "$latest" != "$current_version" ]; then echo "New version available: $latest" - ghr -r owner/repo -d latest ./updates + # Preview before downloading + ghr -r owner/repo -d latest --dry-run + # Then download + ghr -r owner/repo -d latest -f "linux-.*-amd64" ./updates fi ``` @@ -394,6 +530,9 @@ Built with: - [reqwest](https://github.com/seanmonstar/reqwest) - HTTP client - [tokio](https://github.com/tokio-rs/tokio) - Async runtime - [serde](https://github.com/serde-rs/serde) - Serialization framework +- [regex](https://github.com/rust-lang/regex) - Regular expressions +- [globset](https://github.com/BurntSushi/ripgrep/tree/master/crates/globset) - Glob pattern matching +- [thiserror](https://github.com/dtolnay/thiserror) - Error handling ## Support diff --git a/src/auth.rs b/src/auth.rs index b49d156..9a0f8ff 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,9 +1,41 @@ use crate::cli::Cli; -use crate::models::Result; +use crate::errors::{GhrError, Result}; use jlogger_tracing::{jdebug, jinfo}; use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION}; use std::fs; +/// Read GitHub token from .netrc file +fn read_netrc_token() -> Option { + if let Ok(home) = std::env::var("HOME") { + let netrc_path = std::path::Path::new(&home).join(".netrc"); + jdebug!("Trying .netrc at {:?}", netrc_path); + + if let Ok(content) = std::fs::read_to_string(&netrc_path) { + return parse_netrc_github_token(&content); + } + } + None +} + +/// Parse GitHub token from .netrc file content +fn parse_netrc_github_token(content: &str) -> Option { + let lines: Vec<&str> = content.lines().collect(); + let mut in_github = false; + + for line in lines { + let trimmed = line.trim(); + if trimmed.starts_with("machine") && trimmed.contains("github.com") { + jinfo!("Found machine github.com in .netrc"); + in_github = true; + } else if in_github && trimmed.starts_with("password") { + return trimmed.split_whitespace().nth(1).map(String::from); + } else if trimmed.starts_with("machine") { + in_github = false; + } + } + None +} + /// Add authentication header to request headers pub fn add_auth_header(cli: &Cli, header: &mut HeaderMap) -> Result<()> { let mut success = false; @@ -12,10 +44,7 @@ pub fn add_auth_header(cli: &Cli, header: &mut HeaderMap) -> Result<()> { if let Some(token) = &cli.token { jinfo!("Using token from command line"); let auth_value = format!("Bearer {}", token.trim()); - header.insert( - AUTHORIZATION, - HeaderValue::from_str(&auth_value).map_err(|e| e.to_string())?, - ); + header.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?); success = true; } else if let Some(token_file) = &cli.token_file { // Try token file @@ -23,57 +52,29 @@ pub fn add_auth_header(cli: &Cli, header: &mut HeaderMap) -> Result<()> { match fs::read_to_string(token_file) { Ok(token) => { let auth_value = format!("Bearer {}", token.trim()); - header.insert( - AUTHORIZATION, - HeaderValue::from_str(&auth_value).map_err(|e| e.to_string())?, - ); + header.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?); success = true; } Err(e) => { - return Err(format!("Failed to read token file: {}", e)); + return Err(GhrError::Auth(format!("Failed to read token file: {}", e))); } } } else { // Try .netrc as fallback - if let Ok(home) = std::env::var("HOME") { - let netrc_path = std::path::Path::new(&home).join(".netrc"); - jdebug!("Trying .netrc at {:?}", netrc_path); - - if let Ok(content) = fs::read_to_string(&netrc_path) { - jinfo!("Using .netrc for authentication"); - let lines: Vec<&str> = content.lines().collect(); - let mut in_github = false; - - for line in lines { - let trimmed = line.trim(); - - if trimmed.starts_with("machine") { - if trimmed.contains("github.com") { - jinfo!("Found machine github.com in .netrc"); - in_github = true; - } else { - in_github = false; - } - } else if in_github && trimmed.starts_with("password") { - if let Some(password) = trimmed.split_whitespace().nth(1) { - let auth_value = format!("Bearer {}", password); - header.insert( - AUTHORIZATION, - HeaderValue::from_str(&auth_value).map_err(|e| e.to_string())?, - ); - success = true; - break; - } - } - } - } + if let Some(token) = read_netrc_token() { + jinfo!("Using .netrc for authentication"); + let auth_value = format!("Bearer {}", token.trim()); + header.insert(AUTHORIZATION, HeaderValue::from_str(&auth_value)?); + success = true; } } if success { Ok(()) } else { - Err("No authentication method provided".to_string()) + Err(GhrError::Auth( + "No authentication method provided".to_string(), + )) } } @@ -92,25 +93,5 @@ pub fn extract_token_from_cli(cli: &Cli) -> Option { } // Try .netrc - if let Ok(home) = std::env::var("HOME") { - let netrc_path = std::path::Path::new(&home).join(".netrc"); - if let Ok(content) = std::fs::read_to_string(&netrc_path) { - let lines: Vec<&str> = content.lines().collect(); - let mut in_github = false; - for line in lines { - let trimmed = line.trim(); - if trimmed.starts_with("machine") && trimmed.contains("github.com") { - in_github = true; - } else if in_github && trimmed.starts_with("password") { - if let Some(password) = trimmed.split_whitespace().nth(1) { - return Some(password.to_string()); - } - } else if trimmed.starts_with("machine") { - in_github = false; - } - } - } - } - - None + read_netrc_token() } diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..e0474ff --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,147 @@ +use crate::errors::Result; +use jlogger_tracing::jdebug; +use serde::{de::DeserializeOwned, Serialize}; +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; +use tokio::fs; + +/// Cache for GitHub API responses +pub struct Cache { + cache_dir: PathBuf, + ttl: Duration, + enabled: bool, +} + +impl Cache { + /// Create a new cache instance + pub fn new(enabled: bool) -> Self { + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("ghr"); + + Self { + cache_dir, + ttl: Duration::from_secs(24 * 60 * 60), // 24 hours default + enabled, + } + } + + /// Create a cache with custom TTL + #[allow(dead_code)] + pub fn with_ttl(enabled: bool, ttl_hours: u64) -> Self { + let mut cache = Self::new(enabled); + cache.ttl = Duration::from_secs(ttl_hours * 60 * 60); + cache + } + + /// Get cache file path for a given key + fn cache_path(&self, key: &str) -> PathBuf { + // Create a safe filename from the key + let safe_key = key.replace(['/', ':'], "_"); + self.cache_dir.join(format!("{}.json", safe_key)) + } + + /// Get cached value if it exists and is not expired + pub async fn get(&self, key: &str) -> Option { + if !self.enabled { + return None; + } + + let path = self.cache_path(key); + if !path.exists() { + jdebug!("Cache miss: {}", key); + return None; + } + + // Check if expired + let metadata = fs::metadata(&path).await.ok()?; + let modified = metadata.modified().ok()?; + let age = SystemTime::now().duration_since(modified).ok()?; + + if age > self.ttl { + jdebug!("Cache expired: {}", key); + // Cleanup expired entry + let _ = fs::remove_file(&path).await; + return None; + } + + // Read and parse cached data + let data = fs::read_to_string(&path).await.ok()?; + let result: T = serde_json::from_str(&data).ok()?; + + jdebug!("Cache hit: {} (age: {:?})", key, age); + Some(result) + } + + /// Set cached value + pub async fn set(&self, key: &str, value: &T) -> Result<()> { + if !self.enabled { + return Ok(()); + } + + // Ensure cache directory exists + fs::create_dir_all(&self.cache_dir).await?; + + let path = self.cache_path(key); + let data = serde_json::to_string(value)?; + fs::write(&path, data).await?; + + jdebug!("Cache set: {}", key); + Ok(()) + } + + /// Clear all cached entries + #[allow(dead_code)] + pub async fn clear(&self) -> Result<()> { + if self.cache_dir.exists() { + fs::remove_dir_all(&self.cache_dir).await?; + jdebug!("Cache cleared"); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + + #[derive(Debug, Serialize, Deserialize, PartialEq)] + struct TestData { + value: String, + } + + #[tokio::test] + async fn test_cache_disabled() { + let cache = Cache::new(false); + let data = TestData { + value: "test".to_string(), + }; + + cache.set("test-key", &data).await.unwrap(); + let result: Option = cache.get("test-key").await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_cache_set_get() { + let cache = Cache::new(true); + let data = TestData { + value: "test".to_string(), + }; + + cache.set("test-key-2", &data).await.unwrap(); + let result: Option = cache.get("test-key-2").await; + assert_eq!(result, Some(data)); + + // Cleanup + cache.clear().await.unwrap(); + } + + #[tokio::test] + async fn test_cache_miss() { + let cache = Cache::new(true); + let result: Option = cache.get("nonexistent-key").await; + assert!(result.is_none()); + } +} diff --git a/src/cli.rs b/src/cli.rs index 6cca904..b1b8181 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,14 @@ -use clap::{ArgAction, Parser}; +use clap::{ArgAction, Parser, ValueEnum}; + +/// Output format for list and search commands +#[derive(ValueEnum, Clone, Debug, Default)] +pub enum OutputFormat { + /// Table format (default) + #[default] + Table, + /// JSON format + Json, +} /// CLI arguments #[derive(Parser)] @@ -41,11 +51,11 @@ pub struct Cli { pub info: Option, /// Number of packages to fetch - #[arg(short = 'n', long = "num", default_value_t = 10)] + #[arg(short = 'n', long = "num", default_value_t = crate::constants::DEFAULT_NUM_RELEASES)] pub num: usize, /// Maximum number of concurrent downloads - #[arg(short = 'j', long = "concurrency", default_value_t = 5)] + #[arg(short = 'j', long = "concurrency", default_value_t = crate::constants::DEFAULT_CONCURRENCY)] pub concurrency: usize, /// Clone a repository with optional ref (branch/tag/sha1) @@ -62,6 +72,22 @@ pub struct Cli { #[arg(value_name = "DIRECTORY")] pub directory: Option, + /// Preview what will be downloaded or cloned without executing + #[arg(long = "dry-run")] + pub dry_run: bool, + + /// Output format for list and search commands + #[arg(long = "format", value_enum, default_value_t = OutputFormat::Table)] + pub format: OutputFormat, + + /// GitHub API base URL (for GitHub Enterprise) + #[arg(long = "api-url", default_value = crate::constants::GITHUB_API_BASE)] + pub api_url: String, + + /// Enable response caching (24 hour TTL) + #[arg(long = "cache")] + pub cache: bool, + #[arg(short = 'v', long = "verbose", action = ArgAction::Count)] pub verbose: u8, } diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..59ceff0 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,118 @@ +/// GitHub API base URL +pub const GITHUB_API_BASE: &str = "https://api.github.com"; + +/// GitHub API version +pub const GITHUB_API_VERSION: &str = "2022-11-28"; + +/// User agent for API requests +pub const USER_AGENT: &str = concat!("ghr/", env!("CARGO_PKG_VERSION")); + +/// Default concurrency for parallel downloads +pub const DEFAULT_CONCURRENCY: usize = 5; + +/// Default number of releases to fetch +pub const DEFAULT_NUM_RELEASES: usize = 10; + +/// API endpoints +pub mod endpoints { + use super::GITHUB_API_BASE; + + /// Get releases for a repository + pub fn releases(owner: &str, repo: &str) -> String { + releases_with_base(GITHUB_API_BASE, owner, repo) + } + + /// Get releases with custom base URL + pub fn releases_with_base(base_url: &str, owner: &str, repo: &str) -> String { + format!("{}/repos/{}/{}/releases", base_url, owner, repo) + } + + /// Get a specific release by tag + pub fn release_by_tag(owner: &str, repo: &str, tag: &str) -> String { + release_by_tag_with_base(GITHUB_API_BASE, owner, repo, tag) + } + + /// Get a specific release by tag with custom base URL + pub fn release_by_tag_with_base(base_url: &str, owner: &str, repo: &str, tag: &str) -> String { + format!( + "{}/repos/{}/{}/releases/tags/{}", + base_url, owner, repo, tag + ) + } + + /// Get repository information + pub fn repository(owner: &str, repo: &str) -> String { + repository_with_base(GITHUB_API_BASE, owner, repo) + } + + /// Get repository information with custom base URL + pub fn repository_with_base(base_url: &str, owner: &str, repo: &str) -> String { + format!("{}/repos/{}/{}", base_url, owner, repo) + } + + /// Get branch information + pub fn branch(owner: &str, repo: &str, branch: &str) -> String { + branch_with_base(GITHUB_API_BASE, owner, repo, branch) + } + + /// Get branch information with custom base URL + pub fn branch_with_base(base_url: &str, owner: &str, repo: &str, branch: &str) -> String { + format!("{}/repos/{}/{}/branches/{}", base_url, owner, repo, branch) + } + + /// Get tag information + pub fn tag(owner: &str, repo: &str, tag: &str) -> String { + tag_with_base(GITHUB_API_BASE, owner, repo, tag) + } + + /// Get tag information with custom base URL + pub fn tag_with_base(base_url: &str, owner: &str, repo: &str, tag: &str) -> String { + format!( + "{}/repos/{}/{}/git/refs/tags/{}", + base_url, owner, repo, tag + ) + } + + /// Get commit information + pub fn commit(owner: &str, repo: &str, sha: &str) -> String { + commit_with_base(GITHUB_API_BASE, owner, repo, sha) + } + + /// Get commit information with custom base URL + pub fn commit_with_base(base_url: &str, owner: &str, repo: &str, sha: &str) -> String { + format!("{}/repos/{}/{}/commits/{}", base_url, owner, repo, sha) + } + + /// Search repositories + pub fn search_repositories(query: &str, num: usize) -> String { + search_repositories_with_base(GITHUB_API_BASE, query, num) + } + + /// Search repositories with custom base URL + pub fn search_repositories_with_base(base_url: &str, query: &str, num: usize) -> String { + format!( + "{}/search/repositories?q={}&sort=stars&order=desc&per_page={}", + base_url, + urlencoding::encode(query), + num + ) + } +} + +/// HTTP headers +pub mod headers { + /// Accept header for GitHub API v3 + pub const ACCEPT_API_V3: &str = "application/vnd.github.v3+json"; + + /// Accept header for downloading assets + pub const ACCEPT_OCTET_STREAM: &str = "application/octet-stream"; +} + +/// Retry configuration +pub mod retry { + /// Maximum number of retry attempts + pub const MAX_RETRIES: u32 = 3; + + /// Base delay in seconds for exponential backoff + pub const BASE_DELAY_SECS: u64 = 2; +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..f1f456c --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,98 @@ +use thiserror::Error; + +/// Custom error types for gh_release +#[derive(Error, Debug)] +pub enum GhrError { + /// GitHub API returned an error response + #[error("GitHub API error: {0}")] + GitHubApi(String), + + /// Repository not found or access denied + #[error("Repository '{owner}/{repo}' not found or access denied")] + RepositoryNotFound { owner: String, repo: String }, + + /// Release not found + #[error("Release with tag '{tag}' not found")] + ReleaseNotFound { tag: String }, + + /// Git command failed + #[error("Git command failed: {0}")] + GitCommand(String), + + /// Git is not installed or not in PATH + #[error("Git is not installed or not available in PATH")] + GitNotInstalled, + + /// Network error (from reqwest) + #[error("Network error: {0}")] + Network(#[from] reqwest::Error), + + /// IO error (file operations, etc.) + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + /// Authentication failed + #[error("Authentication failed: {0}")] + Auth(String), + + /// Invalid URL format + #[error("Invalid URL format: {url}")] + InvalidUrl { url: String }, + + /// Git ref (branch/tag/commit) not found + #[error("Ref '{ref_name}' not found in {owner}/{repo}")] + RefNotFound { + owner: String, + repo: String, + ref_name: String, + }, + + /// Search pattern parsing error + #[error("Invalid search pattern: {0}")] + InvalidSearchPattern(String), + + /// Missing required argument + #[error("Missing required argument: {0}")] + MissingArgument(String), + + /// No releases found in repository + #[error("No releases found in repository")] + NoReleases, + + /// Header value error + #[error("Invalid header value: {0}")] + InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue), + + /// Regex parsing error + #[error("Invalid regex pattern: {0}")] + RegexError(#[from] regex::Error), + + /// Glob pattern parsing error + #[error("Invalid glob pattern: {0}")] + GlobError(#[from] globset::Error), + + /// JSON parsing/serialization error + #[error("JSON error: {0}")] + JsonError(#[from] serde_json::Error), + + /// Generic error for simple string messages + #[error("{0}")] + Generic(String), +} + +/// Custom result type for gh_release +pub type Result = std::result::Result; + +/// Helper trait to convert &str to GhrError +impl From<&str> for GhrError { + fn from(s: &str) -> Self { + GhrError::Generic(s.to_string()) + } +} + +/// Helper trait to convert String to GhrError +impl From for GhrError { + fn from(s: String) -> Self { + GhrError::Generic(s) + } +} diff --git a/src/filters.rs b/src/filters.rs new file mode 100644 index 0000000..80722e7 --- /dev/null +++ b/src/filters.rs @@ -0,0 +1,122 @@ +use crate::errors::Result; +use globset::{Glob, GlobMatcher}; +use regex::Regex; + +/// Filter type for asset filtering +#[derive(Debug)] +pub enum FilterType { + /// Substring match (e.g., "linux") + Substring(String), + /// Glob pattern (e.g., "*.deb") + Glob(GlobMatcher), + /// Regex pattern (e.g., "linux-.*-amd64") + Regex(Regex), + /// Exclude pattern (e.g., "!windows") + Exclude(Box), +} + +impl FilterType { + /// Check if the given name matches this filter + pub fn matches(&self, name: &str) -> bool { + match self { + FilterType::Substring(s) => name.contains(s), + FilterType::Glob(g) => g.is_match(name), + FilterType::Regex(r) => r.is_match(name), + FilterType::Exclude(f) => !f.matches(name), + } + } +} + +/// Parse a filter string into a FilterType +pub fn parse_filter(s: &str) -> Result { + // Check for exclude pattern + if let Some(pattern) = s.strip_prefix('!') { + return Ok(FilterType::Exclude(Box::new(parse_filter(pattern)?))); + } + + // Check for regex pattern (contains regex metacharacters) - check before glob + if s.contains('^') + || s.contains('$') + || s.contains(".*") + || s.contains("\\.") + || s.contains('(') + || s.contains('[') + { + let regex = Regex::new(s)?; + return Ok(FilterType::Regex(regex)); + } + + // Check for glob pattern (contains * or ?) + if s.contains('*') || s.contains('?') { + let glob = Glob::new(s)?.compile_matcher(); + return Ok(FilterType::Glob(glob)); + } + + // Default to substring match + Ok(FilterType::Substring(s.to_string())) +} + +/// Apply multiple filters to a name +pub fn apply_filters(name: &str, filters: &[FilterType]) -> bool { + if filters.is_empty() { + return true; + } + + // All filters must match (AND logic) + filters.iter().all(|f| f.matches(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_substring_filter() { + let filter = parse_filter("linux").unwrap(); + assert!(filter.matches("linux-amd64")); + assert!(filter.matches("my-linux-app")); + assert!(!filter.matches("windows-x86")); + } + + #[test] + fn test_glob_filter() { + let filter = parse_filter("*.deb").unwrap(); + assert!(filter.matches("app-1.0.0.deb")); + assert!(filter.matches("package.deb")); + assert!(!filter.matches("app.tar.gz")); + } + + #[test] + fn test_regex_filter() { + let filter = parse_filter("linux-.*-amd64").unwrap(); + assert!(filter.matches("linux-musl-amd64")); + assert!(filter.matches("linux-gnu-amd64")); + assert!(!filter.matches("linux-arm64")); + } + + #[test] + fn test_exclude_filter() { + let filter = parse_filter("!windows").unwrap(); + assert!(filter.matches("linux-amd64")); + assert!(!filter.matches("windows-x86")); + assert!(!filter.matches("app-windows.exe")); + } + + #[test] + fn test_apply_multiple_filters() { + let filters = vec![ + parse_filter("*.deb").unwrap(), + parse_filter("!test").unwrap(), + ]; + + assert!(apply_filters("app-1.0.0.deb", &filters)); + assert!(!apply_filters("test-1.0.0.deb", &filters)); + assert!(!apply_filters("app-1.0.0.tar.gz", &filters)); + } + + #[test] + fn test_empty_filters() { + let filters = vec![]; + assert!(apply_filters("any-file.txt", &filters)); + } +} diff --git a/src/git.rs b/src/git.rs index 3417d2d..c177198 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,5 +1,6 @@ use crate::cli::Cli; -use crate::models::{CloneSpec, Result}; +use crate::errors::{GhrError, Result}; +use crate::models::CloneSpec; use jlogger_tracing::{jdebug, jinfo, jwarn}; /// Parse clone URL and extract owner, repo, and optional ref @@ -7,7 +8,9 @@ pub fn parse_clone_url(url: &str) -> Result { let url = url.trim(); if url.is_empty() { - return Err("Clone URL cannot be empty".to_string()); + return Err(GhrError::InvalidUrl { + url: "".to_string(), + }); } // Split by ':' to separate URL and optional ref @@ -37,7 +40,9 @@ pub fn parse_clone_url(url: &str) -> Result { let parts: Vec<&str> = path.split('/').collect(); if parts.len() < 2 { - return Err(format!("Invalid GitHub URL: {}", url_part)); + return Err(GhrError::InvalidUrl { + url: url_part.to_string(), + }); } (parts[0].to_string(), parts[1].to_string()) } else if url_part.starts_with("git@github.com:") { @@ -48,28 +53,33 @@ pub fn parse_clone_url(url: &str) -> Result { let parts: Vec<&str> = path.split('/').collect(); if parts.len() < 2 { - return Err(format!("Invalid GitHub SSH URL: {}", url_part)); + return Err(GhrError::InvalidUrl { + url: url_part.to_string(), + }); } (parts[0].to_string(), parts[1].to_string()) } else if url_part.contains('/') { // Short format: owner/repo let parts: Vec<&str> = url_part.split('/').collect(); if parts.len() < 2 { - return Err(format!("Invalid repository format: {}", url_part)); + return Err(GhrError::InvalidUrl { + url: url_part.to_string(), + }); } ( parts[0].to_string(), parts[1].trim_end_matches(".git").to_string(), ) } else { - return Err(format!( - "Unsupported URL format: {}. Use 'owner/repo', 'https://github.com/owner/repo', or 'git@github.com:owner/repo.git'", - url_part - )); + return Err(GhrError::InvalidUrl { + url: url_part.to_string(), + }); }; if owner.is_empty() || repo.is_empty() { - return Err("Owner and repository name cannot be empty".to_string()); + return Err(GhrError::InvalidUrl { + url: url_part.to_string(), + }); } Ok(CloneSpec { @@ -101,8 +111,11 @@ pub fn get_repo_name(url: &str) -> String { } /// Check if git is installed and available in PATH -pub fn check_git_installed() -> Result<()> { - let output = std::process::Command::new("git").arg("--version").output(); +pub async fn check_git_installed() -> Result<()> { + let output = tokio::process::Command::new("git") + .arg("--version") + .output() + .await; match output { Ok(output) if output.status.success() => { @@ -112,11 +125,8 @@ pub fn check_git_installed() -> Result<()> { ); Ok(()) } - Ok(_) => Err("Git command failed. Please ensure git is properly installed.".to_string()), - Err(_) => Err( - "Git is not installed or not in PATH. Please install git to use the clone feature." - .to_string(), - ), + Ok(_) => Err(GhrError::GitNotInstalled), + Err(_) => Err(GhrError::GitNotInstalled), } } @@ -130,28 +140,36 @@ pub fn construct_clone_url(owner: &str, repo: &str, token: Option<&str>) -> Stri } /// Execute git clone command -pub fn execute_git_clone(clone_url: &str, target_dir: &str, ref_name: Option<&str>) -> Result<()> { +pub async fn execute_git_clone( + clone_url: &str, + target_dir: &str, + ref_name: Option<&str>, +) -> Result<()> { // Check target directory doesn't exist if std::path::Path::new(target_dir).exists() { - return Err(format!( + return Err(GhrError::Generic(format!( "Directory '{}' already exists. Please remove it or choose a different name.", target_dir - )); + ))); } // Execute git clone jinfo!("Executing: git clone {}", target_dir); - let output = std::process::Command::new("git") + let output = tokio::process::Command::new("git") .arg("clone") .arg(clone_url) .arg(target_dir) .output() - .map_err(|e| format!("Failed to execute git clone: {}", e))?; + .await + .map_err(|e| GhrError::GitCommand(format!("Failed to execute git clone: {}", e)))?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr); cleanup_partial_clone(target_dir); - return Err(format!("Git clone failed: {}", error.trim())); + return Err(GhrError::GitCommand(format!( + "Git clone failed: {}", + error.trim() + ))); } // Show git output @@ -165,18 +183,22 @@ pub fn execute_git_clone(clone_url: &str, target_dir: &str, ref_name: Option<&st // Checkout specific ref if provided if let Some(ref_name) = ref_name { jinfo!("Checking out ref '{}'...", ref_name); - let output = std::process::Command::new("git") + let output = tokio::process::Command::new("git") .arg("-C") .arg(target_dir) .arg("checkout") .arg(ref_name) .output() - .map_err(|e| format!("Failed to execute git checkout: {}", e))?; + .await + .map_err(|e| GhrError::GitCommand(format!("Failed to execute git checkout: {}", e)))?; if !output.status.success() { let error = String::from_utf8_lossy(&output.stderr); cleanup_partial_clone(target_dir); - return Err(format!("Git checkout failed: {}", error.trim())); + return Err(GhrError::GitCommand(format!( + "Git checkout failed: {}", + error.trim() + ))); } if !output.stderr.is_empty() { @@ -268,7 +290,8 @@ mod tests { fn test_parse_clone_url_invalid_empty() { let result = parse_clone_url(""); assert!(result.is_err()); - assert!(result.unwrap_err().contains("empty")); + // Check that it's an InvalidUrl error + matches!(result.unwrap_err(), GhrError::InvalidUrl { .. }); } #[test] diff --git a/src/github.rs b/src/github.rs index 05cfcad..980b01b 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,6 +1,41 @@ -use crate::models::{Release, Repository, RepositoryInfo, Result, SearchResponse}; -use jlogger_tracing::jinfo; +use crate::cache::Cache; +use crate::constants; +use crate::errors::{GhrError, Result}; +use crate::models::{Release, Repository, RepositoryInfo, SearchResponse}; +use jlogger_tracing::{jdebug, jinfo}; use reqwest::Client; +use tokio::time::{sleep, Duration}; + +/// Retry an async operation with exponential backoff +/// Only retries on network-related errors, not on logical errors like 404 +async fn retry_with_backoff(operation: F) -> Result +where + F: Fn() -> Fut, + Fut: std::future::Future>, +{ + let max_retries = constants::retry::MAX_RETRIES; + let mut attempts = 0; + + loop { + match operation().await { + Ok(result) => return Ok(result), + Err(e) => { + // Only retry on network errors, not on logical errors + let should_retry = matches!(e, GhrError::Network(_)); + + if should_retry && attempts < max_retries { + let delay = + Duration::from_secs(constants::retry::BASE_DELAY_SECS * 2u64.pow(attempts)); + jdebug!("Retry attempt {} after {:?}: {}", attempts + 1, delay, e); + sleep(delay).await; + attempts += 1; + } else { + return Err(e); + } + } + } + } +} /// Fetch release information from GitHub pub async fn get_release_info( @@ -8,43 +43,85 @@ pub async fn get_release_info( repo: &str, tag: Option<&str>, ) -> Result> { - let url = if let Some(tag) = tag { - format!( - "https://api.github.com/repos/{}/releases/tags/{}", - repo, tag - ) + get_release_info_with_base(client, constants::GITHUB_API_BASE, repo, tag).await +} + +/// Fetch release information from GitHub with custom base URL +pub async fn get_release_info_with_base( + client: &Client, + base_url: &str, + repo: &str, + tag: Option<&str>, +) -> Result> { + get_release_info_with_cache(client, base_url, repo, tag, None).await +} + +/// Fetch release information from GitHub with optional caching +pub async fn get_release_info_with_cache( + client: &Client, + base_url: &str, + repo: &str, + tag: Option<&str>, + cache: Option<&Cache>, +) -> Result> { + // Parse owner/repo from repo string + let parts: Vec<&str> = repo.split('/').collect(); + if parts.len() != 2 { + return Err(GhrError::Generic(format!( + "Invalid repository format: {}", + repo + ))); + } + let (owner, repo_name) = (parts[0], parts[1]); + + // Create cache key + let cache_key = if let Some(tag) = tag { + format!("releases:{}:{}:{}", repo, tag, base_url) } else { - format!("https://api.github.com/repos/{}/releases", repo) + format!("releases:{}:{}", repo, base_url) }; - let response = client - .get(&url) - .send() - .await - .map_err(|e| format!("Failed to send request: {}", e))?; - - if !response.status().is_success() { - return Err(format!( - "GitHub API request failed with status: {}", - response.status() - )); + // Try cache first + if let Some(cache) = cache { + if let Some(cached) = cache.get::>(&cache_key).await { + return Ok(cached); + } } - if tag.is_some() { - // Single release - let release: Release = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; - Ok(vec![release]) + let url = if let Some(tag) = tag { + constants::endpoints::release_by_tag_with_base(base_url, owner, repo_name, tag) } else { - // Multiple releases - let releases: Vec = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; - Ok(releases) + constants::endpoints::releases_with_base(base_url, owner, repo_name) + }; + + let result = retry_with_backoff(|| async { + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + return Err(GhrError::GitHubApi(format!( + "Failed to fetch releases: HTTP {}", + response.status() + ))); + } + + if tag.is_some() { + // Single release + let release: Release = response.json().await?; + Ok(vec![release]) + } else { + // Multiple releases + let releases: Vec = response.json().await?; + Ok(releases) + } + }) + .await?; + + // Cache the result + if let Some(cache) = cache { + let _ = cache.set(&cache_key, &result).await; } + + Ok(result) } /// Search pattern types @@ -60,7 +137,9 @@ pub fn parse_search_pattern(pattern: &str) -> Result { let pattern = pattern.trim(); if pattern.is_empty() { - return Err("Search pattern cannot be empty".to_string()); + return Err(GhrError::InvalidSearchPattern( + "Search pattern cannot be empty".to_string(), + )); } if let Some(slash_pos) = pattern.find('/') { @@ -70,7 +149,9 @@ pub fn parse_search_pattern(pattern: &str) -> Result { if username.is_empty() { // Pattern: "/keyword" if keyword.is_empty() { - return Err("Keyword cannot be empty for global search".to_string()); + return Err(GhrError::InvalidSearchPattern( + "Keyword cannot be empty for global search".to_string(), + )); } Ok(SearchPattern::GlobalKeyword { keyword: keyword.to_string(), @@ -100,6 +181,27 @@ pub async fn search_repositories( client: &Client, pattern: &SearchPattern, num: usize, +) -> Result> { + search_repositories_with_base(client, constants::GITHUB_API_BASE, pattern, num).await +} + +/// Search for repositories with custom base URL +pub async fn search_repositories_with_base( + client: &Client, + base_url: &str, + pattern: &SearchPattern, + num: usize, +) -> Result> { + search_repositories_with_cache(client, base_url, pattern, num, None).await +} + +/// Search for repositories with optional caching +pub async fn search_repositories_with_cache( + client: &Client, + base_url: &str, + pattern: &SearchPattern, + num: usize, + cache: Option<&Cache>, ) -> Result> { let query = match pattern { SearchPattern::UserWithKeyword { username, keyword } => { @@ -113,31 +215,40 @@ pub async fn search_repositories( } }; - let url = format!( - "https://api.github.com/search/repositories?q={}&sort=stars&order=desc&per_page={}", - urlencoding::encode(&query), - num - ); - - let response = client - .get(&url) - .send() - .await - .map_err(|e| format!("Failed to send request: {}", e))?; - - if !response.status().is_success() { - return Err(format!( - "GitHub API request failed with status: {}", - response.status() - )); + // Create cache key + let cache_key = format!("search:{}:{}:{}", query, num, base_url); + + // Try cache first + if let Some(cache) = cache { + if let Some(cached) = cache.get::>(&cache_key).await { + return Ok(cached); + } } - let search_response: SearchResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse response: {}", e))?; + let url = constants::endpoints::search_repositories_with_base(base_url, &query, num); + + let result = retry_with_backoff(|| async { + let response = client.get(&url).send().await?; - Ok(search_response.items) + if !response.status().is_success() { + return Err(GhrError::GitHubApi(format!( + "Failed to search repositories: HTTP {}", + response.status() + ))); + } + + let search_response: SearchResponse = response.json().await?; + + Ok(search_response.items) + }) + .await?; + + // Cache the result + if let Some(cache) = cache { + let _ = cache.set(&cache_key, &result).await; + } + + Ok(result) } /// Validate that a repository exists and is accessible @@ -146,33 +257,39 @@ pub async fn validate_repository( owner: &str, repo: &str, ) -> Result { - let url = format!("https://api.github.com/repos/{}/{}", owner, repo); + validate_repository_with_base(client, constants::GITHUB_API_BASE, owner, repo).await +} + +/// Validate that a repository exists and is accessible with custom base URL +pub async fn validate_repository_with_base( + client: &Client, + base_url: &str, + owner: &str, + repo: &str, +) -> Result { + let url = constants::endpoints::repository_with_base(base_url, owner, repo); jinfo!("Validating repository {}/{}...", owner, repo); - let response = client - .get(&url) - .send() - .await - .map_err(|e| format!("Failed to connect to GitHub API: {}", e))?; + retry_with_backoff(|| async { + let response = client.get(&url).send().await?; - if response.status().is_success() { - let repo_info: RepositoryInfo = response - .json() - .await - .map_err(|e| format!("Failed to parse repository response: {}", e))?; - Ok(repo_info) - } else if response.status() == reqwest::StatusCode::NOT_FOUND { - Err(format!( - "Repository '{}/{}' not found (or you don't have access)", - owner, repo - )) - } else { - Err(format!( - "GitHub API request failed with status: {}", - response.status() - )) - } + if response.status().is_success() { + let repo_info: RepositoryInfo = response.json().await?; + Ok(repo_info) + } else if response.status() == reqwest::StatusCode::NOT_FOUND { + Err(GhrError::RepositoryNotFound { + owner: owner.to_string(), + repo: repo.to_string(), + }) + } else { + Err(GhrError::GitHubApi(format!( + "Failed to validate repository: HTTP {}", + response.status() + ))) + } + }) + .await } /// Validate that a ref (branch/tag/commit) exists in a repository @@ -181,64 +298,70 @@ pub async fn validate_ref( owner: &str, repo: &str, ref_name: &str, +) -> Result { + validate_ref_with_base(client, constants::GITHUB_API_BASE, owner, repo, ref_name).await +} + +/// Validate that a ref (branch/tag/commit) exists in a repository with custom base URL +pub async fn validate_ref_with_base( + client: &Client, + base_url: &str, + owner: &str, + repo: &str, + ref_name: &str, ) -> Result { jinfo!("Validating ref '{}'...", ref_name); // Try as branch first - let branch_url = format!( - "https://api.github.com/repos/{}/{}/branches/{}", - owner, repo, ref_name - ); - - let response = client.get(&branch_url).send().await.map_err(|e| { - format!( - "Failed to connect to GitHub API while checking branch: {}", - e - ) - })?; + let branch_url = constants::endpoints::branch_with_base(base_url, owner, repo, ref_name); + + let response = retry_with_backoff(|| async { + client + .get(&branch_url) + .send() + .await + .map_err(GhrError::Network) + }) + .await?; if response.status().is_success() { return Ok("branch".to_string()); } // Try as tag - let tag_url = format!( - "https://api.github.com/repos/{}/{}/git/refs/tags/{}", - owner, repo, ref_name - ); + let tag_url = constants::endpoints::tag_with_base(base_url, owner, repo, ref_name); - let response = client - .get(&tag_url) - .send() - .await - .map_err(|e| format!("Failed to connect to GitHub API while checking tag: {}", e))?; + let response = retry_with_backoff(|| async { + client.get(&tag_url).send().await.map_err(GhrError::Network) + }) + .await?; if response.status().is_success() { return Ok("tag".to_string()); } // Try as commit SHA - let commit_url = format!( - "https://api.github.com/repos/{}/{}/commits/{}", - owner, repo, ref_name - ); - - let response = client.get(&commit_url).send().await.map_err(|e| { - format!( - "Failed to connect to GitHub API while checking commit: {}", - e - ) - })?; + let commit_url = constants::endpoints::commit_with_base(base_url, owner, repo, ref_name); + + let response = retry_with_backoff(|| async { + client + .get(&commit_url) + .send() + .await + .map_err(GhrError::Network) + }) + .await?; if response.status().is_success() { return Ok("commit".to_string()); } // Ref not found - Err(format!( - "Branch/tag/commit '{}' not found in repository '{}/{}'", - ref_name, owner, repo - )) + Err(GhrError::RefNotFound { + owner: owner.to_string(), + repo: repo.to_string(), + ref_name: ref_name.to_string(), + }) } #[cfg(test)] @@ -299,7 +422,8 @@ mod tests { fn test_parse_search_pattern_empty_string() { let result = parse_search_pattern(""); assert!(result.is_err()); - assert!(result.unwrap_err().contains("empty")); + // Check that it's an InvalidSearchPattern error + matches!(result.unwrap_err(), GhrError::InvalidSearchPattern(_)); } #[test] @@ -312,7 +436,8 @@ mod tests { fn test_parse_search_pattern_empty_global_keyword() { let result = parse_search_pattern("/"); assert!(result.is_err()); - assert!(result.unwrap_err().contains("Keyword cannot be empty")); + // Check that it's an InvalidSearchPattern error + matches!(result.unwrap_err(), GhrError::InvalidSearchPattern(_)); } #[test] diff --git a/src/main.rs b/src/main.rs index 0390814..fa4566c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,20 +1,24 @@ mod auth; +mod cache; mod cli; +mod constants; +mod errors; +mod filters; mod git; mod github; mod models; use chrono::prelude::*; use cli::Cli; +use errors::{GhrError, Result}; use futures::stream::{self, StreamExt}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use jlogger_tracing::{jdebug, jerror, jinfo, JloggerBuilder, LevelFilter, LogTimeFormat}; -use models::Result; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, USER_AGENT}; use reqwest::Client; -use std::fs; use std::path::PathBuf; use std::sync::Arc; +use tokio::fs; use clap::Parser; @@ -24,10 +28,10 @@ async fn main() -> Result<()> { // Validate that either --repo, --search, or --clone is provided if cli.repo.is_none() && cli.search.is_none() && cli.clone.is_none() { - return Err( + return Err(GhrError::MissingArgument( "Either --repo, --search, or --clone must be provided. Use --help for more information." .to_string(), - ); + )); } let verbose = cli.verbose; @@ -47,36 +51,38 @@ async fn main() -> Result<()> { header.insert( ACCEPT, - HeaderValue::from_static("application/vnd.github.v3+json"), + HeaderValue::from_static(constants::headers::ACCEPT_API_V3), ); - header.insert(USER_AGENT, HeaderValue::from_static("gh_release")); + header.insert(USER_AGENT, HeaderValue::from_static(constants::USER_AGENT)); header.insert( "X-GitHub-Api-Version", - HeaderValue::from_static("2022-11-28"), + HeaderValue::from_static(constants::GITHUB_API_VERSION), ); if auth::add_auth_header(&cli, &mut header).is_err() { jinfo!("No authentication method provided, proceeding unauthenticated"); } - let client = Client::builder() - .default_headers(header) - .build() - .map_err(|e| e.to_string())?; + let client = Client::builder().default_headers(header).build()?; + + // Create cache instance + let cache = cache::Cache::new(cli.cache); // CLONE MODE - handle repository cloning if let Some(clone_arg) = cli.clone.as_deref() { jinfo!("Clone mode activated"); // Check git is installed - git::check_git_installed()?; + git::check_git_installed().await?; // Parse clone specification let spec = git::parse_clone_url(clone_arg)?; jinfo!("Cloning repository: {}/{}", spec.owner, spec.repo); // Validate repository exists - let repo_info = github::validate_repository(&client, &spec.owner, &spec.repo).await?; + let repo_info = + github::validate_repository_with_base(&client, &cli.api_url, &spec.owner, &spec.repo) + .await?; jinfo!( "Repository found: {} ({})", repo_info.full_name, @@ -89,7 +95,14 @@ async fn main() -> Result<()> { // Validate ref if specified if let Some(ref_name) = spec.ref_name.as_ref() { - let ref_type = github::validate_ref(&client, &spec.owner, &spec.repo, ref_name).await?; + let ref_type = github::validate_ref_with_base( + &client, + &cli.api_url, + &spec.owner, + &spec.repo, + ref_name, + ) + .await?; jinfo!("Reference '{}' found (type: {})", ref_name, ref_type); } @@ -103,9 +116,21 @@ async fn main() -> Result<()> { // Construct clone URL with auth if available let clone_url = git::construct_clone_url(&spec.owner, &spec.repo, token.as_deref()); + // Handle dry-run mode + if cli.dry_run { + eprintln!("\nDry-run mode: Would clone repository"); + eprintln!(" Repository: {}/{}", spec.owner, spec.repo); + if let Some(ref_name) = &spec.ref_name { + eprintln!(" Ref: {}", ref_name); + } + eprintln!(" Target directory: {}", target_dir); + eprintln!("\nNo action taken (dry-run mode)"); + return Ok(()); + } + // Execute clone jinfo!("Cloning to '{}'...", target_dir); - git::execute_git_clone(&clone_url, target_dir, spec.ref_name.as_deref())?; + git::execute_git_clone(&clone_url, target_dir, spec.ref_name.as_deref()).await?; jinfo!("Successfully cloned repository to '{}'", target_dir); return Ok(()); @@ -116,71 +141,88 @@ async fn main() -> Result<()> { jinfo!("Searching repositories with pattern: {}", search_pattern); let pattern = github::parse_search_pattern(search_pattern)?; - let repositories = github::search_repositories(&client, &pattern, cli.num).await?; + let repositories = github::search_repositories_with_cache( + &client, + &cli.api_url, + &pattern, + cli.num, + Some(&cache), + ) + .await?; if repositories.is_empty() { jinfo!("No repositories found matching the search criteria"); return Ok(()); } - // Display results in table format - eprintln!("{:4} {:<7} {:2}{:40}", "No", "Stars", " ", "Repository",); - eprintln!("{:-<108}", ""); + // Display results based on format + match cli.format { + cli::OutputFormat::Json => { + let json = serde_json::to_string_pretty(&repositories)?; + println!("{}", json); + } + cli::OutputFormat::Table => { + // Display results in table format + eprintln!("{:4} {:<7} {:2}{:40}", "No", "Stars", " ", "Repository",); + eprintln!("{:-<108}", ""); - for (i, repo) in repositories.iter().enumerate() { - eprintln!("{:<4} {}", i + 1, repo.summary()); - } + for (i, repo) in repositories.iter().enumerate() { + eprintln!("{:<4} {}", i + 1, repo.summary()); + } - eprintln!("\nFound {} repositories", repositories.len()); + eprintln!("\nFound {} repositories", repositories.len()); + } + } return Ok(()); } if let Some(download) = cli.download.as_deref() { - let repo = cli - .repo - .as_deref() - .ok_or_else(|| "--repo is required for download mode".to_string())?; - let releases = github::get_release_info(&client, repo, None).await?; + let repo = cli.repo.as_deref().ok_or_else(|| { + GhrError::MissingArgument("--repo is required for download mode".to_string()) + })?; + let releases = + github::get_release_info_with_cache(&client, &cli.api_url, repo, None, Some(&cache)) + .await?; // Support "latest" as a special keyword to download the most recent release let release = if download == "latest" { jinfo!("Downloading latest release"); - releases - .first() - .ok_or_else(|| "No releases found in repository".to_string())? + releases.first().ok_or_else(|| GhrError::NoReleases)? } else { jinfo!("Downloading release: {}", download); releases .iter() .find(|r| r.tag_name == download) - .ok_or_else(|| format!("Release with tag '{}' not found", download))? + .ok_or_else(|| GhrError::ReleaseNotFound { + tag: download.to_string(), + })? }; // Create output directory if specified if let Some(directory) = &cli.directory { - fs::create_dir_all(directory) - .map_err(|e| format!("Failed to create output directory '{}': {}", directory, e))?; + fs::create_dir_all(directory).await?; jinfo!("Saving assets to: {}", directory); } + // Parse filter patterns + let filter_patterns: Vec = if let Some(filter) = cli.filter.as_deref() + { + filter + .split(',') + .map(|f| filters::parse_filter(f.trim())) + .collect::>>()? + } else { + Vec::new() + }; + // Collect assets to download with filtering let mut assets_to_download = Vec::new(); for asset in &release.assets { let name = &asset.name; - let mut do_download = true; - if let Some(filter) = cli.filter.as_deref() { - do_download = false; - let filters = filter.split(',').collect::>(); - for &f in filters.iter() { - if name.contains(f) { - do_download = true; - break; - } - } - } - if !do_download { + // Apply advanced filtering + if !filters::apply_filters(name, &filter_patterns) { jinfo!("Skipping asset '{}' due to filter", name); continue; } @@ -206,6 +248,35 @@ async fn main() -> Result<()> { return Ok(()); } + // Handle dry-run mode + if cli.dry_run { + eprintln!( + "\nDry-run mode: Would download {} asset(s)", + assets_to_download.len() + ); + eprintln!("{:-<80}", ""); + + let mut total_size: u64 = 0; + for (name, _, _, size) in &assets_to_download { + let size_mb = *size as f64 / 1_048_576.0; + eprintln!(" - {} ({:.2} MB)", name, size_mb); + total_size += size; + } + + let total_mb = total_size as f64 / 1_048_576.0; + eprintln!("{:-<80}", ""); + eprintln!("Total size: {:.2} MB", total_mb); + + if let Some(directory) = &cli.directory { + eprintln!("Destination: {}", directory); + } else { + eprintln!("Destination: current directory"); + } + + eprintln!("\nNo action taken (dry-run mode)"); + return Ok(()); + } + jinfo!( "Downloading {} asset(s) with concurrency limit of {}", assets_to_download.len(), @@ -238,15 +309,15 @@ async fn main() -> Result<()> { // Download with progress tracking let response = client .get(&url) - .header(ACCEPT, "application/octet-stream") + .header(ACCEPT, constants::headers::ACCEPT_OCTET_STREAM) .send() .await - .map_err(|e| format!("Failed to download '{}': {}", name, e))?; + .map_err(GhrError::Network)?; let status = response.status(); if !status.is_success() { pb.finish_with_message(format!("Failed: {} (HTTP {})", name, status)); - return Err(format!("HTTP {} for '{}'", status, name)); + return Err(GhrError::GitHubApi(format!("HTTP {} for '{}'", status, name))); } // Read bytes with progress @@ -256,7 +327,7 @@ async fn main() -> Result<()> { while let Some(chunk_result) = stream.next().await { let chunk = - chunk_result.map_err(|e| format!("Download error for '{}': {}", name, e))?; + chunk_result.map_err(GhrError::Network)?; downloaded += chunk.len() as u64; bytes_vec.extend_from_slice(&chunk); pb.set_position(downloaded); @@ -265,9 +336,9 @@ async fn main() -> Result<()> { pb.finish_with_message(format!("Complete: {}", name)); // Write to file - fs::write(&output_path, &bytes_vec).map_err(|e| { - format!("Failed to write file '{}': {}", output_path.display(), e) - })?; + fs::write(&output_path, &bytes_vec) + .await + .map_err(GhrError::Io)?; Ok(name) } @@ -297,17 +368,19 @@ async fn main() -> Result<()> { for error in &errors { jerror!(" - {}", error); } - return Err(format!("Download failed with {} error(s)", errors.len())); + return Err(GhrError::Generic(format!( + "Download failed with {} error(s)", + errors.len() + ))); } return Ok(()); } // INFO MODE or default list mode - let repo = cli - .repo - .as_deref() - .ok_or_else(|| "--repo is required for info/list mode".to_string())?; + let repo = cli.repo.as_deref().ok_or_else(|| { + GhrError::MissingArgument("--repo is required for info/list mode".to_string()) + })?; if let Some(info_tags) = cli.info.as_deref() { // INFO MODE - show detailed information about specific versions @@ -315,7 +388,14 @@ async fn main() -> Result<()> { for tag in tags { jinfo!("Fetching information for release: {}", tag); - let releases = github::get_release_info(&client, repo, Some(tag)).await?; + let releases = github::get_release_info_with_cache( + &client, + &cli.api_url, + repo, + Some(tag), + Some(&cache), + ) + .await?; if let Some(release) = releases.first() { println!("\n{}", "=".repeat(80)); @@ -330,39 +410,49 @@ async fn main() -> Result<()> { } } else { // LIST MODE - show list of recent releases - let releases = github::get_release_info(&client, repo, None).await?; - let releases_to_show = releases.iter().take(cli.num); - - eprintln!( - "{:4} {:20} {:30} {:15} {:10}", - "No", "Tag", "Name", "Published", "Assets" - ); - eprintln!("{:-<108}", ""); - - for (i, release) in releases_to_show.enumerate() { - let name = release.name.as_deref().unwrap_or("N/A"); - - // Parse and format the published date - let published = DateTime::parse_from_rfc3339(&release.published_at) - .ok() - .map(|dt| dt.format("%Y-%m-%d").to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + let releases = + github::get_release_info_with_cache(&client, &cli.api_url, repo, None, Some(&cache)) + .await?; + let releases_to_show: Vec<_> = releases.iter().take(cli.num).collect(); + + match cli.format { + cli::OutputFormat::Json => { + let json = serde_json::to_string_pretty(&releases_to_show)?; + println!("{}", json); + } + cli::OutputFormat::Table => { + eprintln!( + "{:4} {:20} {:30} {:15} {:10}", + "No", "Tag", "Name", "Published", "Assets" + ); + eprintln!("{:-<108}", ""); + + for (i, release) in releases_to_show.iter().enumerate() { + let name = release.name.as_deref().unwrap_or("N/A"); + + // Parse and format the published date + let published = DateTime::parse_from_rfc3339(&release.published_at) + .ok() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + + eprintln!( + "{:<4} {:20} {:30} {:15} {:10}", + i + 1, + release.tag_name, + truncate(name, 30), + published, + release.assets.len() + ); + } - eprintln!( - "{:<4} {:20} {:30} {:15} {:10}", - i + 1, - release.tag_name, - truncate(name, 30), - published, - release.assets.len() - ); + eprintln!( + "\nShowing {} of {} releases", + cli.num.min(releases.len()), + releases.len() + ); + } } - - eprintln!( - "\nShowing {} of {} releases", - cli.num.min(releases.len()), - releases.len() - ); } Ok(()) diff --git a/src/models.rs b/src/models.rs index b730b64..31adf0d 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,8 +1,8 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::fmt::Display; /// GitHub release asset -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Asset { pub name: String, pub browser_download_url: String, @@ -22,7 +22,7 @@ impl Display for Asset { } /// GitHub release -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Release { pub tag_name: String, pub name: Option, @@ -53,7 +53,7 @@ pub struct SearchResponse { /// GitHub repository #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Repository { pub name: String, pub full_name: String, @@ -65,7 +65,7 @@ pub struct Repository { } #[allow(dead_code)] -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, Serialize)] pub struct Owner { pub login: String, } @@ -125,8 +125,7 @@ pub struct RepositoryInfo { pub private: bool, } -/// Custom result type -pub type Result = std::result::Result; +// Result type is now defined in errors.rs #[cfg(test)] mod tests {