use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
pub struct PackageResponse {
#[serde(rename = "name")]
pub _name: String,
#[serde(rename = "slug")]
pub _slug: String,
#[serde(rename = "description")]
pub _description: Option<String>,
#[serde(rename = "author")]
pub _author: Option<String>,
#[serde(rename = "license")]
pub _license: Option<String>,
pub versions: Vec<VersionEntry>,
}
#[derive(Debug, Deserialize)]
pub struct VersionEntry {
pub version: String,
#[serde(rename = "entry")]
pub _entry: Option<String>,
#[serde(rename = "checksum")]
pub _checksum: Option<String>,
#[serde(rename = "published_at")]
pub _published_at: Option<String>,
}
pub struct RegistryClient {
base_url: String,
http: reqwest::Client,
}
impl RegistryClient {
pub fn new(registry_url: &str) -> Self {
Self {
base_url: registry_url.trim_end_matches('/').to_string(),
http: reqwest::Client::new(),
}
}
pub async fn fetch_package_info(&self, name: &str) -> Result<PackageResponse> {
let url = format!("{}/api/v1/packages/{}", self.base_url, name);
let resp = self
.http
.get(&url)
.send()
.await
.with_context(|| format!("Failed to reach registry at {}", url))?;
if resp.status() == reqwest::StatusCode::NOT_FOUND {
return Err(anyhow!("Package '{}' not found in registry", name));
}
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"Registry error for '{}': {} — {}",
name,
status,
body
));
}
resp.json::<PackageResponse>()
.await
.with_context(|| format!("Failed to parse registry response for '{}'", name))
}
pub async fn resolve_version(&self, name: &str, version_req: Option<&str>) -> Result<String> {
let info = self.fetch_package_info(name).await?;
if info.versions.is_empty() {
return Err(anyhow!("Package '{}' has no published versions", name));
}
match version_req {
None | Some("latest") => {
Ok(info.versions[0].version.clone())
}
Some(req_str) => {
let req = semver::VersionReq::parse(req_str)
.with_context(|| format!("Invalid version requirement: '{}'", req_str))?;
let mut candidates: Vec<(semver::Version, &VersionEntry)> = info
.versions
.iter()
.filter_map(|v| semver::Version::parse(&v.version).ok().map(|sv| (sv, v)))
.filter(|(sv, _)| req.matches(sv))
.collect();
candidates.sort_by(|a, b| b.0.cmp(&a.0));
candidates
.first()
.map(|(_, entry)| entry.version.clone())
.ok_or_else(|| {
anyhow!("No version of '{}' matches constraint '{}'", name, req_str)
})
}
}
}
pub async fn download(&self, name: &str, version: &str, dest_dir: &Path) -> Result<PathBuf> {
let url = format!(
"{}/api/v1/packages/{}/{}/download",
self.base_url, name, version
);
let resp = self
.http
.get(&url)
.send()
.await
.with_context(|| format!("Failed to download {}-{}", name, version))?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(anyhow!(
"Download failed for {}-{}: {} — {}",
name,
version,
status,
body
));
}
std::fs::create_dir_all(dest_dir)
.with_context(|| format!("Failed to create dir {}", dest_dir.display()))?;
let archive_path = dest_dir.join(format!("{}-{}.tar.gz", name, version));
let bytes = resp.bytes().await?;
std::fs::write(&archive_path, &bytes)
.with_context(|| format!("Failed to write {}", archive_path.display()))?;
Ok(archive_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_new() {
let c = RegistryClient::new("https://jgr.juglans.ai/");
assert_eq!(c.base_url, "https://jgr.juglans.ai");
}
#[test]
fn test_client_url_no_trailing_slash() {
let c = RegistryClient::new("https://jgr.juglans.ai");
assert_eq!(c.base_url, "https://jgr.juglans.ai");
}
}