diff --git a/.specs/v1.0.0-stability.md b/.specs/v1.0.0-stability.md new file mode 100644 index 0000000..14c285b --- /dev/null +++ b/.specs/v1.0.0-stability.md @@ -0,0 +1,99 @@ +# 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 diff --git a/Cargo.toml b/Cargo.toml index 8df10df..b2b13be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "gitclaw" version = "0.7.0" edition = "2021" +[lib] +name = "gitclaw" +path = "src/lib.rs" + [[bin]] name = "gitclaw" path = "src/main.rs" diff --git a/ROADMAP.md b/ROADMAP.md index 4d7719e..87855cd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,103 +1,5 @@ # ROADMAP -Planned features and improvements toward gitclaw 1.0.0. +*All planned features have been shipped. gitclaw is approaching 1.0.0 stability.* -## 0.4.0 — Dependency Management ✅ - -**Semver range support** - -Install versions matching constraints: - -```bash -gitclaw install user/repo ">=1.0.0" -gitclaw install user/repo "^1.2.3" -``` - -**Lockfile support** - -Reproducible installs via `gitclaw.lock`: - -```bash -gitclaw lock -gitclaw install --locked -``` - -**Package aliases** - -Short names for frequently used packages: - -```bash -gitclaw alias rg BurntSushi/ripgrep -gitclaw install rg -``` - -## 0.5.0 — User Experience ✅ ✅ - -**Asset caching** - -Cache downloaded archives to `~/.gitclaw/cache/` — skip re-download if hash matches. - -```bash -gitclaw cache clean -gitclaw cache size -``` - -**Outdated check** - -```bash -gitclaw list --outdated -``` - -Compares installed version against latest GitHub release. - -**Local installs** - -Project-scoped installation isolated from the global registry: - -```bash -gitclaw install --local user/repo -``` - -## 0.6.0 — Advanced Features ✅ ✅ - -**Release channels** - -```bash -gitclaw install user/repo --channel nightly -gitclaw install user/repo --channel beta -``` - -**Export / import** - -Share package lists between machines: - -```bash -gitclaw export > deps.toml -gitclaw import deps.toml -``` - -## 0.7.0 — Platform Integration ✅ - -**Package manager awareness** - -Warn when a package is already available via a system package manager (apt, etc.). - -## 1.0.0 — Stability - -- All 0.x features stable and documented -- Stable registry format (no breaking changes without migration) -- Stable config format -- Full test coverage across all modules -- Published to crates.io - -## Rejected Ideas - -| Idea | Reason | -|------|--------| -| Package signing | Checksums are sufficient for the use case | -| Auto-update on launch | Too noisy; explicit is better | -| GUI application | Out of scope; TUI is sufficient | -| Telemetry | Privacy concerns | -| Web dashboard | Out of scope for a CLI tool | - -*Last updated: 2026-04-23* +*Last updated: 2026-04-23* \ No newline at end of file diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4f14c44..72bcdc7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,14 +1,14 @@ use clap::{Parser, Subcommand}; use clap_complete::Shell; -use crate::core::constants::{APP_NAME, ENV_VAR_TOKEN}; +use crate::constants::{APP_NAME, ENV_VAR_TOKEN}; #[derive(Parser)] #[command( name = APP_NAME, about = "Install software from GitHub releases.", version, - before_help = crate::output::BANNER + before_help = crate::banner::BANNER )] pub struct Cli { #[command(subcommand)] diff --git a/src/core/alias.rs b/src/core/alias.rs index 7044a20..7763f8d 100644 --- a/src/core/alias.rs +++ b/src/core/alias.rs @@ -7,20 +7,22 @@ use serde::{Deserialize, Serialize}; use crate::core::config::Config; +const ALIASES_FILE: &str = "aliases.toml"; + #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AliasMap { #[serde(flatten)] pub aliases: HashMap, } -const ALIASES_FILE: &str = "aliases.toml"; - impl AliasMap { pub fn load(config: &Config) -> Result { let path = config.install_dir.join(ALIASES_FILE); + if !path.exists() { return Ok(Self::default()); } + let content = fs::read_to_string(&path).with_context(|| "Failed to read aliases file")?; toml::from_str(&content).with_context(|| "Failed to parse aliases file") } @@ -60,6 +62,7 @@ impl AliasMap { pub fn check_clash(&self, name: &str, config: &Config) -> Option { let registry_path = crate::core::util::registry_path_from(&config.install_dir); + if let Ok(reg) = crate::core::registry::Registry::load_from(®istry_path) { for pkg in reg.packages.values() { if pkg.repo == name || pkg.identifier == name { @@ -67,6 +70,7 @@ impl AliasMap { } } } + None } @@ -99,9 +103,11 @@ pub fn handle_alias_add(alias: &str, target: &str, config: &Config) -> Result<() pub fn handle_alias_remove(alias: &str, config: &Config) -> Result<()> { let mut aliases = AliasMap::load(config)?; + if !aliases.remove(alias) { bail!("Alias '{}' not found.", alias); } + aliases.save(config)?; crate::output::print_success(&format!("Alias '{}' removed.", alias)); Ok(()) @@ -132,13 +138,18 @@ pub fn handle_alias_list(config: &Config) -> Result<()> { mod tests { use super::*; - #[test] - fn test_alias_add() { + fn make_config() -> (Config, tempfile::TempDir) { let dir = tempfile::tempdir().unwrap(); let config = Config { install_dir: dir.path().to_path_buf(), ..Config::default() }; + (config, dir) + } + + #[test] + fn test_alias_add() { + let (config, _dir) = make_config(); let mut aliases = AliasMap::default(); aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); assert_eq!(aliases.resolve("rg"), Some("BurntSushi/ripgrep")); @@ -146,11 +157,7 @@ mod tests { #[test] fn test_alias_add_slash_rejected() { - let dir = tempfile::tempdir().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; + let (config, _dir) = make_config(); let mut aliases = AliasMap::default(); assert!(aliases .add("owner/repo", "BurntSushi/ripgrep", &config) @@ -159,11 +166,7 @@ mod tests { #[test] fn test_alias_remove() { - let dir = tempfile::tempdir().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; + let (config, _dir) = make_config(); let mut aliases = AliasMap::default(); aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); assert!(aliases.remove("rg")); @@ -179,11 +182,7 @@ mod tests { #[test] fn test_alias_list_sorted() { - let dir = tempfile::tempdir().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; + let (config, _dir) = make_config(); let mut aliases = AliasMap::default(); aliases.add("fd", "sharkdp/fd", &config).unwrap(); aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); @@ -197,11 +196,7 @@ mod tests { #[test] fn test_alias_roundtrip() { - let dir = tempfile::tempdir().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; + let (config, _dir) = make_config(); let mut aliases = AliasMap::default(); aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); diff --git a/src/core/channel.rs b/src/core/channel.rs index 9a66574..bc84598 100644 --- a/src/core/channel.rs +++ b/src/core/channel.rs @@ -58,6 +58,7 @@ impl Channel { return patterns.clone(); } } + self.default_patterns() } } @@ -77,7 +78,6 @@ pub fn matches_channel(tag: &str, patterns: &[String]) -> bool { } } - // If all patterns are exclusions, tag passes unless excluded let all_exclusions = patterns.iter().all(|p| p.starts_with('!')); if all_exclusions { return true; @@ -91,10 +91,10 @@ fn glob_match(text: &str, pattern: &str) -> bool { return true; } - let starts_with = pattern.starts_with('*'); - let ends_with = pattern.ends_with('*'); + let starts_with_wildcard = pattern.starts_with('*'); + let ends_with_wildcard = pattern.ends_with('*'); - match (starts_with, ends_with) { + match (starts_with_wildcard, ends_with_wildcard) { (true, true) => { let inner = &pattern[1..pattern.len() - 1]; text.contains(inner) diff --git a/src/core/checksum.rs b/src/core/checksum.rs index a9177d6..3e3558e 100644 --- a/src/core/checksum.rs +++ b/src/core/checksum.rs @@ -12,14 +12,6 @@ pub enum ChecksumAlgorithm { Md5, } -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct ChecksumFile { - pub algorithm: ChecksumAlgorithm, - pub filename: String, - pub expected_hash: String, -} - pub fn is_checksum_file(filename: &str) -> Option { let lower = filename.to_lowercase(); if lower.ends_with(".sha256") || lower.contains(".sha256.") { diff --git a/src/core/config.rs b/src/core/config.rs index c1c01b9..fd4f0a3 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -3,6 +3,10 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context, Result}; + +use crate::core::constants::{ + CONFIG_FILE, ENV_VAR_CONFIG, GITCLAW_DIR, LOCAL_CONFIG_FILE, XDG_CONFIG_SUBDIR, +}; use serde::Deserialize; #[derive(Debug, Clone, Deserialize)] @@ -78,7 +82,7 @@ fn default_color() -> String { fn default_install_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from(".")) - .join(".gitclaw") + .join(GITCLAW_DIR) } impl Config { @@ -102,7 +106,7 @@ impl Config { } pub fn load_from_env() -> Result> { - if let Ok(path) = env::var("GITCLAW_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) @@ -113,7 +117,7 @@ impl Config { } pub fn load_from_local() -> Result> { - let path = PathBuf::from(".gitclaw.toml"); + 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")?; @@ -126,7 +130,7 @@ impl Config { pub fn load_from_xdg() -> Result> { if let Some(config_dir) = dirs::config_dir() { - let path = config_dir.join("gitclaw").join("config.toml"); + 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")?; @@ -178,14 +182,4 @@ impl Config { self.output.verbose = other.output.verbose; } } - - #[allow(dead_code)] - pub fn github_token(&self) -> Option<&str> { - self.github_token.as_deref() - } - - #[allow(dead_code)] - pub fn install_dir(&self) -> &PathBuf { - &self.install_dir - } } diff --git a/src/core/constants.rs b/src/core/constants.rs index 174c40e..7041abd 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -6,7 +6,11 @@ pub const REPO_NAME: &str = "gitclaw"; 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 DIR_BIN: &str = "bin"; pub const DIR_PACKAGES: &str = "packages"; @@ -18,3 +22,4 @@ 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 RELEASE_TAG_LATEST: &str = "latest"; diff --git a/src/core/install.rs b/src/core/install.rs index e8103b5..507ff5d 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -9,7 +9,7 @@ use walkdir::WalkDir; use crate::core::checksum::{find_checksum_file, verify_file}; use crate::core::config::Config; -use crate::core::constants::{APP_NAME, TEMP_DIR_PREFIX}; +use crate::core::constants::{APP_NAME, 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}; @@ -87,7 +87,11 @@ pub async fn handle_install( client.get_release(&owner, &repo, v).await? } } - (None, None) => client.get_release(&owner, &repo, "latest").await?, + (None, None) => { + client + .get_release(&owner, &repo, RELEASE_TAG_LATEST) + .await? + } }; let asset = select_best_asset(&release)?; @@ -130,7 +134,6 @@ pub async fn handle_install( let cached_path = crate::core::cache::store(config, &cache_key, &temp_path)?; - // clean up temp let _ = fs::remove_file(&temp_path); cached_path @@ -246,7 +249,11 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { } filtered.into_iter().next().unwrap() } - None => client.get_release(&owner, &repo, "latest").await?, + None => { + client + .get_release(&owner, &repo, RELEASE_TAG_LATEST) + .await? + } }; if latest.tag_name == installed.version { diff --git a/src/core/lockfile.rs b/src/core/lockfile.rs index 819ef48..3e9cbee 100644 --- a/src/core/lockfile.rs +++ b/src/core/lockfile.rs @@ -8,6 +8,8 @@ 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, @@ -22,8 +24,6 @@ pub struct Lockfile { pub packages: Vec, } -const LOCKFILE_NAME: &str = "gitclaw.lock"; - impl Lockfile { pub fn from_registry(registry: &Registry) -> Self { let packages = registry @@ -112,11 +112,11 @@ pub async fn install_locked(config: &crate::core::config::Config) -> Result<()> #[cfg(test)] mod tests { use super::*; + use std::path::PathBuf; - use crate::core::registry::{InstalledPackage, Registry}; - use tempfile; + use crate::core::registry::InstalledPackage; - fn make_pkg( + fn make_installed_package( name: &str, owner: &str, repo: &str, @@ -137,19 +137,17 @@ mod tests { } } - use std::path::PathBuf; - #[test] fn test_lockfile_from_registry() { let mut reg = Registry::default(); - reg.add(make_pkg( + reg.add(make_installed_package( "BurntSushi/ripgrep", "BurntSushi", "ripgrep", "v14.1.0", "ripgrep-14.tar.gz", )); - reg.add(make_pkg( + reg.add(make_installed_package( "sharkdp/fd", "sharkdp", "fd", diff --git a/src/core/registry.rs b/src/core/registry.rs index 8ddc034..2142e6e 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use tracing::debug; use crate::core::config::Config; -use crate::core::constants::APP_NAME_SHORT; +use crate::core::constants::{APP_NAME_SHORT, RELEASE_TAG_LATEST}; use crate::core::util::registry_path_from; use crate::network::github::{parse_package, GithubClient}; use crate::output; @@ -38,20 +38,6 @@ pub struct Registry { } impl Registry { - #[allow(dead_code)] - fn default_path() -> Result { - Ok(dirs::home_dir() - .ok_or_else(|| anyhow!("No home directory"))? - .join(".gitclaw") - .join("registry.toml")) - } - - #[allow(dead_code)] - pub fn load() -> Result { - let p = Self::default_path()?; - Self::load_from(&p) - } - pub fn load_from(path: &PathBuf) -> Result { if !path.exists() { return Ok(Self { @@ -88,18 +74,6 @@ impl Registry { } } -#[allow(dead_code)] -pub fn gitclaw_home() -> Result { - Ok(dirs::home_dir() - .ok_or_else(|| anyhow!("No home directory"))? - .join(".gitclaw")) -} - -#[allow(dead_code)] -pub fn bin_dir() -> Result { - Ok(gitclaw_home()?.join("bin")) -} - pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { let registry_path = registry_path_from(install_dir); let reg = Registry::load_from(®istry_path)?; @@ -186,7 +160,10 @@ pub async fn list_outdated(install_dir: &Path, token: Option<&str>) -> Result<() let mut outdated = Vec::new(); for pkg in reg.packages.values() { - let latest = match client.get_release(&pkg.owner, &pkg.repo, "latest").await { + let latest = match client + .get_release(&pkg.owner, &pkg.repo, RELEASE_TAG_LATEST) + .await + { Ok(r) => r.tag_name, Err(_) => continue, }; diff --git a/src/core/semver.rs b/src/core/semver.rs index 726e01a..ac3f4d7 100644 --- a/src/core/semver.rs +++ b/src/core/semver.rs @@ -29,7 +29,7 @@ impl VersionConstraint { return Ok(VersionConstraint::Range(req)); } - bail!("Cannot parse '{}' as a version or semver range.", trimmed); + bail!("Cannot parse '{}' as a version or semver range.", trimmed) } pub fn matches(&self, version: &Version) -> bool { diff --git a/src/core/updater.rs b/src/core/updater.rs index 7128948..a2cbb07 100644 --- a/src/core/updater.rs +++ b/src/core/updater.rs @@ -8,7 +8,7 @@ use walkdir::WalkDir; use crate::core::config::Config; use crate::core::constants::{ - APP_NAME, DIR_EXTRACTED, REPO_NAME, REPO_OWNER, TEMP_DIR_SELF_UPDATE, + APP_NAME, DIR_EXTRACTED, RELEASE_TAG_LATEST, REPO_NAME, REPO_OWNER, TEMP_DIR_SELF_UPDATE, }; use crate::core::extract::extract_archive; use crate::network::github::{find_matching_asset, GithubClient, Platform}; @@ -24,7 +24,9 @@ fn current_version() -> String { pub async fn check_for_update(config: &Config) -> Result<()> { let client = GithubClient::new(config.github_token.clone())?; - let release = client.get_release(REPO_OWNER, REPO_NAME, "latest").await?; + let release = client + .get_release(REPO_OWNER, REPO_NAME, RELEASE_TAG_LATEST) + .await?; let current = current_version(); let latest = release.tag_name.trim_start_matches('v').to_string(); @@ -50,7 +52,9 @@ pub async fn check_for_update(config: &Config) -> Result<()> { pub async fn perform_update(config: &Config) -> Result<()> { let client = GithubClient::new(config.github_token.clone())?; - let release = client.get_release(REPO_OWNER, REPO_NAME, "latest").await?; + let release = client + .get_release(REPO_OWNER, REPO_NAME, RELEASE_TAG_LATEST) + .await?; let current = current_version(); let latest = release.tag_name.trim_start_matches('v').to_string(); diff --git a/src/core/util.rs b/src/core/util.rs index 70d9656..043ba68 100644 --- a/src/core/util.rs +++ b/src/core/util.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use std::env; use std::path::{Path, PathBuf}; @@ -37,10 +35,6 @@ pub fn packages_dir() -> Result { Ok(gitclaw_dir()?.join(DIR_PACKAGES)) } -pub fn packages_dir_from(base: &Path) -> PathBuf { - base.join(DIR_PACKAGES) -} - pub fn registry_path() -> Result { Ok(gitclaw_dir()?.join(REGISTRY_FILE)) } @@ -53,13 +47,6 @@ pub fn config_path() -> Result { Ok(gitclaw_dir()?.join(CONFIG_FILE)) } -pub fn ensure_dirs() -> Result<()> { - std::fs::create_dir_all(bin_dir()?)?; - std::fs::create_dir_all(downloads_dir()?)?; - std::fs::create_dir_all(packages_dir()?)?; - Ok(()) -} - pub fn is_in_path(binary: &str) -> bool { env::var("PATH") .map(|path| { @@ -71,10 +58,12 @@ pub fn is_in_path(binary: &str) -> bool { pub fn format_bytes(bytes: u64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + if bytes == 0 { return "0 B".to_string(); } - let exp = (bytes as f64).log(1024.0).min(UNITS.len() as f64 - 1.0) as usize; - let value = bytes as f64 / 1024f64.powi(exp as i32); - format!("{:.1} {}", value, UNITS[exp]) + + let exponent = (bytes as f64).log(1024.0).min(UNITS.len() as f64 - 1.0) as usize; + let value = bytes as f64 / 1024f64.powi(exponent as i32); + format!("{:.1} {}", value, UNITS[exponent]) } diff --git a/src/lib.rs b/src/lib.rs index 7195e96..41357d8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ pub mod core; pub mod network; pub mod output; +pub use cli::{AliasAction, CacheAction, Cli, Commands}; pub use core::alias; pub use core::cache; pub use core::channel; diff --git a/src/main.rs b/src/main.rs index 4b820a3..9614a8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,21 +4,17 @@ use anyhow::bail; use clap::{CommandFactory, Parser}; use clap_complete::generate; -mod cli; -mod core; -mod network; -mod output; - -use cli::{AliasAction, CacheAction, Cli, Commands}; -use core::config::Config; -use core::constants::{APP_NAME, APP_NAME_SHORT, DIR_BIN}; -use core::registry::Registry; -use core::util::registry_path_from; +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; #[tokio::main] async fn main() { if let Err(e) = color_eyre::install() { - output::print_error(&format!("Failed to initialize color-eyre: {}.", e)); + banner::print_error(&format!("Failed to initialize color-eyre: {}.", e)); std::process::exit(1); } @@ -34,7 +30,7 @@ async fn main() { let config = match Config::load() { Ok(cfg) => cfg, Err(e) => { - output::print_error(&format!("Failed to load config: {}.", e)); + banner::print_error(&format!("Failed to load config: {}.", e)); std::process::exit(1); } }; @@ -42,7 +38,7 @@ async fn main() { let config = apply_cli_overrides(config, &cli); if let Err(e) = run(cli, config).await { - output::print_error(&format!("{}", e)); + banner::print_error(&format!("{}", e)); std::process::exit(1); } } @@ -51,6 +47,7 @@ fn apply_cli_overrides(mut config: Config, cli: &Cli) -> Config { if cli.token.is_some() { config.github_token = cli.token.clone(); } + config } @@ -70,28 +67,34 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { | Commands::SelfUpdate { .. } | Commands::Run { .. } | Commands::Alias { .. } => { - output::print_version_line(); + banner::print_version_line(); } } match cli.command { Commands::Alias { action } => { - output::print_output_header(); + banner::print_output_header(); + match action { AliasAction::Add { alias, target } => { - core::alias::handle_alias_add(&alias, &target, &config)? + gitclaw::alias::handle_alias_add(&alias, &target, &config)? } - AliasAction::Remove { alias } => core::alias::handle_alias_remove(&alias, &config)?, - AliasAction::List {} => core::alias::handle_alias_list(&config)?, + AliasAction::Remove { alias } => { + gitclaw::alias::handle_alias_remove(&alias, &config)? + } + AliasAction::List {} => gitclaw::alias::handle_alias_list(&config)?, } } + Commands::Cache { action } => { - output::print_output_header(); + banner::print_output_header(); + match action { - CacheAction::Clean {} => core::cache::handle_cache_clean(&config)?, - CacheAction::Size {} => core::cache::handle_cache_size(&config)?, + CacheAction::Clean {} => gitclaw::cache::handle_cache_clean(&config)?, + CacheAction::Size {} => gitclaw::cache::handle_cache_size(&config)?, } } + Commands::Install { packages, force, @@ -101,74 +104,81 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { local, channel, } => { - output::print_output_header(); + banner::print_output_header(); let install_config = if local { let mut cfg = config.clone(); - cfg.install_dir = std::env::current_dir()?.join(".gitclaw"); + cfg.install_dir = std::env::current_dir()?.join(GITCLAW_DIR); cfg } else { config.clone() }; - let ch = match channel.as_deref() { - Some(c) => Some(c.parse::()?), + let channel = match channel.as_deref() { + Some(c) => Some(c.parse::()?), None => None, }; if locked { - core::lockfile::install_locked(&install_config).await? + gitclaw::lockfile::install_locked(&install_config).await? } else if packages.len() == 1 { - core::install::handle_install( + gitclaw::install::handle_install( &packages[0], force, dry_run, verify, &install_config, - ch, + channel, ) .await? } else { - core::install::handle_install_multiple( + gitclaw::install::handle_install_multiple( &packages, force, dry_run, verify, &install_config, - ch, + channel, ) .await? } } Commands::Lock { dir } => { - output::print_output_header(); + banner::print_output_header(); let project_dir = std::path::PathBuf::from(dir); - core::lockfile::generate_lockfile(&config.install_dir, &project_dir)? + gitclaw::lockfile::generate_lockfile(&config.install_dir, &project_dir)? } + Commands::List { verbose, outdated } => { - output::print_output_header(); + banner::print_output_header(); + if outdated { - core::registry::list_outdated(&config.install_dir, config.github_token.as_deref()) - .await? + gitclaw::registry::list_outdated( + &config.install_dir, + config.github_token.as_deref(), + ) + .await? } else { - core::registry::list_installed(verbose, &config.install_dir)? + gitclaw::registry::list_installed(verbose, &config.install_dir)? } } Commands::Update { package } => { - output::print_output_header(); - core::install::handle_update(package.as_deref(), &config).await? + banner::print_output_header(); + gitclaw::install::handle_update(package.as_deref(), &config).await? } Commands::Uninstall { package, local } => { - output::print_output_header(); + banner::print_output_header(); + let install_dir = if local { - std::env::current_dir()?.join(".gitclaw") + std::env::current_dir()?.join(GITCLAW_DIR) } else { config.install_dir.clone() }; - core::registry::uninstall(&package, &install_dir, &config)? + + gitclaw::registry::uninstall(&package, &install_dir, &config)? } Commands::Search { @@ -176,28 +186,31 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { limit, channel, } => { - output::print_output_header(); - let ch = match channel.as_deref() { - Some(c) => Some(c.parse::()?), + banner::print_output_header(); + + let channel = match channel.as_deref() { + Some(c) => Some(c.parse::()?), None => None, }; - network::github::search_releases(&package, limit, &config, ch).await? + + gitclaw::github::search_releases(&package, limit, &config, channel).await? } Commands::Export { output: output_path, } => { - output::print_output_header(); - core::export::handle_export(&config, output_path.as_deref())? + banner::print_output_header(); + gitclaw::export::handle_export(&config, output_path.as_deref())? } Commands::Import { file, force } => { - output::print_output_header(); - core::export::handle_import(&config, &file, force).await? + banner::print_output_header(); + gitclaw::export::handle_import(&config, &file, force).await? } Commands::Completions { shell } => { - output::print_output_header(); + banner::print_output_header(); + let mut cmd = Cli::command(); let name = cmd.get_name().to_string(); generate(shell, &mut cmd, name.clone(), &mut std::io::stdout()); @@ -210,25 +223,26 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { } Commands::Platform {} => { - output::print_output_header(); - let arch = network::platform::current_platform(); - output::print_info(&format!("Detected platform: linux {}", arch)); - output::print_info("Compiled for: Linux"); - output::print_info(&format!("Architecture aliases: {:?}", arch.aliases())); + banner::print_output_header(); + + 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())); } Commands::SelfUpdate { check } => { - output::print_output_header(); + banner::print_output_header(); if check { - core::updater::check_for_update(&config).await? + gitclaw::updater::check_for_update(&config).await? } else { - core::updater::perform_update(&config).await? + gitclaw::updater::perform_update(&config).await? } } Commands::Run { package, args } => { - output::print_output_header(); + banner::print_output_header(); run_package(&package, args, &config).await? } } @@ -238,8 +252,8 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { async fn run_package(package: &str, args: Vec, config: &Config) -> anyhow::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)); + 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() diff --git a/src/network/github.rs b/src/network/github.rs index 64cadc2..66c0018 100644 --- a/src/network/github.rs +++ b/src/network/github.rs @@ -11,7 +11,7 @@ use thiserror::Error; use tracing::{debug, warn}; use crate::core::config::Config; -use crate::core::constants::GITHUB_API_BASE; +use crate::core::constants::{GITHUB_API_BASE, RELEASE_TAG_LATEST}; use crate::output; const GITHUB_API: &str = GITHUB_API_BASE; @@ -94,11 +94,6 @@ impl Platform { } } - #[allow(dead_code)] - fn extensions(&self) -> &[&'static str] { - &[".tar.gz", ".tar.xz", ".tar.bz2", ".tgz", ".zip"] - } - pub fn current() -> Result { match std::env::consts::ARCH { "x86_64" => Ok(Platform::LinuxX86_64), @@ -137,7 +132,7 @@ impl GithubClient { repo: &str, version: &str, ) -> std::result::Result { - if version == "latest" { + if version == RELEASE_TAG_LATEST { self.get_latest_release(user, repo).await } else { self.get_release_by_tag(user, repo, version).await @@ -226,7 +221,7 @@ impl GithubClient { .ok_or_else(|| GithubError::ReleaseNotFound { owner: owner.to_string(), repo: repo.to_string(), - version: "latest".to_string(), + version: RELEASE_TAG_LATEST.to_string(), }) } diff --git a/src/network/platform.rs b/src/network/platform.rs index f2fde90..1c73e57 100644 --- a/src/network/platform.rs +++ b/src/network/platform.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - use thiserror::Error; #[derive(Error, Debug)] diff --git a/tests/alias.rs b/tests/alias.rs deleted file mode 100644 index 1970f09..0000000 --- a/tests/alias.rs +++ /dev/null @@ -1,106 +0,0 @@ -use tempfile::TempDir; - -use gitclaw::config::Config; - -#[test] -fn test_alias_add_and_resolve() { - let dir = TempDir::new().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; - - let mut aliases = gitclaw::alias::AliasMap::default(); - aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); - aliases.save(&config).unwrap(); - - let loaded = gitclaw::alias::AliasMap::load(&config).unwrap(); - assert_eq!(loaded.resolve("rg"), Some("BurntSushi/ripgrep")); - assert_eq!(loaded.resolve("fd"), None); -} - -#[test] -fn test_alias_add_multiple() { - let dir = TempDir::new().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; - - let mut aliases = gitclaw::alias::AliasMap::default(); - aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); - aliases.add("fd", "sharkdp/fd", &config).unwrap(); - aliases.add("bat", "sharkdp/bat", &config).unwrap(); - aliases.save(&config).unwrap(); - - let loaded = gitclaw::alias::AliasMap::load(&config).unwrap(); - assert_eq!(loaded.resolve("rg"), Some("BurntSushi/ripgrep")); - assert_eq!(loaded.resolve("fd"), Some("sharkdp/fd")); - assert_eq!(loaded.resolve("bat"), Some("sharkdp/bat")); -} - -#[test] -fn test_alias_remove() { - let dir = TempDir::new().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; - - let mut aliases = gitclaw::alias::AliasMap::default(); - aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); - aliases.save(&config).unwrap(); - - let mut loaded = gitclaw::alias::AliasMap::load(&config).unwrap(); - assert!(loaded.remove("rg")); - assert!(!loaded.remove("nonexistent")); - loaded.save(&config).unwrap(); - - let reloaded = gitclaw::alias::AliasMap::load(&config).unwrap(); - assert_eq!(reloaded.resolve("rg"), None); -} - -#[test] -fn test_alias_slash_rejected() { - let dir = TempDir::new().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; - let mut aliases = gitclaw::alias::AliasMap::default(); - assert!(aliases - .add("owner/repo", "BurntSushi/ripgrep", &config) - .is_err()); -} - -#[test] -fn test_alias_list_sorted() { - let dir = TempDir::new().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; - let mut aliases = gitclaw::alias::AliasMap::default(); - aliases.add("fd", "sharkdp/fd", &config).unwrap(); - aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); - aliases.add("bat", "sharkdp/bat", &config).unwrap(); - - let list = aliases.list(); - assert_eq!(list.len(), 3); - assert_eq!(list[0].0.as_str(), "bat"); - assert_eq!(list[1].0.as_str(), "fd"); - assert_eq!(list[2].0.as_str(), "rg"); -} - -#[test] -fn test_alias_overwrite() { - let dir = TempDir::new().unwrap(); - let config = Config { - install_dir: dir.path().to_path_buf(), - ..Config::default() - }; - let mut aliases = gitclaw::alias::AliasMap::default(); - aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); - aliases.add("rg", "other/ripgrep", &config).unwrap(); - assert_eq!(aliases.resolve("rg"), Some("other/ripgrep")); -} diff --git a/tests/channel.rs b/tests/channel.rs index b145d34..ab4dd43 100644 --- a/tests/channel.rs +++ b/tests/channel.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use gitclaw::channel::{filter_releases, matches_channel, Channel}; use gitclaw::network::github::Release; @@ -106,3 +108,18 @@ fn test_channel_display() { assert_eq!(Channel::Beta.to_string(), "beta"); assert_eq!(Channel::Nightly.to_string(), "nightly"); } + +#[test] +fn test_channel_patterns_with_overrides() { + let mut overrides = HashMap::new(); + overrides.insert( + "nightly".to_string(), + vec!["*-canary".to_string(), "*-edge".to_string()], + ); + + let patterns = Channel::Nightly.patterns_with_overrides(Some(&overrides)); + assert_eq!(patterns, vec!["*-canary", "*-edge"]); + + let patterns = Channel::Beta.patterns_with_overrides(Some(&overrides)); + assert_eq!(patterns, Channel::Beta.default_patterns()); +} diff --git a/tests/config.rs b/tests/config.rs index 087ae8c..f72bd04 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -128,24 +128,24 @@ fn config_merge_precedence() { } #[test] -fn github_token_accessor() { +fn github_token_field() { let config = Config { github_token: Some("test-token".to_string()), ..Default::default() }; - assert_eq!(config.github_token(), 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(), None); + assert_eq!(config_no_token.github_token.as_deref(), None); } #[test] -fn install_dir_accessor() { +fn install_dir_field() { let config = Config { install_dir: PathBuf::from("/custom/install"), ..Default::default() }; - assert_eq!(config.install_dir(), &PathBuf::from("/custom/install")); + assert_eq!(config.install_dir, PathBuf::from("/custom/install")); } #[test] diff --git a/tests/lockfile.rs b/tests/lockfile.rs deleted file mode 100644 index 1b5fe97..0000000 --- a/tests/lockfile.rs +++ /dev/null @@ -1,108 +0,0 @@ -use std::path::PathBuf; - -use tempfile::TempDir; - -use gitclaw::lockfile::Lockfile; -use gitclaw::registry::{InstalledPackage, Registry}; - -fn make_pkg(name: &str, owner: &str, repo: &str, version: &str, asset: &str) -> InstalledPackage { - InstalledPackage { - name: name.to_string(), - owner: owner.to_string(), - repo: repo.to_string(), - version: version.to_string(), - installed_at: "2026-01-01T00:00:00Z".to_string(), - binary_path: PathBuf::from("/tmp/test"), - install_dir: PathBuf::from("/tmp/test"), - asset_name: asset.to_string(), - identifier: repo.to_string(), - channel: None, - } -} - -#[test] -fn test_lockfile_from_registry() { - let mut reg = Registry::default(); - reg.add(make_pkg( - "BurntSushi/ripgrep", - "BurntSushi", - "ripgrep", - "v14.1.0", - "ripgrep-14.tar.gz", - )); - reg.add(make_pkg( - "sharkdp/fd", - "sharkdp", - "fd", - "v10.2.0", - "fd-10.tar.gz", - )); - - let lockfile = Lockfile::from_registry(®); - assert_eq!(lockfile.packages.len(), 2); - - let rg = lockfile - .packages - .iter() - .find(|p| p.repo == "ripgrep") - .unwrap(); - assert_eq!(rg.owner, "BurntSushi"); - assert_eq!(rg.version, "v14.1.0"); - assert_eq!(rg.asset, "ripgrep-14.tar.gz"); -} - -#[test] -fn test_lockfile_roundtrip() { - let dir = TempDir::new().unwrap(); - - let lockfile = Lockfile { - packages: vec![gitclaw::lockfile::LockEntry { - owner: "BurntSushi".to_string(), - repo: "ripgrep".to_string(), - version: "v14.1.0".to_string(), - asset: "ripgrep-14.tar.gz".to_string(), - }], - }; - - lockfile.save(dir.path()).unwrap(); - let loaded = Lockfile::load(dir.path()).unwrap(); - - assert_eq!(loaded.packages.len(), 1); - assert_eq!(loaded.packages[0].owner, "BurntSushi"); - assert_eq!(loaded.packages[0].repo, "ripgrep"); - assert_eq!(loaded.packages[0].version, "v14.1.0"); -} - -#[test] -fn test_lockfile_toml_format() { - let lockfile = Lockfile { - packages: vec![gitclaw::lockfile::LockEntry { - owner: "sharkdp".to_string(), - repo: "fd".to_string(), - version: "v10.2.0".to_string(), - asset: "fd-10.tar.gz".to_string(), - }], - }; - - let toml_str = toml::to_string_pretty(&lockfile).unwrap(); - assert!(toml_str.contains("[[package]]")); - assert!(toml_str.contains("owner = \"sharkdp\"")); - assert!(toml_str.contains("repo = \"fd\"")); -} - -#[test] -fn test_lockfile_empty_registry() { - let reg = Registry::default(); - let lockfile = Lockfile::from_registry(®); - assert!(lockfile.packages.is_empty()); -} - -#[test] -fn test_lockfile_is_present() { - let dir = TempDir::new().unwrap(); - assert!(!Lockfile::is_present(dir.path())); - - let lockfile = Lockfile::default(); - lockfile.save(dir.path()).unwrap(); - assert!(Lockfile::is_present(dir.path())); -} diff --git a/tests/registry.rs b/tests/registry.rs index 3d2068f..b0d3a28 100644 --- a/tests/registry.rs +++ b/tests/registry.rs @@ -198,16 +198,6 @@ asset_name = "pkg.tar.gz" assert_eq!(pkg.identifier, ""); } -#[test] -fn test_bin_dir() { - let _ = gitclaw::registry::bin_dir(); -} - -#[test] -fn test_gitclaw_home() { - let _ = gitclaw::registry::gitclaw_home(); -} - #[test] fn test_registry_is_not_installed() { let registry = gitclaw::registry::Registry::default(); diff --git a/tests/semver.rs b/tests/semver.rs index 8cb7ed1..794582e 100644 --- a/tests/semver.rs +++ b/tests/semver.rs @@ -1,68 +1,63 @@ -use gitclaw::semver::VersionConstraint; +use semver::Version; -#[test] -fn test_semver_exact_version() { - let c = VersionConstraint::parse("1.2.3").unwrap(); - assert!(c.matches(&semver::Version::parse("1.2.3").unwrap())); - assert!(!c.matches(&semver::Version::parse("1.2.4").unwrap())); -} +use gitclaw::semver::{parse_tag_version, strip_v_prefix, VersionConstraint}; #[test] fn test_semver_caret_range() { - let c = VersionConstraint::parse("^1.2.3").unwrap(); - assert!(c.matches(&semver::Version::parse("1.2.3").unwrap())); - assert!(c.matches(&semver::Version::parse("1.2.9").unwrap())); - assert!(c.matches(&semver::Version::parse("1.3.0").unwrap())); - assert!(!c.matches(&semver::Version::parse("2.0.0").unwrap())); + let constraint = VersionConstraint::parse("^1.2.3").unwrap(); + assert!(constraint.matches(&Version::parse("1.2.3").unwrap())); + assert!(constraint.matches(&Version::parse("1.2.9").unwrap())); + assert!(constraint.matches(&Version::parse("1.3.0").unwrap())); + assert!(!constraint.matches(&Version::parse("2.0.0").unwrap())); } #[test] fn test_semver_tilde_range() { - let c = VersionConstraint::parse("~1.2.3").unwrap(); - assert!(c.matches(&semver::Version::parse("1.2.3").unwrap())); - assert!(c.matches(&semver::Version::parse("1.2.9").unwrap())); - assert!(!c.matches(&semver::Version::parse("1.3.0").unwrap())); + let constraint = VersionConstraint::parse("~1.2.3").unwrap(); + assert!(constraint.matches(&Version::parse("1.2.3").unwrap())); + assert!(constraint.matches(&Version::parse("1.2.9").unwrap())); + assert!(!constraint.matches(&Version::parse("1.3.0").unwrap())); } #[test] fn test_semver_gte_range() { - let c = VersionConstraint::parse(">=1.0.0").unwrap(); - assert!(c.matches(&semver::Version::parse("1.0.0").unwrap())); - assert!(c.matches(&semver::Version::parse("2.0.0").unwrap())); - assert!(!c.matches(&semver::Version::parse("0.9.0").unwrap())); + let constraint = VersionConstraint::parse(">=1.0.0").unwrap(); + assert!(constraint.matches(&Version::parse("1.0.0").unwrap())); + assert!(constraint.matches(&Version::parse("2.0.0").unwrap())); + assert!(!constraint.matches(&Version::parse("0.9.0").unwrap())); } #[test] -fn test_semver_strip_v_prefix() { - assert_eq!(gitclaw::semver::strip_v_prefix("v1.2.3"), "1.2.3"); - assert_eq!(gitclaw::semver::strip_v_prefix("1.2.3"), "1.2.3"); +fn test_semver_lt_range() { + let constraint = VersionConstraint::parse("<2.0.0").unwrap(); + assert!(constraint.matches(&Version::parse("1.9.9").unwrap())); + assert!(!constraint.matches(&Version::parse("2.0.0").unwrap())); } #[test] -fn test_semver_parse_tag_version() { - let v = gitclaw::semver::parse_tag_version("v1.2.3").unwrap(); - assert_eq!(v, semver::Version::parse("1.2.3").unwrap()); - - let v = gitclaw::semver::parse_tag_version("1.2.3").unwrap(); - assert_eq!(v, semver::Version::parse("1.2.3").unwrap()); +fn test_semver_lte_range() { + let constraint = VersionConstraint::parse("<=2.0.0").unwrap(); + assert!(constraint.matches(&Version::parse("2.0.0").unwrap())); + assert!(constraint.matches(&Version::parse("1.9.9").unwrap())); + assert!(!constraint.matches(&Version::parse("2.0.1").unwrap())); } #[test] -fn test_semver_invalid_constraint() { - assert!(VersionConstraint::parse("not-a-version").is_err()); +fn test_semver_strip_v_prefix() { + assert_eq!(strip_v_prefix("v1.2.3"), "1.2.3"); + assert_eq!(strip_v_prefix("1.2.3"), "1.2.3"); } #[test] -fn test_semver_lt_range() { - let c = VersionConstraint::parse("<2.0.0").unwrap(); - assert!(c.matches(&semver::Version::parse("1.9.9").unwrap())); - assert!(!c.matches(&semver::Version::parse("2.0.0").unwrap())); +fn test_semver_parse_tag_version() { + let v = parse_tag_version("v1.2.3").unwrap(); + assert_eq!(v, Version::parse("1.2.3").unwrap()); + + let v = parse_tag_version("1.2.3").unwrap(); + assert_eq!(v, Version::parse("1.2.3").unwrap()); } #[test] -fn test_semver_lte_range() { - let c = VersionConstraint::parse("<=2.0.0").unwrap(); - assert!(c.matches(&semver::Version::parse("2.0.0").unwrap())); - assert!(c.matches(&semver::Version::parse("1.0.0").unwrap())); - assert!(!c.matches(&semver::Version::parse("2.0.1").unwrap())); +fn test_semver_invalid_constraint() { + assert!(VersionConstraint::parse("not-a-version").is_err()); }