rgen_core/
registry.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Utc};
3use reqwest;
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use url::Url;
7
8/// Registry client for fetching rpack metadata from registry.rgen.dev
9#[derive(Debug, Clone)]
10pub struct RegistryClient {
11    base_url: Url,
12    client: reqwest::Client,
13}
14
15/// Registry index structure matching the JSON format
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct RegistryIndex {
18    pub updated: DateTime<Utc>,
19    pub packs: HashMap<String, PackMetadata>,
20}
21
22/// Metadata for a single rpack
23#[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/// Metadata for a specific version of an rpack
43#[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/// Search result for rpacks
53#[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/// Search parameters for advanced search
72#[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/// Resolved pack information for installation
83#[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    /// Create a new registry client
94    pub fn new() -> Result<Self> {
95        // Check environment variable for registry URL
96        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(&registry_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    /// Create a registry client with custom base URL (for testing)
111    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    /// Fetch the registry index
121    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        // Handle file:// URLs for local testing
128        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    /// Search for rpacks matching the query
165    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            // Search in name, description, and tags
173            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        // Sort by relevance (exact matches first, then by name)
187        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    /// Advanced search with filtering options
204    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            // Apply filters
212            if !self.matches_filters(&pack, params) {
213                continue;
214            }
215
216            // Search in name, description, tags, and keywords
217            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                // Convert to SearchResult with extended metadata
230                let search_result = self.convert_to_search_result(id, pack)?;
231                results.push(search_result);
232            }
233        }
234
235        // Sort by relevance and apply limit
236        results.sort_by(|a, b| self.compare_relevance(a, b, &query_lower));
237        results.truncate(params.limit);
238
239        Ok(results)
240    }
241
242    /// Check if a pack matches the search filters
243    fn matches_filters(&self, pack: &PackMetadata, params: &SearchParams<'_>) -> bool {
244        // Category filter
245        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        // Keyword filter
256        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        // Author filter
267        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        // Stable version filter
278        if params.stable_only {
279            if let Ok(version) = semver::Version::parse(&pack.latest_version) {
280                if !version.pre.is_empty() {
281                    return false; // Pre-release versions are not stable
282                }
283            }
284        }
285
286        true
287    }
288
289    /// Convert PackMetadata to SearchResult
290    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    /// Compare search results by relevance
310    fn compare_relevance(
311        &self, a: &SearchResult, b: &SearchResult, query: &str,
312    ) -> std::cmp::Ordering {
313        // Exact matches first
314        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        // Then by downloads (popularity)
324        let download_ordering = match (a.downloads, b.downloads) {
325            (Some(a_dl), Some(b_dl)) => b_dl.cmp(&a_dl), // Higher downloads first
326            (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        // Finally by name
336        a.name.cmp(&b.name)
337    }
338
339    /// Resolve a pack ID to a specific version
340    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    /// Check if a pack has updates available
370    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        // Compare versions using semver
381        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    /// Get popular categories with template counts
397    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)); // Sort by count descending
410
411        Ok(categories)
412    }
413
414    /// Get popular keywords with template counts
415    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)); // Sort by count descending
428
429        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] // Disabled due to file:// URL not supported by reqwest
441    async fn test_registry_client_search() {
442        // Create a temporary directory for mock registry
443        let temp_dir = TempDir::new().unwrap();
444        let index_path = temp_dir.path().join("index.json");
445
446        // Create mock index
447        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        // Create registry client with file:// URL
471        let base_url = Url::from_file_path(temp_dir.path()).unwrap();
472        let client = RegistryClient::with_base_url(base_url).unwrap();
473
474        // Test search
475        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] // Disabled due to file:// URL not supported by reqwest
482    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        // Test resolve
513        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}