diff --git a/.gitignore b/.gitignore index dd91f5b..b3277ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,6 @@ Cargo.lock *~ .DS_Store -# Specs are temporary - reviewed, implemented, then deleted +# Specs: tracked in git, archived after merge .specs/*.md !.specs/TEMPLATE.md diff --git a/.specs/TEMPLATE.md b/.specs/TEMPLATE.md index 63f19d4..b8f131c 100644 --- a/.specs/TEMPLATE.md +++ b/.specs/TEMPLATE.md @@ -1,6 +1,9 @@ # Spec Template -## Feature: [Name] +## Small Change (bug fix, tweak) +Skip this template. Write a clear PR description instead. + +## Feature / New Behavior ### Problem [What problem does this solve?] @@ -23,27 +26,17 @@ - Manual verification: [steps] ### Files to Modify -- [ ] file1.rs -- [ ] file2.rs +- [ ] file.rs ### Documentation Updates - [ ] CHANGELOG.md - [ ] README.md (if user-facing) -- [ ] AGENTS.md (if process changes) --- -## Checkpoint Reviews - -- [ ] 25%: [what's done, blockers?] -- [ ] 50%: [what's done, blockers?] -- [ ] 75%: [what's done, blockers?] -- [ ] 100%: [final review before PR] - -## Post-Mortem (fill after merge) - -**What went well:** - -**What could improve:** +## Checkpoints (tied to deliverables, not percentages) -**Lessons learned:** +- [ ] [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 diff --git a/.specs/v0.4.0-roadmap.md b/.specs/v0.4.0-roadmap.md new file mode 100644 index 0000000..91fe3b0 --- /dev/null +++ b/.specs/v0.4.0-roadmap.md @@ -0,0 +1,110 @@ +# Spec: gitclaw v0.4.0 — Dependency Management + +## Feature: Semver ranges, lockfile, and package aliases + +### Problem +gitclaw v0.3.x installs the latest release by default with no way to: +- Pin or constrain versions (e.g., "install >=1.0.0 but <2.0.0") +- Reproduce installs across machines (no lockfile) +- Use short names for frequently installed packages + +This makes gitclaw unreliable for CI/CD and team environments where +reproducibility matters. + +### Solution +Implement the three features from ROADMAP.md v0.4.0: + +**1. Semver range support** +- Parse semver constraints: `>=1.0.0`, `^1.2.3`, `~1.2.3` +- Find the best matching release from GitHub +- `gitclaw install user/repo "^1.2.3"` installs latest 1.x.x + +**2. Lockfile support** +- `gitclaw lock` generates `gitclaw.lock` from installed packages +- `gitclaw install --locked` installs exact versions from lockfile +- Lockfile format: TOML with owner, repo, version, checksum + +**3. Package aliases** +- `gitclaw alias add rg BurntSushi/ripgrep` +- `gitclaw alias list` +- `gitclaw alias remove rg` +- `gitclaw install rg` resolves alias to full owner/repo + +### Acceptance Criteria +- [ ] `gitclaw install user/repo "^1.0.0"` installs matching version +- [ ] `gitclaw install user/repo ">=2.0.0"` installs matching version +- [ ] `gitclaw install user/repo "~1.2.3"` installs matching version +- [ ] Exact version `gitclaw install user/repo@1.2.3` still works +- [ ] No matching version: clear error message +- [ ] `gitclaw lock` creates `gitclaw.lock` in current directory +- [ ] `gitclaw install --locked` installs exact versions from lockfile +- [ ] `gitclaw alias add ` creates alias +- [ ] `gitclaw install ` resolves and installs +- [ ] Aliases persist in config file +- [ ] All new features have integration tests + +### Edge Cases +- [ ] Semver constraint with no matching release: error with available versions +- [ ] Lockfile with missing/invalid entries: clear error, skip or fail +- [ ] Alias name conflicts with owner/repo format: reject with explanation +- [ ] Circular alias: detect and reject +- [ ] Lockfile in project-local config vs global: precedence rules + +### Test Plan + +**Unit tests:** +- Semver constraint parsing (^, ~, >=, <=, >, <, =) +- Version matching against release list +- Lockfile serialization/deserialization +- Alias resolution and conflict detection + +**Integration tests:** +- Install with semver constraint installs correct version +- Lock command generates valid TOML +- Install --locked reproduces exact versions +- Alias add/list/remove cycle +- Install by alias resolves correctly + +**Manual verification:** +- `gitclaw install BurntSushi/ripgrep "^14"` installs latest 14.x.x +- `gitclaw lock` then `gitclaw install --locked` on another machine +- `gitclaw alias add rg BurntSushi/ripgrep && gitclaw install rg` + +### Files to Modify +- [ ] src/cli/mod.rs (new args: semver constraints, --locked, alias subcommand) +- [ ] src/core/install.rs (semver matching in install flow) +- [ ] src/core/lockfile.rs (new: lockfile generation and parsing) +- [ ] src/core/alias.rs (new: alias management) +- [ ] src/core/config.rs (alias storage in config) +- [ ] src/network/github.rs (version listing for semver matching) +- [ ] tests/lockfile.rs (new: lockfile tests) +- [ ] tests/alias.rs (new: alias tests) +- [ ] Cargo.toml (add `semver` crate) + +### Documentation Updates +- [ ] CHANGELOG.md (v0.4.0 section) +- [ ] README.md (semver syntax, lockfile usage, alias commands) +- [ ] ROADMAP.md (mark v0.4.0 as complete) + +### Dependencies +- `semver` crate for version constraint parsing + +--- + +## Checkpoints (tied to deliverables) + +- [ ] Semver constraint parsing compiles and passes tests +- [ ] Install with semver constraint works end-to-end +- [ ] `gitclaw lock` generates valid TOML lockfile +- [ ] `gitclaw install --locked` reproduces exact versions +- [ ] Alias add/list/remove cycle works +- [ ] `gitclaw install ` resolves and installs correctly +- [ ] Final review: all features integrated, documented + +## Lessons (add to AGENTS.md after merge) + +**What went well:** + +**What could improve:** + +**Lessons learned:** \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c5d8d0c..2baeb1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,10 +86,12 @@ Verify → Test → Build ## Spec-Driven Development 1. Create `.specs/feature-name.md` from TEMPLATE.md before coding -2. Define acceptance criteria and test plan -3. Review spec with user before implementation -4. Checkpoint at 25%, 50%, 75% for feedback -5. Delete spec after merge (gitignored, temporary) +2. Small fixes: skip spec, write a clear PR description +3. Features: full spec with acceptance criteria +4. Review spec with user before implementation +5. Checkpoints tied to deliverables, not percentages +6. Keep specs in git — archive after merge +7. Post-mortem lessons go to AGENTS.md, not the spec ## PR Discipline @@ -101,18 +103,21 @@ Verify → Test → Build ## Definition of Done - [ ] Code complete -- [ ] Tests pass (`cargo test`) -- [ ] Lint clean (`cargo fmt && cargo clippy`) +- [ ] `cargo test` passes +- [ ] `cargo clippy -- -D warnings` clean +- [ ] `cargo fmt --check` clean +- [ ] Run all three locally before pushing - [ ] Documentation updated (CHANGELOG, README if needed) - [ ] Manual verification done - [ ] PR opened and reviewed ## Post-Mortems -After any significant issue or rework, document: +After rework or significant issues: - What went wrong - Root cause -- Prevention for next time +- Prevention +- Add to AGENTS.md so it persists *Last updated: 2026-04-23* diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be63af..ca1edda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-04-23 + +### Added +- Semver range support for install: `gitclaw install user/repo "^1.2.3"` +- Lockfile: `gitclaw lock` generates `gitclaw.lock` from installed packages +- Locked install: `gitclaw install --locked` reproduces exact versions from lockfile +- Package aliases: `gitclaw alias add rg BurntSushi/ripgrep` then `gitclaw install rg` +- `gitclaw alias list` and `gitclaw alias remove` commands +- `semver` crate dependency for version constraint parsing + ## [0.3.2] - 2026-04-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0aee123..081f924 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,7 +641,7 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gitclaw" -version = "0.3.1" +version = "0.4.0" dependencies = [ "anyhow", "assert_cmd", @@ -658,6 +658,7 @@ dependencies = [ "md5", "rand", "reqwest", + "semver", "serde", "serde_json", "sha2", @@ -1412,6 +1413,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.188" diff --git a/Cargo.toml b/Cargo.toml index 3b1ce24..a2df7f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitclaw" -version = "0.3.2" +version = "0.4.0" edition = "2021" [[bin]] @@ -40,6 +40,7 @@ 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 b8f60c7..09e3544 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,15 @@ cargo install --path . ```bash gcw install sharkdp/bat + +gcw install BurntSushi/ripgrep "^14" +gcw lock +gcw install --locked +gcw alias add rg BurntSushi/ripgrep +gcw install rg gcw list gcw update sharkdp/bat -gcw uninstall gitclaw +gcw uninstall bat ``` ## Configuration diff --git a/ROADMAP.md b/ROADMAP.md index 7568f01..370f1f6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ Planned features and improvements toward gitclaw 1.0.0. -## 0.4.0 — Dependency Management +## 0.4.0 — Dependency Management ✅ **Semver range support** diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e0b0cbf..80790c9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -24,8 +24,31 @@ pub struct Cli { pub token: Option, } +#[derive(Subcommand)] +pub enum AliasAction { + #[command(about = "Add a package alias.")] + Add { + #[arg(help = "Short alias name.")] + alias: String, + #[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 {}, +} + #[derive(Subcommand)] pub enum Commands { + #[command(about = "Manage package aliases.")] + Alias { + #[command(subcommand)] + action: AliasAction, + }, #[command(about = "Install packages from GitHub releases.")] Install { #[arg(num_args = 1.., help = "Package(s) to install (format: owner/repo or owner/repo@version).")] @@ -36,6 +59,18 @@ pub enum Commands { dry_run: bool, #[arg(long, help = "Verify checksums after download.")] verify: bool, + #[arg(long, help = "Install exact versions from gitclaw.lock.")] + locked: bool, + }, + #[command(about = "Generate a lockfile from installed packages.")] + Lock { + #[arg( + short, + long, + default_value = ".", + help = "Directory to write gitclaw.lock to." + )] + dir: String, }, #[command(about = "List installed packages.")] List { diff --git a/src/core/alias.rs b/src/core/alias.rs new file mode 100644 index 0000000..7044a20 --- /dev/null +++ b/src/core/alias.rs @@ -0,0 +1,215 @@ +use std::collections::HashMap; +use std::fs; + +use anyhow::{bail, Context, Result}; +use colored::Colorize; +use serde::{Deserialize, Serialize}; + +use crate::core::config::Config; + +#[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") + } + + 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") + } + + pub fn resolve(&self, name: &str) -> Option<&str> { + self.aliases.get(name).map(|s| s.as_str()) + } + + pub fn add(&mut self, alias: &str, target: &str, _config: &Config) -> Result<()> { + if alias.contains('/') { + bail!( + "Alias '{}' cannot contain '/'. Use a short name without slashes.", + alias + ); + } + + if target.contains('/') && target.split('/').count() != 2 { + bail!("Target '{}' must be in owner/repo format.", target); + } + + if let Some(existing) = self.aliases.get(alias) { + if existing == target { + bail!("Alias '{}' already points to '{}'.", alias, target); + } + } + + self.aliases.insert(alias.to_string(), target.to_string()); + Ok(()) + } + + 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 { + return Some(format!("{}/{}", pkg.owner, pkg.repo)); + } + } + } + None + } + + pub fn remove(&mut self, alias: &str) -> bool { + self.aliases.remove(alias).is_some() + } + + pub fn list(&self) -> Vec<(&String, &String)> { + let mut entries: Vec<_> = self.aliases.iter().collect(); + entries.sort_by_key(|(k, _)| *k); + entries + } +} + +pub fn handle_alias_add(alias: &str, target: &str, config: &Config) -> Result<()> { + let mut aliases = AliasMap::load(config)?; + + if let Some(clash) = aliases.check_clash(alias, config) { + crate::output::print_warn(&format!( + "Warning: alias '{}' matches installed package '{}'.", + alias, clash + )); + } + + aliases.add(alias, target, config)?; + aliases.save(config)?; + crate::output::print_success(&format!("Alias '{}' -> '{}' added.", alias, target)); + Ok(()) +} + +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(()) +} + +pub fn handle_alias_list(config: &Config) -> Result<()> { + let aliases = AliasMap::load(config)?; + let entries = aliases.list(); + + if entries.is_empty() { + crate::output::print_info("No aliases configured."); + crate::output::print_info("Use 'gitclaw alias add ' to create one."); + return Ok(()); + } + + println!("{}", format!("{:<20} {}", "Alias", "Target").bold()); + + for (alias, target) in &entries { + println!("{:<20} {}", alias.cyan(), target); + } + + println!(); + crate::output::print_info(&format!("{} alias(es) configured.", entries.len())); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_alias_add() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + install_dir: dir.path().to_path_buf(), + ..Config::default() + }; + let mut aliases = AliasMap::default(); + aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); + assert_eq!(aliases.resolve("rg"), Some("BurntSushi/ripgrep")); + } + + #[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 mut aliases = AliasMap::default(); + assert!(aliases + .add("owner/repo", "BurntSushi/ripgrep", &config) + .is_err()); + } + + #[test] + fn test_alias_remove() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + install_dir: dir.path().to_path_buf(), + ..Config::default() + }; + let mut aliases = AliasMap::default(); + aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); + assert!(aliases.remove("rg")); + assert!(!aliases.remove("rg")); + assert_eq!(aliases.resolve("rg"), None); + } + + #[test] + fn test_alias_resolve_missing() { + let aliases = AliasMap::default(); + assert_eq!(aliases.resolve("nonexistent"), None); + } + + #[test] + fn test_alias_list_sorted() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + install_dir: dir.path().to_path_buf(), + ..Config::default() + }; + let mut aliases = 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[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_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let config = Config { + install_dir: dir.path().to_path_buf(), + ..Config::default() + }; + + let mut aliases = AliasMap::default(); + aliases.add("rg", "BurntSushi/ripgrep", &config).unwrap(); + aliases.add("fd", "sharkdp/fd", &config).unwrap(); + aliases.save(&config).unwrap(); + + let loaded = AliasMap::load(&config).unwrap(); + assert_eq!(loaded.resolve("rg"), Some("BurntSushi/ripgrep")); + assert_eq!(loaded.resolve("fd"), Some("sharkdp/fd")); + } +} diff --git a/src/core/install.rs b/src/core/install.rs index c2ec6e6..3b2473d 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -12,11 +12,13 @@ use crate::core::config::Config; use crate::core::constants::{APP_NAME, 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::network::github::{ find_matching_asset, parse_package, Asset, GithubClient, Platform, Release, }; use crate::output; +use semver::Version; pub async fn handle_install( package: &str, @@ -25,9 +27,34 @@ pub async fn handle_install( verify: bool, config: &Config, ) -> Result<()> { - let (owner, repo, version) = parse_package(package)?; + 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 (owner, repo, version) = parse_package(&resolved)?; let key = format!("{}/{}", owner, repo); + let aliases = crate::core::alias::AliasMap::load(config)?; + if let Some(alias_target) = aliases.resolve(&repo) { + if alias_target != key { + output::print_warn(&format!( + "Package repo '{}' conflicts with alias '{}' -> '{}'.", + repo, repo, alias_target + )); + output::print_info( + "The alias will resolve 'bat' to the aliased target, not this package.", + ); + } + } + drop(aliases); + let registry_path = registry_path_from(&config.install_dir); let mut reg = Registry::load_from(®istry_path)?; @@ -43,7 +70,14 @@ pub async fn handle_install( let client = GithubClient::new(config.github_token.clone())?; let release = match &version { - Some(v) => client.get_release(&owner, &repo, v).await?, + Some(v) => { + if is_semver_constraint(v) { + let constraint = VersionConstraint::parse(v)?; + find_matching_release(&client, &owner, &repo, &constraint).await? + } else { + client.get_release(&owner, &repo, v).await? + } + } None => client.get_release(&owner, &repo, "latest").await?, }; @@ -187,7 +221,7 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { )); } - crate::core::registry::uninstall(package, &config.install_dir)?; + crate::core::registry::uninstall(package, &config.install_dir, config)?; handle_install(package, false, false, false, config).await } @@ -359,3 +393,55 @@ pub async fn handle_install_multiple( Ok(()) } + +fn is_semver_constraint(version: &str) -> bool { + version.starts_with('^') + || version.starts_with('~') + || version.starts_with('>') + || version.starts_with('<') + || version.starts_with('=') +} + +async fn find_matching_release( + client: &GithubClient, + owner: &str, + repo: &str, + constraint: &VersionConstraint, +) -> Result { + let releases = client.get_releases(owner, repo).await?; + + let mut matching: Vec = releases + .into_iter() + .filter(|r| { + parse_tag_version(&r.tag_name) + .map(|v| constraint.matches(&v)) + .unwrap_or(false) + }) + .collect(); + + if matching.is_empty() { + bail!( + "No release matching '{}' found for {}/{}.", + constraint_display(constraint), + owner, + repo + ); + } + + 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()); + vb.cmp(&va) + }); + + Ok(matching.into_iter().next().unwrap()) +} + +fn constraint_display(constraint: &VersionConstraint) -> String { + match constraint { + VersionConstraint::Exact(v) => format!("= {}", v), + VersionConstraint::Range(r) => r.to_string(), + } +} diff --git a/src/core/lockfile.rs b/src/core/lockfile.rs new file mode 100644 index 0000000..8472f79 --- /dev/null +++ b/src/core/lockfile.rs @@ -0,0 +1,226 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::core::registry::Registry; +use crate::core::util::registry_path_from; +use crate::output; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LockEntry { + pub owner: String, + pub repo: String, + pub version: String, + pub asset: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Lockfile { + #[serde(rename = "package")] + pub packages: Vec, +} + +const LOCKFILE_NAME: &str = "gitclaw.lock"; + +impl Lockfile { + pub fn from_registry(registry: &Registry) -> Self { + let packages = registry + .packages + .values() + .map(|p| LockEntry { + owner: p.owner.clone(), + repo: p.repo.clone(), + version: p.version.clone(), + asset: p.asset_name.clone(), + }) + .collect(); + + Lockfile { packages } + } + + pub fn load(dir: &Path) -> Result { + let path = dir.join(LOCKFILE_NAME); + let content = fs::read_to_string(&path).with_context(|| "Failed to read lockfile")?; + toml::from_str(&content).with_context(|| "Failed to parse lockfile") + } + + pub fn save(&self, dir: &Path) -> Result<()> { + let path = dir.join(LOCKFILE_NAME); + let content = + toml::to_string_pretty(self).with_context(|| "Failed to serialize lockfile")?; + fs::write(&path, content).with_context(|| "Failed to write lockfile") + } + + pub fn is_present(dir: &Path) -> bool { + dir.join(LOCKFILE_NAME).exists() + } +} + +pub fn generate_lockfile(install_dir: &Path, project_dir: &Path) -> 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. Nothing to lock."); + return Ok(()); + } + + let lockfile = Lockfile::from_registry(®); + lockfile.save(project_dir)?; + + output::print_success(&format!( + "Lockfile written with {} package(s).", + lockfile.packages.len() + )); + Ok(()) +} + +pub async fn install_locked(config: &crate::core::config::Config) -> Result<()> { + let project_dir = std::env::current_dir()?; + + if !Lockfile::is_present(&project_dir) { + anyhow::bail!( + "No gitclaw.lock found in {}. Run 'gitclaw lock' first.", + project_dir.display() + ); + } + + let lockfile = Lockfile::load(&project_dir)?; + + if lockfile.packages.is_empty() { + output::print_info("Lockfile is empty. Nothing to install."); + return Ok(()); + } + + output::print_info(&format!( + "Installing {} package(s) from lockfile.", + lockfile.packages.len() + )); + + for entry in &lockfile.packages { + let package_spec = format!("{}/{}@{}", entry.owner, entry.repo, entry.version); + crate::core::install::handle_install(&package_spec, false, false, false, config).await?; + } + + output::print_success("All locked packages installed."); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::core::registry::{InstalledPackage, Registry}; + use tempfile; + + 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(), + } + } + + use std::path::PathBuf; + + #[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 = tempfile::tempdir().unwrap(); + + let lockfile = Lockfile { + packages: vec![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![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_is_present() { + let dir = tempfile::tempdir().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/src/core/mod.rs b/src/core/mod.rs index 7510404..4bd81ce 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,8 +1,11 @@ +pub mod alias; pub mod checksum; pub mod config; pub mod constants; pub mod extract; pub mod install; +pub mod lockfile; pub mod registry; +pub mod semver; pub mod updater; pub mod util; diff --git a/src/core/registry.rs b/src/core/registry.rs index 25ac7ce..6b3ccdd 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -7,6 +7,7 @@ use colored::Colorize; use serde::{Deserialize, Serialize}; use tracing::debug; +use crate::core::config::Config; use crate::core::constants::APP_NAME_SHORT; use crate::core::util::registry_path_from; use crate::output; @@ -169,7 +170,7 @@ pub fn list_installed(verbose: bool, install_dir: &Path) -> Result<()> { Ok(()) } -pub fn uninstall(package: &str, install_dir: &Path) -> Result<()> { +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)?; @@ -177,10 +178,19 @@ pub fn uninstall(package: &str, install_dir: &Path) -> Result<()> { let (owner, repo, _) = crate::network::github::parse_package(package)?; format!("{}/{}", owner, repo) } else { + let resolved = 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() + }; + let matches: Vec<_> = reg .packages .values() - .filter(|p| p.identifier == package || p.repo == package) + .filter(|p| p.identifier == resolved || p.repo == resolved) .map(|p| p.name.clone()) .collect(); diff --git a/src/core/semver.rs b/src/core/semver.rs new file mode 100644 index 0000000..726e01a --- /dev/null +++ b/src/core/semver.rs @@ -0,0 +1,108 @@ +use anyhow::{bail, Result}; +use semver::{Version, VersionReq}; + +pub enum VersionConstraint { + Exact(Version), + Range(VersionReq), +} + +impl VersionConstraint { + pub fn parse(input: &str) -> Result { + let trimmed = input.trim(); + + if trimmed.starts_with('^') + || trimmed.starts_with('~') + || trimmed.starts_with('>') + || trimmed.starts_with('<') + || trimmed.starts_with('=') + { + let req = VersionReq::parse(trimmed) + .map_err(|e| anyhow::anyhow!("Invalid semver range '{}': {}.", trimmed, e))?; + return Ok(VersionConstraint::Range(req)); + } + + if let Ok(v) = Version::parse(trimmed) { + return Ok(VersionConstraint::Exact(v)); + } + + if let Ok(req) = VersionReq::parse(trimmed) { + return Ok(VersionConstraint::Range(req)); + } + + bail!("Cannot parse '{}' as a version or semver range.", trimmed); + } + + pub fn matches(&self, version: &Version) -> bool { + match self { + VersionConstraint::Exact(v) => v == version, + VersionConstraint::Range(req) => req.matches(version), + } + } +} + +pub fn strip_v_prefix(tag: &str) -> &str { + tag.strip_prefix('v').unwrap_or(tag) +} + +pub fn parse_tag_version(tag: &str) -> Result { + let raw = strip_v_prefix(tag); + Version::parse(raw) + .map_err(|e| anyhow::anyhow!("Cannot parse version from tag '{}': {}.", tag, e)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_version() { + let c = VersionConstraint::parse("1.2.3").unwrap(); + assert!(c.matches(&Version::parse("1.2.3").unwrap())); + assert!(!c.matches(&Version::parse("1.2.4").unwrap())); + } + + #[test] + fn test_caret_range() { + let c = VersionConstraint::parse("^1.2.3").unwrap(); + assert!(c.matches(&Version::parse("1.2.3").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(c.matches(&Version::parse("1.3.0").unwrap())); + assert!(!c.matches(&Version::parse("2.0.0").unwrap())); + } + + #[test] + fn test_tilde_range() { + let c = VersionConstraint::parse("~1.2.3").unwrap(); + assert!(c.matches(&Version::parse("1.2.3").unwrap())); + assert!(c.matches(&Version::parse("1.2.9").unwrap())); + assert!(!c.matches(&Version::parse("1.3.0").unwrap())); + } + + #[test] + fn test_gte_range() { + let c = VersionConstraint::parse(">=1.0.0").unwrap(); + assert!(c.matches(&Version::parse("1.0.0").unwrap())); + assert!(c.matches(&Version::parse("2.0.0").unwrap())); + assert!(!c.matches(&Version::parse("0.9.0").unwrap())); + } + + #[test] + fn test_strip_v() { + 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_parse_tag() { + 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_invalid_constraint() { + assert!(VersionConstraint::parse("not-a-version").is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index e27275a..c279926 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,12 +3,15 @@ pub mod core; pub mod network; pub mod output; +pub use core::alias; pub use core::checksum; pub use core::config; pub use core::constants; pub use core::extract; pub use core::install; +pub use core::lockfile; pub use core::registry; +pub use core::semver; pub use core::updater; pub use core::util; pub use network::github; diff --git a/src/main.rs b/src/main.rs index 111ba83..4fda2d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ mod core; mod network; mod output; -use cli::{Cli, Commands}; +use cli::{AliasAction, Cli, Commands}; use core::config::Config; use core::constants::{APP_NAME, APP_NAME_SHORT, DIR_BIN}; use core::registry::Registry; @@ -57,6 +57,7 @@ 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::Lock { .. } | Commands::List { .. } | Commands::Update { .. } | Commands::Uninstall { .. } @@ -64,21 +65,35 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { | Commands::Completions { .. } | Commands::Platform { .. } | Commands::SelfUpdate { .. } - | Commands::Run { .. } => { + | Commands::Run { .. } + | Commands::Alias { .. } => { output::print_version_line(); } } match cli.command { + Commands::Alias { action } => { + output::print_output_header(); + match action { + AliasAction::Add { alias, target } => { + core::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)?, + } + } Commands::Install { packages, force, dry_run, verify, + locked, } => { output::print_output_header(); - if packages.len() == 1 { + if locked { + core::lockfile::install_locked(&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) @@ -86,6 +101,11 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { } } + Commands::Lock { dir } => { + output::print_output_header(); + let project_dir = std::path::PathBuf::from(dir); + core::lockfile::generate_lockfile(&config.install_dir, &project_dir)? + } Commands::List { verbose } => { output::print_output_header(); core::registry::list_installed(verbose, &config.install_dir)? @@ -98,7 +118,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { Commands::Uninstall { package } => { output::print_output_header(); - core::registry::uninstall(&package, &config.install_dir)? + core::registry::uninstall(&package, &config.install_dir, &config)? } Commands::Search { package, limit } => { @@ -147,8 +167,19 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { } async fn run_package(package: &str, args: Vec, config: &Config) -> anyhow::Result<()> { - let (owner, repo) = if package.contains('/') { - let parts: Vec<&str> = package.split('/').collect(); + 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 (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'."); } @@ -160,20 +191,20 @@ async fn run_package(package: &str, args: Vec, config: &Config) -> anyho let matches: Vec<_> = reg .packages .values() - .filter(|p| p.repo == package) + .filter(|p| p.repo == resolved) .collect(); match matches.len() { 0 => bail!( "Package '{}' not installed. Use '{} install owner/{}' first.", - package, + resolved, APP_NAME, - package + resolved ), 1 => (matches[0].owner.clone(), matches[0].repo.clone()), _ => bail!( "Multiple packages named '{}'. Use full name (owner/repo).", - package + resolved ), } }; diff --git a/src/network/github.rs b/src/network/github.rs index 4543455..ceb4ff6 100644 --- a/src/network/github.rs +++ b/src/network/github.rs @@ -230,7 +230,7 @@ impl GithubClient { }) } - async fn get_releases( + pub async fn get_releases( &self, owner: &str, repo: &str, diff --git a/tests/alias.rs b/tests/alias.rs new file mode 100644 index 0000000..1970f09 --- /dev/null +++ b/tests/alias.rs @@ -0,0 +1,106 @@ +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/lockfile.rs b/tests/lockfile.rs new file mode 100644 index 0000000..383b3d0 --- /dev/null +++ b/tests/lockfile.rs @@ -0,0 +1,107 @@ +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(), + } +} + +#[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/semver.rs b/tests/semver.rs new file mode 100644 index 0000000..8cb7ed1 --- /dev/null +++ b/tests/semver.rs @@ -0,0 +1,68 @@ +use gitclaw::semver::VersionConstraint; + +#[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())); +} + +#[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())); +} + +#[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())); +} + +#[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())); +} + +#[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"); +} + +#[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()); +} + +#[test] +fn test_semver_invalid_constraint() { + assert!(VersionConstraint::parse("not-a-version").is_err()); +} + +#[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())); +} + +#[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())); +}