diff --git a/.specs/v0.4.0-roadmap.md b/.specs/v0.4.0-roadmap.md deleted file mode 100644 index 91fe3b0..0000000 --- a/.specs/v0.4.0-roadmap.md +++ /dev/null @@ -1,110 +0,0 @@ -# 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 2baeb1d..14bf07a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,7 +90,7 @@ Verify → Test → Build 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 +6. Specs are temporary planning artifacts — delete after merge 7. Post-mortem lessons go to AGENTS.md, not the spec ## PR Discipline diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6fa13..8ee531b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-04-23 + +### Added +- Release channels: `gitclaw install user/repo --channel nightly|beta|stable` +- `gitclaw search user/repo --channel ` filters releases by channel +- Channel pattern overrides in `.gitclaw.toml` under `[channels]` +- `gitclaw export` outputs installed packages as TOML to stdout +- `gitclaw export -o deps.toml` writes to file +- `gitclaw import deps.toml` installs all packages from a TOML file +- Import skips already-installed packages unless `--force` is set +- Export output is deterministic: sorted by owner then repo +- New modules: `src/core/channel.rs`, `src/core/export.rs` + ## [0.5.0] - 2026-04-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1cf2514..a5e0fdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -641,7 +641,7 @@ checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" [[package]] name = "gitclaw" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index d6e0065..eaa8feb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gitclaw" -version = "0.5.0" +version = "0.6.0" edition = "2021" [[bin]] diff --git a/README.md b/README.md index 668c41c..e9b608c 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,12 @@ gcw cache clean gcw install --local sharkdp/bat # project-local install gcw uninstall --local bat + +gcw install BurntSushi/ripgrep --channel nightly +gcw search sharkdp/fd --channel beta + +gcw export -o deps.toml +gcw import deps.toml ``` ## Configuration diff --git a/ROADMAP.md b/ROADMAP.md index 370f1f6..1a5d8c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -31,7 +31,7 @@ gitclaw alias rg BurntSushi/ripgrep gitclaw install rg ``` -## 0.5.0 — User Experience +## 0.5.0 — User Experience ✅ ✅ **Asset caching** @@ -58,7 +58,7 @@ Project-scoped installation isolated from the global registry: gitclaw install --local user/repo ``` -## 0.6.0 — Advanced Features +## 0.6.0 — Advanced Features ✅ **Release channels** diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 89852d3..4f14c44 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -76,6 +76,8 @@ pub enum Commands { locked: bool, #[arg(long, help = "Install to project-local .gitclaw/ directory.")] local: bool, + #[arg(long, help = "Release channel: stable, beta, or nightly.")] + channel: Option, }, #[command(about = "Generate a lockfile from installed packages.")] Lock { @@ -117,6 +119,20 @@ pub enum Commands { help = "Maximum number of releases to show." )] limit: usize, + #[arg(long, help = "Filter by release channel: stable, beta, or nightly.")] + channel: Option, + }, + #[command(about = "Export installed packages to TOML.")] + Export { + #[arg(short, long, help = "Output file (default: stdout).")] + output: Option, + }, + #[command(about = "Install packages from a TOML file.")] + Import { + #[arg(help = "TOML file to import packages from.")] + file: String, + #[arg(long, help = "Force reinstall already-installed packages.")] + force: bool, }, #[command(about = "Generate shell completions.")] Completions { diff --git a/src/core/channel.rs b/src/core/channel.rs new file mode 100644 index 0000000..9a66574 --- /dev/null +++ b/src/core/channel.rs @@ -0,0 +1,250 @@ +use std::collections::HashMap; +use std::str::FromStr; + +use anyhow::{bail, Result}; +use serde::{Deserialize, Serialize}; + +use crate::network::github::Release; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Channel { + Stable, + Beta, + Nightly, +} + +impl std::fmt::Display for Channel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Channel::Stable => write!(f, "stable"), + Channel::Beta => write!(f, "beta"), + Channel::Nightly => write!(f, "nightly"), + } + } +} + +impl FromStr for Channel { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "stable" => Ok(Channel::Stable), + "beta" => Ok(Channel::Beta), + "nightly" => Ok(Channel::Nightly), + other => bail!( + "Unknown channel: '{}'. Use stable, beta, or nightly.", + other + ), + } + } +} + +impl Channel { + pub fn default_patterns(&self) -> Vec { + match self { + Channel::Stable => vec!["!*-*".to_string()], + Channel::Beta => vec!["*-beta".to_string(), "*-rc*".to_string()], + Channel::Nightly => vec!["*-nightly".to_string(), "*-dev".to_string()], + } + } + + pub fn patterns_with_overrides( + &self, + overrides: Option<&HashMap>>, + ) -> Vec { + if let Some(over) = overrides { + if let Some(patterns) = over.get(&self.to_string()) { + return patterns.clone(); + } + } + self.default_patterns() + } +} + +pub fn matches_channel(tag: &str, patterns: &[String]) -> bool { + if patterns.is_empty() { + return false; + } + + for pattern in patterns { + if let Some(exclude) = pattern.strip_prefix('!') { + if glob_match(tag, exclude) { + return false; + } + } else if glob_match(tag, pattern) { + return true; + } + } + + // If all patterns are exclusions, tag passes unless excluded + let all_exclusions = patterns.iter().all(|p| p.starts_with('!')); + if all_exclusions { + return true; + } + + false +} + +fn glob_match(text: &str, pattern: &str) -> bool { + if pattern == "*" { + return true; + } + + let starts_with = pattern.starts_with('*'); + let ends_with = pattern.ends_with('*'); + + match (starts_with, ends_with) { + (true, true) => { + let inner = &pattern[1..pattern.len() - 1]; + text.contains(inner) + } + (true, false) => { + let suffix = &pattern[1..]; + text.ends_with(suffix) + } + (false, true) => { + let prefix = &pattern[..pattern.len() - 1]; + text.starts_with(prefix) + } + (false, false) => text == pattern, + } +} + +pub fn filter_releases( + releases: &[Release], + channel: Channel, + overrides: Option<&HashMap>>, +) -> Vec { + let patterns = channel.patterns_with_overrides(overrides); + + releases + .iter() + .filter(|r| matches_channel(&r.tag_name, &patterns)) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_channel_from_str() { + assert_eq!("stable".parse::().unwrap(), Channel::Stable); + assert_eq!("beta".parse::().unwrap(), Channel::Beta); + assert_eq!("nightly".parse::().unwrap(), Channel::Nightly); + assert_eq!("STABLE".parse::().unwrap(), Channel::Stable); + assert!("unknown".parse::().is_err()); + } + + #[test] + fn test_channel_display() { + assert_eq!(Channel::Stable.to_string(), "stable"); + assert_eq!(Channel::Beta.to_string(), "beta"); + assert_eq!(Channel::Nightly.to_string(), "nightly"); + } + + #[test] + fn test_glob_match_exact() { + assert!(glob_match("1.0.0-beta", "1.0.0-beta")); + assert!(!glob_match("1.0.0-beta", "2.0.0-beta")); + } + + #[test] + fn test_glob_match_wildcard_prefix() { + assert!(glob_match("v1.0.0-nightly", "*-nightly")); + assert!(glob_match("v2.0.0-rc1", "*-rc*")); + assert!(!glob_match("v1.0.0", "*-nightly")); + } + + #[test] + fn test_glob_match_wildcard_suffix() { + assert!(glob_match("v1.0.0-rc1", "v1.0.0-*")); + assert!(!glob_match("v2.0.0-beta", "v1.0.0-*")); + } + + #[test] + fn test_glob_match_star() { + assert!(glob_match("anything", "*")); + } + + #[test] + fn test_matches_channel_beta() { + let patterns = Channel::Beta.default_patterns(); + assert!(matches_channel("v1.0.0-beta", &patterns)); + assert!(matches_channel("v1.0.0-rc1", &patterns)); + assert!(matches_channel("v1.0.0-rc2", &patterns)); + assert!(!matches_channel("v1.0.0", &patterns)); + assert!(!matches_channel("v1.0.0-nightly", &patterns)); + } + + #[test] + fn test_matches_channel_nightly() { + let patterns = Channel::Nightly.default_patterns(); + assert!(matches_channel("v1.0.0-nightly", &patterns)); + assert!(matches_channel("v1.0.0-dev", &patterns)); + assert!(!matches_channel("v1.0.0", &patterns)); + assert!(!matches_channel("v1.0.0-beta", &patterns)); + } + + #[test] + fn test_matches_channel_stable_exclusion() { + let patterns = Channel::Stable.default_patterns(); + assert!(matches_channel("v1.0.0", &patterns)); + assert!(!matches_channel("v1.0.0-beta", &patterns)); + assert!(!matches_channel("v1.0.0-nightly", &patterns)); + assert!(!matches_channel("v1.0.0-rc1", &patterns)); + } + + #[test] + fn test_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()); + } + + #[test] + fn test_filter_releases() { + let releases = vec![ + Release { + tag_name: "v1.0.0".to_string(), + name: None, + body: None, + assets: vec![], + }, + Release { + tag_name: "v1.1.0-beta".to_string(), + name: None, + body: None, + assets: vec![], + }, + Release { + tag_name: "v1.0.1-nightly".to_string(), + name: None, + body: None, + assets: vec![], + }, + ]; + + let stable = filter_releases(&releases, Channel::Stable, None); + assert_eq!(stable.len(), 1); + assert_eq!(stable[0].tag_name, "v1.0.0"); + + let beta = filter_releases(&releases, Channel::Beta, None); + assert_eq!(beta.len(), 1); + assert_eq!(beta[0].tag_name, "v1.1.0-beta"); + + let nightly = filter_releases(&releases, Channel::Nightly, None); + assert_eq!(nightly.len(), 1); + assert_eq!(nightly[0].tag_name, "v1.0.1-nightly"); + } +} diff --git a/src/core/export.rs b/src/core/export.rs new file mode 100644 index 0000000..1a1e74d --- /dev/null +++ b/src/core/export.rs @@ -0,0 +1,119 @@ +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::core::config::Config; +use crate::core::registry::Registry; +use crate::core::util::registry_path_from; +use crate::output; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ExportEntry { + pub owner: String, + pub repo: String, + pub version: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ExportFile { + #[serde(rename = "package")] + pub packages: Vec, +} + +impl ExportFile { + pub fn from_registry(registry: &Registry) -> Self { + let mut entries: Vec = registry + .packages + .values() + .map(|p| ExportEntry { + owner: p.owner.clone(), + repo: p.repo.clone(), + version: p.version.clone(), + }) + .collect(); + + entries.sort_by(|a, b| a.owner.cmp(&b.owner).then_with(|| a.repo.cmp(&b.repo))); + + ExportFile { packages: entries } + } + + pub fn to_toml(&self) -> Result { + toml::to_string_pretty(self).context("Serialize export file") + } + + pub fn from_toml(s: &str) -> Result { + toml::from_str(s).context("Parse export file") + } + + pub fn from_file(path: &Path) -> Result { + let content = fs::read_to_string(path) + .with_context(|| format!("Read export file {}", path.display()))?; + Self::from_toml(&content) + } +} + +pub fn handle_export(config: &Config, output_path: Option<&str>) -> Result<()> { + let registry_path = registry_path_from(&config.install_dir); + let reg = Registry::load_from(®istry_path)?; + + if reg.packages.is_empty() { + output::print_info("No packages installed. Nothing to export."); + return Ok(()); + } + + let export = ExportFile::from_registry(®); + let toml_str = export.to_toml()?; + + match output_path { + Some(path) => { + fs::write(path, &toml_str).with_context(|| format!("Write to {}", path))?; + output::print_success(&format!( + "Exported {} package(s) to {}.", + export.packages.len(), + path + )); + } + None => { + println!("{}", toml_str); + } + } + + Ok(()) +} + +pub async fn handle_import(config: &Config, file: &str, force: bool) -> Result<()> { + let path = Path::new(file); + let export = ExportFile::from_file(path)?; + + if export.packages.is_empty() { + output::print_info("No packages found in import file."); + return Ok(()); + } + + output::print_info(&format!( + "Importing {} package(s) from {}.", + export.packages.len(), + file + )); + + for entry in &export.packages { + let spec = format!("{}/{}@{}", entry.owner, entry.repo, entry.version); + + if !force { + let registry_path = registry_path_from(&config.install_dir); + let reg = Registry::load_from(®istry_path)?; + let key = format!("{}/{}", entry.owner, entry.repo); + + if reg.is_installed(&key) { + output::print_info(&format!("{} already installed. Skipping.", key)); + continue; + } + } + + crate::core::install::handle_install(&spec, force, false, false, config, None).await?; + } + + Ok(()) +} diff --git a/src/core/install.rs b/src/core/install.rs index 632ba6b..1e26857 100644 --- a/src/core/install.rs +++ b/src/core/install.rs @@ -26,6 +26,7 @@ pub async fn handle_install( dry_run: bool, verify: bool, config: &Config, + channel: Option, ) -> Result<()> { let resolved = if !package.contains('/') { if let Some(alias_target) = crate::core::alias::AliasMap::load(config)?.resolve(package) { @@ -69,8 +70,16 @@ pub async fn handle_install( let client = GithubClient::new(config.github_token.clone())?; - let release = match &version { - Some(v) => { + let release = match (&version, channel) { + (_, Some(ch)) => { + let releases = client.get_releases(&owner, &repo).await?; + let filtered = crate::core::channel::filter_releases(&releases, ch, None); + if filtered.is_empty() { + bail!("No {} release found for {}/{}.", ch, owner, repo); + } + filtered.into_iter().next().unwrap() + } + (Some(v), None) => { if is_semver_constraint(v) { let constraint = VersionConstraint::parse(v)?; find_matching_release(&client, &owner, &repo, &constraint).await? @@ -78,7 +87,7 @@ pub async fn handle_install( client.get_release(&owner, &repo, v).await? } } - None => client.get_release(&owner, &repo, "latest").await?, + (None, None) => client.get_release(&owner, &repo, "latest").await?, }; let asset = select_best_asset(&release)?; @@ -239,7 +248,7 @@ async fn update_one(package: &str, config: &Config) -> Result<()> { } crate::core::registry::uninstall(package, &config.install_dir, config)?; - handle_install(package, false, false, false, config).await + handle_install(package, false, false, false, config, None).await } async fn update_all(config: &Config) -> Result<()> { @@ -355,6 +364,7 @@ pub async fn handle_install_multiple( dry_run: bool, verify: bool, config: &Config, + channel: Option, ) -> Result<()> { let total = packages.len(); @@ -367,10 +377,11 @@ pub async fn handle_install_multiple( for pkg in packages { let pkg = pkg.clone(); let config = config.clone(); + let ch = channel; let task = tokio::spawn( - async move { handle_install(&pkg, force, dry_run, verify, &config).await }, + async move { handle_install(&pkg, force, dry_run, verify, &config, ch).await }, ); tasks.push(task); diff --git a/src/core/lockfile.rs b/src/core/lockfile.rs index 8472f79..b8936ed 100644 --- a/src/core/lockfile.rs +++ b/src/core/lockfile.rs @@ -101,7 +101,8 @@ pub async fn install_locked(config: &crate::core::config::Config) -> Result<()> 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?; + crate::core::install::handle_install(&package_spec, false, false, false, config, None) + .await?; } output::print_success("All locked packages installed."); diff --git a/src/core/mod.rs b/src/core/mod.rs index 3b5db4e..7b94689 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,8 +1,10 @@ pub mod alias; pub mod cache; +pub mod channel; pub mod checksum; pub mod config; pub mod constants; +pub mod export; pub mod extract; pub mod install; pub mod lockfile; diff --git a/src/lib.rs b/src/lib.rs index dad795c..7195e96 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,9 +5,11 @@ pub mod output; pub use core::alias; pub use core::cache; +pub use core::channel; pub use core::checksum; pub use core::config; pub use core::constants; +pub use core::export; pub use core::extract; pub use core::install; pub use core::lockfile; diff --git a/src/main.rs b/src/main.rs index 5297b5a..4b820a3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,8 @@ fn apply_cli_overrides(mut config: Config, cli: &Cli) -> Config { async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { match &cli.command { Commands::Cache { .. } + | Commands::Export { .. } + | Commands::Import { .. } | Commands::Install { .. } | Commands::Lock { .. } | Commands::List { .. } @@ -97,6 +99,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { verify, locked, local, + channel, } => { output::print_output_header(); @@ -108,11 +111,23 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { config.clone() }; + let ch = match channel.as_deref() { + Some(c) => Some(c.parse::()?), + None => None, + }; + if locked { core::lockfile::install_locked(&install_config).await? } else if packages.len() == 1 { - core::install::handle_install(&packages[0], force, dry_run, verify, &install_config) - .await? + core::install::handle_install( + &packages[0], + force, + dry_run, + verify, + &install_config, + ch, + ) + .await? } else { core::install::handle_install_multiple( &packages, @@ -120,6 +135,7 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { dry_run, verify, &install_config, + ch, ) .await? } @@ -155,9 +171,29 @@ async fn run(cli: Cli, config: Config) -> anyhow::Result<()> { core::registry::uninstall(&package, &install_dir, &config)? } - Commands::Search { package, limit } => { + Commands::Search { + package, + limit, + channel, + } => { + output::print_output_header(); + let ch = match channel.as_deref() { + Some(c) => Some(c.parse::()?), + None => None, + }; + network::github::search_releases(&package, limit, &config, ch).await? + } + + Commands::Export { + output: output_path, + } => { + output::print_output_header(); + core::export::handle_export(&config, output_path.as_deref())? + } + + Commands::Import { file, force } => { output::print_output_header(); - network::github::search_releases(&package, limit, &config).await? + core::export::handle_import(&config, &file, force).await? } Commands::Completions { shell } => { diff --git a/src/network/github.rs b/src/network/github.rs index ceb4ff6..64cadc2 100644 --- a/src/network/github.rs +++ b/src/network/github.rs @@ -429,7 +429,12 @@ pub fn parse_package(input: &str) -> Result<(String, String, Option)> { Ok((parts[0].to_string(), parts[1].to_string(), version)) } -pub async fn search_releases(package: &str, limit: usize, config: &Config) -> Result<()> { +pub async fn search_releases( + package: &str, + limit: usize, + config: &Config, + channel: Option, +) -> Result<()> { let (owner, repo, _) = parse_package(package)?; let client = GithubClient::new(config.github_token.clone())?; @@ -452,7 +457,11 @@ pub async fn search_releases(package: &str, limit: usize, config: &Config) -> Re .map(|s| s.contains("rel=\"next\"")) .unwrap_or(false); - let releases: Vec = resp.json().await?; + let mut releases: Vec = resp.json().await?; + + if let Some(ch) = channel { + releases = crate::core::channel::filter_releases(&releases, ch, None); + } if releases.is_empty() { output::print_info("No releases found."); diff --git a/tests/channel.rs b/tests/channel.rs new file mode 100644 index 0000000..b145d34 --- /dev/null +++ b/tests/channel.rs @@ -0,0 +1,108 @@ +use gitclaw::channel::{filter_releases, matches_channel, Channel}; +use gitclaw::network::github::Release; + +#[test] +fn test_channel_stable_matches_plain_tag() { + let patterns = Channel::Stable.default_patterns(); + assert!(matches_channel("v1.0.0", &patterns)); + assert!(matches_channel("1.0.0", &patterns)); +} + +#[test] +fn test_channel_stable_excludes_prerelease() { + let patterns = Channel::Stable.default_patterns(); + assert!(!matches_channel("v1.0.0-beta", &patterns)); + assert!(!matches_channel("v1.0.0-nightly", &patterns)); + assert!(!matches_channel("v1.0.0-rc1", &patterns)); +} + +#[test] +fn test_channel_beta_matches() { + let patterns = Channel::Beta.default_patterns(); + assert!(matches_channel("v1.0.0-beta", &patterns)); + assert!(matches_channel("v1.0.0-rc1", &patterns)); + assert!(matches_channel("v1.0.0-rc2", &patterns)); +} + +#[test] +fn test_channel_beta_excludes_stable_and_nightly() { + let patterns = Channel::Beta.default_patterns(); + assert!(!matches_channel("v1.0.0", &patterns)); + assert!(!matches_channel("v1.0.0-nightly", &patterns)); +} + +#[test] +fn test_channel_nightly_matches() { + let patterns = Channel::Nightly.default_patterns(); + assert!(matches_channel("v1.0.0-nightly", &patterns)); + assert!(matches_channel("v1.0.0-dev", &patterns)); +} + +#[test] +fn test_channel_nightly_excludes_stable_and_beta() { + let patterns = Channel::Nightly.default_patterns(); + assert!(!matches_channel("v1.0.0", &patterns)); + assert!(!matches_channel("v1.0.0-beta", &patterns)); +} + +#[test] +fn test_filter_releases_by_channel() { + let releases = vec![ + Release { + tag_name: "v1.0.0".to_string(), + name: None, + body: None, + assets: vec![], + }, + Release { + tag_name: "v1.1.0-beta".to_string(), + name: None, + body: None, + assets: vec![], + }, + Release { + tag_name: "v1.0.1-nightly".to_string(), + name: None, + body: None, + assets: vec![], + }, + Release { + tag_name: "v2.0.0-rc1".to_string(), + name: None, + body: None, + assets: vec![], + }, + ]; + + let stable = filter_releases(&releases, Channel::Stable, None); + assert_eq!(stable.len(), 1); + assert_eq!(stable[0].tag_name, "v1.0.0"); + + let beta = filter_releases(&releases, Channel::Beta, None); + assert_eq!(beta.len(), 2); + + let nightly = filter_releases(&releases, Channel::Nightly, None); + assert_eq!(nightly.len(), 1); + assert_eq!(nightly[0].tag_name, "v1.0.1-nightly"); +} + +#[test] +fn test_channel_parse() { + let ch: Channel = "stable".parse().unwrap(); + assert_eq!(ch, Channel::Stable); + + let ch: Channel = "beta".parse().unwrap(); + assert_eq!(ch, Channel::Beta); + + let ch: Channel = "nightly".parse().unwrap(); + assert_eq!(ch, Channel::Nightly); + + assert!("unknown".parse::().is_err()); +} + +#[test] +fn test_channel_display() { + assert_eq!(Channel::Stable.to_string(), "stable"); + assert_eq!(Channel::Beta.to_string(), "beta"); + assert_eq!(Channel::Nightly.to_string(), "nightly"); +} diff --git a/tests/export_import.rs b/tests/export_import.rs new file mode 100644 index 0000000..39759aa --- /dev/null +++ b/tests/export_import.rs @@ -0,0 +1,181 @@ +use tempfile::TempDir; + +use gitclaw::config::Config; +use gitclaw::export::{ExportEntry, ExportFile}; +use gitclaw::registry::{InstalledPackage, Registry}; +use gitclaw::util::registry_path_from; + +use std::path::PathBuf; + +fn make_config() -> (Config, TempDir) { + let dir = TempDir::new().unwrap(); + let config = Config { + install_dir: dir.path().to_path_buf(), + ..Config::default() + }; + (config, dir) +} + +fn sample_pkg(name: &str, owner: &str, repo: &str, version: &str) -> InstalledPackage { + InstalledPackage { + name: name.to_string(), + owner: owner.to_string(), + repo: repo.to_string(), + version: version.to_string(), + installed_at: chrono::Utc::now().to_rfc3339(), + binary_path: PathBuf::from("/tmp/bin"), + install_dir: PathBuf::from("/tmp/install"), + asset_name: format!("{}-{}.tar.gz", repo, version), + identifier: repo.to_string(), + } +} + +#[test] +fn test_export_entry_serialization() { + let entry = ExportEntry { + owner: "BurntSushi".to_string(), + repo: "ripgrep".to_string(), + version: "13.0.0".to_string(), + }; + + let export = ExportFile { + packages: vec![entry], + }; + + let toml_str = export.to_toml().unwrap(); + assert!(toml_str.contains("BurntSushi")); + assert!(toml_str.contains("ripgrep")); + assert!(toml_str.contains("13.0.0")); +} + +#[test] +fn test_export_entry_deserialization() { + let toml_str = r#" +[[package]] +owner = "sharkdp" +repo = "fd" +version = "8.7.0" +"#; + + let export = ExportFile::from_toml(toml_str).unwrap(); + assert_eq!(export.packages.len(), 1); + assert_eq!(export.packages[0].owner, "sharkdp"); + assert_eq!(export.packages[0].repo, "fd"); + assert_eq!(export.packages[0].version, "8.7.0"); +} + +#[test] +fn test_export_roundtrip() { + let export = ExportFile { + packages: vec![ + ExportEntry { + owner: "BurntSushi".to_string(), + repo: "ripgrep".to_string(), + version: "13.0.0".to_string(), + }, + ExportEntry { + owner: "sharkdp".to_string(), + repo: "fd".to_string(), + version: "8.7.0".to_string(), + }, + ], + }; + + let toml_str = export.to_toml().unwrap(); + let reloaded = ExportFile::from_toml(&toml_str).unwrap(); + assert_eq!(reloaded.packages.len(), 2); + assert_eq!(reloaded.packages[0], export.packages[0]); + assert_eq!(reloaded.packages[1], export.packages[1]); +} + +#[test] +fn test_export_from_registry_sorted() { + let mut reg = Registry::default(); + reg.add(sample_pkg("sharkdp/fd", "sharkdp", "fd", "8.7.0")); + reg.add(sample_pkg( + "BurntSushi/ripgrep", + "BurntSushi", + "ripgrep", + "13.0.0", + )); + reg.add(sample_pkg("sharkdp/bat", "sharkdp", "bat", "0.24.0")); + + let export = ExportFile::from_registry(®); + // Sorted by owner then repo + assert_eq!(export.packages[0].owner, "BurntSushi"); + assert_eq!(export.packages[1].repo, "bat"); + assert_eq!(export.packages[2].repo, "fd"); +} + +#[test] +fn test_export_from_empty_registry() { + let reg = Registry::default(); + let export = ExportFile::from_registry(®); + assert!(export.packages.is_empty()); +} + +#[test] +fn test_export_file_write_and_read() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deps.toml"); + + let export = ExportFile { + packages: vec![ExportEntry { + owner: "BurntSushi".to_string(), + repo: "ripgrep".to_string(), + version: "13.0.0".to_string(), + }], + }; + + let toml_str = export.to_toml().unwrap(); + std::fs::write(&path, &toml_str).unwrap(); + + let loaded = ExportFile::from_file(&path).unwrap(); + assert_eq!(loaded.packages.len(), 1); + assert_eq!(loaded.packages[0].repo, "ripgrep"); +} + +#[test] +fn test_export_from_registry_with_config() { + let (config, _dir) = make_config(); + let reg_path = 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(sample_pkg( + "BurntSushi/ripgrep", + "BurntSushi", + "ripgrep", + "13.0.0", + )); + reg.save().unwrap(); + + let loaded = Registry::load_from(®_path).unwrap(); + let export = ExportFile::from_registry(&loaded); + assert_eq!(export.packages.len(), 1); +} + +#[test] +fn test_export_toml_format() { + let export = ExportFile { + packages: vec![ + ExportEntry { + owner: "sharkdp".to_string(), + repo: "bat".to_string(), + version: "0.24.0".to_string(), + }, + ExportEntry { + owner: "sharkdp".to_string(), + repo: "fd".to_string(), + version: "8.7.0".to_string(), + }, + ], + }; + + let toml_str = export.to_toml().unwrap(); + // Should use [[package]] array format + assert!(toml_str.contains("[[package]]")); + assert!(toml_str.contains("owner = \"sharkdp\"")); + assert!(toml_str.contains("repo = \"bat\"")); + assert!(toml_str.contains("repo = \"fd\"")); +}