use std::path::PathBuf;
use std::time::Duration;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub const DEFAULT_REGISTRY_URL: &str = "https://registry.supernovae.studio/api/v1";
pub const REGISTRY_URL_ENV: &str = "NIKA_REGISTRY_URL";
pub const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Error, Debug)]
pub enum RegistryApiError {
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("Package not found: {0}")]
PackageNotFound(String),
#[error("Version not found: {0}@{1}")]
VersionNotFound(String, String),
#[error("API error: {status} - {message}")]
ApiError { status: u16, message: String },
#[error("Invalid response: {0}")]
InvalidResponse(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[error("Rate limited: retry after {retry_after} seconds")]
RateLimited { retry_after: u64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackageInfo {
pub name: String,
pub latest_version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub authors: Option<Vec<String>>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub keywords: Option<Vec<String>>,
#[serde(default)]
pub downloads: Option<u64>,
#[serde(default)]
pub versions: Vec<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionInfo {
pub name: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub dependencies: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub skills: Option<Vec<SkillInfo>>,
#[serde(default)]
pub size: Option<u64>,
#[serde(default)]
pub checksum: Option<String>,
#[serde(default)]
pub published_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillInfo {
pub name: String,
pub path: String,
#[serde(default)]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub name: String,
pub version: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub keywords: Option<Vec<String>>,
#[serde(default)]
pub downloads: Option<u64>,
#[serde(default)]
pub score: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResponse {
pub total: usize,
pub page: usize,
pub per_page: usize,
pub results: Vec<SearchResult>,
}
#[derive(Debug, Clone)]
pub struct RegistryClient {
client: Client,
base_url: String,
}
impl Default for RegistryClient {
fn default() -> Self {
Self::new()
}
}
impl RegistryClient {
pub fn new() -> Self {
let base_url =
std::env::var(REGISTRY_URL_ENV).unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string());
let client = Client::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
.build()
.expect("Failed to build HTTP client");
Self { client, base_url }
}
pub fn with_url(base_url: impl Into<String>) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
.user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
.build()
.expect("Failed to build HTTP client");
Self {
client,
base_url: base_url.into(),
}
}
pub fn with_timeout(timeout_secs: u64) -> Self {
let base_url =
std::env::var(REGISTRY_URL_ENV).unwrap_or_else(|_| DEFAULT_REGISTRY_URL.to_string());
let client = Client::builder()
.timeout(Duration::from_secs(timeout_secs))
.user_agent(format!("nika/{}", env!("CARGO_PKG_VERSION")))
.build()
.expect("Failed to build HTTP client");
Self { client, base_url }
}
pub async fn get_package(&self, name: &str) -> Result<PackageInfo, RegistryApiError> {
let url = format!("{}/packages/{}", self.base_url, encode_package_name(name));
let response = self.client.get(&url).send().await?;
match response.status().as_u16() {
200 => response
.json::<PackageInfo>()
.await
.map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
404 => Err(RegistryApiError::PackageNotFound(name.to_string())),
429 => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(60);
Err(RegistryApiError::RateLimited { retry_after })
}
status => {
let message = response.text().await.unwrap_or_default();
Err(RegistryApiError::ApiError { status, message })
}
}
}
pub async fn get_version(
&self,
name: &str,
version: &str,
) -> Result<VersionInfo, RegistryApiError> {
let url = format!(
"{}/packages/{}/{}",
self.base_url,
encode_package_name(name),
version
);
let response = self.client.get(&url).send().await?;
match response.status().as_u16() {
200 => response
.json::<VersionInfo>()
.await
.map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
404 => Err(RegistryApiError::VersionNotFound(
name.to_string(),
version.to_string(),
)),
429 => {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(60);
Err(RegistryApiError::RateLimited { retry_after })
}
status => {
let message = response.text().await.unwrap_or_default();
Err(RegistryApiError::ApiError { status, message })
}
}
}
pub async fn get_versions(&self, name: &str) -> Result<Vec<String>, RegistryApiError> {
let url = format!(
"{}/packages/{}/versions",
self.base_url,
encode_package_name(name)
);
let response = self.client.get(&url).send().await?;
match response.status().as_u16() {
200 => {
#[derive(Deserialize)]
struct VersionsResponse {
versions: Vec<String>,
}
let resp: VersionsResponse = response
.json()
.await
.map_err(|e| RegistryApiError::InvalidResponse(e.to_string()))?;
Ok(resp.versions)
}
404 => Err(RegistryApiError::PackageNotFound(name.to_string())),
429 => {
let retry_after = 60;
Err(RegistryApiError::RateLimited { retry_after })
}
status => {
let message = response.text().await.unwrap_or_default();
Err(RegistryApiError::ApiError { status, message })
}
}
}
pub async fn search(
&self,
query: &str,
page: usize,
per_page: usize,
) -> Result<SearchResponse, RegistryApiError> {
let url = format!(
"{}/search?q={}&page={}&per_page={}",
self.base_url,
urlencoding::encode(query),
page,
per_page.min(100)
);
let response = self.client.get(&url).send().await?;
match response.status().as_u16() {
200 => response
.json::<SearchResponse>()
.await
.map_err(|e| RegistryApiError::InvalidResponse(e.to_string())),
429 => {
let retry_after = 60;
Err(RegistryApiError::RateLimited { retry_after })
}
status => {
let message = response.text().await.unwrap_or_default();
Err(RegistryApiError::ApiError { status, message })
}
}
}
pub async fn download(&self, name: &str, version: &str) -> Result<Vec<u8>, RegistryApiError> {
let url = format!(
"{}/packages/{}/{}/download",
self.base_url,
encode_package_name(name),
version
);
let response = self.client.get(&url).send().await?;
match response.status().as_u16() {
200 => response
.bytes()
.await
.map(|b| b.to_vec())
.map_err(RegistryApiError::from),
404 => Err(RegistryApiError::VersionNotFound(
name.to_string(),
version.to_string(),
)),
429 => {
let retry_after = 60;
Err(RegistryApiError::RateLimited { retry_after })
}
status => {
let message = response.text().await.unwrap_or_default();
Err(RegistryApiError::ApiError { status, message })
}
}
}
pub async fn download_and_extract(
&self,
name: &str,
version: &str,
target_dir: &PathBuf,
) -> Result<PathBuf, RegistryApiError> {
use flate2::read::GzDecoder;
use tar::Archive;
let bytes = self.download(name, version).await?;
std::fs::create_dir_all(target_dir)?;
let gz = GzDecoder::new(bytes.as_slice());
let mut archive = Archive::new(gz);
archive.unpack(target_dir)?;
Ok(target_dir.clone())
}
pub async fn package_exists(&self, name: &str) -> Result<bool, RegistryApiError> {
match self.get_package(name).await {
Ok(_) => Ok(true),
Err(RegistryApiError::PackageNotFound(_)) => Ok(false),
Err(e) => Err(e),
}
}
pub async fn version_exists(
&self,
name: &str,
version: &str,
) -> Result<bool, RegistryApiError> {
match self.get_version(name, version).await {
Ok(_) => Ok(true),
Err(RegistryApiError::VersionNotFound(_, _)) => Ok(false),
Err(e) => Err(e),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
}
fn encode_package_name(name: &str) -> String {
name.replace('/', "%2F")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_package_name() {
assert_eq!(encode_package_name("@spn/core"), "@spn%2Fcore");
assert_eq!(encode_package_name("simple-pkg"), "simple-pkg");
assert_eq!(
encode_package_name("@workflows/seo-audit"),
"@workflows%2Fseo-audit"
);
}
#[test]
fn test_registry_client_default() {
let client = RegistryClient::new();
assert!(client.base_url.contains("registry") || client.base_url.contains("supernovae"));
}
#[test]
fn test_registry_client_with_url() {
let client = RegistryClient::with_url("https://custom.registry.local/api");
assert_eq!(client.base_url, "https://custom.registry.local/api");
}
#[test]
fn test_package_info_deserialize() {
let json = r#"{
"name": "@spn/core",
"latest_version": "1.0.0",
"description": "Core skills",
"versions": ["1.0.0", "0.9.0"]
}"#;
let info: PackageInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.name, "@spn/core");
assert_eq!(info.latest_version, "1.0.0");
assert_eq!(info.versions.len(), 2);
}
#[test]
fn test_version_info_deserialize() {
let json = r#"{
"name": "@spn/core",
"version": "1.0.0",
"description": "Core skills package",
"skills": [
{"name": "brainstorm", "path": "skills/brainstorm.md"}
]
}"#;
let info: VersionInfo = serde_json::from_str(json).unwrap();
assert_eq!(info.name, "@spn/core");
assert_eq!(info.version, "1.0.0");
assert!(info.skills.is_some());
assert_eq!(info.skills.as_ref().unwrap().len(), 1);
}
#[test]
fn test_search_response_deserialize() {
let json = r#"{
"total": 42,
"page": 1,
"per_page": 20,
"results": [
{
"name": "@spn/core",
"version": "1.0.0",
"description": "Core package",
"score": 0.95
}
]
}"#;
let response: SearchResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.total, 42);
assert_eq!(response.results.len(), 1);
assert_eq!(response.results[0].name, "@spn/core");
}
#[test]
fn test_skill_info_deserialize() {
let json = r#"{
"name": "brainstorm",
"path": "skills/brainstorm.skill.md",
"description": "Collaborative ideation"
}"#;
let skill: SkillInfo = serde_json::from_str(json).unwrap();
assert_eq!(skill.name, "brainstorm");
assert_eq!(skill.path, "skills/brainstorm.skill.md");
}
#[test]
fn test_registry_api_error_display() {
let err = RegistryApiError::PackageNotFound("@test/pkg".to_string());
assert_eq!(err.to_string(), "Package not found: @test/pkg");
let err = RegistryApiError::VersionNotFound("@test/pkg".to_string(), "1.0.0".to_string());
assert_eq!(err.to_string(), "Version not found: @test/pkg@1.0.0");
let err = RegistryApiError::RateLimited { retry_after: 60 };
assert_eq!(err.to_string(), "Rate limited: retry after 60 seconds");
}
#[test]
fn test_package_info_optional_fields() {
let json = r#"{
"name": "@minimal/pkg",
"latest_version": "0.1.0",
"versions": []
}"#;
let info: PackageInfo = serde_json::from_str(json).unwrap();
assert!(info.description.is_none());
assert!(info.authors.is_none());
assert!(info.license.is_none());
assert!(info.downloads.is_none());
}
}