Skip to main content

deps_bundler/
registry.rs

1//! rubygems.org registry client.
2//!
3//! Provides access to rubygems.org API for version lookups and search.
4
5use crate::types::{BundlerVersion, GemInfo};
6use crate::version::{compare_versions, version_matches_requirement};
7use deps_core::{HttpCache, Result};
8use serde::Deserialize;
9use std::any::Any;
10use std::sync::Arc;
11
12const RUBYGEMS_API_BASE: &str = "https://rubygems.org/api/v1";
13
14/// Base URL for gem pages on rubygems.org.
15pub const RUBYGEMS_URL: &str = "https://rubygems.org/gems";
16
17/// Returns the URL for a gem's page on rubygems.org.
18pub fn gem_url(name: &str) -> String {
19    format!("{RUBYGEMS_URL}/{name}")
20}
21
22/// Client for interacting with rubygems.org registry.
23#[derive(Clone)]
24pub struct RubyGemsRegistry {
25    cache: Arc<HttpCache>,
26}
27
28impl RubyGemsRegistry {
29    /// Creates a new registry client with the given HTTP cache.
30    pub const fn new(cache: Arc<HttpCache>) -> Self {
31        Self { cache }
32    }
33
34    /// Fetches all versions for a gem.
35    pub async fn get_versions(&self, name: &str) -> Result<Vec<BundlerVersion>> {
36        let url = format!("{}/versions/{}.json", RUBYGEMS_API_BASE, name);
37        let data = self.cache.get_cached(&url).await?;
38        parse_versions_response(&data, name)
39    }
40
41    /// Finds the latest version matching the given requirement.
42    pub async fn get_latest_matching(
43        &self,
44        name: &str,
45        req_str: &str,
46    ) -> Result<Option<BundlerVersion>> {
47        let versions = self.get_versions(name).await?;
48        Ok(versions
49            .into_iter()
50            .find(|v| version_matches_requirement(&v.number, req_str) && !v.yanked))
51    }
52
53    /// Searches for gems by name/keywords.
54    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<GemInfo>> {
55        let url = format!(
56            "{}/search.json?query={}",
57            RUBYGEMS_API_BASE,
58            urlencoding::encode(query)
59        );
60        let data = self.cache.get_cached(&url).await?;
61        let gems = parse_search_response(&data)?;
62        Ok(gems.into_iter().take(limit).collect())
63    }
64
65    /// Gets detailed gem information.
66    pub async fn get_gem_info(&self, name: &str) -> Result<GemInfo> {
67        let url = format!("{}/gems/{}.json", RUBYGEMS_API_BASE, name);
68        let data = self.cache.get_cached(&url).await?;
69        parse_gem_info(&data)
70    }
71}
72
73#[derive(Deserialize)]
74struct VersionEntry {
75    number: String,
76    #[serde(default)]
77    prerelease: bool,
78    #[serde(default)]
79    yanked: bool,
80    created_at: Option<String>,
81    #[serde(default = "default_platform")]
82    platform: String,
83}
84
85fn default_platform() -> String {
86    "ruby".to_string()
87}
88
89fn parse_versions_response(data: &[u8], _gem_name: &str) -> Result<Vec<BundlerVersion>> {
90    let entries: Vec<VersionEntry> = serde_json::from_slice(data)?;
91
92    let mut versions: Vec<BundlerVersion> = entries
93        .into_iter()
94        .map(|e| BundlerVersion {
95            number: e.number,
96            prerelease: e.prerelease,
97            yanked: e.yanked,
98            created_at: e.created_at,
99            platform: e.platform,
100        })
101        .collect();
102
103    // Sort by version descending (newest first)
104    versions.sort_by(|a, b| compare_versions(&b.number, &a.number));
105
106    Ok(versions)
107}
108
109#[derive(Deserialize)]
110struct SearchEntry {
111    name: String,
112    info: Option<String>,
113    version: String,
114    #[serde(default)]
115    downloads: u64,
116}
117
118fn parse_search_response(data: &[u8]) -> Result<Vec<GemInfo>> {
119    let entries: Vec<SearchEntry> = serde_json::from_slice(data)?;
120
121    Ok(entries
122        .into_iter()
123        .map(|e| GemInfo {
124            name: e.name,
125            info: e.info,
126            homepage_uri: None,
127            source_code_uri: None,
128            documentation_uri: None,
129            version: e.version,
130            licenses: vec![],
131            authors: None,
132            downloads: e.downloads,
133        })
134        .collect())
135}
136
137#[derive(Deserialize)]
138struct GemInfoResponse {
139    name: String,
140    info: Option<String>,
141    version: String,
142    homepage_uri: Option<String>,
143    source_code_uri: Option<String>,
144    documentation_uri: Option<String>,
145    #[serde(default)]
146    licenses: Vec<String>,
147    authors: Option<String>,
148    #[serde(default)]
149    downloads: u64,
150}
151
152fn parse_gem_info(data: &[u8]) -> Result<GemInfo> {
153    let response: GemInfoResponse = serde_json::from_slice(data)?;
154
155    Ok(GemInfo {
156        name: response.name,
157        info: response.info,
158        homepage_uri: response.homepage_uri,
159        source_code_uri: response.source_code_uri,
160        documentation_uri: response.documentation_uri,
161        version: response.version,
162        licenses: response.licenses,
163        authors: response.authors,
164        downloads: response.downloads,
165    })
166}
167
168impl deps_core::Version for BundlerVersion {
169    fn version_string(&self) -> &str {
170        &self.number
171    }
172
173    fn is_yanked(&self) -> bool {
174        self.yanked
175    }
176
177    fn as_any(&self) -> &dyn std::any::Any {
178        self
179    }
180}
181
182impl deps_core::Metadata for GemInfo {
183    fn name(&self) -> &str {
184        &self.name
185    }
186
187    fn description(&self) -> Option<&str> {
188        self.info.as_deref()
189    }
190
191    fn repository(&self) -> Option<&str> {
192        self.source_code_uri.as_deref()
193    }
194
195    fn documentation(&self) -> Option<&str> {
196        self.documentation_uri.as_deref()
197    }
198
199    fn latest_version(&self) -> &str {
200        &self.version
201    }
202
203    fn as_any(&self) -> &dyn std::any::Any {
204        self
205    }
206}
207
208// Implement Registry trait for trait object support
209impl deps_core::Registry for RubyGemsRegistry {
210    fn get_versions<'a>(
211        &'a self,
212        name: &'a str,
213    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
214        Box::pin(async move {
215            let versions = self.get_versions(name).await?;
216            Ok(versions
217                .into_iter()
218                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
219                .collect())
220        })
221    }
222
223    fn get_latest_matching<'a>(
224        &'a self,
225        name: &'a str,
226        req: &'a str,
227    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
228        Box::pin(async move {
229            let version = self.get_latest_matching(name, req).await?;
230            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
231        })
232    }
233
234    fn search<'a>(
235        &'a self,
236        query: &'a str,
237        limit: usize,
238    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
239        Box::pin(async move {
240            let results = self.search(query, limit).await?;
241            Ok(results
242                .into_iter()
243                .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
244                .collect())
245        })
246    }
247
248    fn package_url(&self, name: &str) -> String {
249        gem_url(name)
250    }
251
252    fn as_any(&self) -> &dyn Any {
253        self
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_gem_url() {
263        assert_eq!(gem_url("rails"), "https://rubygems.org/gems/rails");
264        assert_eq!(gem_url("nokogiri"), "https://rubygems.org/gems/nokogiri");
265    }
266
267    #[test]
268    fn test_gem_url_special_chars() {
269        assert_eq!(
270            gem_url("rspec-rails"),
271            "https://rubygems.org/gems/rspec-rails"
272        );
273        assert_eq!(
274            gem_url("activerecord-import"),
275            "https://rubygems.org/gems/activerecord-import"
276        );
277    }
278
279    #[test]
280    fn test_parse_versions_response() {
281        let json = r#"[
282            {"number": "7.0.8", "prerelease": false, "yanked": false, "platform": "ruby"},
283            {"number": "7.0.7", "prerelease": false, "yanked": false, "platform": "ruby"},
284            {"number": "7.1.0.beta1", "prerelease": true, "yanked": false, "platform": "ruby"}
285        ]"#;
286
287        let versions = parse_versions_response(json.as_bytes(), "rails").unwrap();
288        assert_eq!(versions.len(), 3);
289        assert!(versions[0].prerelease); // 7.1.0.beta1 should be sorted first due to higher major
290    }
291
292    #[test]
293    fn test_parse_versions_response_with_yanked() {
294        let json = r#"[
295            {"number": "1.0.0", "prerelease": false, "yanked": true, "platform": "ruby"},
296            {"number": "0.9.0", "prerelease": false, "yanked": false, "platform": "ruby"}
297        ]"#;
298
299        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
300        assert_eq!(versions.len(), 2);
301        assert!(versions[0].yanked);
302        assert!(!versions[1].yanked);
303    }
304
305    #[test]
306    fn test_parse_versions_response_with_created_at() {
307        let json = r#"[
308            {"number": "1.0.0", "prerelease": false, "yanked": false, "created_at": "2024-01-15T10:30:00Z", "platform": "ruby"}
309        ]"#;
310
311        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
312        assert_eq!(versions.len(), 1);
313        assert_eq!(
314            versions[0].created_at,
315            Some("2024-01-15T10:30:00Z".to_string())
316        );
317    }
318
319    #[test]
320    fn test_parse_versions_response_default_platform() {
321        let json = r#"[
322            {"number": "1.0.0", "prerelease": false, "yanked": false}
323        ]"#;
324
325        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
326        assert_eq!(versions.len(), 1);
327        assert_eq!(versions[0].platform, "ruby");
328    }
329
330    #[test]
331    fn test_parse_versions_response_sorting() {
332        let json = r#"[
333            {"number": "1.0.0", "prerelease": false, "yanked": false},
334            {"number": "2.0.0", "prerelease": false, "yanked": false},
335            {"number": "1.5.0", "prerelease": false, "yanked": false}
336        ]"#;
337
338        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
339        assert_eq!(versions[0].number, "2.0.0");
340        assert_eq!(versions[1].number, "1.5.0");
341        assert_eq!(versions[2].number, "1.0.0");
342    }
343
344    #[test]
345    fn test_parse_versions_response_empty() {
346        let json = r"[]";
347        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
348        assert!(versions.is_empty());
349    }
350
351    #[test]
352    fn test_parse_search_response() {
353        let json = r#"[
354            {"name": "rails", "info": "Ruby on Rails", "version": "7.0.8", "downloads": 500000000},
355            {"name": "railties", "info": "Core", "version": "7.0.8", "downloads": 100000000}
356        ]"#;
357
358        let results = parse_search_response(json.as_bytes()).unwrap();
359        assert_eq!(results.len(), 2);
360        assert_eq!(results[0].name, "rails");
361        assert_eq!(results[0].info, Some("Ruby on Rails".to_string()));
362        assert_eq!(results[0].version, "7.0.8");
363        assert_eq!(results[0].downloads, 500_000_000);
364    }
365
366    #[test]
367    fn test_parse_search_response_minimal() {
368        let json = r#"[
369            {"name": "test", "version": "1.0.0"}
370        ]"#;
371
372        let results = parse_search_response(json.as_bytes()).unwrap();
373        assert_eq!(results.len(), 1);
374        assert_eq!(results[0].name, "test");
375        assert!(results[0].info.is_none());
376        assert_eq!(results[0].downloads, 0);
377    }
378
379    #[test]
380    fn test_parse_search_response_empty() {
381        let json = r"[]";
382        let results = parse_search_response(json.as_bytes()).unwrap();
383        assert!(results.is_empty());
384    }
385
386    #[test]
387    fn test_parse_gem_info_full() {
388        let json = r#"{
389            "name": "rails",
390            "info": "Full-stack web application framework",
391            "version": "7.0.8",
392            "homepage_uri": "https://rubyonrails.org",
393            "source_code_uri": "https://github.com/rails/rails",
394            "documentation_uri": "https://api.rubyonrails.org",
395            "licenses": ["MIT"],
396            "authors": "David Heinemeier Hansson",
397            "downloads": 500000000
398        }"#;
399
400        let info = parse_gem_info(json.as_bytes()).unwrap();
401        assert_eq!(info.name, "rails");
402        assert_eq!(
403            info.info,
404            Some("Full-stack web application framework".to_string())
405        );
406        assert_eq!(info.version, "7.0.8");
407        assert_eq!(
408            info.homepage_uri,
409            Some("https://rubyonrails.org".to_string())
410        );
411        assert_eq!(
412            info.source_code_uri,
413            Some("https://github.com/rails/rails".to_string())
414        );
415        assert_eq!(
416            info.documentation_uri,
417            Some("https://api.rubyonrails.org".to_string())
418        );
419        assert_eq!(info.licenses, vec!["MIT"]);
420        assert_eq!(info.authors, Some("David Heinemeier Hansson".to_string()));
421        assert_eq!(info.downloads, 500_000_000);
422    }
423
424    #[test]
425    fn test_parse_gem_info_minimal() {
426        let json = r#"{
427            "name": "minimal",
428            "version": "0.1.0"
429        }"#;
430
431        let info = parse_gem_info(json.as_bytes()).unwrap();
432        assert_eq!(info.name, "minimal");
433        assert_eq!(info.version, "0.1.0");
434        assert!(info.info.is_none());
435        assert!(info.homepage_uri.is_none());
436        assert!(info.source_code_uri.is_none());
437        assert!(info.documentation_uri.is_none());
438        assert!(info.licenses.is_empty());
439        assert!(info.authors.is_none());
440        assert_eq!(info.downloads, 0);
441    }
442
443    #[test]
444    fn test_parse_gem_info_with_multiple_licenses() {
445        let json = r#"{
446            "name": "test",
447            "version": "1.0.0",
448            "licenses": ["MIT", "Apache-2.0", "BSD-3-Clause"]
449        }"#;
450
451        let info = parse_gem_info(json.as_bytes()).unwrap();
452        assert_eq!(info.licenses.len(), 3);
453        assert!(info.licenses.contains(&"MIT".to_string()));
454        assert!(info.licenses.contains(&"Apache-2.0".to_string()));
455    }
456
457    #[tokio::test]
458    async fn test_registry_creation() {
459        let cache = Arc::new(HttpCache::new());
460        let _registry = RubyGemsRegistry::new(cache);
461    }
462
463    #[test]
464    fn test_version_trait() {
465        use deps_core::Version;
466
467        let version = BundlerVersion {
468            number: "1.0.0".into(),
469            prerelease: false,
470            yanked: true,
471            created_at: None,
472            platform: "ruby".into(),
473        };
474
475        assert_eq!(version.version_string(), "1.0.0");
476        assert!(version.is_yanked());
477        assert!(version.features().is_empty());
478    }
479
480    #[test]
481    fn test_metadata_trait() {
482        use deps_core::Metadata;
483
484        let gem = GemInfo {
485            name: "test".into(),
486            info: Some("A test gem".into()),
487            homepage_uri: None,
488            source_code_uri: Some("https://github.com/test/test".into()),
489            documentation_uri: Some("https://docs.test.com".into()),
490            version: "1.0.0".into(),
491            licenses: vec![],
492            authors: None,
493            downloads: 0,
494        };
495
496        assert_eq!(gem.name(), "test");
497        assert_eq!(gem.description(), Some("A test gem"));
498        assert_eq!(gem.repository(), Some("https://github.com/test/test"));
499        assert_eq!(gem.documentation(), Some("https://docs.test.com"));
500        assert_eq!(gem.latest_version(), "1.0.0");
501    }
502
503    #[test]
504    fn test_metadata_trait_empty_optionals() {
505        use deps_core::Metadata;
506
507        let gem = GemInfo {
508            name: "empty".into(),
509            info: None,
510            homepage_uri: None,
511            source_code_uri: None,
512            documentation_uri: None,
513            version: "0.1.0".into(),
514            licenses: vec![],
515            authors: None,
516            downloads: 0,
517        };
518
519        assert!(gem.description().is_none());
520        assert!(gem.repository().is_none());
521        assert!(gem.documentation().is_none());
522    }
523
524    #[test]
525    fn test_registry_package_url() {
526        use deps_core::Registry;
527
528        let cache = Arc::new(HttpCache::new());
529        let registry = RubyGemsRegistry::new(cache);
530
531        assert_eq!(
532            registry.package_url("rails"),
533            "https://rubygems.org/gems/rails"
534        );
535    }
536
537    #[test]
538    fn test_registry_as_any() {
539        use deps_core::Registry;
540
541        let cache = Arc::new(HttpCache::new());
542        let registry = RubyGemsRegistry::new(cache);
543
544        let any = registry.as_any();
545        assert!(any.is::<RubyGemsRegistry>());
546        assert!(any.downcast_ref::<RubyGemsRegistry>().is_some());
547    }
548
549    #[test]
550    fn test_default_platform_function() {
551        assert_eq!(default_platform(), "ruby");
552    }
553
554    #[tokio::test]
555    #[ignore] // Requires network access
556    async fn test_fetch_real_rails_versions() {
557        let cache = Arc::new(HttpCache::new());
558        let registry = RubyGemsRegistry::new(cache);
559        let versions = registry.get_versions("rails").await.unwrap();
560
561        assert!(!versions.is_empty());
562        assert!(versions.iter().any(|v| v.number.starts_with("7.")));
563    }
564}