use anyhow::{Context, Result};
use serde_json::json;
use std::time::Duration;
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Registry {
Pypi,
Npm,
Wasm,
Rubygems,
Maven,
Nuget,
Packagist,
Cratesio,
Hex,
Homebrew,
GithubRelease,
}
impl std::fmt::Display for Registry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Registry::Pypi => write!(f, "pypi"),
Registry::Npm | Registry::Wasm => write!(f, "npm"),
Registry::Rubygems => write!(f, "rubygems"),
Registry::Maven => write!(f, "maven"),
Registry::Nuget => write!(f, "nuget"),
Registry::Packagist => write!(f, "packagist"),
Registry::Cratesio => write!(f, "cratesio"),
Registry::Hex => write!(f, "hex"),
Registry::Homebrew => write!(f, "homebrew"),
Registry::GithubRelease => write!(f, "github-release"),
}
}
}
pub fn check(registry: Registry, package: &str, version: &str, extra: &ExtraParams, output_json: bool) -> Result<bool> {
let exists = match registry {
Registry::Pypi => check_pypi(package, version)?,
Registry::Npm | Registry::Wasm => check_npm(package, version)?,
Registry::Rubygems => check_rubygems(package, version)?,
Registry::Maven => check_maven(package, version)?,
Registry::Nuget => check_nuget(package, version, extra.nuget_source.as_deref())?,
Registry::Packagist => check_packagist(package, version)?,
Registry::Cratesio => check_cratesio(package, version)?,
Registry::Hex => check_hex(package, version)?,
Registry::Homebrew => check_homebrew(package, version, extra.tap_repo.as_deref())?,
Registry::GithubRelease => check_github_release(
package,
version,
extra.repo.as_deref(),
extra.asset_prefix.as_deref(),
&extra.required_assets,
)?,
};
if output_json {
let out = json!({
"registry": registry.to_string(),
"package": package,
"version": version,
"exists": exists,
});
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
println!("exists={}", if exists { "true" } else { "false" });
}
Ok(exists)
}
#[derive(Debug, Default)]
pub struct ExtraParams {
pub nuget_source: Option<String>,
pub tap_repo: Option<String>,
pub repo: Option<String>,
pub asset_prefix: Option<String>,
pub required_assets: Vec<String>,
}
fn build_agent() -> ureq::Agent {
ureq::Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.build()
.new_agent()
}
fn classify(result: std::result::Result<ureq::http::Response<ureq::Body>, ureq::Error>) -> Result<HttpOutcome> {
match result {
Ok(resp) => Ok(HttpOutcome::Ok(resp)),
Err(ureq::Error::StatusCode(404)) => Ok(HttpOutcome::NotFound),
Err(e) => Err(anyhow::anyhow!("HTTP request failed: {e}")),
}
}
enum HttpOutcome {
Ok(ureq::http::Response<ureq::Body>),
NotFound,
}
fn http_get_ok(url: &str) -> Result<bool> {
let agent = build_agent();
let response = agent.get(url).header("User-Agent", "alef-publish/1.0").call();
match classify(response).with_context(|| format!("HTTP GET {url}"))? {
HttpOutcome::Ok(_) => Ok(true),
HttpOutcome::NotFound => Ok(false),
}
}
fn http_get_json(url: &str) -> Result<Option<serde_json::Value>> {
let agent = build_agent();
let response = agent
.get(url)
.header("User-Agent", "alef-publish/1.0")
.header("Accept", "application/json")
.call();
match classify(response).with_context(|| format!("HTTP GET {url}"))? {
HttpOutcome::Ok(resp) => {
let text = resp
.into_body()
.read_to_string()
.with_context(|| format!("reading body from {url}"))?;
let val: serde_json::Value =
serde_json::from_str(&text).with_context(|| format!("parsing JSON from {url}"))?;
Ok(Some(val))
}
HttpOutcome::NotFound => Ok(None),
}
}
fn check_pypi(package: &str, version: &str) -> Result<bool> {
let url = format!("https://pypi.org/pypi/{package}/{version}/json");
http_get_ok(&url)
}
fn check_npm(package: &str, version: &str) -> Result<bool> {
let url = format!("https://registry.npmjs.org/{package}/{version}");
http_get_ok(&url)
}
fn check_cratesio(package: &str, version: &str) -> Result<bool> {
let url = format!("https://crates.io/api/v1/crates/{package}/{version}");
let agent = build_agent();
let response = agent
.get(&url)
.header("User-Agent", "alef-publish/1.0 (https://github.com/kreuzberg-dev/alef)")
.call();
match classify(response).with_context(|| format!("HTTP GET {url}"))? {
HttpOutcome::Ok(_) => Ok(true),
HttpOutcome::NotFound => Ok(false),
}
}
fn check_rubygems(package: &str, version: &str) -> Result<bool> {
let url = format!("https://rubygems.org/api/v1/versions/{package}.json");
match http_get_json(&url)? {
None => Ok(false),
Some(val) => {
if let Some(versions) = val.as_array() {
for v in versions {
if v["number"].as_str() == Some(version) {
return Ok(true);
}
}
}
Ok(false)
}
}
}
fn check_hex(package: &str, version: &str) -> Result<bool> {
let url = format!("https://hex.pm/api/packages/{package}/releases/{version}");
http_get_ok(&url)
}
fn check_maven(package: &str, version: &str) -> Result<bool> {
let (group_id, artifact_id) = if let Some(colon) = package.find(':') {
(&package[..colon], &package[colon + 1..])
} else {
anyhow::bail!("Maven package must be 'groupId:artifactId', got: {package}");
};
let group_path = group_id.replace('.', "/");
let url = format!("https://repo1.maven.org/maven2/{group_path}/{artifact_id}/{version}/");
http_get_ok(&url)
}
fn check_nuget(package: &str, version: &str, source: Option<&str>) -> Result<bool> {
let base = source.unwrap_or("https://api.nuget.org");
let pkg_lower = package.to_lowercase();
let url = format!("{base}/v3/registration5-gz-semver2/{pkg_lower}/{version}.json");
http_get_ok(&url)
}
fn check_packagist(package: &str, version: &str) -> Result<bool> {
let url = format!("https://repo.packagist.org/p2/{package}.json");
match http_get_json(&url)? {
None => Ok(false),
Some(val) => {
if let Some(packages) = val["packages"][package].as_array() {
for pkg in packages {
if pkg["version"].as_str() == Some(version) || pkg["version_normalized"].as_str() == Some(version) {
return Ok(true);
}
}
}
Ok(false)
}
}
}
fn check_homebrew(package: &str, _version: &str, tap_repo: Option<&str>) -> Result<bool> {
let repo = tap_repo.unwrap_or("Homebrew/homebrew-core");
if repo == "Homebrew/homebrew-core" {
let url = format!("https://formulae.brew.sh/api/formula/{package}.json");
return http_get_ok(&url);
}
let url = format!("https://raw.githubusercontent.com/{repo}/HEAD/Formula/{package}.rb");
http_get_ok(&url)
}
fn check_github_release(
package: &str,
version: &str,
repo: Option<&str>,
asset_prefix: Option<&str>,
required_assets: &[String],
) -> Result<bool> {
let repo = repo
.filter(|r| !r.is_empty())
.with_context(|| format!("--repo is required for github-release check of {package}"))?;
let tag = if version.starts_with('v') {
version.to_string()
} else {
format!("v{version}")
};
let url = format!("https://api.github.com/repos/{repo}/releases/tags/{tag}");
let agent = build_agent();
let response = agent
.get(&url)
.header("User-Agent", "alef-publish/1.0")
.header("Accept", "application/vnd.github+json")
.call();
let resp = match classify(response).with_context(|| format!("GitHub API GET {url}"))? {
HttpOutcome::Ok(resp) => resp,
HttpOutcome::NotFound => return Ok(false),
};
let asset_prefix = asset_prefix.filter(|s| !s.is_empty());
let has_asset_filter = asset_prefix.is_some() || !required_assets.is_empty();
if !has_asset_filter {
return Ok(true);
}
let body = resp
.into_body()
.read_to_string()
.with_context(|| format!("reading body from {url}"))?;
let json: serde_json::Value = serde_json::from_str(&body).with_context(|| format!("parsing JSON from {url}"))?;
let asset_names: Vec<&str> = json["assets"]
.as_array()
.map(|arr| arr.iter().filter_map(|a| a["name"].as_str()).collect::<Vec<_>>())
.unwrap_or_default();
if let Some(prefix) = asset_prefix
&& !asset_names.iter().any(|n| n.starts_with(prefix))
{
return Ok(false);
}
for required in required_assets {
if !asset_names.iter().any(|n| *n == required) {
return Ok(false);
}
}
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_display() {
assert_eq!(Registry::Pypi.to_string(), "pypi");
assert_eq!(Registry::Npm.to_string(), "npm");
assert_eq!(Registry::GithubRelease.to_string(), "github-release");
}
#[test]
fn maven_package_parse_colon() {
let result = check_maven("com.example:my-lib", "1.0.0");
let _ = result; }
#[test]
fn maven_package_no_colon_errors() {
let result = check_maven("invalid-package-name", "1.0.0");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("groupId:artifactId"));
}
#[test]
fn extra_params_default() {
let extra = ExtraParams::default();
assert!(extra.nuget_source.is_none());
assert!(extra.tap_repo.is_none());
assert!(extra.repo.is_none());
assert!(extra.asset_prefix.is_none());
assert!(extra.required_assets.is_empty());
}
#[test]
fn github_release_requires_repo() {
let result = check_github_release("alef", "1.0.0", None, None, &[]);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("--repo"));
}
}