use crate::core::metadata::SkillMetadata;
use crate::core::registry::auth::Auth;
use crate::core::registry::config::RegistryConfig;
use crate::core::registry_index::{Dependency as RegistryDependency, IndexMetadata};
use crate::core::service::ServiceError;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::collections::HashMap;
pub struct RegistryClient {
config: RegistryConfig,
client: Client,
auth: Option<Box<dyn Auth>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexEntry {
pub name: String,
pub vers: String,
pub deps: Vec<RegistryDependency>,
pub cksum: String,
pub features: HashMap<String, Vec<String>>,
pub yanked: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub links: Option<String>,
pub download_url: String,
#[serde(default)]
pub metadata: Option<IndexMetadata>,
}
impl RegistryClient {
pub fn new(config: RegistryConfig) -> Result<Self, ServiceError> {
let client = Client::builder()
.user_agent("fastskill/0.6.8")
.build()
.map_err(|e| ServiceError::Custom(format!("Failed to create HTTP client: {}", e)))?;
let auth: Option<Box<dyn Auth>> = if let Some(ref auth_config) = config.auth {
match auth_config {
crate::core::registry::config::AuthConfig::Pat { env_var } => Some(Box::new(
crate::core::registry::auth::GitHubPat::new(env_var.clone()),
)),
crate::core::registry::config::AuthConfig::Ssh { key_path } => Some(Box::new(
crate::core::registry::auth::SshKey::new(key_path.clone()),
)),
crate::core::registry::config::AuthConfig::ApiKey { env_var } => Some(Box::new(
crate::core::registry::auth::ApiKey::new(env_var.clone()),
)),
}
} else {
None
};
Ok(Self {
config,
client,
auth,
})
}
fn get_index_url(&self, skill_id: &str) -> String {
format!(
"{}/{}",
self.config.index_url.trim_end_matches('/'),
skill_id
)
}
pub async fn get_skill(&self, name: &str) -> Result<Vec<IndexEntry>, ServiceError> {
let url = self.get_index_url(name);
let mut request = self.client.get(&url);
if let Some(ref auth) = self.auth {
if auth.is_configured() {
if let Ok(header_value) = auth.get_auth_header() {
request = request.header("Authorization", header_value);
}
}
}
let response = request
.send()
.await
.map_err(|e| ServiceError::Custom(format!("Failed to fetch skill index: {}", e)))?;
if !response.status().is_success() {
if response.status() == 404 {
return Ok(Vec::new()); }
return Err(ServiceError::Custom(format!(
"Failed to fetch skill index: HTTP {}",
response.status()
)));
}
let content = response
.text()
.await
.map_err(|e| ServiceError::Custom(format!("Failed to read index file: {}", e)))?;
let mut entries = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<IndexEntry>(line) {
Ok(entry) => entries.push(entry),
Err(e) => {
eprintln!(
"Warning: Failed to parse index entry: {} (line: {})",
e, line
);
}
}
}
Ok(entries)
}
async fn get_index_entry(
&self,
name: &str,
version: &str,
) -> Result<Option<IndexEntry>, ServiceError> {
let entries = self.get_skill(name).await?;
Ok(entries.into_iter().find(|e| e.vers == version))
}
pub async fn get_versions(&self, name: &str) -> Result<Vec<String>, ServiceError> {
let entries = self.get_skill(name).await?;
let mut versions: Vec<String> = entries.iter().map(|e| e.vers.clone()).collect();
versions.sort_by(|a, b| {
b.cmp(a)
});
Ok(versions)
}
pub async fn get_latest_version(
&self,
name: &str,
include_pre_release: bool,
) -> Result<Option<String>, ServiceError> {
let versions = self.get_versions(name).await?;
if versions.is_empty() {
return Ok(None);
}
let candidates: Vec<&String> = if include_pre_release {
versions.iter().collect()
} else {
versions
.iter()
.filter(|v| !v.contains('-')) .collect()
};
if candidates.is_empty() {
return Ok(None);
}
use semver::Version;
let mut parsed_versions: Vec<(Version, &String)> = candidates
.iter()
.filter_map(|v| {
Version::parse(v).ok().map(|ver| (ver, *v))
})
.collect();
if parsed_versions.is_empty() {
return Ok(Some(candidates[0].clone()));
}
parsed_versions.sort_by(|a, b| b.0.cmp(&a.0));
Ok(Some(parsed_versions[0].1.clone()))
}
pub async fn get_version(
&self,
name: &str,
version: &str,
) -> Result<Option<IndexEntry>, ServiceError> {
self.get_index_entry(name, version).await
}
pub async fn download(&self, name: &str, version: &str) -> Result<Vec<u8>, ServiceError> {
let entry = self.get_version(name, version).await?.ok_or_else(|| {
ServiceError::Custom(format!(
"Skill {} version {} not found in registry",
name, version
))
})?;
if entry.yanked {
return Err(ServiceError::Custom(format!(
"Skill {} version {} has been yanked",
name, version
)));
}
let mut request = self.client.get(&entry.download_url);
if let Some(ref auth) = self.auth {
if auth.is_configured() {
if let Ok(header_value) = auth.get_auth_header() {
request = request.header("Authorization", header_value);
}
}
}
let response = request
.send()
.await
.map_err(|e| ServiceError::Custom(format!("Failed to download package: {}", e)))?;
if !response.status().is_success() {
return Err(ServiceError::Custom(format!(
"Failed to download package: HTTP {}",
response.status()
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| ServiceError::Custom(format!("Failed to read package data: {}", e)))?;
let calculated = format!("sha256:{:x}", sha2::Sha256::digest(&bytes));
if calculated != entry.cksum {
return Err(ServiceError::Custom(format!(
"Checksum mismatch: expected {}, got {}",
entry.cksum, calculated
)));
}
Ok(bytes.to_vec())
}
pub async fn search(&self, _query: &str) -> Result<Vec<SkillMetadata>, ServiceError> {
Ok(Vec::new())
}
}