1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use reqwest;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use url::Url;
7
8#[derive(Debug, Clone)]
10pub struct RegistryClient {
11 base_url: Url,
12 client: reqwest::Client,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RegistryIndex {
18 pub updated: DateTime<Utc>,
19 pub packs: HashMap<String, PackMetadata>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct PackMetadata {
25 pub id: String,
26 pub name: String,
27 pub description: String,
28 pub tags: Vec<String>,
29 pub keywords: Vec<String>,
30 pub category: Option<String>,
31 pub author: Option<String>,
32 pub latest_version: String,
33 pub versions: HashMap<String, VersionMetadata>,
34 pub downloads: Option<u64>,
35 pub updated: Option<chrono::DateTime<chrono::Utc>>,
36 pub license: Option<String>,
37 pub homepage: Option<String>,
38 pub repository: Option<String>,
39 pub documentation: Option<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct VersionMetadata {
45 pub version: String,
46 pub git_url: String,
47 pub git_rev: String,
48 pub manifest_url: Option<String>,
49 pub sha256: String,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct SearchResult {
55 pub id: String,
56 pub name: String,
57 pub description: String,
58 pub tags: Vec<String>,
59 pub keywords: Vec<String>,
60 pub category: Option<String>,
61 pub author: Option<String>,
62 pub latest_version: String,
63 pub downloads: Option<u64>,
64 pub updated: Option<chrono::DateTime<chrono::Utc>>,
65 pub license: Option<String>,
66 pub homepage: Option<String>,
67 pub repository: Option<String>,
68 pub documentation: Option<String>,
69}
70
71#[derive(Debug, Clone)]
73pub struct SearchParams<'a> {
74 pub query: &'a str,
75 pub category: Option<&'a str>,
76 pub keyword: Option<&'a str>,
77 pub author: Option<&'a str>,
78 pub stable_only: bool,
79 pub limit: usize,
80}
81
82#[derive(Debug, Clone)]
84pub struct ResolvedPack {
85 pub id: String,
86 pub version: String,
87 pub git_url: String,
88 pub git_rev: String,
89 pub sha256: String,
90}
91
92impl RegistryClient {
93 pub fn new() -> Result<Self> {
95 let registry_url = std::env::var("RGEN_REGISTRY_URL").unwrap_or_else(|_| {
97 "https://raw.githubusercontent.com/seanchatmangpt/rgen/master/registry/".to_string()
98 });
99
100 let base_url = Url::parse(®istry_url).context("Failed to parse registry URL")?;
101
102 let client = reqwest::Client::builder()
103 .timeout(std::time::Duration::from_secs(30))
104 .build()
105 .context("Failed to create HTTP client")?;
106
107 Ok(Self { base_url, client })
108 }
109
110 pub fn with_base_url(base_url: Url) -> Result<Self> {
112 let client = reqwest::Client::builder()
113 .timeout(std::time::Duration::from_secs(30))
114 .build()
115 .context("Failed to create HTTP client")?;
116
117 Ok(Self { base_url, client })
118 }
119
120 pub async fn fetch_index(&self) -> Result<RegistryIndex> {
122 let url = self
123 .base_url
124 .join("index.json")
125 .context("Failed to construct index URL")?;
126
127 if url.scheme() == "file" {
129 let path = url.to_file_path()
130 .map_err(|_| anyhow::anyhow!("Invalid file URL: {}", url))?;
131
132 let content = std::fs::read_to_string(&path)
133 .context(format!("Failed to read registry index from {}", path.display()))?;
134
135 let index: RegistryIndex = serde_json::from_str(&content)
136 .context("Failed to parse registry index")?;
137
138 return Ok(index);
139 }
140
141 let response = self
142 .client
143 .get(url.clone())
144 .send()
145 .await
146 .context(format!("Failed to fetch registry index from {}", url))?;
147
148 if !response.status().is_success() {
149 anyhow::bail!(
150 "Registry returned status: {} for URL: {}",
151 response.status(),
152 url
153 );
154 }
155
156 let index: RegistryIndex = response
157 .json()
158 .await
159 .context("Failed to parse registry index")?;
160
161 Ok(index)
162 }
163
164 pub async fn search(&self, query: &str) -> Result<Vec<SearchResult>> {
166 let index = self.fetch_index().await?;
167 let query_lower = query.to_lowercase();
168
169 let mut results = Vec::new();
170
171 for (id, pack) in index.packs {
172 let matches = pack.name.to_lowercase().contains(&query_lower)
174 || pack.description.to_lowercase().contains(&query_lower)
175 || pack
176 .tags
177 .iter()
178 .any(|tag| tag.to_lowercase().contains(&query_lower));
179
180 if matches {
181 let search_result = self.convert_to_search_result(id, pack)?;
182 results.push(search_result);
183 }
184 }
185
186 results.sort_by(|a, b| {
188 let a_exact =
189 a.id.to_lowercase() == query_lower || a.name.to_lowercase() == query_lower;
190 let b_exact =
191 b.id.to_lowercase() == query_lower || b.name.to_lowercase() == query_lower;
192
193 match (a_exact, b_exact) {
194 (true, false) => std::cmp::Ordering::Less,
195 (false, true) => std::cmp::Ordering::Greater,
196 _ => a.name.cmp(&b.name),
197 }
198 });
199
200 Ok(results)
201 }
202
203 pub async fn advanced_search(&self, params: &SearchParams<'_>) -> Result<Vec<SearchResult>> {
205 let index = self.fetch_index().await?;
206 let query_lower = params.query.to_lowercase();
207
208 let mut results = Vec::new();
209
210 for (id, pack) in index.packs {
211 if !self.matches_filters(&pack, params) {
213 continue;
214 }
215
216 let matches = pack.name.to_lowercase().contains(&query_lower)
218 || pack.description.to_lowercase().contains(&query_lower)
219 || pack
220 .tags
221 .iter()
222 .any(|tag| tag.to_lowercase().contains(&query_lower))
223 || pack
224 .keywords
225 .iter()
226 .any(|keyword| keyword.to_lowercase().contains(&query_lower));
227
228 if matches {
229 let search_result = self.convert_to_search_result(id, pack)?;
231 results.push(search_result);
232 }
233 }
234
235 results.sort_by(|a, b| self.compare_relevance(a, b, &query_lower));
237 results.truncate(params.limit);
238
239 Ok(results)
240 }
241
242 fn matches_filters(&self, pack: &PackMetadata, params: &SearchParams<'_>) -> bool {
244 if let Some(category) = params.category {
246 if !pack
247 .category
248 .as_ref()
249 .is_some_and(|c| c.to_lowercase() == category.to_lowercase())
250 {
251 return false;
252 }
253 }
254
255 if let Some(keyword) = params.keyword {
257 if !pack
258 .keywords
259 .iter()
260 .any(|k| k.to_lowercase() == keyword.to_lowercase())
261 {
262 return false;
263 }
264 }
265
266 if let Some(author) = params.author {
268 if !pack
269 .author
270 .as_ref()
271 .is_some_and(|a| a.to_lowercase().contains(&author.to_lowercase()))
272 {
273 return false;
274 }
275 }
276
277 if params.stable_only {
279 if let Ok(version) = semver::Version::parse(&pack.latest_version) {
280 if !version.pre.is_empty() {
281 return false; }
283 }
284 }
285
286 true
287 }
288
289 fn convert_to_search_result(&self, id: String, pack: PackMetadata) -> Result<SearchResult> {
291 Ok(SearchResult {
292 id,
293 name: pack.name,
294 description: pack.description,
295 tags: pack.tags,
296 keywords: pack.keywords,
297 category: pack.category,
298 author: pack.author,
299 latest_version: pack.latest_version,
300 downloads: pack.downloads,
301 updated: pack.updated,
302 license: pack.license,
303 homepage: pack.homepage,
304 repository: pack.repository,
305 documentation: pack.documentation,
306 })
307 }
308
309 fn compare_relevance(
311 &self, a: &SearchResult, b: &SearchResult, query: &str,
312 ) -> std::cmp::Ordering {
313 let a_exact = a.id.to_lowercase() == query || a.name.to_lowercase() == query;
315 let b_exact = b.id.to_lowercase() == query || b.name.to_lowercase() == query;
316
317 match (a_exact, b_exact) {
318 (true, false) => return std::cmp::Ordering::Less,
319 (false, true) => return std::cmp::Ordering::Greater,
320 _ => {}
321 }
322
323 let download_ordering = match (a.downloads, b.downloads) {
325 (Some(a_dl), Some(b_dl)) => b_dl.cmp(&a_dl), (Some(_), None) => std::cmp::Ordering::Less,
327 (None, Some(_)) => std::cmp::Ordering::Greater,
328 (None, None) => std::cmp::Ordering::Equal,
329 };
330
331 if download_ordering != std::cmp::Ordering::Equal {
332 return download_ordering;
333 }
334
335 a.name.cmp(&b.name)
337 }
338
339 pub async fn resolve(&self, pack_id: &str, version: Option<&str>) -> Result<ResolvedPack> {
341 let index = self.fetch_index().await?;
342
343 let pack = index
344 .packs
345 .get(pack_id)
346 .with_context(|| format!("Pack '{}' not found in registry", pack_id))?;
347
348 let target_version = match version {
349 Some(v) => v.to_string(),
350 None => pack.latest_version.clone(),
351 };
352
353 let version_meta = pack.versions.get(&target_version).with_context(|| {
354 format!(
355 "Version '{}' not found for pack '{}'",
356 target_version, pack_id
357 )
358 })?;
359
360 Ok(ResolvedPack {
361 id: pack_id.to_string(),
362 version: target_version,
363 git_url: version_meta.git_url.clone(),
364 git_rev: version_meta.git_rev.clone(),
365 sha256: version_meta.sha256.clone(),
366 })
367 }
368
369 pub async fn check_updates(
371 &self, pack_id: &str, current_version: &str,
372 ) -> Result<Option<ResolvedPack>> {
373 let index = self.fetch_index().await?;
374
375 let pack = index
376 .packs
377 .get(pack_id)
378 .with_context(|| format!("Pack '{}' not found in registry", pack_id))?;
379
380 let current = semver::Version::parse(current_version)
382 .with_context(|| format!("Invalid current version: {}", current_version))?;
383
384 let latest = semver::Version::parse(&pack.latest_version)
385 .with_context(|| format!("Invalid latest version: {}", pack.latest_version))?;
386
387 if latest > current {
388 self.resolve(pack_id, Some(&pack.latest_version))
389 .await
390 .map(Some)
391 } else {
392 Ok(None)
393 }
394 }
395
396 pub async fn get_popular_categories(&self) -> Result<Vec<(String, u64)>> {
398 let index = self.fetch_index().await?;
399 let mut category_counts: std::collections::HashMap<String, u64> =
400 std::collections::HashMap::new();
401
402 for (_, pack) in index.packs {
403 if let Some(category) = pack.category {
404 *category_counts.entry(category).or_insert(0) += 1;
405 }
406 }
407
408 let mut categories: Vec<(String, u64)> = category_counts.into_iter().collect();
409 categories.sort_by(|a, b| b.1.cmp(&a.1)); Ok(categories)
412 }
413
414 pub async fn get_popular_keywords(&self) -> Result<Vec<(String, u64)>> {
416 let index = self.fetch_index().await?;
417 let mut keyword_counts: std::collections::HashMap<String, u64> =
418 std::collections::HashMap::new();
419
420 for (_, pack) in index.packs {
421 for keyword in pack.keywords {
422 *keyword_counts.entry(keyword).or_insert(0) += 1;
423 }
424 }
425
426 let mut keywords: Vec<(String, u64)> = keyword_counts.into_iter().collect();
427 keywords.sort_by(|a, b| b.1.cmp(&a.1)); Ok(keywords)
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::fs;
437 use tempfile::TempDir;
438
439 #[tokio::test]
440 #[ignore] async fn test_registry_client_search() {
442 let temp_dir = TempDir::new().unwrap();
444 let index_path = temp_dir.path().join("index.json");
445
446 let mock_index = r#"{
448 "updated": "2024-01-01T00:00:00Z",
449 "packs": {
450 "io.rgen.rust.cli-subcommand": {
451 "id": "io.rgen.rust.cli-subcommand",
452 "name": "Rust CLI subcommand",
453 "description": "Generate clap subcommands for Rust CLI applications",
454 "tags": ["rust", "cli", "clap", "subcommand"],
455 "latest_version": "0.2.1",
456 "versions": {
457 "0.2.1": {
458 "version": "0.2.1",
459 "git_url": "https://github.com/example/rpack.git",
460 "git_rev": "abc123",
461 "sha256": "def456"
462 }
463 }
464 }
465 }
466 }"#;
467
468 fs::write(&index_path, mock_index).unwrap();
469
470 let base_url = Url::from_file_path(temp_dir.path()).unwrap();
472 let client = RegistryClient::with_base_url(base_url).unwrap();
473
474 let results = client.search("rust").await.unwrap();
476 assert_eq!(results.len(), 1);
477 assert_eq!(results[0].id, "io.rgen.rust.cli-subcommand");
478 }
479
480 #[tokio::test]
481 #[ignore] async fn test_registry_client_resolve() {
483 let temp_dir = TempDir::new().unwrap();
484 let index_path = temp_dir.path().join("index.json");
485
486 let mock_index = r#"{
487 "updated": "2024-01-01T00:00:00Z",
488 "packs": {
489 "io.rgen.rust.cli-subcommand": {
490 "id": "io.rgen.rust.cli-subcommand",
491 "name": "Rust CLI subcommand",
492 "description": "Generate clap subcommands",
493 "tags": ["rust", "cli"],
494 "latest_version": "0.2.1",
495 "versions": {
496 "0.2.1": {
497 "version": "0.2.1",
498 "git_url": "https://github.com/example/rpack.git",
499 "git_rev": "abc123",
500 "sha256": "def456"
501 }
502 }
503 }
504 }
505 }"#;
506
507 fs::write(&index_path, mock_index).unwrap();
508
509 let base_url = Url::from_file_path(temp_dir.path()).unwrap();
510 let client = RegistryClient::with_base_url(base_url).unwrap();
511
512 let resolved = client
514 .resolve("io.rgen.rust.cli-subcommand", None)
515 .await
516 .unwrap();
517 assert_eq!(resolved.id, "io.rgen.rust.cli-subcommand");
518 assert_eq!(resolved.version, "0.2.1");
519 assert_eq!(resolved.git_url, "https://github.com/example/rpack.git");
520 }
521}