From 9987661a26a30d84fcf828f81ffd01701fc773ab Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 21:24:55 +0000 Subject: [PATCH 1/3] feat: v0.5.0 asset caching, outdated check, and local installs - Add asset caching to ~/.gitclaw/cache/ with automatic reuse - Add gitclaw cache clean and gitclaw cache size commands - Add gitclaw list --outdated to check for newer versions - Add --local flag for project-scoped installs to ./.gitclaw/ - Add sha2 dependency for cache integrity --- CHANGELOG.md | 11 +++++ Cargo.lock | 4 +- Cargo.toml | 5 ++- README.md | 7 +++ src/cli/mod.rs | 19 ++++++++ src/core/cache.rs | 103 +++++++++++++++++++++++++++++++++++++++++++ src/core/install.rs | 29 +++++++++--- src/core/mod.rs | 1 + src/core/registry.rs | 50 ++++++++++++++++++++- src/main.rs | 54 ++++++++++++++++++----- 10 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 src/core/cache.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ca1edda..ab6fa13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-04-23 + +### Added +- Asset caching: downloaded archives cached to `~/.gitclaw/cache/`, reused on subsequent installs +- `gitclaw cache clean` — remove all cached archives +- `gitclaw cache size` — show total cache size on disk +- `gitclaw list --outdated` — compare installed versions against latest GitHub releases +- Local installs: `gitclaw install --local user/repo` installs to `./.gitclaw/` +- `gitclaw uninstall --local` — uninstall from local project directory +- `sha2` crate dependency for cache integrity verification + ## [0.4.0] - 2026-04-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 081f924..f3373d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,9 +1415,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.28" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index a2df7f7..d6e0065 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitclaw" -version = "0.4.0" +version = "0.5.0" edition = "2021" [[bin]] @@ -36,11 +36,12 @@ walkdir = "=2.4.0" futures = "=0.3.29" tracing = "=0.1.40" tracing-subscriber = { version = "=0.3.18", features = ["env-filter"] } +semver = "=1.0.23" sha2 = "=0.10.8" md5 = "=0.7.0" colored = "=2.1.0" rand = "=0.8.5" -semver = "1" + [dev-dependencies] assert_cmd = "2.0.12" diff --git a/README.md b/README.md index 09e3544..668c41c 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,15 @@ gcw install --locked gcw alias add rg BurntSushi/ripgrep gcw install rg gcw list +gcw list --outdated gcw update sharkdp/bat gcw uninstall bat + +gcw cache size +gcw cache clean + +gcw install --local sharkdp/bat # project-local install +gcw uninstall --local bat ``` ## Configuration diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 80790c9..89852d3 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -24,6 +24,14 @@ pub struct Cli { pub token: Option, } +#[derive(Subcommand)] +pub enum CacheAction { + #[command(about = "Remove all cached archives.")] + Clean {}, + #[command(about = "Show total cache size on disk.")] + Size {}, +} + #[derive(Subcommand)] pub enum AliasAction { #[command(about = "Add a package alias.")] @@ -49,6 +57,11 @@ 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).")] @@ -61,6 +74,8 @@ pub enum Commands { verify: bool, #[arg(long, help = "Install exact versions from gitclaw.lock.")] locked: bool, + #[arg(long, help = "Install to project-local .gitclaw/ directory.")] + local: bool, }, #[command(about = "Generate a lockfile from installed packages.")] Lock { @@ -76,6 +91,8 @@ pub enum Commands { List { #[arg(short, long, help = "Show detailed information.")] verbose: bool, + #[arg(long, help = "Show packages with newer versions available.")] + outdated: bool, }, #[command(about = "Update installed packages.")] Update { @@ -86,6 +103,8 @@ pub enum Commands { Uninstall { #[arg(help = "Package to uninstall (format: owner/repo or identifier).")] package: String, + #[arg(long, help = "Uninstall from project-local .gitclaw/ directory.")] + local: bool, }, #[command(about = "Search for releases on GitHub.")] Search { diff --git a/src/core/cache.rs b/src/core/cache.rs new file mode 100644 index 0000000..932b4aa --- /dev/null +++ b/src/core/cache.rs @@ -0,0 +1,103 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use sha2::{Digest, Sha256}; + +use crate::core::config::Config; +use crate::core::util; +use crate::output; + +pub fn cache_dir(config: &Config) -> PathBuf { + config.install_dir.join("cache") +} + +pub fn cache_key(owner: &str, repo: &str, version: &str, filename: &str) -> String { + format!("{}_{}_{}_{}", owner, repo, version, filename) +} + +pub fn cache_path(config: &Config, key: &str) -> PathBuf { + cache_dir(config).join(key) +} + +pub fn file_hash(path: &Path) -> Result { + let data = fs::read(path).with_context(|| format!("Read {}", path.display()))?; + let mut hasher = Sha256::new(); + hasher.update(&data); + Ok(format!("{:x}", hasher.finalize())) +} + +pub fn get_cached(config: &Config, key: &str, expected_hash: Option<&str>) -> Option { + let path = cache_path(config, key); + if !path.exists() { + return None; + } + + if let Some(expected) = expected_hash { + match file_hash(&path) { + Ok(actual) if actual == expected => Some(path), + _ => None, + } + } else { + Some(path) + } +} + +pub fn store(config: &Config, key: &str, source: &Path) -> Result { + let dir = cache_dir(config); + fs::create_dir_all(&dir)?; + let dest = dir.join(key); + fs::copy(source, &dest).with_context(|| format!("Copy to cache {}", dest.display()))?; + Ok(dest) +} + +pub fn clean(config: &Config) -> Result { + let dir = cache_dir(config); + if !dir.exists() { + return Ok(0); + } + + let mut count = 0u64; + for entry in fs::read_dir(&dir)? { + let entry = entry?; + if entry.file_type()?.is_file() { + fs::remove_file(entry.path())?; + count += 1; + } + } + + Ok(count) +} + +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(); + } + } + + Ok(total) +} + +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(()) +} + +pub fn handle_cache_size(config: &Config) -> Result<()> { + let bytes = size(config)?; + output::print_info(&format!("Cache size: {}.", util::format_bytes(bytes))); + Ok(()) +} diff --git a/src/core/install.rs b/src/core/install.rs index 3b2473d..632ba6b 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -102,13 +102,30 @@ pub async fn handle_install( output::print_kv("Asset", &asset.name); } - let temp_dir = std::env::temp_dir().join(format!("{}{}-{}", TEMP_DIR_PREFIX, owner, repo)); - std::fs::create_dir_all(&temp_dir)?; - let download_path = temp_dir.join(&asset.name); + let cache_key = crate::core::cache::cache_key(&owner, &repo, &release.tag_name, &asset.name); + let cached = crate::core::cache::get_cached(config, &cache_key, None); - client - .download_asset(asset, &download_path, config.download.show_progress) - .await?; + let download_path = if let Some(cached_path) = cached { + if !config.output.quiet { + output::print_info("Using cached archive."); + } + cached_path + } else { + let temp_dir = std::env::temp_dir().join(format!("{}{}-{}", TEMP_DIR_PREFIX, owner, repo)); + std::fs::create_dir_all(&temp_dir)?; + let temp_path = temp_dir.join(&asset.name); + + client + .download_asset(asset, &temp_path, config.download.show_progress) + .await?; + + let cached_path = crate::core::cache::store(config, &cache_key, &temp_path)?; + + // clean up temp + let _ = fs::remove_file(&temp_path); + + cached_path + }; println!(); diff --git a/src/core/mod.rs b/src/core/mod.rs index 4bd81ce..3b5db4e 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod alias; +pub mod cache; pub mod checksum; pub mod config; pub mod constants; diff --git a/src/core/registry.rs b/src/core/registry.rs index 6b3ccdd..b7b2762 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -10,6 +10,7 @@ use tracing::debug; use crate::core::config::Config; use crate::core::constants::APP_NAME_SHORT; use crate::core::util::registry_path_from; +use crate::network::github::{parse_package, GithubClient}; use crate::output; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -170,12 +171,59 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { Ok(()) } +pub async fn list_outdated(install_dir: &Path, token: Option<&str>) -> Result<()> { + let registry_path = registry_path_from(install_dir); + let reg = Registry::load_from(®istry_path)?; + + if reg.packages.is_empty() { + output::print_info("No packages installed."); + return Ok(()); + } + + let client = GithubClient::new(token.map(|s| s.to_string()))?; + let mut outdated = Vec::new(); + + for pkg in reg.packages.values() { + let latest = match client.get_release(&pkg.owner, &pkg.repo, "latest").await { + Ok(r) => r.tag_name, + Err(_) => continue, + }; + + if latest != pkg.version { + outdated.push((pkg.name.clone(), pkg.version.clone(), latest)); + } + } + + if outdated.is_empty() { + output::print_success("All packages are up to date."); + return Ok(()); + } + + println!( + "{}", + format!("{:<30} {:<20} {}", "Package", "Installed", "Latest").bold() + ); + + for (name, installed, latest) in &outdated { + println!( + "{:<30} {:<20} {}", + name.dimmed(), + installed, + latest.green().bold() + ); + } + + println!(); + output::print_info(&format!("{} package(s) outdated.", outdated.len())); + Ok(()) +} + pub fn uninstall(package: &str, install_dir: &Path, config: &Config) -> Result<()> { let registry_path = registry_path_from(install_dir); let mut reg = Registry::load_from(®istry_path)?; let key = if package.contains('/') { - let (owner, repo, _) = crate::network::github::parse_package(package)?; + let (owner, repo, _) = parse_package(package)?; format!("{}/{}", owner, repo) } else { let resolved = if let Some(alias_target) = diff --git a/src/main.rs b/src/main.rs index 4fda2d6..5297b5a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod core; mod network; mod output; -use cli::{AliasAction, Cli, Commands}; +use cli::{AliasAction, CacheAction, Cli, Commands}; use core::config::Config; use core::constants::{APP_NAME, APP_NAME_SHORT, DIR_BIN}; use core::registry::Registry; @@ -56,7 +56,8 @@ fn apply_cli_overrides(mut config: Config, cli: &Cli) -> Config { async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { match &cli.command { - Commands::Install { .. } + Commands::Cache { .. } + | Commands::Install { .. } | Commands::Lock { .. } | Commands::List { .. } | Commands::Update { .. } @@ -82,22 +83,45 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { AliasAction::List {} => core::alias::handle_alias_list(&config)?, } } + Commands::Cache { action } => { + output::print_output_header(); + match action { + CacheAction::Clean {} => core::cache::handle_cache_clean(&config)?, + CacheAction::Size {} => core::cache::handle_cache_size(&config)?, + } + } Commands::Install { packages, force, dry_run, verify, locked, + local, } => { output::print_output_header(); + let install_config = if local { + let mut cfg = config.clone(); + cfg.install_dir = std::env::current_dir()?.join(".gitclaw"); + cfg + } else { + config.clone() + }; + if locked { - core::lockfile::install_locked(&config).await? + core::lockfile::install_locked(&install_config).await? } else if packages.len() == 1 { - core::install::handle_install(&packages[0], force, dry_run, verify, &config).await? - } else { - core::install::handle_install_multiple(&packages, force, dry_run, verify, &config) + core::install::handle_install(&packages[0], force, dry_run, verify, &install_config) .await? + } else { + core::install::handle_install_multiple( + &packages, + force, + dry_run, + verify, + &install_config, + ) + .await? } } @@ -106,9 +130,14 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { let project_dir = std::path::PathBuf::from(dir); core::lockfile::generate_lockfile(&config.install_dir, &project_dir)? } - Commands::List { verbose } => { + Commands::List { verbose, outdated } => { output::print_output_header(); - core::registry::list_installed(verbose, &config.install_dir)? + if outdated { + core::registry::list_outdated(&config.install_dir, config.github_token.as_deref()) + .await? + } else { + core::registry::list_installed(verbose, &config.install_dir)? + } } Commands::Update { package } => { @@ -116,9 +145,14 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { core::install::handle_update(package.as_deref(), &config).await? } - Commands::Uninstall { package } => { + Commands::Uninstall { package, local } => { output::print_output_header(); - core::registry::uninstall(&package, &config.install_dir, &config)? + let install_dir = if local { + std::env::current_dir()?.join(".gitclaw") + } else { + config.install_dir.clone() + }; + core::registry::uninstall(&package, &install_dir, &config)? } Commands::Search { package, limit } => { From 8f6ee29add96ec362e025c7b32b00fee619e5ced Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 21:43:19 +0000 Subject: [PATCH 2/3] test: add cache, outdated, and local install tests - tests/cache.rs: 19 tests for cache key, hash, store, get, clean, size - tests/outdated.rs: 5 tests for version comparison logic - tests/local.rs: 5 tests for local install dir structure and registry isolation - Export cache module from lib.rs for test access --- Cargo.lock | 2 +- src/lib.rs | 1 + tests/cache.rs | 235 ++++++++++++++++++++++++++++++++++++++++++++++ tests/local.rs | 98 +++++++++++++++++++ tests/outdated.rs | 38 ++++++++ 5 files changed, 373 insertions(+), 1 deletion(-) create mode 100644 tests/cache.rs create mode 100644 tests/local.rs create mode 100644 tests/outdated.rs diff --git a/Cargo.lock b/Cargo.lock index f3373d6..1cf2514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,7 +641,7 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gitclaw" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/src/lib.rs b/src/lib.rs index c279926..dad795c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,6 +4,7 @@ pub mod network; pub mod output; pub use core::alias; +pub use core::cache; pub use core::checksum; pub use core::config; pub use core::constants; diff --git a/tests/cache.rs b/tests/cache.rs new file mode 100644 index 0000000..2fba876 --- /dev/null +++ b/tests/cache.rs @@ -0,0 +1,235 @@ +use tempfile::TempDir; + +use gitclaw::cache; +use gitclaw::config::Config; + +fn make_config() -> (Config, TempDir) { + let dir = TempDir::new().unwrap(); + let config = Config { + install_dir: dir.path().to_path_buf(), + ..Config::default() + }; + (config, dir) +} + +#[test] +fn test_cache_key_format() { + let key = cache::cache_key("BurntSushi", "ripgrep", "13.0.0", "ripgrep.tar.gz"); + assert_eq!(key, "BurntSushi_ripgrep_13.0.0_ripgrep.tar.gz"); +} + +#[test] +fn test_cache_dir_uses_config() { + let (config, _dir) = make_config(); + let cache_dir = cache::cache_dir(&config); + assert!(cache_dir.ends_with("cache")); + assert!(cache_dir.starts_with(config.install_dir)); +} + +#[test] +fn test_cache_path_constructs_correctly() { + let (config, _dir) = make_config(); + let path = cache::cache_path(&config, "test_key"); + assert!(path.ends_with("test_key")); + assert_eq!(path.parent().unwrap(), cache::cache_dir(&config)); +} + +#[test] +fn test_file_hash_deterministic() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("test_file"); + std::fs::write(&file, b"hello world").unwrap(); + + let hash1 = cache::file_hash(&file).unwrap(); + let hash2 = cache::file_hash(&file).unwrap(); + assert_eq!(hash1, hash2); + assert!(!hash1.is_empty()); +} + +#[test] +fn test_file_hash_different_content() { + let dir = TempDir::new().unwrap(); + let file_a = dir.path().join("a"); + let file_b = dir.path().join("b"); + std::fs::write(&file_a, b"content a").unwrap(); + std::fs::write(&file_b, b"content b").unwrap(); + + let hash_a = cache::file_hash(&file_a).unwrap(); + let hash_b = cache::file_hash(&file_b).unwrap(); + assert_ne!(hash_a, hash_b); +} + +#[test] +fn test_file_hash_nonexistent() { + let result = cache::file_hash(std::path::Path::new("/nonexistent/file")); + assert!(result.is_err()); +} + +#[test] +fn test_get_cached_miss() { + let (config, _dir) = make_config(); + let result = cache::get_cached(&config, "nonexistent_key", None); + assert!(result.is_none()); +} + +#[test] +fn test_get_cached_hit() { + let (config, _dir) = make_config(); + let key = "test_asset.tar.gz"; + let cache_dir = cache::cache_dir(&config); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cached_path = cache_dir.join(key); + std::fs::write(&cached_path, b"cached content").unwrap(); + + let result = cache::get_cached(&config, key, None); + assert!(result.is_some()); + assert_eq!(result.unwrap(), cached_path); +} + +#[test] +fn test_get_cached_hash_mismatch() { + let (config, _dir) = make_config(); + let key = "test_asset.tar.gz"; + let cache_dir = cache::cache_dir(&config); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cached_path = cache_dir.join(key); + std::fs::write(&cached_path, b"cached content").unwrap(); + + let result = cache::get_cached(&config, key, Some("wrong_hash")); + assert!(result.is_none()); +} + +#[test] +fn test_get_cached_hash_match() { + let (config, _dir) = make_config(); + let key = "test_asset.tar.gz"; + let cache_dir = cache::cache_dir(&config); + std::fs::create_dir_all(&cache_dir).unwrap(); + let cached_path = cache_dir.join(key); + std::fs::write(&cached_path, b"cached content").unwrap(); + + let hash = cache::file_hash(&cached_path).unwrap(); + let result = cache::get_cached(&config, key, Some(&hash)); + assert!(result.is_some()); +} + +#[test] +fn test_store_creates_cache_dir_and_file() { + let (config, _dir) = make_config(); + let source_dir = TempDir::new().unwrap(); + let source_file = source_dir.path().join("asset.tar.gz"); + std::fs::write(&source_file, b"downloaded content").unwrap(); + + let result = cache::store(&config, "owner_repo_1.0.0_asset.tar.gz", &source_file); + assert!(result.is_ok()); + + let stored = result.unwrap(); + assert!(stored.exists()); + let content = std::fs::read_to_string(&stored).unwrap(); + assert_eq!(content, "downloaded content"); +} + +#[test] +fn test_store_overwrites_existing() { + let (config, _dir) = make_config(); + let source_dir = TempDir::new().unwrap(); + let source_file = source_dir.path().join("asset.tar.gz"); + + std::fs::write(&source_file, b"version 1").unwrap(); + cache::store(&config, "test_key", &source_file).unwrap(); + + std::fs::write(&source_file, b"version 2").unwrap(); + let stored = cache::store(&config, "test_key", &source_file).unwrap(); + let content = std::fs::read_to_string(&stored).unwrap(); + assert_eq!(content, "version 2"); +} + +#[test] +fn test_clean_removes_files() { + let (config, _dir) = make_config(); + let cache_dir = cache::cache_dir(&config); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("file_a"), b"a").unwrap(); + std::fs::write(cache_dir.join("file_b"), b"bb").unwrap(); + + let count = cache::clean(&config).unwrap(); + assert_eq!(count, 2); + assert!(!cache_dir.join("file_a").exists()); + assert!(!cache_dir.join("file_b").exists()); +} + +#[test] +fn test_clean_empty_dir() { + let (config, _dir) = make_config(); + let count = cache::clean(&config).unwrap(); + assert_eq!(count, 0); +} + +#[test] +fn test_clean_preserves_subdirs() { + let (config, _dir) = make_config(); + let cache_dir = cache::cache_dir(&config); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("file_a"), b"a").unwrap(); + std::fs::create_dir(cache_dir.join("subdir")).unwrap(); + + let count = cache::clean(&config).unwrap(); + assert_eq!(count, 1); + assert!(cache_dir.join("subdir").exists()); + assert!(!cache_dir.join("file_a").exists()); +} + +#[test] +fn test_size_empty() { + let (config, _dir) = make_config(); + let size = cache::size(&config).unwrap(); + assert_eq!(size, 0); +} + +#[test] +fn test_size_with_files() { + let (config, _dir) = make_config(); + let cache_dir = cache::cache_dir(&config); + std::fs::create_dir_all(&cache_dir).unwrap(); + std::fs::write(cache_dir.join("small"), b"12345").unwrap(); + std::fs::write(cache_dir.join("large"), b"1234567890").unwrap(); + + let size = cache::size(&config).unwrap(); + assert_eq!(size, 15); +} + +#[test] +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"); + 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 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); +} \ No newline at end of file diff --git a/tests/local.rs b/tests/local.rs new file mode 100644 index 0000000..63ac027 --- /dev/null +++ b/tests/local.rs @@ -0,0 +1,98 @@ +use tempfile::TempDir; + +use gitclaw::config::Config; +use gitclaw::registry::Registry; +use gitclaw::util; + +#[test] +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() + }; + + assert!(config.install_dir.ends_with(".gitclaw")); + let bin = util::bin_dir_from(&config.install_dir); + assert!(bin.ends_with("bin")); + assert!(bin.starts_with(local_dir.to_str().unwrap())); +} + +#[test] +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() + }; + + let reg_path = util::registry_path_from(&config.install_dir); + assert!(reg_path.ends_with("registry.toml")); + assert!(reg_path.starts_with(local_dir.to_str().unwrap())); +} + +#[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() + }; + + 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); + + assert_ne!(local_reg_path, global_reg_path); +} + +#[test] +fn test_local_registry_load_save() { + let dir = TempDir::new().unwrap(); + let local_dir = dir.path().join(".gitclaw"); + let config = Config { + install_dir: local_dir.clone(), + ..Config::default() + }; + + 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(), + 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(), + }); + reg.save().unwrap(); + + let loaded = Registry::load_from(®_path).unwrap(); + assert!(loaded.is_installed("sharkdp/bat")); + assert_eq!(loaded.packages.len(), 1); +} + +#[test] +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() + }; + + let cache_dir = gitclaw::cache::cache_dir(&config); + assert!(cache_dir.starts_with(local_dir.to_str().unwrap())); + assert!(cache_dir.ends_with("cache")); +} \ No newline at end of file diff --git a/tests/outdated.rs b/tests/outdated.rs new file mode 100644 index 0000000..a15fe18 --- /dev/null +++ b/tests/outdated.rs @@ -0,0 +1,38 @@ +use gitclaw::semver; + +#[test] +fn test_semver_version_comparison() { + let v1 = semver::parse_tag_version("1.0.0").unwrap(); + let v2 = semver::parse_tag_version("2.0.0").unwrap(); + assert!(v2 > v1); +} + +#[test] +fn test_semver_tag_parsing() { + let tag = "v13.0.0"; + let version = semver::parse_tag_version(tag).unwrap(); + assert_eq!(version.major, 13); + assert_eq!(version.minor, 0); + assert_eq!(version.patch, 0); +} + +#[test] +fn test_semver_tag_without_v() { + let tag = "13.0.0"; + let version = semver::parse_tag_version(tag).unwrap(); + assert_eq!(version.major, 13); +} + +#[test] +fn test_installed_vs_latest_different() { + let installed = "v1.0.0"; + let latest = "v2.0.0"; + assert_ne!(installed, latest); +} + +#[test] +fn test_installed_vs_latest_same() { + let installed = "v1.0.0"; + let latest = "v1.0.0"; + assert_eq!(installed, latest); +} \ No newline at end of file From ab594b4fa020867844b0167cc8d4ab774bf3c560 Mon Sep 17 00:00:00 2001 From: clawdeeo Date: Thu, 23 Apr 2026 21:50:55 +0000 Subject: [PATCH 3/3] style: add missing trailing newlines in test files --- tests/cache.rs | 2 +- tests/local.rs | 2 +- tests/outdated.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/cache.rs b/tests/cache.rs index 2fba876..017aa89 100644 --- a/tests/cache.rs +++ b/tests/cache.rs @@ -232,4 +232,4 @@ fn test_full_cache_roundtrip() { let removed = cache::clean(&config).unwrap(); assert_eq!(removed, 1); assert_eq!(cache::size(&config).unwrap(), 0); -} \ No newline at end of file +} diff --git a/tests/local.rs b/tests/local.rs index 63ac027..be8e34a 100644 --- a/tests/local.rs +++ b/tests/local.rs @@ -95,4 +95,4 @@ fn test_local_cache_dir_isolation() { let cache_dir = gitclaw::cache::cache_dir(&config); assert!(cache_dir.starts_with(local_dir.to_str().unwrap())); assert!(cache_dir.ends_with("cache")); -} \ No newline at end of file +} diff --git a/tests/outdated.rs b/tests/outdated.rs index a15fe18..ab2e38b 100644 --- a/tests/outdated.rs +++ b/tests/outdated.rs @@ -35,4 +35,4 @@ fn test_installed_vs_latest_same() { let installed = "v1.0.0"; let latest = "v1.0.0"; assert_eq!(installed, latest); -} \ No newline at end of file +}