From 0950b86c28e8af3195dad96bfa2b6118784b9f48 Mon Sep 17 00:00:00 2001 From: Francesco Sardone Date: Fri, 24 Apr 2026 12:19:22 +0200 Subject: [PATCH 1/6] chore: update spec template file --- .specs/TEMPLATE.md | 68 +++++++++++++++----------- .specs/v1.0.0-stability.md | 99 -------------------------------------- 2 files changed, 41 insertions(+), 126 deletions(-) delete mode 100644 .specs/v1.0.0-stability.md diff --git a/.specs/TEMPLATE.md b/.specs/TEMPLATE.md index b8f131c..60c9ce0 100644 --- a/.specs/TEMPLATE.md +++ b/.specs/TEMPLATE.md @@ -1,42 +1,56 @@ -# Spec Template +# Spec Title — Short Descriptor + +## Goal + +[One or two sentences: what does this spec achieve and why.] + +--- ## Small Change (bug fix, tweak) Skip this template. Write a clear PR description instead. -## Feature / New Behavior +--- -### Problem -[What problem does this solve?] +## 1. Section Name -### Solution -[High-level approach] +**What:** [One sentence describing the deliverable.] -### Acceptance Criteria -- [ ] Criterion 1 -- [ ] Criterion 2 -- [ ] Criterion 3 +**Spec:** +- Requirement 1 +- Requirement 2 +- Requirement 3 -### Edge Cases -- [ ] Edge case 1: [expected behavior] -- [ ] Edge case 2: [expected behavior] +**Checkpoint:** [Concrete, verifiable condition that confirms this section is done.] -### Test Plan -- Unit tests: [what to test] -- Integration tests: [what to test] -- Manual verification: [steps] +--- -### Files to Modify -- [ ] file.rs +## 2. Section Name -### Documentation Updates -- [ ] CHANGELOG.md -- [ ] README.md (if user-facing) +**What:** [One sentence describing the deliverable.] + +**Spec:** +- Requirement 1 +- Requirement 2 + +**Checkpoint:** [Concrete, verifiable condition.] --- -## Checkpoints (tied to deliverables, not percentages) +## Implementation Order + +1. Section that must come first (e.g., audit / discovery) +2. Section that depends on 1 +3. Section that depends on 2 +4. Final pass (fmt, clippy, test, version bump if applicable) + +## Test Plan + +- [Test category]: [what is tested] +- [Test category]: [what is tested] +- `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass -- [ ] [Deliverable 1 — e.g., "Semver parsing compiles and passes tests"] -- [ ] [Deliverable 2 — e.g., "Lockfile generates valid TOML"] -- [ ] [Deliverable 3 — e.g., "Alias cycle works end-to-end"] -- [ ] [Final review before PR] \ No newline at end of file +## Files to Modify + +- [ ] file.rs +- [ ] CHANGELOG.md +- [ ] README.md (if user-facing) diff --git a/.specs/v1.0.0-stability.md b/.specs/v1.0.0-stability.md deleted file mode 100644 index 14c285b..0000000 --- a/.specs/v1.0.0-stability.md +++ /dev/null @@ -1,99 +0,0 @@ -# v1.0.0 — Stability - -## Goal - -Harden the codebase for a stable 1.0.0 release: full test coverage, stable formats, clean documentation, and a repository audit. - ---- - -## 1. Repository Audit - -**What:** Review the entire repo for stale files, inconsistencies, and housekeeping. - -**Spec:** -- Remove unused dev-dependencies from `Cargo.toml` -- Verify all `#[allow(dead_code)]` and `#[allow(clippy::...)]` are still needed; remove stale ones -- Check for TODO/FIXME/HACK comments — resolve or remove -- Verify `.gitignore` is clean (no stale entries) -- Verify `AGENTS.md` reflects current state (module table, test structure, conventions) -- Remove any dead code paths or unreachable branches -- Ensure all public API items are used by integration tests - -**Checkpoint:** `cargo clippy -- -D warnings` and `cargo test` pass; no stale annotations or dead code. - ---- - -## 2. Stable Registry Format - -**What:** Document and freeze the registry TOML schema so future changes include migration paths. - -**Spec:** -- Document `InstalledPackage` schema in a `SCHEMA.md` file at repo root -- List all fields, their types, and whether they're required or defaulted -- State: any future field additions must use `#[serde(default)]` for backward compatibility -- Any future field removals must include a migration step in the CHANGELOG -- Add a `format_version` field to `Registry` (default `"1"`) for future migration detection - -**Checkpoint:** `SCHEMA.md` exists and matches the actual `InstalledPackage` struct; `format_version` roundtrips through TOML. - ---- - -## 3. Stable Config Format - -**What:** Same treatment for the config schema. - -**Spec:** -- Document config schema in `SCHEMA.md` alongside registry -- List all fields, types, defaults, and config sources (env, local, XDG, legacy) -- State: any future config additions must have defaults; no breaking removals without a major version bump - -**Checkpoint:** Config schema documented; matches actual `Config` struct. - ---- - -## 4. Full Test Coverage - -**What:** Identify untested public API and add integration tests. - -**Spec:** -- List all `pub fn` in the crate; identify gaps vs `tests/` -- Minimum coverage targets: - - All CLI commands have at least one integration test - - All `pub fn` in `core/` and `network/` modules are exercised - - Error paths for invalid input are tested -- Add `tests/cli.rs` for command-line parsing edge cases -- Add `tests/integration.rs` for end-to-end workflows (install -> list -> update -> uninstall) -- Note: network-dependent tests (actual GitHub API calls) are out of scope for CI; focus on unit/integration tests that mock or avoid network - -**Checkpoint:** Every `pub fn` has at least one test; `cargo test` passes with 0 failures. - ---- - -## 5. Documentation Cleanup - -**What:** Ensure README, CHANGELOG, and AGENTS.md are accurate and complete. - -**Spec:** -- README.md: verify all examples work, all commands listed, supported platforms accurate -- CHANGELOG.md: verify all entries match actual releases -- AGENTS.md: update module table to include all current modules (channel, export, cache, etc.) -- ROADMAP.md: clear — all planned features shipped - -**Checkpoint:** No outdated info in any documentation file. - ---- - -## Implementation Order - -1. Repository audit (find issues first) -2. Documentation cleanup (fix what audit found) -3. Stable formats (SCHEMA.md, format_version field) -4. Full test coverage (fill gaps) -5. Final pass: fmt, clippy, test, version bump to 1.0.0 - -## Test Plan - -- All existing tests pass -- New tests cover previously untested public API -- `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass -- SCHEMA.md validated against actual structs \ No newline at end of file From 1f4c8103a1919c7aa24fe0a28bfb44e80c0c0009 Mon Sep 17 00:00:00 2001 From: Francesco Sardone Date: Fri, 24 Apr 2026 15:10:02 +0200 Subject: [PATCH 2/6] refactor: code clean up and better structuring --- src/cli/mod.rs | 29 ++++++-- src/core/alias.rs | 28 ++++++-- src/core/cache.rs | 10 ++- src/core/checksum.rs | 39 ++++++++--- src/core/config.rs | 83 +++++++++++----------- src/core/constants.rs | 16 ++++- src/core/extract.rs | 91 ++++++++++++++++-------- src/core/install.rs | 142 ++++++++++++------------------------- src/core/lockfile.rs | 3 +- src/core/mod.rs | 1 + src/core/registry.rs | 30 ++++---- src/core/run.rs | 59 ++++++++++++++++ src/core/updater.rs | 47 +++++-------- src/core/util.rs | 61 ++++++++++++++-- src/lib.rs | 1 + src/main.rs | 102 ++++----------------------- src/network/github.rs | 147 +++++++++++---------------------------- src/network/platform.rs | 67 +++++++++++++----- src/output/mod.rs | 14 +++- tests/channel_persist.rs | 18 +++-- tests/config.rs | 20 +++--- 21 files changed, 542 insertions(+), 466 deletions(-) create mode 100644 src/core/run.rs diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 72bcdc7..6ace352 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,7 +1,10 @@ +use std::path::PathBuf; + use clap::{Parser, Subcommand}; use clap_complete::Shell; -use crate::constants::{APP_NAME, ENV_VAR_TOKEN}; +use crate::constants::{APP_NAME, ENV_VAR_TOKEN, SEARCH_LIMIT_DEFAULT}; +use crate::core::channel::Channel; #[derive(Parser)] #[command( @@ -28,6 +31,7 @@ pub struct Cli { pub enum CacheAction { #[command(about = "Remove all cached archives.")] Clean {}, + #[command(about = "Show total cache size on disk.")] Size {}, } @@ -41,11 +45,13 @@ pub enum AliasAction { #[arg(help = "Full package name (owner/repo).")] target: String, }, + #[command(about = "Remove a package alias.")] Remove { #[arg(help = "Alias to remove.")] alias: String, }, + #[command(about = "List all aliases.")] List {}, } @@ -57,11 +63,13 @@ pub enum Commands { #[command(subcommand)] action: AliasAction, }, + #[command(about = "Manage the asset cache.")] Cache { #[command(subcommand)] action: CacheAction, }, + #[command(about = "Install packages from GitHub releases.")] Install { #[arg(num_args = 1.., help = "Package(s) to install (format: owner/repo or owner/repo@version).")] @@ -77,8 +85,9 @@ pub enum Commands { #[arg(long, help = "Install to project-local .gitclaw/ directory.")] local: bool, #[arg(long, help = "Release channel: stable, beta, or nightly.")] - channel: Option, + channel: Option, }, + #[command(about = "Generate a lockfile from installed packages.")] Lock { #[arg( @@ -87,8 +96,9 @@ pub enum Commands { default_value = ".", help = "Directory to write gitclaw.lock to." )] - dir: String, + dir: PathBuf, }, + #[command(about = "List installed packages.")] List { #[arg(short, long, help = "Show detailed information.")] @@ -96,11 +106,13 @@ pub enum Commands { #[arg(long, help = "Show packages with newer versions available.")] outdated: bool, }, + #[command(about = "Update installed packages.")] Update { #[arg(help = "Package to update (omit to update all).")] package: Option, }, + #[command(about = "Uninstall a package.")] Uninstall { #[arg(help = "Package to uninstall (format: owner/repo or identifier).")] @@ -108,6 +120,7 @@ pub enum Commands { #[arg(long, help = "Uninstall from project-local .gitclaw/ directory.")] local: bool, }, + #[command(about = "Search for releases on GitHub.")] Search { #[arg(help = "Repository to search (format: owner/repo).")] @@ -115,18 +128,20 @@ pub enum Commands { #[arg( short, long, - default_value = "10", + default_value = SEARCH_LIMIT_DEFAULT, help = "Maximum number of releases to show." )] limit: usize, #[arg(long, help = "Filter by release channel: stable, beta, or nightly.")] - channel: Option, + channel: Option, }, + #[command(about = "Export installed packages to TOML.")] Export { #[arg(short, long, help = "Output file (default: stdout).")] output: Option, }, + #[command(about = "Install packages from a TOML file.")] Import { #[arg(help = "TOML file to import packages from.")] @@ -134,18 +149,22 @@ pub enum Commands { #[arg(long, help = "Force reinstall already-installed packages.")] force: bool, }, + #[command(about = "Generate shell completions.")] Completions { #[arg(value_enum, help = "Shell to generate completions for.")] shell: Shell, }, + #[command(about = "Show platform information.")] Platform {}, + #[command(name = "self", about = "Update gitclaw to the latest version.")] SelfUpdate { #[arg(long, help = "Only check for updates, do not install.")] check: bool, }, + #[command(about = "Run an installed package.")] Run { #[arg(help = "Package to run (format: owner/repo or identifier).")] diff --git a/src/core/alias.rs b/src/core/alias.rs index 7763f8d..5b8c0ee 100644 --- a/src/core/alias.rs +++ b/src/core/alias.rs @@ -6,8 +6,7 @@ use colored::Colorize; use serde::{Deserialize, Serialize}; use crate::core::config::Config; - -const ALIASES_FILE: &str = "aliases.toml"; +use crate::core::constants::{ALIASES_FILE, KV_KEY_WIDTH}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AliasMap { @@ -29,8 +28,10 @@ impl AliasMap { pub fn save(&self, config: &Config) -> Result<()> { let path = config.install_dir.join(ALIASES_FILE); + let content = toml::to_string_pretty(self).with_context(|| "Failed to serialize aliases")?; + fs::write(&path, content).with_context(|| "Failed to write aliases file") } @@ -123,10 +124,13 @@ pub fn handle_alias_list(config: &Config) -> Result<()> { return Ok(()); } - println!("{}", format!("{:<20} {}", "Alias", "Target").bold()); + println!( + "{}", + format!("{: (Config, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); + let config = Config { install_dir: dir.path().to_path_buf(), ..Config::default() }; + (config, dir) } @@ -159,6 +165,7 @@ mod tests { fn test_alias_add_slash_rejected() { let (config, _dir) = make_config(); let mut aliases = AliasMap::default(); + assert!(aliases .add("owner/repo", "BurntSushi/ripgrep", &config) .is_err()); @@ -208,3 +215,16 @@ mod tests { assert_eq!(loaded.resolve("fd"), Some("sharkdp/fd")); } } + +pub fn resolve_package_input(input: &str, config: &Config) -> Result { + if input.contains('/') { + return Ok(input.to_string()); + } + + if let Some(target) = AliasMap::load(config)?.resolve(input) { + crate::output::print_info(&format!("Alias '{}' -> '{}'.", input, target)); + return Ok(target.to_string()); + } + + Ok(input.to_string()) +} diff --git a/src/core/cache.rs b/src/core/cache.rs index 932b4aa..d6c9b38 100644 --- a/src/core/cache.rs +++ b/src/core/cache.rs @@ -5,11 +5,12 @@ use anyhow::{Context, Result}; use sha2::{Digest, Sha256}; use crate::core::config::Config; +use crate::core::constants::DIR_CACHE; use crate::core::util; use crate::output; pub fn cache_dir(config: &Config) -> PathBuf { - config.install_dir.join("cache") + config.install_dir.join(DIR_CACHE) } pub fn cache_key(owner: &str, repo: &str, version: &str, filename: &str) -> String { @@ -29,6 +30,7 @@ pub fn file_hash(path: &Path) -> Result { pub fn get_cached(config: &Config, key: &str, expected_hash: Option<&str>) -> Option { let path = cache_path(config, key); + if !path.exists() { return None; } @@ -53,6 +55,7 @@ pub fn store(config: &Config, key: &str, source: &Path) -> Result { pub fn clean(config: &Config) -> Result { let dir = cache_dir(config); + if !dir.exists() { return Ok(0); } @@ -71,13 +74,16 @@ pub fn clean(config: &Config) -> Result { pub fn size(config: &Config) -> Result { let dir = cache_dir(config); + if !dir.exists() { return Ok(0); } let mut total = 0u64; + for entry in fs::read_dir(&dir)? { let entry = entry?; + if entry.file_type()?.is_file() { total += entry.metadata()?.len(); } @@ -88,11 +94,13 @@ pub fn size(config: &Config) -> Result { pub fn handle_cache_clean(config: &Config) -> Result<()> { let removed = clean(config)?; + if removed == 0 { output::print_info("Cache is already empty."); } else { output::print_success(&format!("Removed {} cached file(s).", removed)); } + Ok(()) } diff --git a/src/core/checksum.rs b/src/core/checksum.rs index 3e3558e..1ef6dd3 100644 --- a/src/core/checksum.rs +++ b/src/core/checksum.rs @@ -14,6 +14,7 @@ pub enum ChecksumAlgorithm { pub fn is_checksum_file(filename: &str) -> Option { let lower = filename.to_lowercase(); + if lower.ends_with(".sha256") || lower.contains(".sha256.") { Some(ChecksumAlgorithm::Sha256) } else if lower.ends_with(".sha512") || lower.contains(".sha512.") { @@ -25,27 +26,42 @@ pub fn is_checksum_file(filename: &str) -> Option { } } +pub fn is_checksum_asset(name: &str) -> bool { + let lower = name.to_lowercase(); + + lower.ends_with(".sha256") + || lower.ends_with(".sha512") + || lower.ends_with(".sha") + || lower.ends_with(".sig") + || lower.ends_with(".asc") + || lower.ends_with(".md5") + || lower.ends_with(".checksum") + || lower.contains("checksum") + || lower.contains("sha256sum") + || lower.contains("sha512sum") +} + pub fn find_checksum_file( asset_name: &str, assets: &[crate::network::github::Asset], ) -> Option<(ChecksumAlgorithm, String)> { - let patterns = vec![ - format!("{}.sha256", asset_name), - format!("{}.sha512", asset_name), - format!("{}.md5", asset_name), + let suffixes: [(&str, ChecksumAlgorithm); 3] = [ + (".sha256", ChecksumAlgorithm::Sha256), + (".sha512", ChecksumAlgorithm::Sha512), + (".md5", ChecksumAlgorithm::Md5), ]; for asset in assets { - for pattern in &patterns { - if asset.name == *pattern { - let algo = is_checksum_file(&asset.name)?; - return Some((algo, asset.browser_download_url.clone())); + for (suffix, algo) in &suffixes { + if asset.name == format!("{}{}", asset_name, suffix) { + return Some((*algo, asset.browser_download_url.clone())); } } } for asset in assets { let name_lower = asset.name.to_lowercase(); + if name_lower.contains("checksum") || name_lower.contains("sha256sum") { return Some(( ChecksumAlgorithm::Sha256, @@ -76,6 +92,7 @@ pub fn verify_file(file_path: &Path, expected: &str, algo: ChecksumAlgorithm) -> pub fn calculate_checksum(file_path: &Path, algo: ChecksumAlgorithm) -> Result { let mut file = fs::File::open(file_path).context("Failed to open file for checksum")?; let mut buffer = Vec::new(); + file.read_to_end(&mut buffer) .context("Failed to read file")?; @@ -85,11 +102,13 @@ pub fn calculate_checksum(file_path: &Path, algo: ChecksumAlgorithm) -> Result { let mut hasher = Sha512::new(); hasher.update(&buffer); format!("{:x}", hasher.finalize()) } + ChecksumAlgorithm::Md5 => { let hash = md5::compute(&buffer); format!("{:x}", hash) @@ -102,18 +121,22 @@ pub fn calculate_checksum(file_path: &Path, algo: ChecksumAlgorithm) -> Result Option { for line in content.lines() { let line = line.trim(); + if line.is_empty() || line.starts_with('#') { continue; } let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { let hash = parts[0]; let filename = parts[1].trim_start_matches('*'); + if filename == target_filename { return Some(hash.to_string()); } } } + None } diff --git a/src/core/config.rs b/src/core/config.rs index fd4f0a3..dba6c8c 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -3,18 +3,29 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; use crate::core::constants::{ CONFIG_FILE, ENV_VAR_CONFIG, GITCLAW_DIR, LOCAL_CONFIG_FILE, XDG_CONFIG_SUBDIR, }; -use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ColorMode { + #[default] + Auto, + Never, + Always, +} #[derive(Debug, Clone, Deserialize)] pub struct DownloadConfig { #[serde(default = "default_true")] pub show_progress: bool, + #[serde(default = "default_true")] pub prefer_strip: bool, + #[serde(default = "default_true")] pub verify_checksums: bool, } @@ -29,33 +40,27 @@ impl Default for DownloadConfig { } } -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, Default)] pub struct OutputConfig { - #[serde(default = "default_color")] - pub color: String, + #[serde(default)] + pub color: ColorMode, + #[serde(default)] pub quiet: bool, + #[serde(default)] pub verbose: bool, } -impl Default for OutputConfig { - fn default() -> Self { - Self { - color: "auto".to_string(), - quiet: false, - verbose: false, - } - } -} - #[derive(Debug, Clone, Deserialize)] pub struct Config { #[serde(default = "default_install_dir")] pub install_dir: PathBuf, pub github_token: Option, + #[serde(default)] pub download: DownloadConfig, + #[serde(default)] pub output: OutputConfig, } @@ -75,10 +80,6 @@ fn default_true() -> bool { true } -fn default_color() -> String { - "auto".to_string() -} - fn default_install_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) @@ -92,12 +93,15 @@ impl Config { if let Some(legacy) = Self::load_from_legacy()? { config.merge(legacy); } + if let Some(xdg) = Self::load_from_xdg()? { config.merge(xdg); } + if let Some(local) = Self::load_from_local()? { config.merge(local); } + if let Some(env) = Self::load_from_env()? { config.merge(env); } @@ -109,8 +113,10 @@ impl Config { if let Ok(path) = env::var(ENV_VAR_CONFIG) { let content = fs::read_to_string(&path) .with_context(|| format!("Failed to read config from GITCLAW_CONFIG: {}", path))?; + let config: Config = toml::from_str(&content) .with_context(|| format!("Failed to parse config from GITCLAW_CONFIG: {}", path))?; + return Ok(Some(config)); } Ok(None) @@ -118,41 +124,53 @@ impl Config { pub fn load_from_local() -> Result> { let path = PathBuf::from(LOCAL_CONFIG_FILE); + if path.exists() { let content = fs::read_to_string(&path).with_context(|| "Failed to read project-local config")?; + let config: Config = toml::from_str(&content).with_context(|| "Failed to parse project-local config")?; + return Ok(Some(config)); } + Ok(None) } pub fn load_from_xdg() -> Result> { if let Some(config_dir) = dirs::config_dir() { let path = config_dir.join(XDG_CONFIG_SUBDIR).join(CONFIG_FILE); + if path.exists() { let content = fs::read_to_string(&path).with_context(|| "Failed to read XDG config")?; + let config: Config = toml::from_str(&content).with_context(|| "Failed to parse XDG config")?; + return Ok(Some(config)); } } + Ok(None) } pub fn load_from_legacy() -> Result> { if let Some(home) = dirs::home_dir() { let path = home.join(".gitclaw.toml"); + if path.exists() { let content = fs::read_to_string(&path).with_context(|| "Failed to read legacy config")?; + let config: Config = toml::from_str(&content).with_context(|| "Failed to parse legacy config")?; + return Ok(Some(config)); } } + Ok(None) } @@ -160,26 +178,13 @@ impl Config { if let Some(token) = other.github_token { self.github_token = Some(token); } - if other.install_dir != default_install_dir() { - self.install_dir = other.install_dir; - } - if !other.download.show_progress { - self.download.show_progress = other.download.show_progress; - } - if !other.download.prefer_strip { - self.download.prefer_strip = other.download.prefer_strip; - } - if !other.download.verify_checksums { - self.download.verify_checksums = other.download.verify_checksums; - } - if other.output.color != "auto" { - self.output.color = other.output.color; - } - if other.output.quiet { - self.output.quiet = other.output.quiet; - } - if other.output.verbose { - self.output.verbose = other.output.verbose; - } + + self.install_dir = other.install_dir; + self.download.show_progress = other.download.show_progress; + self.download.prefer_strip = other.download.prefer_strip; + self.download.verify_checksums = other.download.verify_checksums; + self.output.color = other.output.color; + self.output.quiet = other.output.quiet; + self.output.verbose = other.output.verbose; } } diff --git a/src/core/constants.rs b/src/core/constants.rs index 7041abd..9615282 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -7,10 +7,11 @@ pub const GITCLAW_DIR: &str = ".gitclaw"; pub const REGISTRY_FILE: &str = "registry.toml"; pub const CONFIG_FILE: &str = "config.toml"; pub const LOCAL_CONFIG_FILE: &str = ".gitclaw.toml"; -pub const LEGACY_CONFIG_FILE: &str = ".gitclaw.toml"; pub const XDG_CONFIG_SUBDIR: &str = "gitclaw"; pub const ENV_VAR_TOKEN: &str = "GITHUB_TOKEN"; pub const ENV_VAR_CONFIG: &str = "GITCLAW_CONFIG"; +pub const ALIASES_FILE: &str = "aliases.toml"; +pub const LOCKFILE_NAME: &str = "gitclaw.lock"; pub const DIR_BIN: &str = "bin"; pub const DIR_PACKAGES: &str = "packages"; @@ -23,3 +24,16 @@ pub const TEMP_DIR_SELF_UPDATE: &str = "gitclaw-self-update"; pub const GITHUB_API_BASE: &str = "https://api.github.com"; pub const RELEASE_TAG_LATEST: &str = "latest"; + +pub const KV_KEY_WIDTH: usize = 20; +pub const SEARCH_LIMIT_DEFAULT: &str = "10"; +pub const SEARCH_LIMIT_MAX: usize = 100; +pub const EXEC_PERMISSION_MODE: u32 = 0o755; +pub const EXEC_PERMISSION_BITS: u32 = 0o111; +pub const DATE_PREFIX_LEN: usize = 10; + +pub const SCORE_PLATFORM_MATCH: i32 = 10; +pub const SCORE_LINUX_PARTIAL: i32 = 5; +pub const SCORE_KNOWN_EXTENSION: i32 = 5; +pub const SCORE_SHELL_SCRIPT: i32 = 2; +pub const SCORE_CHECKSUM_PENALTY: i32 = -100; diff --git a/src/core/extract.rs b/src/core/extract.rs index 0313eb2..e4d0da2 100644 --- a/src/core/extract.rs +++ b/src/core/extract.rs @@ -10,6 +10,7 @@ pub enum ArchiveType { Zip, TarBz2, TarXz, + TarZst, Deb, PlainBinary, } @@ -18,10 +19,13 @@ pub enum ArchiveType { pub enum ExtractionError { #[error("IO error during extraction: {0}")] Io(#[from] io::Error), + #[error("Zip extraction error: {0}")] Zip(#[from] zip::result::ZipError), + #[error("Unknown archive type for file: {0}")] UnknownArchiveType(String), + #[error("Unsupported archive format: {0}")] UnsupportedFormat(String), } @@ -44,6 +48,8 @@ pub fn detect_archive_type(file_path: &Path) -> Result { Ok(ArchiveType::TarBz2) } else if lower.ends_with(".tar.xz") || lower.ends_with(".txz") { Ok(ArchiveType::TarXz) + } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") { + Ok(ArchiveType::TarZst) } else if lower.ends_with(".tar") { Ok(ArchiveType::TarGz) } else if lower.ends_with(".deb") { @@ -89,6 +95,7 @@ pub fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<()> { { use std::os::unix::fs::PermissionsExt; + if let Some(mode) = file.unix_mode() { fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?; } @@ -162,8 +169,8 @@ pub fn extract_deb(archive_path: &Path, dest_dir: &Path) -> Result<()> { } fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { - #[allow(clippy::byte_char_slices)] - const AR_MAGIC: &[u8] = &[b'!', b'<', b'a', b'r', b'c', b'h', b'>', b'\n']; + const AR_MAGIC: &[u8] = b"!\n"; + if deb_content.len() < AR_MAGIC.len() || &deb_content[0..AR_MAGIC.len()] != AR_MAGIC { return Err(ExtractionError::UnsupportedFormat( "Invalid .deb file: missing ar magic".to_string(), @@ -171,32 +178,55 @@ fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { } let mut offset = AR_MAGIC.len(); + while offset + 60 <= deb_content.len() { let header = &deb_content[offset..offset + 60]; offset += 60; let name_field = &header[0..16]; + let name_end = name_field .iter() .rposition(|&b| b != b' ' && b != b'/') .map(|i| i + 1) .unwrap_or(0); - let name_bytes = &name_field[0..name_end]; - let name = std::str::from_utf8(name_bytes).unwrap_or("").to_string(); - let size_10 = std::str::from_utf8(&header[48..58]).unwrap_or("0").trim(); - let size_8 = std::str::from_utf8(&header[48..56]).unwrap_or("0").trim(); + let name_bytes = &name_field[0..name_end]; - let size: usize = size_10 + let name = std::str::from_utf8(name_bytes) + .map_err(|_| { + ExtractionError::UnsupportedFormat( + "Invalid UTF-8 in .deb header name field".to_string(), + ) + })? + .to_string(); + + let size_field = std::str::from_utf8(&header[48..58]) + .map_err(|_| { + ExtractionError::UnsupportedFormat( + "Invalid UTF-8 in .deb header size field".to_string(), + ) + })? + .trim(); + + let size: usize = size_field .parse() - .ok() - .or_else(|| size_8.parse().ok()) + .or_else(|_| { + std::str::from_utf8(&header[48..56]) + .map_err(|_| { + ExtractionError::UnsupportedFormat( + "Invalid UTF-8 in .deb size field fallback".to_string(), + ) + }) + .map(|s| s.trim().parse::().unwrap_or(0)) + }) .unwrap_or(0); if size == 0 || offset + size > deb_content.len() { if offset >= deb_content.len() { break; } + continue; } @@ -211,6 +241,7 @@ fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { } offset += size; + if !offset.is_multiple_of(2) { offset += 1; } @@ -241,33 +272,33 @@ fn extract_tar_auto(tar_path: &Path, dest_dir: &Path) -> Result<()> { } } +fn dispatch_extract(archive_type: ArchiveType, archive_path: &Path, dest: &Path) -> Result<()> { + match archive_type { + ArchiveType::TarGz => extract_tar_gz(archive_path, dest), + ArchiveType::Zip => extract_zip(archive_path, dest), + ArchiveType::TarBz2 => extract_tar_bz2(archive_path, dest), + ArchiveType::TarXz => extract_tar_xz(archive_path, dest), + ArchiveType::TarZst => extract_tar_zst(archive_path, dest), + ArchiveType::Deb => extract_deb(archive_path, dest), + ArchiveType::PlainBinary => extract_plain_binary(archive_path, dest), + } +} + pub fn extract_archive(archive_path: &Path, dest_dir: &Path, prefer_strip: bool) -> Result<()> { let archive_type = detect_archive_type(archive_path)?; - if prefer_strip { - match archive_type { - ArchiveType::TarGz => extract_tar_gz(archive_path, dest_dir), - ArchiveType::Zip => extract_zip(archive_path, dest_dir), - ArchiveType::TarBz2 => extract_tar_bz2(archive_path, dest_dir), - ArchiveType::TarXz => extract_tar_xz(archive_path, dest_dir), - ArchiveType::Deb => extract_deb(archive_path, dest_dir), - ArchiveType::PlainBinary => extract_plain_binary(archive_path, dest_dir), - } + let effective_dest = if prefer_strip { + dest_dir.to_path_buf() } else { let name = archive_path .file_stem() .and_then(|n| n.to_str()) .unwrap_or("extracted"); - let effective_dest = dest_dir.join(name); - fs::create_dir_all(&effective_dest)?; - - match archive_type { - ArchiveType::TarGz => extract_tar_gz(archive_path, &effective_dest), - ArchiveType::Zip => extract_zip(archive_path, &effective_dest), - ArchiveType::TarBz2 => extract_tar_bz2(archive_path, &effective_dest), - ArchiveType::TarXz => extract_tar_xz(archive_path, &effective_dest), - ArchiveType::Deb => extract_deb(archive_path, &effective_dest), - ArchiveType::PlainBinary => extract_plain_binary(archive_path, &effective_dest), - } - } + + let d = dest_dir.join(name); + fs::create_dir_all(&d)?; + d + }; + + dispatch_extract(archive_type, archive_path, &effective_dest) } diff --git a/src/core/install.rs b/src/core/install.rs index 507ff5d..47c2ed4 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -1,24 +1,19 @@ -use std::fs; -use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Result}; -use colored::Colorize; -use futures::future::join_all; -use walkdir::WalkDir; - use crate::core::checksum::{find_checksum_file, verify_file}; use crate::core::config::Config; -use crate::core::constants::{APP_NAME, RELEASE_TAG_LATEST, TEMP_DIR_PREFIX}; +use crate::core::constants::{APP_NAME, DIR_PACKAGES, RELEASE_TAG_LATEST, TEMP_DIR_PREFIX}; use crate::core::extract::extract_archive; use crate::core::registry::{InstalledPackage, Registry}; use crate::core::semver::{parse_tag_version, VersionConstraint}; -use crate::core::util::{bin_dir_from, registry_path_from}; +use crate::core::util::{bin_dir_from, find_binary, package_key, registry_path_from}; use crate::network::github::{ find_matching_asset, parse_package, Asset, GithubClient, Platform, Release, }; use crate::output; +use anyhow::{bail, Result}; +use colored::Colorize; +use futures::future::join_all; use semver::Version; +use std::fs; pub async fn handle_install( package: &str, @@ -28,19 +23,10 @@ pub async fn handle_install( config: &Config, channel: Option, ) -> Result<()> { - let resolved = if !package.contains('/') { - if let Some(alias_target) = crate::core::alias::AliasMap::load(config)?.resolve(package) { - output::print_info(&format!("Alias '{}' -> '{}'.", package, alias_target)); - alias_target.to_string() - } else { - package.to_string() - } - } else { - package.to_string() - }; + let resolved = crate::core::alias::resolve_package_input(package, config)?; let (owner, repo, version) = parse_package(&resolved)?; - let key = format!("{}/{}", owner, repo); + let key = package_key(&owner, &repo); let aliases = crate::core::alias::AliasMap::load(config)?; if let Some(alias_target) = aliases.resolve(&repo) { @@ -60,7 +46,10 @@ pub async fn handle_install( let mut reg = Registry::load_from(®istry_path)?; if !force && reg.is_installed(&key) { - let pkg = reg.packages.get(&key).unwrap(); + let pkg = reg + .packages + .get(&key) + .ok_or_else(|| anyhow::anyhow!("Invariant: installed package missing from registry"))?; output::print_warn(&format!( "{} already installed ({}). Use --force to reinstall.", key, pkg.version @@ -71,14 +60,7 @@ pub async fn handle_install( let client = GithubClient::new(config.github_token.clone())?; let release = match (&version, channel) { - (_, Some(ch)) => { - let releases = client.get_releases(&owner, &repo).await?; - let filtered = crate::core::channel::filter_releases(&releases, ch, None); - if filtered.is_empty() { - bail!("No {} release found for {}/{}.", ch, owner, repo); - } - filtered.into_iter().next().unwrap() - } + (_, Some(ch)) => fetch_latest_for_channel(&client, &owner, &repo, ch).await?, (Some(v), None) => { if is_semver_constraint(v) { let constraint = VersionConstraint::parse(v)?; @@ -96,7 +78,7 @@ pub async fn handle_install( let asset = select_best_asset(&release)?; - let pkg_install_dir = config.install_dir.join("packages").join(&key); + let pkg_install_dir = config.install_dir.join(DIR_PACKAGES).join(&key); let bin_dir = bin_dir_from(&config.install_dir); if dry_run { @@ -163,7 +145,7 @@ pub async fn handle_install( } } - let pkg_install_dir = config.install_dir.join("packages").join(&key); + let pkg_install_dir = config.install_dir.join(DIR_PACKAGES).join(&key); fs::create_dir_all(&pkg_install_dir)?; if !config.output.quiet { @@ -189,7 +171,7 @@ pub async fn handle_install( install_dir: pkg_install_dir.clone(), asset_name: asset.name.clone(), identifier: repo.clone(), - channel: channel.map(|c| c.to_string()), + channel, }; reg.add(pkg); @@ -215,7 +197,7 @@ pub async fn handle_update(package: Option<&str>, config: &Config) -> Result<()> async fn update_one(package: &str, config: &Config) -> Result<()> { let (owner, repo, _) = parse_package(package)?; - let key = format!("{}/{}", owner, repo); + let key = package_key(&owner, &repo); let registry_path = registry_path_from(&config.install_dir); let reg = Registry::load_from(®istry_path)?; @@ -223,7 +205,10 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { bail!("{} not installed. Use '{} install' first.", key, APP_NAME); } - let installed = reg.packages.get(&key).unwrap(); + let installed = reg + .packages + .get(&key) + .ok_or_else(|| anyhow::anyhow!("Invariant: installed package missing from registry"))?; if !config.output.quiet { output::print_info(&format!( @@ -235,20 +220,10 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { let client = GithubClient::new(config.github_token.clone())?; - let ch = match installed.channel.as_deref() { - Some(c) => Some(c.parse::()?), - None => None, - }; + let ch = installed.channel; let latest = match ch { - Some(channel) => { - let releases = client.get_releases(&owner, &repo).await?; - let filtered = crate::core::channel::filter_releases(&releases, channel, None); - if filtered.is_empty() { - bail!("No {} release found for {}/{}.", channel, owner, repo); - } - filtered.into_iter().next().unwrap() - } + Some(channel) => fetch_latest_for_channel(&client, &owner, &repo, channel).await?, None => { client .get_release(&owner, &repo, RELEASE_TAG_LATEST) @@ -311,6 +286,22 @@ async fn update_all(config: &Config) -> Result<()> { Ok(()) } +async fn fetch_latest_for_channel( + client: &GithubClient, + owner: &str, + repo: &str, + channel: crate::core::channel::Channel, +) -> Result { + let releases = client.get_releases(owner, repo).await?; + let filtered = crate::core::channel::filter_releases(&releases, channel, None); + if filtered.is_empty() { + bail!("No {} release found for {}/{}.", channel, owner, repo); + } + Ok(filtered.into_iter().next().ok_or_else(|| { + anyhow::anyhow!("Invariant: filtered list was non-empty but next() returned None") + })?) +} + fn select_best_asset(release: &Release) -> Result<&Asset> { if release.assets.is_empty() { bail!("Release {} has no assets", release.tag_name); @@ -328,49 +319,7 @@ fn select_best_asset(release: &Release) -> Result<&Asset> { } } -fn find_binary(dir: &Path, repo_name: &str) -> Result { - for entry in WalkDir::new(dir).max_depth(3) { - let entry = entry?; - if !entry.file_type().is_file() { - continue; - } - - let stem = entry - .path() - .file_stem() - .unwrap_or_default() - .to_string_lossy(); - - if stem != repo_name { - continue; - } - - if fs::metadata(entry.path()) - .map(|m| m.permissions().mode() & 0o111 != 0) - .unwrap_or(false) - { - return Ok(entry.path().to_path_buf()); - } - } - - for entry in WalkDir::new(dir).max_depth(3) { - let entry = entry?; - if !entry.file_type().is_file() { - continue; - } - - if fs::metadata(entry.path()) - .map(|m| m.permissions().mode() & 0o111 != 0) - .unwrap_or(false) - { - return Ok(entry.path().to_path_buf()); - } - } - - bail!("No binary found in {}", dir.display()) -} - -fn create_symlink(binary: &Path, name: &str, bin_dir: &Path) -> Result<()> { +fn create_symlink(binary: &std::path::Path, name: &str, bin_dir: &std::path::Path) -> Result<()> { fs::create_dir_all(bin_dir)?; let link = bin_dir.join(name); @@ -480,15 +429,16 @@ async fn find_matching_release( ); } + let fallback = Version::new(0, 0, 0); matching.sort_by(|a, b| { - let va = - parse_tag_version(&a.tag_name).unwrap_or_else(|_| Version::parse("0.0.0").unwrap()); - let vb = - parse_tag_version(&b.tag_name).unwrap_or_else(|_| Version::parse("0.0.0").unwrap()); + let va = parse_tag_version(&a.tag_name).unwrap_or_else(|_| fallback.clone()); + let vb = parse_tag_version(&b.tag_name).unwrap_or_else(|_| fallback.clone()); vb.cmp(&va) }); - Ok(matching.into_iter().next().unwrap()) + Ok(matching.into_iter().next().ok_or_else(|| { + anyhow::anyhow!("Invariant: matching list was non-empty but next() returned None") + })?) } fn constraint_display(constraint: &VersionConstraint) -> String { diff --git a/src/core/lockfile.rs b/src/core/lockfile.rs index 3e9cbee..977330e 100644 --- a/src/core/lockfile.rs +++ b/src/core/lockfile.rs @@ -4,12 +4,11 @@ use std::path::Path; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; +use crate::core::constants::LOCKFILE_NAME; use crate::core::registry::Registry; use crate::core::util::registry_path_from; use crate::output; -const LOCKFILE_NAME: &str = "gitclaw.lock"; - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LockEntry { pub owner: String, diff --git a/src/core/mod.rs b/src/core/mod.rs index 7b94689..0eb3d0e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -9,6 +9,7 @@ pub mod extract; pub mod install; pub mod lockfile; pub mod registry; +pub mod run; pub mod semver; pub mod updater; pub mod util; diff --git a/src/core/registry.rs b/src/core/registry.rs index 2142e6e..c01178b 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -7,8 +7,9 @@ use colored::Colorize; use serde::{Deserialize, Serialize}; use tracing::debug; +use crate::core::channel::Channel; use crate::core::config::Config; -use crate::core::constants::{APP_NAME_SHORT, RELEASE_TAG_LATEST}; +use crate::core::constants::{APP_NAME_SHORT, DATE_PREFIX_LEN, DIR_BIN, RELEASE_TAG_LATEST}; use crate::core::util::registry_path_from; use crate::network::github::{parse_package, GithubClient}; use crate::output; @@ -26,7 +27,7 @@ pub struct InstalledPackage { #[serde(default)] pub identifier: String, #[serde(default)] - pub channel: Option, + pub channel: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] @@ -99,7 +100,7 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { output::print_kv( "Installed", - &pkg.installed_at[..10.min(pkg.installed_at.len())], + &pkg.installed_at[..DATE_PREFIX_LEN.min(pkg.installed_at.len())], ); } } else { @@ -116,14 +117,16 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { pkgs.sort_by_key(|p| &p.name); for pkg in pkgs { - let date = &pkg.installed_at[..10.min(pkg.installed_at.len())]; + let date = &pkg.installed_at[..DATE_PREFIX_LEN.min(pkg.installed_at.len())]; - let path_short = pkg - .binary_path - .display() - .to_string() - .replace(&dirs::home_dir().unwrap().display().to_string(), "~") - .replace("/packages/", "/p/"); + let path_short = if let Some(home) = dirs::home_dir() { + pkg.binary_path + .display() + .to_string() + .replace(&home.display().to_string(), "~") + } else { + pkg.binary_path.display().to_string() + }; let path_display = if path_short.len() > 28 { format!("{}...", &path_short[..25]) @@ -223,7 +226,10 @@ pub fn uninstall(package: &str, install_dir: &Path, config: &Config) -> Result<( match matches.len() { 0 => anyhow::bail!("Package '{}' not installed.", package), - 1 => matches.into_iter().next().unwrap(), + 1 => matches + .into_iter() + .next() + .ok_or_else(|| anyhow::anyhow!("Invariant: single match disappeared"))?, _ => anyhow::bail!( "Multiple packages match '{}'. Use full name (owner/repo).", package @@ -238,7 +244,7 @@ pub fn uninstall(package: &str, install_dir: &Path, config: &Config) -> Result<( if pkg.install_dir.exists() { fs::remove_dir_all(&pkg.install_dir).context("Remove install dir")?; } - let link = install_dir.join("bin").join(&pkg.repo); + let link = install_dir.join(DIR_BIN).join(&pkg.repo); if link.exists() || link.is_symlink() { fs::remove_file(&link).context("Remove symlink")?; } diff --git a/src/core/run.rs b/src/core/run.rs new file mode 100644 index 0000000..7d96ebd --- /dev/null +++ b/src/core/run.rs @@ -0,0 +1,59 @@ +use std::process::Command; + +use anyhow::bail; + +use crate::config::Config; +use crate::constants::{APP_NAME, DIR_BIN}; +use crate::registry::Registry; +use crate::util::registry_path_from; + +pub async fn handle_run(package: &str, args: Vec, config: &Config) -> anyhow::Result<()> { + let resolved = crate::alias::resolve_package_input(package, config)?; + + let (owner, repo) = if resolved.contains('/') { + let (o, r, _) = crate::github::parse_package(&resolved)?; + (o, r) + } else { + let registry_path = registry_path_from(&config.install_dir); + let reg = Registry::load_from(®istry_path)?; + + let matches: Vec<_> = reg + .packages + .values() + .filter(|p| p.repo == resolved) + .collect(); + + match matches.len() { + 0 => bail!( + "Package '{}' not installed. Use '{} install owner/{}' first.", + resolved, + APP_NAME, + resolved + ), + 1 => (matches[0].owner.clone(), matches[0].repo.clone()), + _ => bail!( + "Multiple packages named '{}'. Use full name (owner/repo).", + resolved + ), + } + }; + + let binary_path = config.install_dir.join(DIR_BIN).join(&repo); + + if !binary_path.exists() { + bail!( + "Binary for '{}/{}' not found at {}.", + owner, + repo, + binary_path.display() + ); + } + + let status = Command::new(&binary_path).args(args).status()?; + + if !status.success() { + bail!("Process exited with code: {:?}.", status.code()); + } + + Ok(()) +} diff --git a/src/core/updater.rs b/src/core/updater.rs index a2cbb07..9a5ac38 100644 --- a/src/core/updater.rs +++ b/src/core/updater.rs @@ -4,13 +4,14 @@ use std::path::PathBuf; use anyhow::{anyhow, bail, Context, Result}; use colored::Colorize; -use walkdir::WalkDir; use crate::core::config::Config; use crate::core::constants::{ - APP_NAME, DIR_EXTRACTED, RELEASE_TAG_LATEST, REPO_NAME, REPO_OWNER, TEMP_DIR_SELF_UPDATE, + APP_NAME, DIR_EXTRACTED, EXEC_PERMISSION_MODE, RELEASE_TAG_LATEST, REPO_NAME, REPO_OWNER, + TEMP_DIR_SELF_UPDATE, }; -use crate::core::extract::extract_archive; +use crate::core::extract::{detect_archive_type, extract_archive, ArchiveType}; +use crate::core::util::find_binary; use crate::network::github::{find_matching_asset, GithubClient, Platform}; use crate::output; @@ -92,10 +93,19 @@ pub async fn perform_update(config: &Config) -> Result<()> { let current_exe = current_executable()?; - if asset.name.ends_with(".tar.gz") - || asset.name.ends_with(".zip") - || asset.name.ends_with(".tar.xz") - { + let archive_type = detect_archive_type(&download_path).ok(); + let is_archive = matches!( + archive_type, + Some( + ArchiveType::TarGz + | ArchiveType::Zip + | ArchiveType::TarXz + | ArchiveType::TarBz2 + | ArchiveType::TarZst + ) + ); + + if is_archive { let extract_dir = temp_dir.join(DIR_EXTRACTED); output::print_info("Extracting."); extract_archive(&download_path, &extract_dir, true)?; @@ -114,27 +124,6 @@ pub async fn perform_update(config: &Config) -> Result<()> { Ok(()) } -fn find_binary(dir: &std::path::Path, name: &str) -> Result { - for entry in WalkDir::new(dir).max_depth(2) { - let entry = entry?; - if !entry.file_type().is_file() { - continue; - } - - let file_name = entry - .path() - .file_stem() - .unwrap_or_default() - .to_string_lossy(); - - if file_name == name { - return Ok(entry.path().to_path_buf()); - } - } - - bail!("Binary '{}' not found in extracted archive.", name) -} - fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<()> { let backup = current.with_extension("backup"); std::fs::rename(current, &backup)?; @@ -142,7 +131,7 @@ fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<() match std::fs::copy(new, current) { Ok(_) => { let mut perms = std::fs::metadata(current)?.permissions(); - perms.set_mode(0o755); + perms.set_mode(EXEC_PERMISSION_MODE); std::fs::set_permissions(current, perms)?; let _ = std::fs::remove_file(&backup); Ok(()) diff --git a/src/core/util.rs b/src/core/util.rs index 043ba68..3268450 100644 --- a/src/core/util.rs +++ b/src/core/util.rs @@ -1,10 +1,14 @@ use std::env; +use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; -use anyhow::{anyhow, Result}; +use anyhow::{anyhow, bail, Result}; +use walkdir::WalkDir; use crate::core::constants::{ - CONFIG_FILE, DIR_BIN, DIR_CACHE, DIR_DOWNLOADS, DIR_PACKAGES, GITCLAW_DIR, REGISTRY_FILE, + CONFIG_FILE, DIR_BIN, DIR_CACHE, DIR_DOWNLOADS, DIR_PACKAGES, EXEC_PERMISSION_BITS, + GITCLAW_DIR, REGISTRY_FILE, }; pub fn home_dir() -> Result { @@ -48,11 +52,8 @@ pub fn config_path() -> Result { } pub fn is_in_path(binary: &str) -> bool { - env::var("PATH") - .map(|path| { - path.split(':') - .any(|dir| PathBuf::from(dir).join(binary).exists()) - }) + env::var_os("PATH") + .map(|path| std::env::split_paths(&path).any(|dir| dir.join(binary).exists())) .unwrap_or(false) } @@ -67,3 +68,49 @@ pub fn format_bytes(bytes: u64) -> String { let value = bytes as f64 / 1024f64.powi(exponent as i32); format!("{:.1} {}", value, UNITS[exponent]) } + +pub fn package_key(owner: &str, repo: &str) -> String { + format!("{}/{}", owner, repo) +} + +pub fn find_binary(dir: &Path, repo_name: &str) -> Result { + for entry in WalkDir::new(dir).max_depth(3) { + let entry = entry?; + if !entry.file_type().is_file() { + continue; + } + + let stem = entry + .path() + .file_stem() + .unwrap_or_default() + .to_string_lossy(); + + if stem != repo_name { + continue; + } + + if fs::metadata(entry.path()) + .map(|m| m.permissions().mode() & EXEC_PERMISSION_BITS != 0) + .unwrap_or(false) + { + return Ok(entry.path().to_path_buf()); + } + } + + for entry in WalkDir::new(dir).max_depth(3) { + let entry = entry?; + if !entry.file_type().is_file() { + continue; + } + + if fs::metadata(entry.path()) + .map(|m| m.permissions().mode() & EXEC_PERMISSION_BITS != 0) + .unwrap_or(false) + { + return Ok(entry.path().to_path_buf()); + } + } + + bail!("No binary found in {}", dir.display()) +} diff --git a/src/lib.rs b/src/lib.rs index 41357d8..852c2bc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ pub use core::extract; pub use core::install; pub use core::lockfile; pub use core::registry; +pub use core::run; pub use core::semver; pub use core::updater; pub use core::util; diff --git a/src/main.rs b/src/main.rs index 9614a8e..0b27055 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,10 @@ -use std::process::Command; - -use anyhow::bail; use clap::{CommandFactory, Parser}; use clap_complete::generate; use gitclaw::banner; use gitclaw::cli::{AliasAction, CacheAction, Cli, Commands}; use gitclaw::config::Config; -use gitclaw::constants::{APP_NAME, APP_NAME_SHORT, DIR_BIN, GITCLAW_DIR}; -use gitclaw::registry::Registry; -use gitclaw::util::registry_path_from; +use gitclaw::constants::{APP_NAME_SHORT, GITCLAW_DIR}; #[tokio::main] async fn main() { @@ -51,6 +46,12 @@ fn apply_cli_overrides(mut config: Config, cli: &Cli) -> Config { config } +fn local_install_dir(config: &Config) -> anyhow::Result { + let mut cfg = config.clone(); + cfg.install_dir = std::env::current_dir()?.join(GITCLAW_DIR); + Ok(cfg) +} + async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { match &cli.command { Commands::Cache { .. } @@ -79,9 +80,11 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { AliasAction::Add { alias, target } => { gitclaw::alias::handle_alias_add(&alias, &target, &config)? } + AliasAction::Remove { alias } => { gitclaw::alias::handle_alias_remove(&alias, &config)? } + AliasAction::List {} => gitclaw::alias::handle_alias_list(&config)?, } } @@ -107,18 +110,11 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { banner::print_output_header(); let install_config = if local { - let mut cfg = config.clone(); - cfg.install_dir = std::env::current_dir()?.join(GITCLAW_DIR); - cfg + local_install_dir(&config)? } else { config.clone() }; - let channel = match channel.as_deref() { - Some(c) => Some(c.parse::()?), - None => None, - }; - if locked { gitclaw::lockfile::install_locked(&install_config).await? } else if packages.len() == 1 { @@ -146,8 +142,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { Commands::Lock { dir } => { banner::print_output_header(); - let project_dir = std::path::PathBuf::from(dir); - gitclaw::lockfile::generate_lockfile(&config.install_dir, &project_dir)? + gitclaw::lockfile::generate_lockfile(&config.install_dir, &dir)? } Commands::List { verbose, outdated } => { @@ -173,7 +168,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { banner::print_output_header(); let install_dir = if local { - std::env::current_dir()?.join(GITCLAW_DIR) + local_install_dir(&config)?.install_dir } else { config.install_dir.clone() }; @@ -188,11 +183,6 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { } => { banner::print_output_header(); - let channel = match channel.as_deref() { - Some(c) => Some(c.parse::()?), - None => None, - }; - gitclaw::github::search_releases(&package, limit, &config, channel).await? } @@ -214,6 +204,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { let mut cmd = Cli::command(); let name = cmd.get_name().to_string(); generate(shell, &mut cmd, name.clone(), &mut std::io::stdout()); + generate( shell, &mut Cli::command(), @@ -225,7 +216,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { Commands::Platform {} => { banner::print_output_header(); - let arch = gitclaw::platform::current_platform(); + let arch = gitclaw::platform::current_platform()?; banner::print_info(&format!("Detected platform: linux {}", arch)); banner::print_info("Compiled for: Linux"); banner::print_info(&format!("Architecture aliases: {:?}", arch.aliases())); @@ -243,71 +234,8 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { Commands::Run { package, args } => { banner::print_output_header(); - run_package(&package, args, &config).await? - } - } - - Ok(()) -} - -async fn run_package(package: &str, args: Vec, config: &Config) -> anyhow::Result<()> { - let resolved = if !package.contains('/') { - if let Some(alias_target) = gitclaw::alias::AliasMap::load(config)?.resolve(package) { - banner::print_info(&format!("Alias '{}' -> '{}'.", package, alias_target)); - alias_target.to_string() - } else { - package.to_string() - } - } else { - package.to_string() - }; - - let (owner, repo) = if resolved.contains('/') { - let parts: Vec<&str> = resolved.split('/').collect(); - if parts.len() != 2 { - bail!("Invalid package format. Use 'owner/repo' or just 'repo'."); - } - (parts[0].to_string(), parts[1].to_string()) - } else { - let registry_path = registry_path_from(&config.install_dir); - let reg = Registry::load_from(®istry_path)?; - - let matches: Vec<_> = reg - .packages - .values() - .filter(|p| p.repo == resolved) - .collect(); - - match matches.len() { - 0 => bail!( - "Package '{}' not installed. Use '{} install owner/{}' first.", - resolved, - APP_NAME, - resolved - ), - 1 => (matches[0].owner.clone(), matches[0].repo.clone()), - _ => bail!( - "Multiple packages named '{}'. Use full name (owner/repo).", - resolved - ), + gitclaw::run::handle_run(&package, args, &config).await? } - }; - - let binary_path = config.install_dir.join(DIR_BIN).join(&repo); - - if !binary_path.exists() { - bail!( - "Binary for '{}/{}' not found at {}.", - owner, - repo, - binary_path.display() - ); - } - - let status = Command::new(&binary_path).args(args).status()?; - - if !status.success() { - bail!("Process exited with code: {:?}.", status.code()); } Ok(()) diff --git a/src/network/github.rs b/src/network/github.rs index 66c0018..c9ceec7 100644 --- a/src/network/github.rs +++ b/src/network/github.rs @@ -11,11 +11,11 @@ use thiserror::Error; use tracing::{debug, warn}; use crate::core::config::Config; -use crate::core::constants::{GITHUB_API_BASE, RELEASE_TAG_LATEST}; +use crate::core::constants::{GITHUB_API_BASE, RELEASE_TAG_LATEST, SEARCH_LIMIT_MAX}; +use crate::core::util::format_bytes; +use crate::network::platform::{detect_arch, Arch}; use crate::output; -const GITHUB_API: &str = GITHUB_API_BASE; - #[derive(Error, Debug)] pub enum GithubError { #[error("GitHub API error: {status} - {message}")] @@ -76,29 +76,10 @@ impl std::fmt::Display for Platform { } impl Platform { - fn aliases(&self) -> &[&'static str] { - match self { - Platform::LinuxX86_64 => &[ - "linux-x86_64", - "linux-amd64", - "linux-x64", - "x86_64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", - ], - Platform::LinuxAarch64 => &[ - "linux-aarch64", - "linux-arm64", - "aarch64-unknown-linux-gnu", - "aarch64-unknown-linux-musl", - ], - } - } - pub fn current() -> Result { - match std::env::consts::ARCH { - "x86_64" => Ok(Platform::LinuxX86_64), - "aarch64" | "arm64" => Ok(Platform::LinuxAarch64), - arch => bail!("Unsupported architecture: {}.", arch), + match detect_arch().map_err(|e| anyhow::anyhow!("{}", e))? { + Arch::X86_64 => Ok(Platform::LinuxX86_64), + Arch::Aarch64 => Ok(Platform::LinuxAarch64), } } } @@ -153,7 +134,7 @@ impl GithubClient { let url = format!( "{}/repos/{}/{}/releases/tags/{}", - GITHUB_API, owner, repo, tag_normalized + GITHUB_API_BASE, owner, repo, tag_normalized ); debug!("GET {}", url); @@ -166,7 +147,7 @@ impl GithubClient { if tag_normalized.starts_with('v') && tag_normalized != tag { let url = format!( "{}/repos/{}/{}/releases/tags/{}", - GITHUB_API, owner, repo, tag + GITHUB_API_BASE, owner, repo, tag ); let resp = self.add_auth(self.client.get(&url)).send().await?; @@ -205,7 +186,10 @@ impl GithubClient { owner: &str, repo: &str, ) -> std::result::Result { - let url = format!("{}/repos/{}/{}/releases/latest", GITHUB_API, owner, repo); + let url = format!( + "{}/repos/{}/{}/releases/latest", + GITHUB_API_BASE, owner, repo + ); let resp = self.add_auth(self.client.get(&url)).send().await?; if resp.status().is_success() { @@ -230,17 +214,11 @@ impl GithubClient { owner: &str, repo: &str, ) -> std::result::Result, GithubError> { - let url = format!("{}/repos/{}/{}/releases", GITHUB_API, owner, repo); + let url = format!("{}/repos/{}/{}/releases", GITHUB_API_BASE, owner, repo); debug!("GET {}", url); let resp = self.add_auth(self.client.get(&url)).send().await?; - - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let message = resp.text().await.unwrap_or_default(); - return Err(GithubError::ApiError { status, message }); - } - + let resp = check_api_response(resp).await?; Ok(resp.json().await?) } @@ -316,6 +294,20 @@ impl GithubClient { } } +async fn check_api_response( + resp: reqwest::Response, +) -> std::result::Result { + if resp.status().is_success() { + return Ok(resp); + } + let status = resp.status().as_u16(); + let message = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + Err(GithubError::ApiError { status, message }) +} + pub fn find_matching_asset( release: &Release, platform: Platform, @@ -323,7 +315,7 @@ pub fn find_matching_asset( let candidates: Vec<&Asset> = release .assets .iter() - .filter(|a| !is_checksum_file(&a.name)) + .filter(|a| !crate::core::checksum::is_checksum_asset(&a.name)) .collect(); if candidates.is_empty() { @@ -333,49 +325,15 @@ pub fn find_matching_asset( }); } - let aliases = platform.aliases(); + let arch = match platform { + Platform::LinuxX86_64 => Arch::X86_64, + Platform::LinuxAarch64 => Arch::Aarch64, + }; + let mut best: Option<(i32, &Asset)> = None; for asset in candidates { - let name_lower = asset.name.to_lowercase(); - let mut score = 0; - - for alias in aliases { - if name_lower.contains(alias) { - score += 10; - break; - } - } - - if score == 0 && name_lower.contains("linux") { - score += 5; - } - - if score >= 5 { - if name_lower.ends_with(".tar.gz") - || name_lower.ends_with(".tgz") - || name_lower.ends_with(".tar.xz") - || name_lower.ends_with(".tar.bz2") - || name_lower.ends_with(".zip") - || name_lower.ends_with(".appimage") - || name_lower.ends_with(".deb") - || name_lower.ends_with(".rpm") - || name_lower.ends_with(".tar") - { - score += 5; - } - - if name_lower.ends_with(".sh") { - score += 2; - } - } - - if name_lower.contains("checksum") - || name_lower.contains("sha256") - || name_lower.contains("sha512") - { - score -= 100; - } + let score = crate::network::platform::score_asset(&asset.name, arch); if score > 0 { match best { @@ -395,17 +353,6 @@ pub fn find_matching_asset( }) } -fn is_checksum_file(name: &str) -> bool { - let lower = name.to_lowercase(); - lower.ends_with(".sha256") - || lower.ends_with(".sha512") - || lower.ends_with(".sha") - || lower.ends_with(".sig") - || lower.ends_with(".asc") - || lower.ends_with(".checksum") - || lower.contains("checksum") -} - pub fn parse_package(input: &str) -> Result<(String, String, Option)> { let s = input .trim_start_matches("https://github.com/") @@ -434,16 +381,13 @@ pub async fn search_releases( let client = GithubClient::new(config.github_token.clone())?; - let per_page = limit.min(100); + let per_page = limit.min(SEARCH_LIMIT_MAX); let url = format!( "{}/repos/{}/{}/releases?per_page={}", - GITHUB_API, owner, repo, per_page + GITHUB_API_BASE, owner, repo, per_page ); let resp = client.add_auth(client.client.get(&url)).send().await?; - - if !resp.status().is_success() { - bail!("GitHub API error: {}.", resp.status()); - } + let resp = check_api_response(resp).await?; let has_next = resp .headers() @@ -488,7 +432,7 @@ pub async fn search_releases( r.tag_name.green().bold(), name_display.dimmed(), asset_count.to_string().cyan(), - format_size(total_size).cyan() + format_bytes(total_size).cyan() ); } @@ -505,16 +449,3 @@ pub async fn search_releases( Ok(()) } - -fn format_size(b: u64) -> String { - const KB: f64 = 1024.0; - let b = b as f64; - - if b < KB { - format!("{:.0} B", b) - } else if b < KB * KB { - format!("{:.1} KB", b / KB) - } else { - format!("{:.1} MB", b / KB / KB) - } -} diff --git a/src/network/platform.rs b/src/network/platform.rs index 1c73e57..65fa2f0 100644 --- a/src/network/platform.rs +++ b/src/network/platform.rs @@ -1,5 +1,10 @@ use thiserror::Error; +use crate::core::constants::{ + SCORE_CHECKSUM_PENALTY, SCORE_KNOWN_EXTENSION, SCORE_LINUX_PARTIAL, SCORE_PLATFORM_MATCH, + SCORE_SHELL_SCRIPT, +}; + #[derive(Error, Debug)] pub enum PlatformError { #[error("Unsupported architecture: {0}")] @@ -24,8 +29,24 @@ impl std::fmt::Display for Arch { impl Arch { pub fn aliases(&self) -> &[&'static str] { match self { - Arch::X86_64 => &["x86_64", "amd64", "x64"], - Arch::Aarch64 => &["aarch64", "arm64"], + Arch::X86_64 => &[ + "linux-x86_64", + "linux-amd64", + "linux-x64", + "x86_64-unknown-linux-gnu", + "x86_64-unknown-linux-musl", + "x86_64", + "amd64", + "x64", + ], + Arch::Aarch64 => &[ + "linux-aarch64", + "linux-arm64", + "aarch64-unknown-linux-gnu", + "aarch64-unknown-linux-musl", + "aarch64", + "arm64", + ], } } } @@ -38,36 +59,46 @@ pub fn detect_arch() -> Result { } } -pub fn current_platform() -> Arch { - detect_arch().expect("Linux x86_64 or aarch64 required") +pub fn current_platform() -> Result { + detect_arch() } pub fn score_asset(name: &str, arch: Arch) -> i32 { let lower = name.to_lowercase(); let mut score = 0; - if lower.contains("linux") { - score += 10; - } - for alias in arch.aliases() { if lower.contains(alias) { - score += 10; + score += SCORE_PLATFORM_MATCH; break; } } - if lower.ends_with(".tar.gz") || lower.ends_with(".tar.xz") || lower.ends_with(".tgz") { - score += 5; + if score == 0 && lower.contains("linux") { + score += SCORE_LINUX_PARTIAL; + } + + if score >= SCORE_LINUX_PARTIAL { + if lower.ends_with(".tar.gz") + || lower.ends_with(".tgz") + || lower.ends_with(".tar.xz") + || lower.ends_with(".tar.bz2") + || lower.ends_with(".zip") + || lower.ends_with(".appimage") + || lower.ends_with(".deb") + || lower.ends_with(".rpm") + || lower.ends_with(".tar") + { + score += SCORE_KNOWN_EXTENSION; + } + + if lower.ends_with(".sh") { + score += SCORE_SHELL_SCRIPT; + } } - if lower.contains("checksum") - || lower.contains("sha256") - || lower.contains(".asc") - || lower.contains(".sig") - || lower.contains(".sha") - { - score -= 50; + if crate::core::checksum::is_checksum_asset(&lower) { + score += SCORE_CHECKSUM_PENALTY; } score diff --git a/src/output/mod.rs b/src/output/mod.rs index 3fdbb58..e695a34 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,6 +1,8 @@ use colored::Colorize; use rand::seq::SliceRandom; +use crate::core::constants::{APP_NAME, KV_KEY_WIDTH}; + pub const BANNER: &str = r#" _ _ _ (_) | | | @@ -37,11 +39,13 @@ const TAGLINES: [&str; 20] = [ pub fn print_version_line() { let version = env!("CARGO_PKG_VERSION"); - let tagline = TAGLINES.choose(&mut rand::thread_rng()).unwrap(); + let tagline = TAGLINES + .choose(&mut rand::thread_rng()) + .unwrap_or(&TAGLINES[0]); println!( "{} {}", - format!("gitclaw v{}", version).cyan().bold(), + format!("{} v{}", APP_NAME, version).cyan().bold(), format!("({})", tagline).dimmed() ); @@ -73,7 +77,11 @@ pub fn print_error(text: &str) { } pub fn print_kv(key: &str, value: &str) { - println!(" {} {}", format!("{:20}", key).dimmed(), value); + println!( + " {} {}", + format!("{:width$}", key, width = KV_KEY_WIDTH).dimmed(), + value + ); } pub fn print_install_complete(name: &str, binary_path: &str) { diff --git a/tests/channel_persist.rs b/tests/channel_persist.rs index 1b472a0..19fff45 100644 --- a/tests/channel_persist.rs +++ b/tests/channel_persist.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use gitclaw::core::channel::Channel; use gitclaw::registry::{InstalledPackage, Registry}; use tempfile::TempDir; @@ -8,7 +9,7 @@ fn make_pkg_with_channel( owner: &str, repo: &str, version: &str, - channel: Option<&str>, + channel: Option, ) -> InstalledPackage { InstalledPackage { name: name.to_string(), @@ -20,7 +21,7 @@ fn make_pkg_with_channel( install_dir: PathBuf::from("/tmp/test"), asset_name: format!("{}.tar.gz", repo), identifier: repo.to_string(), - channel: channel.map(|s| s.to_string()), + channel, } } @@ -31,9 +32,10 @@ fn test_installed_package_with_channel() { "user", "repo", "1.0.0-nightly", - Some("nightly"), + Some(Channel::Nightly), ); - assert_eq!(pkg.channel, Some("nightly".to_string())); + + assert_eq!(pkg.channel, Some(Channel::Nightly)); } #[test] @@ -47,15 +49,16 @@ fn test_registry_save_load_with_channel() { let dir = TempDir::new().unwrap(); let reg_path = dir.path().join("registry.toml"); std::fs::create_dir_all(dir.path()).unwrap(); - let mut reg = Registry::load_from(®_path).unwrap(); + reg.add(make_pkg_with_channel( "BurntSushi/ripgrep", "BurntSushi", "ripgrep", "14.0.0-nightly", - Some("nightly"), + Some(Channel::Nightly), )); + reg.add(make_pkg_with_channel( "sharkdp/fd", "sharkdp", @@ -63,11 +66,12 @@ fn test_registry_save_load_with_channel() { "8.7.0", None, )); + reg.save().unwrap(); let loaded = Registry::load_from(®_path).unwrap(); let rg = loaded.packages.get("BurntSushi/ripgrep").unwrap(); - assert_eq!(rg.channel, Some("nightly".to_string())); + assert_eq!(rg.channel, Some(Channel::Nightly)); let fd = loaded.packages.get("sharkdp/fd").unwrap(); assert_eq!(fd.channel, None); diff --git a/tests/config.rs b/tests/config.rs index f72bd04..bf71179 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -3,16 +3,15 @@ use std::fs; use std::path::PathBuf; use tempfile::TempDir; -use gitclaw::config::{Config, DownloadConfig, OutputConfig}; +use gitclaw::config::{ColorMode, Config, DownloadConfig, OutputConfig}; #[test] fn default_config_values() { let config = Config::default(); - assert!(config.download.show_progress); assert!(config.download.prefer_strip); assert!(config.download.verify_checksums); - assert_eq!(config.output.color, "auto"); + assert_eq!(config.output.color, ColorMode::Auto); assert!(!config.output.quiet); assert!(!config.output.verbose); } @@ -44,7 +43,7 @@ color = "never" assert_eq!(config.install_dir, PathBuf::from("/custom/bin")); assert_eq!(config.github_token, Some("test-token".to_string())); assert!(!config.download.show_progress); - assert_eq!(config.output.color, "never"); + assert_eq!(config.output.color, ColorMode::Never); env::set_current_dir(original_dir).unwrap(); } @@ -102,17 +101,19 @@ verbose = true fn config_merge_precedence() { let mut config = Config::default(); assert!(config.download.show_progress); - assert_eq!(config.output.color, "auto"); + assert_eq!(config.output.color, ColorMode::Auto); let other = Config { install_dir: PathBuf::from("/other"), github_token: Some("other".to_string()), + download: DownloadConfig { show_progress: false, ..Default::default() }, + output: OutputConfig { - color: "never".to_string(), + color: ColorMode::Never, verbose: true, ..Default::default() }, @@ -123,7 +124,7 @@ fn config_merge_precedence() { assert_eq!(config.install_dir, PathBuf::from("/other")); assert_eq!(config.github_token, Some("other".to_string())); assert!(!config.download.show_progress); - assert_eq!(config.output.color, "never"); + assert_eq!(config.output.color, ColorMode::Never); assert!(config.output.verbose); } @@ -133,8 +134,8 @@ fn github_token_field() { github_token: Some("test-token".to_string()), ..Default::default() }; - assert_eq!(config.github_token.as_deref(), Some("test-token")); + assert_eq!(config.github_token.as_deref(), Some("test-token")); let config_no_token = Config::default(); assert_eq!(config_no_token.github_token.as_deref(), None); } @@ -145,6 +146,7 @@ fn install_dir_field() { install_dir: PathBuf::from("/custom/install"), ..Default::default() }; + assert_eq!(config.install_dir, PathBuf::from("/custom/install")); } @@ -159,7 +161,7 @@ fn download_config_default() { #[test] fn output_config_default() { let out = OutputConfig::default(); - assert_eq!(out.color, "auto"); + assert_eq!(out.color, ColorMode::Auto); assert!(!out.quiet); assert!(!out.verbose); } From 697ed945bbe80889a208aab08c55681723e32d2b Mon Sep 17 00:00:00 2001 From: Francesco Sardone Date: Fri, 24 Apr 2026 15:42:23 +0200 Subject: [PATCH 3/6] refactor: code clean up and better structuring --- AGENTS.md | 15 ++++++- src/core/checksum.rs | 43 +++++++++++-------- src/core/config.rs | 5 ++- src/core/constants.rs | 77 ++++++++++++++++++++++++++++++++++ src/core/extract.rs | 60 ++++++++++++++++----------- src/core/registry.rs | 25 +++++++---- src/core/updater.rs | 4 +- src/core/util.rs | 7 ++-- src/network/github.rs | 72 ++++++++++++++++++++++---------- src/network/platform.rs | 66 +++++++++++++++-------------- tests/cache.rs | 16 +++----- tests/channel_persist.rs | 19 +++++---- tests/export_import.rs | 89 +++++++++++++++++++++------------------- tests/fixtures.rs | 12 ++++++ tests/github.rs | 23 +++++++---- tests/local.rs | 31 +++++++++----- tests/registry.rs | 30 ++++++++------ 17 files changed, 393 insertions(+), 201 deletions(-) create mode 100644 tests/fixtures.rs diff --git a/AGENTS.md b/AGENTS.md index 2d6bf56..3d5f347 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,20 @@ Development guide for gitclaw contributors and agents. 6. Specs are temporary planning artifacts, delete after merge 7. Post-mortem lessons go to AGENTS.md, not the spec -## CI Flow +## Constants + +- All named constants live in `src/core/constants.rs` — no magic strings or numbers elsewhere in `src/` +- Archive extension constants: `EXT_TAR_GZ`, `EXT_TGZ`, `EXT_ZIP`, etc. +- ar header offsets for `.deb` parsing: private `const` inside `extract.rs` (not in `constants.rs`, not `pub`) +- Output prefix tags (`[EXEC]`, `[INFO]`, etc.): inline literals in `output/mod.rs` only +- GitHub API path templates: `const &str` with `{}` placeholders, used with `.replacen("{}", value, 1)` at call sites + +## Test Fixtures + +- Shared cross-file fixture strings (owner, repo, version, asset names that appear in 2+ test files) go in `tests/fixtures.rs` +- Each test file that uses them adds `mod fixtures;` and imports what it needs +- Integer-only or single-file literals (e.g. `Asset.id = 12345`) stay as local `const` at the top of that file +- `tests/fixtures.rs` contains: `OWNER`, `REPO`, `VERSION`, `ASSET`, `PACKAGE`, `FD_OWNER`, `FD_REPO`, `FD_VERSION`, `BAT_REPO`, `BAT_VERSION` Verify, Test, Build diff --git a/src/core/checksum.rs b/src/core/checksum.rs index 1ef6dd3..06c6834 100644 --- a/src/core/checksum.rs +++ b/src/core/checksum.rs @@ -5,6 +5,11 @@ use std::path::Path; use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256, Sha512}; +use crate::core::constants::{ + EXT_CHECKSUM, EXT_MD5, EXT_SHA, EXT_SHA256, EXT_SHA512, EXT_SIG, EXT_ASC, + STR_CHECKSUM, STR_SHA256SUM, STR_SHA512SUM, +}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ChecksumAlgorithm { Sha256, @@ -12,14 +17,18 @@ pub enum ChecksumAlgorithm { Md5, } +const INFIX_SHA256: &str = ".sha256."; +const INFIX_SHA512: &str = ".sha512."; +const INFIX_MD5: &str = ".md5."; + pub fn is_checksum_file(filename: &str) -> Option { let lower = filename.to_lowercase(); - if lower.ends_with(".sha256") || lower.contains(".sha256.") { + if lower.ends_with(EXT_SHA256) || lower.contains(INFIX_SHA256) { Some(ChecksumAlgorithm::Sha256) - } else if lower.ends_with(".sha512") || lower.contains(".sha512.") { + } else if lower.ends_with(EXT_SHA512) || lower.contains(INFIX_SHA512) { Some(ChecksumAlgorithm::Sha512) - } else if lower.ends_with(".md5") || lower.contains(".md5.") { + } else if lower.ends_with(EXT_MD5) || lower.contains(INFIX_MD5) { Some(ChecksumAlgorithm::Md5) } else { None @@ -29,16 +38,16 @@ pub fn is_checksum_file(filename: &str) -> Option { pub fn is_checksum_asset(name: &str) -> bool { let lower = name.to_lowercase(); - lower.ends_with(".sha256") - || lower.ends_with(".sha512") - || lower.ends_with(".sha") - || lower.ends_with(".sig") - || lower.ends_with(".asc") - || lower.ends_with(".md5") - || lower.ends_with(".checksum") - || lower.contains("checksum") - || lower.contains("sha256sum") - || lower.contains("sha512sum") + lower.ends_with(EXT_SHA256) + || lower.ends_with(EXT_SHA512) + || lower.ends_with(EXT_SHA) + || lower.ends_with(EXT_SIG) + || lower.ends_with(EXT_ASC) + || lower.ends_with(EXT_MD5) + || lower.ends_with(EXT_CHECKSUM) + || lower.contains(STR_CHECKSUM) + || lower.contains(STR_SHA256SUM) + || lower.contains(STR_SHA512SUM) } pub fn find_checksum_file( @@ -46,9 +55,9 @@ pub fn find_checksum_file( assets: &[crate::network::github::Asset], ) -> Option<(ChecksumAlgorithm, String)> { let suffixes: [(&str, ChecksumAlgorithm); 3] = [ - (".sha256", ChecksumAlgorithm::Sha256), - (".sha512", ChecksumAlgorithm::Sha512), - (".md5", ChecksumAlgorithm::Md5), + (EXT_SHA256, ChecksumAlgorithm::Sha256), + (EXT_SHA512, ChecksumAlgorithm::Sha512), + (EXT_MD5, ChecksumAlgorithm::Md5), ]; for asset in assets { @@ -62,7 +71,7 @@ pub fn find_checksum_file( for asset in assets { let name_lower = asset.name.to_lowercase(); - if name_lower.contains("checksum") || name_lower.contains("sha256sum") { + if name_lower.contains(STR_CHECKSUM) || name_lower.contains(STR_SHA256SUM) { return Some(( ChecksumAlgorithm::Sha256, asset.browser_download_url.clone(), diff --git a/src/core/config.rs b/src/core/config.rs index dba6c8c..d506ce0 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -6,7 +6,8 @@ use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use crate::core::constants::{ - CONFIG_FILE, ENV_VAR_CONFIG, GITCLAW_DIR, LOCAL_CONFIG_FILE, XDG_CONFIG_SUBDIR, + CONFIG_FILE, ENV_VAR_CONFIG, GITCLAW_DIR, LEGACY_HOME_CONFIG_FILE, LOCAL_CONFIG_FILE, + XDG_CONFIG_SUBDIR, }; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -158,7 +159,7 @@ impl Config { pub fn load_from_legacy() -> Result> { if let Some(home) = dirs::home_dir() { - let path = home.join(".gitclaw.toml"); + let path = home.join(LEGACY_HOME_CONFIG_FILE); if path.exists() { let content = diff --git a/src/core/constants.rs b/src/core/constants.rs index 9615282..e84a8fa 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -7,6 +7,7 @@ pub const GITCLAW_DIR: &str = ".gitclaw"; pub const REGISTRY_FILE: &str = "registry.toml"; pub const CONFIG_FILE: &str = "config.toml"; pub const LOCAL_CONFIG_FILE: &str = ".gitclaw.toml"; +pub const LEGACY_HOME_CONFIG_FILE: &str = ".gitclaw.toml"; pub const XDG_CONFIG_SUBDIR: &str = "gitclaw"; pub const ENV_VAR_TOKEN: &str = "GITHUB_TOKEN"; pub const ENV_VAR_CONFIG: &str = "GITCLAW_CONFIG"; @@ -23,17 +24,93 @@ pub const TEMP_DIR_PREFIX: &str = "gitclaw-"; pub const TEMP_DIR_SELF_UPDATE: &str = "gitclaw-self-update"; pub const GITHUB_API_BASE: &str = "https://api.github.com"; +pub const GITHUB_URL_PREFIX: &str = "https://github.com/"; +pub const GITHUB_URL_PREFIX_SHORT: &str = "github.com/"; +pub const GITHUB_API_PATH_RELEASES_TAG: &str = "/repos/{}/{}/releases/tags/{}"; +pub const GITHUB_API_PATH_RELEASES_LATEST: &str = "/repos/{}/{}/releases/latest"; +pub const GITHUB_API_PATH_RELEASES: &str = "/repos/{}/{}/releases"; +pub const GITHUB_API_PATH_RELEASES_PAGED: &str = "/repos/{}/{}/releases?per_page={}"; + pub const RELEASE_TAG_LATEST: &str = "latest"; +pub const HTTP_HEADER_LINK: &str = "link"; +pub const HTTP_LINK_REL_NEXT: &str = "rel=\"next\""; + pub const KV_KEY_WIDTH: usize = 20; pub const SEARCH_LIMIT_DEFAULT: &str = "10"; pub const SEARCH_LIMIT_MAX: usize = 100; pub const EXEC_PERMISSION_MODE: u32 = 0o755; pub const EXEC_PERMISSION_BITS: u32 = 0o111; pub const DATE_PREFIX_LEN: usize = 10; +pub const WALK_MAX_DEPTH: usize = 3; +pub const BYTES_PER_KIB: u64 = 1024; +pub const UPDATER_BACKUP_EXT: &str = "backup"; pub const SCORE_PLATFORM_MATCH: i32 = 10; pub const SCORE_LINUX_PARTIAL: i32 = 5; pub const SCORE_KNOWN_EXTENSION: i32 = 5; pub const SCORE_SHELL_SCRIPT: i32 = 2; pub const SCORE_CHECKSUM_PENALTY: i32 = -100; + +pub const EXT_TAR_GZ: &str = ".tar.gz"; +pub const EXT_TGZ: &str = ".tgz"; +pub const EXT_ZIP: &str = ".zip"; +pub const EXT_TAR_BZ2: &str = ".tar.bz2"; +pub const EXT_TBZ2: &str = ".tbz2"; +pub const EXT_TAR_XZ: &str = ".tar.xz"; +pub const EXT_TXZ: &str = ".txz"; +pub const EXT_TAR_ZST: &str = ".tar.zst"; +pub const EXT_TZST: &str = ".tzst"; +pub const EXT_TAR: &str = ".tar"; +pub const EXT_DEB: &str = ".deb"; +pub const EXT_BIN: &str = ".bin"; +pub const EXT_APPIMAGE: &str = ".appimage"; +pub const EXT_RPM: &str = ".rpm"; +pub const EXT_SH: &str = ".sh"; + +pub const EXT_SHA256: &str = ".sha256"; +pub const EXT_SHA512: &str = ".sha512"; +pub const EXT_MD5: &str = ".md5"; +pub const EXT_SHA: &str = ".sha"; +pub const EXT_SIG: &str = ".sig"; +pub const EXT_ASC: &str = ".asc"; +pub const EXT_CHECKSUM: &str = ".checksum"; +pub const STR_CHECKSUM: &str = "checksum"; +pub const STR_SHA256SUM: &str = "sha256sum"; +pub const STR_SHA512SUM: &str = "sha512sum"; + +pub const DEB_DATA_TAR_GZ: &str = "data.tar.gz"; +pub const DEB_DATA_TAR_XZ: &str = "data.tar.xz"; +pub const DEB_DATA_TAR_BZ2: &str = "data.tar.bz2"; +pub const DEB_DATA_TAR_ZST: &str = "data.tar.zst"; +pub const DEB_DATA_TAR: &str = "data.tar"; + +pub const COL_SEARCH_TAG: usize = 20; +pub const COL_SEARCH_NAME: usize = 42; +pub const COL_SEARCH_ASSETS: usize = 8; +pub const COL_SEARCH_NAME_MAX: usize = 40; +pub const COL_SEARCH_NAME_TRUNCATE: usize = 37; + +pub const COL_LIST_PACKAGE: usize = 25;pub const COL_LIST_IDENTIFIER: usize = 20; +pub const COL_LIST_VERSION: usize = 15; +pub const COL_LIST_PATH: usize = 30; +pub const COL_LIST_PATH_MAX: usize = 28; +pub const COL_LIST_PATH_TRUNCATE: usize = 25; + +pub const ARCH_X86_64: &str = "x86_64"; +pub const ARCH_AARCH64: &str = "aarch64"; +pub const ARCH_AMD64: &str = "amd64"; +pub const ARCH_ARM64: &str = "arm64"; +pub const ARCH_X64: &str = "x64"; + +pub const PLATFORM_LINUX: &str = "linux"; +pub const PLATFORM_LINUX_X86_64: &str = "linux-x86_64"; +pub const PLATFORM_LINUX_AMD64: &str = "linux-amd64"; +pub const PLATFORM_LINUX_X64: &str = "linux-x64"; +pub const PLATFORM_LINUX_AARCH64: &str = "linux-aarch64"; +pub const PLATFORM_LINUX_ARM64: &str = "linux-arm64"; +pub const PLATFORM_X86_64_GNU: &str = "x86_64-unknown-linux-gnu"; +pub const PLATFORM_X86_64_MUSL: &str = "x86_64-unknown-linux-musl"; +pub const PLATFORM_AARCH64_GNU: &str = "aarch64-unknown-linux-gnu"; +pub const PLATFORM_AARCH64_MUSL: &str = "aarch64-unknown-linux-musl"; + diff --git a/src/core/extract.rs b/src/core/extract.rs index e4d0da2..1303df2 100644 --- a/src/core/extract.rs +++ b/src/core/extract.rs @@ -4,6 +4,18 @@ use std::path::Path; use thiserror::Error; +use crate::core::constants::{ + DEB_DATA_TAR, DEB_DATA_TAR_BZ2, DEB_DATA_TAR_GZ, DEB_DATA_TAR_XZ, DEB_DATA_TAR_ZST, + DIR_EXTRACTED, EXT_BIN, EXT_DEB, EXT_TAR, EXT_TAR_BZ2, EXT_TAR_GZ, EXT_TAR_XZ, EXT_TAR_ZST, + EXT_TBZ2, EXT_TGZ, EXT_TZST, EXT_TXZ, EXT_ZIP, +}; + +const AR_HEADER_SIZE: usize = 60; +const AR_FILENAME_END: usize = 16; +const AR_FILESIZE_START: usize = 48; +const AR_FILESIZE_END: usize = 58; +const AR_FILESIZE_END_SHORT: usize = 56; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ArchiveType { TarGz, @@ -40,21 +52,21 @@ pub fn detect_archive_type(file_path: &Path) -> Result { let lower = filename.to_lowercase(); - if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") { + if lower.ends_with(EXT_TAR_GZ) || lower.ends_with(EXT_TGZ) { Ok(ArchiveType::TarGz) - } else if lower.ends_with(".zip") { + } else if lower.ends_with(EXT_ZIP) { Ok(ArchiveType::Zip) - } else if lower.ends_with(".tar.bz2") || lower.ends_with(".tbz2") { + } else if lower.ends_with(EXT_TAR_BZ2) || lower.ends_with(EXT_TBZ2) { Ok(ArchiveType::TarBz2) - } else if lower.ends_with(".tar.xz") || lower.ends_with(".txz") { + } else if lower.ends_with(EXT_TAR_XZ) || lower.ends_with(EXT_TXZ) { Ok(ArchiveType::TarXz) - } else if lower.ends_with(".tar.zst") || lower.ends_with(".tzst") { + } else if lower.ends_with(EXT_TAR_ZST) || lower.ends_with(EXT_TZST) { Ok(ArchiveType::TarZst) - } else if lower.ends_with(".tar") { + } else if lower.ends_with(EXT_TAR) { Ok(ArchiveType::TarGz) - } else if lower.ends_with(".deb") { + } else if lower.ends_with(EXT_DEB) { Ok(ArchiveType::Deb) - } else if lower.ends_with(".bin") || !lower.contains('.') { + } else if lower.ends_with(EXT_BIN) || !lower.contains('.') { Ok(ArchiveType::PlainBinary) } else { Err(ExtractionError::UnknownArchiveType(filename.to_string())) @@ -179,11 +191,11 @@ fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { let mut offset = AR_MAGIC.len(); - while offset + 60 <= deb_content.len() { - let header = &deb_content[offset..offset + 60]; - offset += 60; + while offset + AR_HEADER_SIZE <= deb_content.len() { + let header = &deb_content[offset..offset + AR_HEADER_SIZE]; + offset += AR_HEADER_SIZE; - let name_field = &header[0..16]; + let name_field = &header[0..AR_FILENAME_END]; let name_end = name_field .iter() @@ -201,7 +213,7 @@ fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { })? .to_string(); - let size_field = std::str::from_utf8(&header[48..58]) + let size_field = std::str::from_utf8(&header[AR_FILESIZE_START..AR_FILESIZE_END]) .map_err(|_| { ExtractionError::UnsupportedFormat( "Invalid UTF-8 in .deb header size field".to_string(), @@ -212,7 +224,7 @@ fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { let size: usize = size_field .parse() .or_else(|_| { - std::str::from_utf8(&header[48..56]) + std::str::from_utf8(&header[AR_FILESIZE_START..AR_FILESIZE_END_SHORT]) .map_err(|_| { ExtractionError::UnsupportedFormat( "Invalid UTF-8 in .deb size field fallback".to_string(), @@ -230,11 +242,11 @@ fn extract_data_tar_from_deb(deb_content: &[u8]) -> Result<(Vec, String)> { continue; } - if name == "data.tar.gz" - || name == "data.tar.xz" - || name == "data.tar.bz2" - || name == "data.tar.zst" - || name == "data.tar" + if name == DEB_DATA_TAR_GZ + || name == DEB_DATA_TAR_XZ + || name == DEB_DATA_TAR_BZ2 + || name == DEB_DATA_TAR_ZST + || name == DEB_DATA_TAR { let data = deb_content[offset..offset + size].to_vec(); return Ok((data, name)); @@ -256,13 +268,13 @@ fn extract_tar_auto(tar_path: &Path, dest_dir: &Path) -> Result<()> { let ext = tar_path.extension().and_then(|e| e.to_str()).unwrap_or(""); let full_name = tar_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if full_name.ends_with(".tar.gz") || ext == "gz" { + if full_name.ends_with(EXT_TAR_GZ) || ext == "gz" { extract_tar_gz(tar_path, dest_dir) - } else if full_name.ends_with(".tar.xz") || ext == "xz" { + } else if full_name.ends_with(EXT_TAR_XZ) || ext == "xz" { extract_tar_xz(tar_path, dest_dir) - } else if full_name.ends_with(".tar.bz2") || ext == "bz2" { + } else if full_name.ends_with(EXT_TAR_BZ2) || ext == "bz2" { extract_tar_bz2(tar_path, dest_dir) - } else if full_name.ends_with(".tar.zst") || ext == "zst" { + } else if full_name.ends_with(EXT_TAR_ZST) || ext == "zst" { extract_tar_zst(tar_path, dest_dir) } else { let file = fs::File::open(tar_path)?; @@ -293,7 +305,7 @@ pub fn extract_archive(archive_path: &Path, dest_dir: &Path, prefer_strip: bool) let name = archive_path .file_stem() .and_then(|n| n.to_str()) - .unwrap_or("extracted"); + .unwrap_or(DIR_EXTRACTED); let d = dest_dir.join(name); fs::create_dir_all(&d)?; diff --git a/src/core/registry.rs b/src/core/registry.rs index c01178b..a259eb3 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -9,7 +9,10 @@ use tracing::debug; use crate::core::channel::Channel; use crate::core::config::Config; -use crate::core::constants::{APP_NAME_SHORT, DATE_PREFIX_LEN, DIR_BIN, RELEASE_TAG_LATEST}; +use crate::core::constants::{ + APP_NAME_SHORT, COL_LIST_IDENTIFIER, COL_LIST_PACKAGE, COL_LIST_PATH, COL_LIST_PATH_MAX, + COL_LIST_PATH_TRUNCATE, COL_LIST_VERSION, DATE_PREFIX_LEN, DIR_BIN, RELEASE_TAG_LATEST, +}; use crate::core::util::registry_path_from; use crate::network::github::{parse_package, GithubClient}; use crate::output; @@ -107,8 +110,12 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { println!( "{}", format!( - "{:<25} {:<20} {:<15} {:<30} {}", - "Package", "Identifier", "Version", "Path", "Date" + "{: Result<()> { pkg.binary_path.display().to_string() }; - let path_display = if path_short.len() > 28 { - format!("{}...", &path_short[..25]) + let path_display = if path_short.len() > COL_LIST_PATH_MAX { + format!("{}...", &path_short[..COL_LIST_PATH_TRUNCATE]) } else { path_short }; println!( - "{:<25} {:<20} {:<15} {:<30} {}", + "{: Result<()> { } fn replace_binary(new: &std::path::Path, current: &std::path::Path) -> Result<()> { - let backup = current.with_extension("backup"); + let backup = current.with_extension(UPDATER_BACKUP_EXT); std::fs::rename(current, &backup)?; match std::fs::copy(new, current) { diff --git a/src/core/util.rs b/src/core/util.rs index 3268450..34907f3 100644 --- a/src/core/util.rs +++ b/src/core/util.rs @@ -8,7 +8,7 @@ use walkdir::WalkDir; use crate::core::constants::{ CONFIG_FILE, DIR_BIN, DIR_CACHE, DIR_DOWNLOADS, DIR_PACKAGES, EXEC_PERMISSION_BITS, - GITCLAW_DIR, REGISTRY_FILE, + GITCLAW_DIR, REGISTRY_FILE, WALK_MAX_DEPTH, }; pub fn home_dir() -> Result { @@ -74,8 +74,9 @@ pub fn package_key(owner: &str, repo: &str) -> String { } pub fn find_binary(dir: &Path, repo_name: &str) -> Result { - for entry in WalkDir::new(dir).max_depth(3) { + for entry in WalkDir::new(dir).max_depth(WALK_MAX_DEPTH) { let entry = entry?; + if !entry.file_type().is_file() { continue; } @@ -98,7 +99,7 @@ pub fn find_binary(dir: &Path, repo_name: &str) -> Result { } } - for entry in WalkDir::new(dir).max_depth(3) { + for entry in WalkDir::new(dir).max_depth(WALK_MAX_DEPTH) { let entry = entry?; if !entry.file_type().is_file() { continue; diff --git a/src/network/github.rs b/src/network/github.rs index c9ceec7..0130f77 100644 --- a/src/network/github.rs +++ b/src/network/github.rs @@ -11,7 +11,13 @@ use thiserror::Error; use tracing::{debug, warn}; use crate::core::config::Config; -use crate::core::constants::{GITHUB_API_BASE, RELEASE_TAG_LATEST, SEARCH_LIMIT_MAX}; +use crate::core::constants::{ + COL_SEARCH_ASSETS, COL_SEARCH_NAME, COL_SEARCH_NAME_MAX, COL_SEARCH_NAME_TRUNCATE, + COL_SEARCH_TAG, GITHUB_API_BASE, GITHUB_API_PATH_RELEASES, GITHUB_API_PATH_RELEASES_LATEST, + GITHUB_API_PATH_RELEASES_PAGED, GITHUB_API_PATH_RELEASES_TAG, GITHUB_URL_PREFIX, + GITHUB_URL_PREFIX_SHORT, HTTP_HEADER_LINK, HTTP_LINK_REL_NEXT, PLATFORM_LINUX_AARCH64, + PLATFORM_LINUX_X86_64, RELEASE_TAG_LATEST, SEARCH_LIMIT_MAX, +}; use crate::core::util::format_bytes; use crate::network::platform::{detect_arch, Arch}; use crate::output; @@ -69,8 +75,8 @@ pub enum Platform { impl std::fmt::Display for Platform { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Platform::LinuxX86_64 => write!(f, "linux-x86_64"), - Platform::LinuxAarch64 => write!(f, "linux-aarch64"), + Platform::LinuxX86_64 => write!(f, "{}", PLATFORM_LINUX_X86_64), + Platform::LinuxAarch64 => write!(f, "{}", PLATFORM_LINUX_AARCH64), } } } @@ -133,8 +139,11 @@ impl GithubClient { }; let url = format!( - "{}/repos/{}/{}/releases/tags/{}", - GITHUB_API_BASE, owner, repo, tag_normalized + "{}{}", GITHUB_API_BASE, + GITHUB_API_PATH_RELEASES_TAG + .replacen("{}", owner, 1) + .replacen("{}", repo, 1) + .replacen("{}", &tag_normalized, 1) ); debug!("GET {}", url); @@ -146,8 +155,11 @@ impl GithubClient { if tag_normalized.starts_with('v') && tag_normalized != tag { let url = format!( - "{}/repos/{}/{}/releases/tags/{}", - GITHUB_API_BASE, owner, repo, tag + "{}{}", GITHUB_API_BASE, + GITHUB_API_PATH_RELEASES_TAG + .replacen("{}", owner, 1) + .replacen("{}", repo, 1) + .replacen("{}", tag, 1) ); let resp = self.add_auth(self.client.get(&url)).send().await?; @@ -187,8 +199,10 @@ impl GithubClient { repo: &str, ) -> std::result::Result { let url = format!( - "{}/repos/{}/{}/releases/latest", - GITHUB_API_BASE, owner, repo + "{}{}", GITHUB_API_BASE, + GITHUB_API_PATH_RELEASES_LATEST + .replacen("{}", owner, 1) + .replacen("{}", repo, 1) ); let resp = self.add_auth(self.client.get(&url)).send().await?; @@ -214,7 +228,12 @@ impl GithubClient { owner: &str, repo: &str, ) -> std::result::Result, GithubError> { - let url = format!("{}/repos/{}/{}/releases", GITHUB_API_BASE, owner, repo); + let url = format!( + "{}{}", GITHUB_API_BASE, + GITHUB_API_PATH_RELEASES + .replacen("{}", owner, 1) + .replacen("{}", repo, 1) + ); debug!("GET {}", url); let resp = self.add_auth(self.client.get(&url)).send().await?; @@ -355,8 +374,8 @@ pub fn find_matching_asset( pub fn parse_package(input: &str) -> Result<(String, String, Option)> { let s = input - .trim_start_matches("https://github.com/") - .trim_start_matches("github.com/"); + .trim_start_matches(GITHUB_URL_PREFIX) + .trim_start_matches(GITHUB_URL_PREFIX_SHORT); let (repo_part, version) = match s.split_once('@') { Some((r, v)) => (r, Some(v.to_string())), @@ -383,17 +402,20 @@ pub async fn search_releases( let per_page = limit.min(SEARCH_LIMIT_MAX); let url = format!( - "{}/repos/{}/{}/releases?per_page={}", - GITHUB_API_BASE, owner, repo, per_page + "{}{}", GITHUB_API_BASE, + GITHUB_API_PATH_RELEASES_PAGED + .replacen("{}", &owner, 1) + .replacen("{}", &repo, 1) + .replacen("{}", &per_page.to_string(), 1) ); let resp = client.add_auth(client.client.get(&url)).send().await?; let resp = check_api_response(resp).await?; let has_next = resp .headers() - .get("link") + .get(HTTP_HEADER_LINK) .and_then(|v| v.to_str().ok()) - .map(|s| s.contains("rel=\"next\"")) + .map(|s| s.contains(HTTP_LINK_REL_NEXT)) .unwrap_or(false); let mut releases: Vec = resp.json().await?; @@ -410,16 +432,19 @@ pub async fn search_releases( println!( "{}", format!( - "{:<20} {:<42} {:<8} {}", - "Tag", "Name", "Assets", "Total Size" + "{: 40 { - format!("{}...", &name[..37]) + let name_display = if name.len() > COL_SEARCH_NAME_MAX { + format!("{}...", &name[..COL_SEARCH_NAME_TRUNCATE]) } else { name }; @@ -428,11 +453,14 @@ pub async fn search_releases( let total_size: u64 = r.assets.iter().map(|a| a.size).sum(); println!( - "{:<20} {:<42} {:<8} {}", + "{:) -> std::fmt::Result { match self { - Arch::X86_64 => write!(f, "x86_64"), - Arch::Aarch64 => write!(f, "aarch64"), + Arch::X86_64 => write!(f, "{}", ARCH_X86_64), + Arch::Aarch64 => write!(f, "{}", ARCH_AARCH64), } } } @@ -30,22 +34,22 @@ impl Arch { pub fn aliases(&self) -> &[&'static str] { match self { Arch::X86_64 => &[ - "linux-x86_64", - "linux-amd64", - "linux-x64", - "x86_64-unknown-linux-gnu", - "x86_64-unknown-linux-musl", - "x86_64", - "amd64", - "x64", + PLATFORM_LINUX_X86_64, + PLATFORM_LINUX_AMD64, + PLATFORM_LINUX_X64, + PLATFORM_X86_64_GNU, + PLATFORM_X86_64_MUSL, + ARCH_X86_64, + ARCH_AMD64, + ARCH_X64, ], Arch::Aarch64 => &[ - "linux-aarch64", - "linux-arm64", - "aarch64-unknown-linux-gnu", - "aarch64-unknown-linux-musl", - "aarch64", - "arm64", + PLATFORM_LINUX_AARCH64, + PLATFORM_LINUX_ARM64, + PLATFORM_AARCH64_GNU, + PLATFORM_AARCH64_MUSL, + ARCH_AARCH64, + ARCH_ARM64, ], } } @@ -53,8 +57,8 @@ impl Arch { pub fn detect_arch() -> Result { match std::env::consts::ARCH { - "x86_64" => Ok(Arch::X86_64), - "aarch64" | "arm64" => Ok(Arch::Aarch64), + ARCH_X86_64 => Ok(Arch::X86_64), + ARCH_AARCH64 | ARCH_ARM64 => Ok(Arch::Aarch64), other => Err(PlatformError::UnsupportedArch(other.to_string())), } } @@ -74,25 +78,25 @@ pub fn score_asset(name: &str, arch: Arch) -> i32 { } } - if score == 0 && lower.contains("linux") { + if score == 0 && lower.contains(PLATFORM_LINUX) { score += SCORE_LINUX_PARTIAL; } if score >= SCORE_LINUX_PARTIAL { - if lower.ends_with(".tar.gz") - || lower.ends_with(".tgz") - || lower.ends_with(".tar.xz") - || lower.ends_with(".tar.bz2") - || lower.ends_with(".zip") - || lower.ends_with(".appimage") - || lower.ends_with(".deb") - || lower.ends_with(".rpm") - || lower.ends_with(".tar") + if lower.ends_with(EXT_TAR_GZ) + || lower.ends_with(EXT_TGZ) + || lower.ends_with(EXT_TAR_XZ) + || lower.ends_with(EXT_TAR_BZ2) + || lower.ends_with(EXT_ZIP) + || lower.ends_with(EXT_APPIMAGE) + || lower.ends_with(EXT_DEB) + || lower.ends_with(EXT_RPM) + || lower.ends_with(EXT_TAR) { score += SCORE_KNOWN_EXTENSION; } - if lower.ends_with(".sh") { + if lower.ends_with(EXT_SH) { score += SCORE_SHELL_SCRIPT; } } diff --git a/tests/cache.rs b/tests/cache.rs index 017aa89..0aac7ac 100644 --- a/tests/cache.rs +++ b/tests/cache.rs @@ -1,3 +1,6 @@ +mod fixtures; + +use fixtures::{ASSET, OWNER, REPO, VERSION}; use tempfile::TempDir; use gitclaw::cache; @@ -14,7 +17,7 @@ fn make_config() -> (Config, TempDir) { #[test] fn test_cache_key_format() { - let key = cache::cache_key("BurntSushi", "ripgrep", "13.0.0", "ripgrep.tar.gz"); + let key = cache::cache_key(OWNER, REPO, VERSION, "ripgrep.tar.gz"); assert_eq!(key, "BurntSushi_ripgrep_13.0.0_ripgrep.tar.gz"); } @@ -202,33 +205,26 @@ fn test_size_with_files() { fn test_full_cache_roundtrip() { let (config, _dir) = make_config(); - // Simulate download let source_dir = TempDir::new().unwrap(); - let source = source_dir.path().join("ripgrep-13.0.0.tar.gz"); + let source = source_dir.path().join(ASSET); std::fs::write(&source, b"ripgrep binary content").unwrap(); - // Store in cache - let key = cache::cache_key("BurntSushi", "ripgrep", "13.0.0", "ripgrep-13.0.0.tar.gz"); + let key = cache::cache_key(OWNER, REPO, VERSION, ASSET); let cached = cache::store(&config, &key, &source).unwrap(); let hash = cache::file_hash(&cached).unwrap(); - // Cache hit with matching hash let result = cache::get_cached(&config, &key, Some(&hash)); assert!(result.is_some()); - // Cache miss with wrong hash let result = cache::get_cached(&config, &key, Some("wrong_hash")); assert!(result.is_none()); - // Cache hit without hash check let result = cache::get_cached(&config, &key, None); assert!(result.is_some()); - // Verify size let size = cache::size(&config).unwrap(); assert!(size > 0); - // Clean and verify let removed = cache::clean(&config).unwrap(); assert_eq!(removed, 1); assert_eq!(cache::size(&config).unwrap(), 0); diff --git a/tests/channel_persist.rs b/tests/channel_persist.rs index 19fff45..3a33f45 100644 --- a/tests/channel_persist.rs +++ b/tests/channel_persist.rs @@ -1,3 +1,6 @@ +mod fixtures; + +use fixtures::{FD_OWNER, FD_REPO, OWNER, REPO}; use std::path::PathBuf; use gitclaw::core::channel::Channel; @@ -52,17 +55,17 @@ fn test_registry_save_load_with_channel() { let mut reg = Registry::load_from(®_path).unwrap(); reg.add(make_pkg_with_channel( - "BurntSushi/ripgrep", - "BurntSushi", - "ripgrep", + &format!("{}/{}", OWNER, REPO), + OWNER, + REPO, "14.0.0-nightly", Some(Channel::Nightly), )); reg.add(make_pkg_with_channel( - "sharkdp/fd", - "sharkdp", - "fd", + &format!("{}/{}", FD_OWNER, FD_REPO), + FD_OWNER, + FD_REPO, "8.7.0", None, )); @@ -70,10 +73,10 @@ fn test_registry_save_load_with_channel() { reg.save().unwrap(); let loaded = Registry::load_from(®_path).unwrap(); - let rg = loaded.packages.get("BurntSushi/ripgrep").unwrap(); + let rg = loaded.packages.get(&format!("{}/{}", OWNER, REPO)).unwrap(); assert_eq!(rg.channel, Some(Channel::Nightly)); - let fd = loaded.packages.get("sharkdp/fd").unwrap(); + let fd = loaded.packages.get(&format!("{}/{}", FD_OWNER, FD_REPO)).unwrap(); assert_eq!(fd.channel, None); } diff --git a/tests/export_import.rs b/tests/export_import.rs index 66d7b46..c077dd5 100644 --- a/tests/export_import.rs +++ b/tests/export_import.rs @@ -1,3 +1,6 @@ +mod fixtures; + +use fixtures::{BAT_REPO, BAT_VERSION, FD_OWNER, FD_REPO, FD_VERSION, OWNER, REPO, VERSION}; use tempfile::TempDir; use gitclaw::config::Config; @@ -34,9 +37,9 @@ fn sample_pkg(name: &str, owner: &str, repo: &str, version: &str) -> InstalledPa #[test] fn test_export_entry_serialization() { let entry = ExportEntry { - owner: "BurntSushi".to_string(), - repo: "ripgrep".to_string(), - version: "13.0.0".to_string(), + owner: OWNER.to_string(), + repo: REPO.to_string(), + version: VERSION.to_string(), }; let export = ExportFile { @@ -44,9 +47,9 @@ fn test_export_entry_serialization() { }; let toml_str = export.to_toml().unwrap(); - assert!(toml_str.contains("BurntSushi")); - assert!(toml_str.contains("ripgrep")); - assert!(toml_str.contains("13.0.0")); + assert!(toml_str.contains(OWNER)); + assert!(toml_str.contains(REPO)); + assert!(toml_str.contains(VERSION)); } #[test] @@ -60,9 +63,9 @@ version = "8.7.0" let export = ExportFile::from_toml(toml_str).unwrap(); assert_eq!(export.packages.len(), 1); - assert_eq!(export.packages[0].owner, "sharkdp"); - assert_eq!(export.packages[0].repo, "fd"); - assert_eq!(export.packages[0].version, "8.7.0"); + assert_eq!(export.packages[0].owner, FD_OWNER); + assert_eq!(export.packages[0].repo, FD_REPO); + assert_eq!(export.packages[0].version, FD_VERSION); } #[test] @@ -70,14 +73,14 @@ fn test_export_roundtrip() { let export = ExportFile { packages: vec![ ExportEntry { - owner: "BurntSushi".to_string(), - repo: "ripgrep".to_string(), - version: "13.0.0".to_string(), + owner: OWNER.to_string(), + repo: REPO.to_string(), + version: VERSION.to_string(), }, ExportEntry { - owner: "sharkdp".to_string(), - repo: "fd".to_string(), - version: "8.7.0".to_string(), + owner: FD_OWNER.to_string(), + repo: FD_REPO.to_string(), + version: FD_VERSION.to_string(), }, ], }; @@ -92,20 +95,21 @@ fn test_export_roundtrip() { #[test] fn test_export_from_registry_sorted() { let mut reg = Registry::default(); - reg.add(sample_pkg("sharkdp/fd", "sharkdp", "fd", "8.7.0")); + reg.add(sample_pkg(&format!("{}/{}", FD_OWNER, FD_REPO), FD_OWNER, FD_REPO, FD_VERSION)); + reg.add(sample_pkg( - "BurntSushi/ripgrep", - "BurntSushi", - "ripgrep", - "13.0.0", + &format!("{}/{}", OWNER, REPO), + OWNER, + REPO, + VERSION, )); - reg.add(sample_pkg("sharkdp/bat", "sharkdp", "bat", "0.24.0")); + + reg.add(sample_pkg(&format!("{}/{}", FD_OWNER, BAT_REPO), FD_OWNER, BAT_REPO, BAT_VERSION)); let export = ExportFile::from_registry(®); - // Sorted by owner then repo - assert_eq!(export.packages[0].owner, "BurntSushi"); - assert_eq!(export.packages[1].repo, "bat"); - assert_eq!(export.packages[2].repo, "fd"); + assert_eq!(export.packages[0].owner, OWNER); + assert_eq!(export.packages[1].repo, BAT_REPO); + assert_eq!(export.packages[2].repo, FD_REPO); } #[test] @@ -122,9 +126,9 @@ fn test_export_file_write_and_read() { let export = ExportFile { packages: vec![ExportEntry { - owner: "BurntSushi".to_string(), - repo: "ripgrep".to_string(), - version: "13.0.0".to_string(), + owner: OWNER.to_string(), + repo: REPO.to_string(), + version: VERSION.to_string(), }], }; @@ -133,7 +137,7 @@ fn test_export_file_write_and_read() { let loaded = ExportFile::from_file(&path).unwrap(); assert_eq!(loaded.packages.len(), 1); - assert_eq!(loaded.packages[0].repo, "ripgrep"); + assert_eq!(loaded.packages[0].repo, REPO); } #[test] @@ -144,10 +148,10 @@ fn test_export_from_registry_with_config() { let mut reg = Registry::load_from(®_path).unwrap(); reg.add(sample_pkg( - "BurntSushi/ripgrep", - "BurntSushi", - "ripgrep", - "13.0.0", + &format!("{}/{}", OWNER, REPO), + OWNER, + REPO, + VERSION, )); reg.save().unwrap(); @@ -161,22 +165,21 @@ fn test_export_toml_format() { let export = ExportFile { packages: vec![ ExportEntry { - owner: "sharkdp".to_string(), - repo: "bat".to_string(), - version: "0.24.0".to_string(), + owner: FD_OWNER.to_string(), + repo: BAT_REPO.to_string(), + version: BAT_VERSION.to_string(), }, ExportEntry { - owner: "sharkdp".to_string(), - repo: "fd".to_string(), - version: "8.7.0".to_string(), + owner: FD_OWNER.to_string(), + repo: FD_REPO.to_string(), + version: FD_VERSION.to_string(), }, ], }; let toml_str = export.to_toml().unwrap(); - // Should use [[package]] array format assert!(toml_str.contains("[[package]]")); - assert!(toml_str.contains("owner = \"sharkdp\"")); - assert!(toml_str.contains("repo = \"bat\"")); - assert!(toml_str.contains("repo = \"fd\"")); + assert!(toml_str.contains(&format!("owner = \"{}\"", FD_OWNER))); + assert!(toml_str.contains(&format!("repo = \"{}\"", BAT_REPO))); + assert!(toml_str.contains(&format!("repo = \"{}\"", FD_REPO))); } diff --git a/tests/fixtures.rs b/tests/fixtures.rs new file mode 100644 index 0000000..3ce0549 --- /dev/null +++ b/tests/fixtures.rs @@ -0,0 +1,12 @@ +pub const OWNER: &str = "BurntSushi"; +pub const REPO: &str = "ripgrep"; +pub const VERSION: &str = "13.0.0"; +pub const ASSET: &str = "ripgrep-13.0.0.tar.gz"; +pub const PACKAGE: &str = "BurntSushi/ripgrep"; + +pub const FD_OWNER: &str = "sharkdp"; +pub const FD_REPO: &str = "fd"; +pub const FD_VERSION: &str = "8.7.0"; + +pub const BAT_REPO: &str = "bat"; +pub const BAT_VERSION: &str = "0.24.0"; diff --git a/tests/github.rs b/tests/github.rs index 39f65bf..5d4cb64 100644 --- a/tests/github.rs +++ b/tests/github.rs @@ -1,18 +1,23 @@ +mod fixtures; + +use fixtures::{OWNER, PACKAGE, REPO, VERSION}; + #[test] fn test_parse_package_simple() { - let (owner, repo, version) = gitclaw::github::parse_package("BurntSushi/ripgrep").unwrap(); - assert_eq!(owner, "BurntSushi"); - assert_eq!(repo, "ripgrep"); + let (owner, repo, version) = gitclaw::github::parse_package(PACKAGE).unwrap(); + assert_eq!(owner, OWNER); + assert_eq!(repo, REPO); assert!(version.is_none()); } #[test] fn test_parse_package_with_version() { let (owner, repo, version) = - gitclaw::github::parse_package("BurntSushi/ripgrep@13.0.0").unwrap(); - assert_eq!(owner, "BurntSushi"); - assert_eq!(repo, "ripgrep"); - assert_eq!(version, Some("13.0.0".to_string())); + gitclaw::github::parse_package(&format!("{}@{}", PACKAGE, VERSION)).unwrap(); + + assert_eq!(owner, OWNER); + assert_eq!(repo, REPO); + assert_eq!(version, Some(VERSION.to_string())); } #[test] @@ -48,6 +53,7 @@ fn test_release_struct_creation() { tag_name: "v1.0.0".to_string(), name: Some("Version 1.0.0".to_string()), body: Some("Release notes".to_string()), + assets: vec![Asset { id: 12345, name: "app-linux-x86_64.tar.gz".to_string(), @@ -132,6 +138,7 @@ fn test_github_error_display() { status: 404, message: "Not Found".to_string(), }; + let msg = format!("{}", err); assert!(msg.contains("404")); assert!(msg.contains("Not Found")); @@ -146,6 +153,7 @@ fn test_github_error_release_not_found() { repo: "repo".to_string(), version: "v1.0.0".to_string(), }; + let msg = format!("{}", err); assert!(msg.contains("user/repo@v1.0.0")); } @@ -158,6 +166,7 @@ fn test_github_error_no_matching_asset() { platform: "linux-x86_64".to_string(), release: "v1.0.0".to_string(), }; + let msg = format!("{}", err); assert!(msg.contains("linux-x86_64")); assert!(msg.contains("v1.0.0")); diff --git a/tests/local.rs b/tests/local.rs index 0b049e5..a1b0c0d 100644 --- a/tests/local.rs +++ b/tests/local.rs @@ -1,3 +1,6 @@ +mod fixtures; + +use fixtures::{BAT_REPO, BAT_VERSION, FD_OWNER}; use tempfile::TempDir; use gitclaw::config::Config; @@ -8,6 +11,7 @@ use gitclaw::util; fn test_local_install_dir_structure() { let dir = TempDir::new().unwrap(); let local_dir = dir.path().join(".gitclaw"); + let config = Config { install_dir: local_dir.clone(), ..Config::default() @@ -23,6 +27,7 @@ fn test_local_install_dir_structure() { fn test_local_registry_path() { let dir = TempDir::new().unwrap(); let local_dir = dir.path().join(".gitclaw"); + let config = Config { install_dir: local_dir.clone(), ..Config::default() @@ -36,6 +41,7 @@ fn test_local_registry_path() { #[test] fn test_local_registry_isolation() { let local_dir = TempDir::new().unwrap(); + let local_config = Config { install_dir: local_dir.path().join(".gitclaw"), ..Config::default() @@ -43,12 +49,13 @@ fn test_local_registry_isolation() { let local_reg_path = util::registry_path_from(&local_config.install_dir); let global_dir = TempDir::new().unwrap(); + let global_config = Config { install_dir: global_dir.path().to_path_buf(), ..Config::default() }; - let global_reg_path = util::registry_path_from(&global_config.install_dir); + let global_reg_path = util::registry_path_from(&global_config.install_dir); assert_ne!(local_reg_path, global_reg_path); } @@ -63,24 +70,25 @@ fn test_local_registry_load_save() { let reg_path = util::registry_path_from(&config.install_dir); std::fs::create_dir_all(reg_path.parent().unwrap()).unwrap(); - let mut reg = Registry::load_from(®_path).unwrap(); + reg.add(gitclaw::registry::InstalledPackage { - name: "sharkdp/bat".to_string(), - owner: "sharkdp".to_string(), - repo: "bat".to_string(), - version: "0.24.0".to_string(), + name: format!("{}/{}", FD_OWNER, BAT_REPO), + owner: FD_OWNER.to_string(), + repo: BAT_REPO.to_string(), + version: BAT_VERSION.to_string(), installed_at: chrono::Utc::now().to_rfc3339(), - binary_path: local_dir.join("bin").join("bat"), - install_dir: local_dir.join("packages").join("sharkdp").join("bat"), - asset_name: "bat-v0.24.0-x86_64-linux.tar.gz".to_string(), - identifier: "bat".to_string(), + binary_path: local_dir.join("bin").join(BAT_REPO), + install_dir: local_dir.join("packages").join(FD_OWNER).join(BAT_REPO), + asset_name: format!("{}-v{}-x86_64-linux.tar.gz", BAT_REPO, BAT_VERSION), + identifier: BAT_REPO.to_string(), channel: None, }); + reg.save().unwrap(); let loaded = Registry::load_from(®_path).unwrap(); - assert!(loaded.is_installed("sharkdp/bat")); + assert!(loaded.is_installed(&format!("{}/{}", FD_OWNER, BAT_REPO))); assert_eq!(loaded.packages.len(), 1); } @@ -88,6 +96,7 @@ fn test_local_registry_load_save() { fn test_local_cache_dir_isolation() { let dir = TempDir::new().unwrap(); let local_dir = dir.path().join(".gitclaw"); + let config = Config { install_dir: local_dir.clone(), ..Config::default() diff --git a/tests/registry.rs b/tests/registry.rs index b0d3a28..cd6b529 100644 --- a/tests/registry.rs +++ b/tests/registry.rs @@ -1,28 +1,32 @@ +mod fixtures; + +use fixtures::{OWNER, PACKAGE, REPO, VERSION}; use std::path::PathBuf; #[test] fn test_installed_package_struct() { let pkg = gitclaw::registry::InstalledPackage { - name: "BurntSushi/ripgrep".to_string(), - owner: "BurntSushi".to_string(), - repo: "ripgrep".to_string(), - version: "13.0.0".to_string(), + name: PACKAGE.to_string(), + owner: OWNER.to_string(), + repo: REPO.to_string(), + version: VERSION.to_string(), installed_at: chrono::Utc::now().to_rfc3339(), binary_path: PathBuf::from("/home/user/.gitclaw/bin/rg"), - install_dir: PathBuf::from("/home/user/.gitclaw/packages/BurntSushi/ripgrep"), - asset_name: "ripgrep-13.0.0-x86_64-unknown-linux-musl.tar.gz".to_string(), - identifier: "ripgrep".to_string(), + install_dir: PathBuf::from(format!("/home/user/.gitclaw/packages/{}/{}", OWNER, REPO)), + asset_name: format!("{}-{}-x86_64-unknown-linux-musl.tar.gz", REPO, VERSION), + identifier: REPO.to_string(), channel: None, }; - assert_eq!(pkg.name, "BurntSushi/ripgrep"); - assert_eq!(pkg.owner, "BurntSushi"); - assert_eq!(pkg.repo, "ripgrep"); - assert_eq!(pkg.version, "13.0.0"); - assert_eq!(pkg.identifier, "ripgrep"); + assert_eq!(pkg.name, PACKAGE); + assert_eq!(pkg.owner, OWNER); + assert_eq!(pkg.repo, REPO); + assert_eq!(pkg.version, VERSION); + assert_eq!(pkg.identifier, REPO); + assert_eq!( pkg.asset_name, - "ripgrep-13.0.0-x86_64-unknown-linux-musl.tar.gz" + format!("{}-{}-x86_64-unknown-linux-musl.tar.gz", REPO, VERSION) ); } From 591d84f0dd98143cb3bc4ec80d71f5d317be159a Mon Sep 17 00:00:00 2001 From: Francesco Sardone Date: Fri, 24 Apr 2026 15:58:08 +0200 Subject: [PATCH 4/6] refactor: clean fmt and clippy errors --- README.md | 4 ++-- src/core/checksum.rs | 4 ++-- src/core/config.rs | 6 +++--- src/core/constants.rs | 4 ++-- src/core/extract.rs | 2 +- src/core/install.rs | 10 ++++++---- src/core/registry.rs | 6 +++++- src/network/github.rs | 20 ++++++++++++++------ src/network/platform.rs | 10 +++++----- tests/channel_persist.rs | 5 ++++- tests/export_import.rs | 14 ++++++++++++-- 11 files changed, 56 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 62cd838..1196b85 100644 --- a/README.md +++ b/README.md @@ -77,8 +77,8 @@ verbose = false ## Development ```bash -cargo fmt -- --check -cargo clippy -- -D warnings +cargo fmt +cargo clippy cargo test ``` diff --git a/src/core/checksum.rs b/src/core/checksum.rs index 06c6834..0c2cb53 100644 --- a/src/core/checksum.rs +++ b/src/core/checksum.rs @@ -6,8 +6,8 @@ use anyhow::{bail, Context, Result}; use sha2::{Digest, Sha256, Sha512}; use crate::core::constants::{ - EXT_CHECKSUM, EXT_MD5, EXT_SHA, EXT_SHA256, EXT_SHA512, EXT_SIG, EXT_ASC, - STR_CHECKSUM, STR_SHA256SUM, STR_SHA512SUM, + EXT_ASC, EXT_CHECKSUM, EXT_MD5, EXT_SHA, EXT_SHA256, EXT_SHA512, EXT_SIG, STR_CHECKSUM, + STR_SHA256SUM, STR_SHA512SUM, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/core/config.rs b/src/core/config.rs index d506ce0..26b02f1 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -135,7 +135,7 @@ impl Config { return Ok(Some(config)); } - + Ok(None) } @@ -153,7 +153,7 @@ impl Config { return Ok(Some(config)); } } - + Ok(None) } @@ -171,7 +171,7 @@ impl Config { return Ok(Some(config)); } } - + Ok(None) } diff --git a/src/core/constants.rs b/src/core/constants.rs index e84a8fa..4de8b8f 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -91,7 +91,8 @@ pub const COL_SEARCH_ASSETS: usize = 8; pub const COL_SEARCH_NAME_MAX: usize = 40; pub const COL_SEARCH_NAME_TRUNCATE: usize = 37; -pub const COL_LIST_PACKAGE: usize = 25;pub const COL_LIST_IDENTIFIER: usize = 20; +pub const COL_LIST_PACKAGE: usize = 25; +pub const COL_LIST_IDENTIFIER: usize = 20; pub const COL_LIST_VERSION: usize = 15; pub const COL_LIST_PATH: usize = 30; pub const COL_LIST_PATH_MAX: usize = 28; @@ -113,4 +114,3 @@ pub const PLATFORM_X86_64_GNU: &str = "x86_64-unknown-linux-gnu"; pub const PLATFORM_X86_64_MUSL: &str = "x86_64-unknown-linux-musl"; pub const PLATFORM_AARCH64_GNU: &str = "aarch64-unknown-linux-gnu"; pub const PLATFORM_AARCH64_MUSL: &str = "aarch64-unknown-linux-musl"; - diff --git a/src/core/extract.rs b/src/core/extract.rs index 1303df2..43d049d 100644 --- a/src/core/extract.rs +++ b/src/core/extract.rs @@ -7,7 +7,7 @@ use thiserror::Error; use crate::core::constants::{ DEB_DATA_TAR, DEB_DATA_TAR_BZ2, DEB_DATA_TAR_GZ, DEB_DATA_TAR_XZ, DEB_DATA_TAR_ZST, DIR_EXTRACTED, EXT_BIN, EXT_DEB, EXT_TAR, EXT_TAR_BZ2, EXT_TAR_GZ, EXT_TAR_XZ, EXT_TAR_ZST, - EXT_TBZ2, EXT_TGZ, EXT_TZST, EXT_TXZ, EXT_ZIP, + EXT_TBZ2, EXT_TGZ, EXT_TXZ, EXT_TZST, EXT_ZIP, }; const AR_HEADER_SIZE: usize = 60; diff --git a/src/core/install.rs b/src/core/install.rs index 47c2ed4..946fc00 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -294,12 +294,14 @@ async fn fetch_latest_for_channel( ) -> Result { let releases = client.get_releases(owner, repo).await?; let filtered = crate::core::channel::filter_releases(&releases, channel, None); + if filtered.is_empty() { bail!("No {} release found for {}/{}.", channel, owner, repo); } - Ok(filtered.into_iter().next().ok_or_else(|| { + + filtered.into_iter().next().ok_or_else(|| { anyhow::anyhow!("Invariant: filtered list was non-empty but next() returned None") - })?) + }) } fn select_best_asset(release: &Release) -> Result<&Asset> { @@ -436,9 +438,9 @@ async fn find_matching_release( vb.cmp(&va) }); - Ok(matching.into_iter().next().ok_or_else(|| { + matching.into_iter().next().ok_or_else(|| { anyhow::anyhow!("Invariant: matching list was non-empty but next() returned None") - })?) + }) } fn constraint_display(constraint: &VersionConstraint) -> String { diff --git a/src/core/registry.rs b/src/core/registry.rs index a259eb3..c6e614d 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -111,7 +111,11 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { "{}", format!( "{: std::result::Result { let url = format!( - "{}{}", GITHUB_API_BASE, + "{}{}", + GITHUB_API_BASE, GITHUB_API_PATH_RELEASES_LATEST .replacen("{}", owner, 1) .replacen("{}", repo, 1) @@ -229,7 +232,8 @@ impl GithubClient { repo: &str, ) -> std::result::Result, GithubError> { let url = format!( - "{}{}", GITHUB_API_BASE, + "{}{}", + GITHUB_API_BASE, GITHUB_API_PATH_RELEASES .replacen("{}", owner, 1) .replacen("{}", repo, 1) @@ -402,7 +406,8 @@ pub async fn search_releases( let per_page = limit.min(SEARCH_LIMIT_MAX); let url = format!( - "{}{}", GITHUB_API_BASE, + "{}{}", + GITHUB_API_BASE, GITHUB_API_PATH_RELEASES_PAGED .replacen("{}", &owner, 1) .replacen("{}", &repo, 1) @@ -433,7 +438,10 @@ pub async fn search_releases( "{}", format!( "{: Date: Fri, 24 Apr 2026 17:15:04 +0200 Subject: [PATCH 5/6] refactor: add bin folder with proper idiomatic entry structure --- .specs/TEMPLATE.md | 5 +++- AGENTS.md | 32 +++++++++++----------- CONTRIBUTING.md | 9 ++++--- Cargo.toml | 8 ------ README.md | 40 +++++++++++++-------------- ROADMAP.md | 4 +-- src/{main.rs => app.rs} | 60 +++++++++++++++++++---------------------- src/bin/gcw.rs | 4 +++ src/bin/gitclaw.rs | 4 +++ src/lib.rs | 5 ++++ tests/fixtures.rs | 2 ++ 11 files changed, 91 insertions(+), 82 deletions(-) rename src/{main.rs => app.rs} (73%) create mode 100644 src/bin/gcw.rs create mode 100644 src/bin/gitclaw.rs diff --git a/.specs/TEMPLATE.md b/.specs/TEMPLATE.md index 60c9ce0..1e4a9f6 100644 --- a/.specs/TEMPLATE.md +++ b/.specs/TEMPLATE.md @@ -7,6 +7,7 @@ --- ## Small Change (bug fix, tweak) + Skip this template. Write a clear PR description instead. --- @@ -16,6 +17,7 @@ Skip this template. Write a clear PR description instead. **What:** [One sentence describing the deliverable.] **Spec:** + - Requirement 1 - Requirement 2 - Requirement 3 @@ -29,6 +31,7 @@ Skip this template. Write a clear PR description instead. **What:** [One sentence describing the deliverable.] **Spec:** + - Requirement 1 - Requirement 2 @@ -47,7 +50,7 @@ Skip this template. Write a clear PR description instead. - [Test category]: [what is tested] - [Test category]: [what is tested] -- `cargo fmt && cargo clippy -- -D warnings && cargo test` must pass +- `cargo fmt && cargo clippy && cargo test` must pass ## Files to Modify diff --git a/AGENTS.md b/AGENTS.md index 3d5f347..28bbea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,21 +12,21 @@ Development guide for gitclaw contributors and agents. ## Source Modules -| File | Responsibility | -|------|----------------| -| `main.rs` | Entry point, CLI dispatch, `run_package()` | -| `lib.rs` | Module re-exports | -| `cli/mod.rs` | Clap CLI definition | -| `output/mod.rs` | `print_success` (`[EXEC]`), `print_info` (cyan), `print_warn` (yellow), `print_error` (red), `print_kv`, `print_header` | -| `core/config.rs` | `Config`, `DownloadConfig`, `OutputConfig`, config loading/merging | -| `core/checksum.rs` | `ChecksumAlgorithm`, `verify_file`, `calculate_checksum`, `parse_checksum_file` | -| `core/extract.rs` | `ArchiveType`, `extract_archive`, `detect_archive_type` | -| `core/install.rs` | `handle_install`, `handle_update`, `handle_install_multiple` | -| `core/registry.rs` | `InstalledPackage`, `Registry`, `list_installed`, `uninstall` | -| `core/updater.rs` | `check_for_update`, `perform_update` | -| `core/util.rs` | Path helpers, `format_bytes` | -| `network/github.rs` | `GithubClient`, `Release`, `Asset`, `Platform`, `GithubError`, `parse_package`, `find_matching_asset` | -| `network/platform.rs` | `Arch`, `PlatformError`, `detect_arch`, `current_platform`, `score_asset`, `find_best_asset` | +| File | Responsibility | +| --------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `main.rs` | Entry point, CLI dispatch, `run_package()` | +| `lib.rs` | Module re-exports | +| `cli/mod.rs` | Clap CLI definition | +| `output/mod.rs` | `print_success` (`[EXEC]`), `print_info` (cyan), `print_warn` (yellow), `print_error` (red), `print_kv`, `print_header` | +| `core/config.rs` | `Config`, `DownloadConfig`, `OutputConfig`, config loading/merging | +| `core/checksum.rs` | `ChecksumAlgorithm`, `verify_file`, `calculate_checksum`, `parse_checksum_file` | +| `core/extract.rs` | `ArchiveType`, `extract_archive`, `detect_archive_type` | +| `core/install.rs` | `handle_install`, `handle_update`, `handle_install_multiple` | +| `core/registry.rs` | `InstalledPackage`, `Registry`, `list_installed`, `uninstall` | +| `core/updater.rs` | `check_for_update`, `perform_update` | +| `core/util.rs` | Path helpers, `format_bytes` | +| `network/github.rs` | `GithubClient`, `Release`, `Asset`, `Platform`, `GithubError`, `parse_package`, `find_matching_asset` | +| `network/platform.rs` | `Arch`, `PlatformError`, `detect_arch`, `current_platform`, `score_asset`, `find_best_asset` | ## Output Conventions @@ -79,4 +79,4 @@ Verify, Test, Build After rework or significant issues, document: what went wrong, root cause, prevention. Add to this file so it persists. -*Last updated: 2026-04-24* \ No newline at end of file +_Last updated: 2026-04-24_ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 748598c..2717201 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,12 +25,15 @@ cargo test ## Submitting Changes 1. Branch from `main` with a prefix: `fix/`, `feat/`, `docs/`, `chore/` + 2. Verify before pushing: + ```bash - cargo fmt -- --check - cargo clippy -- -D warnings + cargo fmt + cargo clippy cargo test ``` + 3. Open a PR, squash merge into `main` ## Commit Messages @@ -42,4 +45,4 @@ docs: update readme chore: bump dependency ``` -Lowercase, imperative, no period. \ No newline at end of file +Lowercase, imperative, no period. diff --git a/Cargo.toml b/Cargo.toml index b2b13be..ad78fca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,14 +7,6 @@ edition = "2021" name = "gitclaw" path = "src/lib.rs" -[[bin]] -name = "gitclaw" -path = "src/main.rs" - -[[bin]] -name = "gcw" -path = "src/main.rs" - [dependencies] clap = { version = "=4.4.18", features = ["derive", "env"] } clap_complete = "=4.4.5" diff --git a/README.md b/README.md index 1196b85..92bc1f3 100644 --- a/README.md +++ b/README.md @@ -21,22 +21,22 @@ gcw list ## Commands -| Command | Description | -|---------|-------------| -| alias | Manage package aliases. | -| cache | Manage the asset cache. | -| completions | Generate shell completions. | -| export | Export installed packages to TOML. | -| import | Install packages from a TOML file. | -| install | Install packages from GitHub releases. | -| list | List installed packages. | -| lock | Generate a lockfile from installed packages. | -| platform | Show platform information. | -| run | Run an installed package. | -| search | Search for releases on GitHub. | -| self | Update gitclaw to the latest version. | -| uninstall | Uninstall a package. | -| update | Update installed packages. | +| Command | Description | +| ----------- | -------------------------------------------- | +| alias | Manage package aliases. | +| cache | Manage the asset cache. | +| completions | Generate shell completions. | +| export | Export installed packages to TOML. | +| import | Install packages from a TOML file. | +| install | Install packages from GitHub releases. | +| list | List installed packages. | +| lock | Generate a lockfile from installed packages. | +| platform | Show platform information. | +| run | Run an installed package. | +| search | Search for releases on GitHub. | +| self | Update gitclaw to the latest version. | +| uninstall | Uninstall a package. | +| update | Update installed packages. | ## Configuration @@ -70,9 +70,9 @@ verbose = false ## Supported Platforms -| OS | x86_64 | aarch64 | -|----|--------|---------| -| Linux | yes | yes | +| OS | x86_64 | aarch64 | +| ----- | ------ | ------- | +| Linux | yes | yes | ## Development @@ -86,4 +86,4 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## License -MIT -- Copyright (c) 2026 Francesco Sardone (Airscript) \ No newline at end of file +MIT -- Copyright (c) 2026 Francesco Sardone (Airscript) diff --git a/ROADMAP.md b/ROADMAP.md index 87855cd..9d5071f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,5 @@ # ROADMAP -*All planned features have been shipped. gitclaw is approaching 1.0.0 stability.* +_All planned features have been shipped. gitclaw is approaching 1.0.0 stability._ -*Last updated: 2026-04-23* \ No newline at end of file +_Last updated: 2026-04-23_ diff --git a/src/main.rs b/src/app.rs similarity index 73% rename from src/main.rs rename to src/app.rs index 0b27055..53f6eb2 100644 --- a/src/main.rs +++ b/src/app.rs @@ -1,13 +1,12 @@ use clap::{CommandFactory, Parser}; use clap_complete::generate; -use gitclaw::banner; -use gitclaw::cli::{AliasAction, CacheAction, Cli, Commands}; -use gitclaw::config::Config; -use gitclaw::constants::{APP_NAME_SHORT, GITCLAW_DIR}; +use crate::banner; +use crate::cli::{AliasAction, CacheAction, Cli, Commands}; +use crate::config::Config; +use crate::constants::{APP_NAME_SHORT, GITCLAW_DIR}; -#[tokio::main] -async fn main() { +pub async fn start() { if let Err(e) = color_eyre::install() { banner::print_error(&format!("Failed to initialize color-eyre: {}.", e)); std::process::exit(1); @@ -32,7 +31,7 @@ async fn main() { let config = apply_cli_overrides(config, &cli); - if let Err(e) = run(cli, config).await { + if let Err(e) = dispatch(cli, config).await { banner::print_error(&format!("{}", e)); std::process::exit(1); } @@ -52,7 +51,7 @@ fn local_install_dir(config: &Config) -> anyhow::Result { Ok(cfg) } -async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { +async fn dispatch(cli: Cli, config: Config) -> anyhow::Result<()> { match &cli.command { Commands::Cache { .. } | Commands::Export { .. } @@ -78,14 +77,14 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { match action { AliasAction::Add { alias, target } => { - gitclaw::alias::handle_alias_add(&alias, &target, &config)? + crate::alias::handle_alias_add(&alias, &target, &config)? } AliasAction::Remove { alias } => { - gitclaw::alias::handle_alias_remove(&alias, &config)? + crate::alias::handle_alias_remove(&alias, &config)? } - AliasAction::List {} => gitclaw::alias::handle_alias_list(&config)?, + AliasAction::List {} => crate::alias::handle_alias_list(&config)?, } } @@ -93,8 +92,8 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { banner::print_output_header(); match action { - CacheAction::Clean {} => gitclaw::cache::handle_cache_clean(&config)?, - CacheAction::Size {} => gitclaw::cache::handle_cache_size(&config)?, + CacheAction::Clean {} => crate::cache::handle_cache_clean(&config)?, + CacheAction::Size {} => crate::cache::handle_cache_size(&config)?, } } @@ -116,9 +115,9 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { }; if locked { - gitclaw::lockfile::install_locked(&install_config).await? + crate::lockfile::install_locked(&install_config).await? } else if packages.len() == 1 { - gitclaw::install::handle_install( + crate::install::handle_install( &packages[0], force, dry_run, @@ -128,7 +127,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { ) .await? } else { - gitclaw::install::handle_install_multiple( + crate::install::handle_install_multiple( &packages, force, dry_run, @@ -142,26 +141,23 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { Commands::Lock { dir } => { banner::print_output_header(); - gitclaw::lockfile::generate_lockfile(&config.install_dir, &dir)? + crate::lockfile::generate_lockfile(&config.install_dir, &dir)? } Commands::List { verbose, outdated } => { banner::print_output_header(); if outdated { - gitclaw::registry::list_outdated( - &config.install_dir, - config.github_token.as_deref(), - ) - .await? + crate::registry::list_outdated(&config.install_dir, config.github_token.as_deref()) + .await? } else { - gitclaw::registry::list_installed(verbose, &config.install_dir)? + crate::registry::list_installed(verbose, &config.install_dir)? } } Commands::Update { package } => { banner::print_output_header(); - gitclaw::install::handle_update(package.as_deref(), &config).await? + crate::install::handle_update(package.as_deref(), &config).await? } Commands::Uninstall { package, local } => { @@ -173,7 +169,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { config.install_dir.clone() }; - gitclaw::registry::uninstall(&package, &install_dir, &config)? + crate::registry::uninstall(&package, &install_dir, &config)? } Commands::Search { @@ -183,19 +179,19 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { } => { banner::print_output_header(); - gitclaw::github::search_releases(&package, limit, &config, channel).await? + crate::github::search_releases(&package, limit, &config, channel).await? } Commands::Export { output: output_path, } => { banner::print_output_header(); - gitclaw::export::handle_export(&config, output_path.as_deref())? + crate::export::handle_export(&config, output_path.as_deref())? } Commands::Import { file, force } => { banner::print_output_header(); - gitclaw::export::handle_import(&config, &file, force).await? + crate::export::handle_import(&config, &file, force).await? } Commands::Completions { shell } => { @@ -216,7 +212,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { Commands::Platform {} => { banner::print_output_header(); - let arch = gitclaw::platform::current_platform()?; + let arch = crate::platform::current_platform()?; banner::print_info(&format!("Detected platform: linux {}", arch)); banner::print_info("Compiled for: Linux"); banner::print_info(&format!("Architecture aliases: {:?}", arch.aliases())); @@ -226,15 +222,15 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { banner::print_output_header(); if check { - gitclaw::updater::check_for_update(&config).await? + crate::updater::check_for_update(&config).await? } else { - gitclaw::updater::perform_update(&config).await? + crate::updater::perform_update(&config).await? } } Commands::Run { package, args } => { banner::print_output_header(); - gitclaw::run::handle_run(&package, args, &config).await? + crate::run::handle_run(&package, args, &config).await? } } diff --git a/src/bin/gcw.rs b/src/bin/gcw.rs new file mode 100644 index 0000000..6d8c0d0 --- /dev/null +++ b/src/bin/gcw.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() { + gitclaw::start().await; +} diff --git a/src/bin/gitclaw.rs b/src/bin/gitclaw.rs new file mode 100644 index 0000000..6d8c0d0 --- /dev/null +++ b/src/bin/gitclaw.rs @@ -0,0 +1,4 @@ +#[tokio::main] +async fn main() { + gitclaw::start().await; +} diff --git a/src/lib.rs b/src/lib.rs index 852c2bc..31ded34 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,13 @@ +mod app; pub mod cli; pub mod core; pub mod network; pub mod output; +pub async fn start() { + app::start().await; +} + pub use cli::{AliasAction, CacheAction, Cli, Commands}; pub use core::alias; pub use core::cache; diff --git a/tests/fixtures.rs b/tests/fixtures.rs index 3ce0549..43ba058 100644 --- a/tests/fixtures.rs +++ b/tests/fixtures.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + pub const OWNER: &str = "BurntSushi"; pub const REPO: &str = "ripgrep"; pub const VERSION: &str = "13.0.0"; From 3c81d45ca7c65244e8c9cfc144a8e3dd934426ae Mon Sep 17 00:00:00 2001 From: Francesco Sardone Date: Fri, 24 Apr 2026 17:18:43 +0200 Subject: [PATCH 6/6] ci: remove clippy warning deactivation --- .github/workflows/verify.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 1530188..bdbbb23 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -26,4 +26,4 @@ jobs: - uses: actions/checkout@v4 - run: apt-get update && apt-get install -y pkg-config libssl-dev - run: rustup component add clippy - - run: cargo clippy -- -D warnings + - run: cargo clippy