use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use reqwest;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use url::Url;
#[derive(Debug, Clone)]
pub struct RegistryClient {
base_url: Url,
client: reqwest::Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegistryIndex {
pub updated: DateTime<Utc>,
pub packs: HashMap<String, PackMetadata>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PackMetadata {
pub id: String,
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub keywords: Vec<String>,
pub category: Option<String>,
pub author: Option<String>,
pub latest_version: String,
pub versions: HashMap<String, VersionMetadata>,
pub downloads: Option<u64>,
pub updated: Option<chrono::DateTime<chrono::Utc>>,
pub license: Option<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub documentation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VersionMetadata {
pub version: String,
pub git_url: String,
pub git_rev: String,
pub manifest_url: Option<String>,
pub sha256: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub id: String,
pub name: String,
pub description: String,
pub tags: Vec<String>,
pub keywords: Vec<String>,
pub category: Option<String>,
pub author: Option<String>,
pub latest_version: String,
pub downloads: Option<u64>,
pub updated: Option<chrono::DateTime<chrono::Utc>>,
pub license: Option<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub documentation: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SearchParams<'a> {
pub query: &'a str,
pub category: Option<&'a str>,
pub keyword: Option<&'a str>,
pub author: Option<&'a str>,
pub stable_only: bool,
pub limit: usize,
}
#[derive(Debug, Clone)]
pub struct ResolvedPack {
pub id: String,
pub version: String,
pub git_url: String,
pub git_rev: String,
pub sha256: String,
}
impl RegistryClient {
pub fn new() -> Result<Self> {
let registry_url = std::env::var("RGEN_REGISTRY_URL").unwrap_or_else(|_| {
"https://raw.githubusercontent.com/seanchatmangpt/rgen/master/registry/".to_string()
});
let base_url = Url::parse(®istry_url).context("Failed to parse registry URL")?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
Ok(Self { base_url, client })
}
pub fn with_base_url(base_url: Url) -> Result<Self> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("Failed to create HTTP client")?;
Ok(Self { base_url, client })
}
pub async fn fetch_index(&self) -> Result<RegistryIndex> {
let url = self
.base_url
.join("index.json")
.context("Failed to construct index URL")?;
if url.scheme() == "file" {
let path = url.to_file_path()
.map_err(|_| anyhow::anyhow!("Invalid file URL: {}", url))?;
let content = std::fs::read_to_string(&path)
.context(format!("Failed to read registry index from {}", path.display()))?;
let index: RegistryIndex = serde_json::from_str(&content)
.context("Failed to parse registry index")?;
return Ok(index);
}
let response = self
.client
.get(url.clone())
.send()
.await
.context(format!("Failed to fetch registry index from {}", url))?;
if !response.status().is_success() {
anyhow::bail!(
"Registry returned status: {} for URL: {}",
response.status(),
url
);
}
let index: RegistryIndex = response
.json()
.await
.context("Failed to parse registry index")?;
Ok(index)
}
pub async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
let index = self.fetch_index().await?;
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for (id, pack) in index.packs {
let matches = pack.name.to_lowercase().contains(&query_lower)
|| pack.description.to_lowercase().contains(&query_lower)
|| pack
.tags
.iter()
.any(|tag| tag.to_lowercase().contains(&query_lower));
if matches {
let search_result = self.convert_to_search_result(id, pack)?;
results.push(search_result);
}
}
results.sort_by(|a, b| {
let a_exact =
a.id.to_lowercase() == query_lower || a.name.to_lowercase() == query_lower;
let b_exact =
b.id.to_lowercase() == query_lower || b.name.to_lowercase() == query_lower;
match (a_exact, b_exact) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.cmp(&b.name),
}
});
Ok(results)
}
pub async fn advanced_search(&self, params: &SearchParams<'_>) -> Result<Vec<SearchResult>> {
let index = self.fetch_index().await?;
let query_lower = params.query.to_lowercase();
let mut results = Vec::new();
for (id, pack) in index.packs {
if !self.matches_filters(&pack, params) {
continue;
}
let matches = pack.name.to_lowercase().contains(&query_lower)
|| pack.description.to_lowercase().contains(&query_lower)
|| pack
.tags
.iter()
.any(|tag| tag.to_lowercase().contains(&query_lower))
|| pack
.keywords
.iter()
.any(|keyword| keyword.to_lowercase().contains(&query_lower));
if matches {
let search_result = self.convert_to_search_result(id, pack)?;
results.push(search_result);
}
}
results.sort_by(|a, b| self.compare_relevance(a, b, &query_lower));
results.truncate(params.limit);
Ok(results)
}
fn matches_filters(&self, pack: &PackMetadata, params: &SearchParams<'_>) -> bool {
if let Some(category) = params.category {
if !pack
.category
.as_ref()
.is_some_and(|c| c.to_lowercase() == category.to_lowercase())
{
return false;
}
}
if let Some(keyword) = params.keyword {
if !pack
.keywords
.iter()
.any(|k| k.to_lowercase() == keyword.to_lowercase())
{
return false;
}
}
if let Some(author) = params.author {
if !pack
.author
.as_ref()
.is_some_and(|a| a.to_lowercase().contains(&author.to_lowercase()))
{
return false;
}
}
if params.stable_only {
if let Ok(version) = semver::Version::parse(&pack.latest_version) {
if !version.pre.is_empty() {
return false; }
}
}
true
}
fn convert_to_search_result(&self, id: String, pack: PackMetadata) -> Result<SearchResult> {
Ok(SearchResult {
id,
name: pack.name,
description: pack.description,
tags: pack.tags,
keywords: pack.keywords,
category: pack.category,
author: pack.author,
latest_version: pack.latest_version,
downloads: pack.downloads,
updated: pack.updated,
license: pack.license,
homepage: pack.homepage,
repository: pack.repository,
documentation: pack.documentation,
})
}
fn compare_relevance(
&self, a: &SearchResult, b: &SearchResult, query: &str,
) -> std::cmp::Ordering {
let a_exact = a.id.to_lowercase() == query || a.name.to_lowercase() == query;
let b_exact = b.id.to_lowercase() == query || b.name.to_lowercase() == query;
match (a_exact, b_exact) {
(true, false) => return std::cmp::Ordering::Less,
(false, true) => return std::cmp::Ordering::Greater,
_ => {}
}
let download_ordering = match (a.downloads, b.downloads) {
(Some(a_dl), Some(b_dl)) => b_dl.cmp(&a_dl), (Some(_), None) => std::cmp::Ordering::Less,
(None, Some(_)) => std::cmp::Ordering::Greater,
(None, None) => std::cmp::Ordering::Equal,
};
if download_ordering != std::cmp::Ordering::Equal {
return download_ordering;
}
a.name.cmp(&b.name)
}
pub async fn resolve(&self, pack_id: &str, version: Option<&str>) -> Result<ResolvedPack> {
let index = self.fetch_index().await?;
let pack = index
.packs
.get(pack_id)
.with_context(|| format!("Pack '{}' not found in registry", pack_id))?;
let target_version = match version {
Some(v) => v.to_string(),
None => pack.latest_version.clone(),
};
let version_meta = pack.versions.get(&target_version).with_context(|| {
format!(
"Version '{}' not found for pack '{}'",
target_version, pack_id
)
})?;
Ok(ResolvedPack {
id: pack_id.to_string(),
version: target_version,
git_url: version_meta.git_url.clone(),
git_rev: version_meta.git_rev.clone(),
sha256: version_meta.sha256.clone(),
})
}
pub async fn check_updates(
&self, pack_id: &str, current_version: &str,
) -> Result<Option<ResolvedPack>> {
let index = self.fetch_index().await?;
let pack = index
.packs
.get(pack_id)
.with_context(|| format!("Pack '{}' not found in registry", pack_id))?;
let current = semver::Version::parse(current_version)
.with_context(|| format!("Invalid current version: {}", current_version))?;
let latest = semver::Version::parse(&pack.latest_version)
.with_context(|| format!("Invalid latest version: {}", pack.latest_version))?;
if latest > current {
self.resolve(pack_id, Some(&pack.latest_version))
.await
.map(Some)
} else {
Ok(None)
}
}
pub async fn get_popular_categories(&self) -> Result<Vec<(String, u64)>> {
let index = self.fetch_index().await?;
let mut category_counts: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
for (_, pack) in index.packs {
if let Some(category) = pack.category {
*category_counts.entry(category).or_insert(0) += 1;
}
}
let mut categories: Vec<(String, u64)> = category_counts.into_iter().collect();
categories.sort_by(|a, b| b.1.cmp(&a.1));
Ok(categories)
}
pub async fn get_popular_keywords(&self) -> Result<Vec<(String, u64)>> {
let index = self.fetch_index().await?;
let mut keyword_counts: std::collections::HashMap<String, u64> =
std::collections::HashMap::new();
for (_, pack) in index.packs {
for keyword in pack.keywords {
*keyword_counts.entry(keyword).or_insert(0) += 1;
}
}
let mut keywords: Vec<(String, u64)> = keyword_counts.into_iter().collect();
keywords.sort_by(|a, b| b.1.cmp(&a.1));
Ok(keywords)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[tokio::test]
#[ignore] async fn test_registry_client_search() {
let temp_dir = TempDir::new().unwrap();
let index_path = temp_dir.path().join("index.json");
let mock_index = r#"{
"updated": "2024-01-01T00:00:00Z",
"packs": {
"io.rgen.rust.cli-subcommand": {
"id": "io.rgen.rust.cli-subcommand",
"name": "Rust CLI subcommand",
"description": "Generate clap subcommands for Rust CLI applications",
"tags": ["rust", "cli", "clap", "subcommand"],
"latest_version": "0.2.1",
"versions": {
"0.2.1": {
"version": "0.2.1",
"git_url": "https://github.com/example/rpack.git",
"git_rev": "abc123",
"sha256": "def456"
}
}
}
}
}"#;
fs::write(&index_path, mock_index).unwrap();
let base_url = Url::from_file_path(temp_dir.path()).unwrap();
let client = RegistryClient::with_base_url(base_url).unwrap();
let results = client.search("rust").await.unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "io.rgen.rust.cli-subcommand");
}
#[tokio::test]
#[ignore] async fn test_registry_client_resolve() {
let temp_dir = TempDir::new().unwrap();
let index_path = temp_dir.path().join("index.json");
let mock_index = r#"{
"updated": "2024-01-01T00:00:00Z",
"packs": {
"io.rgen.rust.cli-subcommand": {
"id": "io.rgen.rust.cli-subcommand",
"name": "Rust CLI subcommand",
"description": "Generate clap subcommands",
"tags": ["rust", "cli"],
"latest_version": "0.2.1",
"versions": {
"0.2.1": {
"version": "0.2.1",
"git_url": "https://github.com/example/rpack.git",
"git_rev": "abc123",
"sha256": "def456"
}
}
}
}
}"#;
fs::write(&index_path, mock_index).unwrap();
let base_url = Url::from_file_path(temp_dir.path()).unwrap();
let client = RegistryClient::with_base_url(base_url).unwrap();
let resolved = client
.resolve("io.rgen.rust.cli-subcommand", None)
.await
.unwrap();
assert_eq!(resolved.id, "io.rgen.rust.cli-subcommand");
assert_eq!(resolved.version, "0.2.1");
assert_eq!(resolved.git_url, "https://github.com/example/rpack.git");
}
}