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::collections::HashMap;
11use std::sync::Arc;
12
13const RUBYGEMS_API_BASE: &str = "https://rubygems.org/api/v1";
14
15/// Display name for RubyGems used in not-found and API-response error messages.
16pub const REGISTRY: &str = "RubyGems";
17
18/// Base URL for gem pages on rubygems.org.
19pub const RUBYGEMS_URL: &str = "https://rubygems.org/gems";
20
21/// Returns the URL for a gem's page on rubygems.org.
22///
23/// Display link only, never fetched by this process — unlike `versions_url`/`gem_info_url`
24/// (fetch sinks), so it is deliberately not gated against a `.`/`..` name (see
25/// [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link scope split, #379).
26pub fn gem_url(name: &str) -> String {
27    format!("{RUBYGEMS_URL}/{}", urlencoding::encode(name))
28}
29
30/// Builds the rubygems.org API request URL for a gem's version list.
31fn versions_url(name: &str) -> String {
32    format!(
33        "{RUBYGEMS_API_BASE}/versions/{}.json",
34        urlencoding::encode(name)
35    )
36}
37
38/// Builds the rubygems.org API request URL for a gem's detailed info.
39fn gem_info_url(name: &str) -> String {
40    format!(
41        "{RUBYGEMS_API_BASE}/gems/{}.json",
42        urlencoding::encode(name)
43    )
44}
45
46/// Client for interacting with rubygems.org registry.
47#[derive(Clone)]
48pub struct RubyGemsRegistry {
49    cache: Arc<HttpCache>,
50}
51
52impl RubyGemsRegistry {
53    /// Creates a new registry client with the given HTTP cache.
54    pub const fn new(cache: Arc<HttpCache>) -> Self {
55        Self { cache }
56    }
57
58    /// Fetches all versions for a gem.
59    pub async fn get_versions(&self, name: &str) -> Result<Vec<BundlerVersion>> {
60        let url = versions_url(name);
61        let data = self.cache.get_cached(&url).await?;
62        parse_versions_response(&data, name)
63    }
64
65    /// Finds the latest version matching the given requirement.
66    pub async fn get_latest_matching(
67        &self,
68        name: &str,
69        req_str: &str,
70    ) -> Result<Option<BundlerVersion>> {
71        let versions = self.get_versions(name).await?;
72        Ok(versions
73            .into_iter()
74            .find(|v| version_matches_requirement(v.number.as_str(), req_str) && !v.yanked))
75    }
76
77    /// Searches for gems by name/keywords.
78    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<GemInfo>> {
79        let url = format!(
80            "{}/search.json?query={}",
81            RUBYGEMS_API_BASE,
82            urlencoding::encode(query)
83        );
84        let data = self.cache.get_cached(&url).await?;
85        let gems = parse_search_response(&data)?;
86        Ok(gems.into_iter().take(limit).collect())
87    }
88
89    /// Gets detailed gem information.
90    pub async fn get_gem_info(&self, name: &str) -> Result<GemInfo> {
91        let url = gem_info_url(name);
92        let data = self.cache.get_cached(&url).await?;
93        parse_gem_info(&data)
94    }
95}
96
97#[derive(Deserialize)]
98struct VersionEntry {
99    number: String,
100    #[serde(default)]
101    prerelease: bool,
102    // RubyGems' `versions.json` never sends this key: yanked versions are
103    // omitted from the response entirely rather than flagged (verified live
104    // 2026-08-24 against rest-client's known-yanked 1.6.2/1.6.10-1.6.13,
105    // absent from the API but shown yanked on the HTML versions page). This
106    // field is therefore permanently `false` in practice — see
107    // `reports_yanked` below (#298).
108    #[serde(default)]
109    yanked: bool,
110    created_at: Option<String>,
111    #[serde(default = "default_platform")]
112    platform: String,
113}
114
115fn default_platform() -> String {
116    "ruby".to_string()
117}
118
119fn parse_versions_response(data: &[u8], _gem_name: &str) -> Result<Vec<BundlerVersion>> {
120    let entries: Vec<VersionEntry> = deps_core::parse_json_checked(data)?;
121
122    // RubyGems' `versions.json` returns one entry per (version, platform) pair, so a gem
123    // shipping platform-specific prebuilt gems (nokogiri, ffi, sassc, ...) has multiple
124    // entries sharing the same `number` across platforms. Dedup by version number, keeping
125    // the `ruby` platform entry when a duplicate exists since it's the generic variant.
126    //
127    // First-seen (JSON) order is preserved rather than collected via `HashMap::into_values`
128    // (whose iteration order is randomized per-process): `compare_versions` can still tie two
129    // entries (e.g. `"1.0"` and `"1.0.0"`, zero-padded to equal), so the later stable sort's
130    // tie-break depends on this order staying deterministic across hovers.
131    let mut versions: Vec<BundlerVersion> = Vec::with_capacity(entries.len());
132    let mut index_by_number: HashMap<String, usize> = HashMap::with_capacity(entries.len());
133    for e in entries {
134        let version = BundlerVersion {
135            number: e.number.into(),
136            prerelease: e.prerelease,
137            yanked: e.yanked,
138            published_at: e
139                .created_at
140                .as_deref()
141                .and_then(deps_core::PublishTime::parse_rfc3339),
142            platform: e.platform,
143        };
144        if let Some(&idx) = index_by_number.get(version.number.as_str()) {
145            if version.platform == "ruby" && versions[idx].platform != "ruby" {
146                versions[idx] = version;
147            }
148        } else {
149            index_by_number.insert(version.number.to_string(), versions.len());
150            versions.push(version);
151        }
152    }
153
154    // Sort by version descending (newest first)
155    versions.sort_by(|a, b| compare_versions(b.number.as_str(), a.number.as_str()));
156
157    Ok(versions)
158}
159
160#[derive(Deserialize)]
161struct SearchEntry {
162    name: String,
163    info: Option<String>,
164    version: String,
165    #[serde(default)]
166    downloads: u64,
167}
168
169fn parse_search_response(data: &[u8]) -> Result<Vec<GemInfo>> {
170    let entries: Vec<SearchEntry> = deps_core::parse_json_checked(data)?;
171
172    Ok(entries
173        .into_iter()
174        .map(|e| GemInfo {
175            name: e.name.into(),
176            info: e.info,
177            homepage_uri: None,
178            source_code_uri: None,
179            documentation_uri: None,
180            version: e.version.into(),
181            licenses: vec![],
182            authors: None,
183            downloads: e.downloads,
184        })
185        .collect())
186}
187
188#[derive(Deserialize)]
189struct GemInfoResponse {
190    name: String,
191    info: Option<String>,
192    version: String,
193    homepage_uri: Option<String>,
194    source_code_uri: Option<String>,
195    documentation_uri: Option<String>,
196    #[serde(default)]
197    licenses: Vec<String>,
198    authors: Option<String>,
199    #[serde(default)]
200    downloads: u64,
201}
202
203fn parse_gem_info(data: &[u8]) -> Result<GemInfo> {
204    let response: GemInfoResponse = deps_core::parse_json_checked(data)?;
205
206    Ok(GemInfo {
207        name: response.name.into(),
208        info: response.info,
209        homepage_uri: response.homepage_uri,
210        source_code_uri: response.source_code_uri,
211        documentation_uri: response.documentation_uri,
212        version: response.version.into(),
213        licenses: response.licenses,
214        authors: response.authors,
215        downloads: response.downloads,
216    })
217}
218
219impl deps_core::Version for BundlerVersion {
220    fn version_string(&self) -> &deps_core::ConcreteVersion {
221        &self.number
222    }
223
224    fn removal_status(&self) -> deps_core::RemovalStatus {
225        deps_core::RemovalStatus::from_yanked(self.yanked)
226    }
227
228    // RubyGems flags prereleases with dot notation (`7.1.0.beta1`, `7.1.0.rc1`), not the
229    // hyphenated style (`-beta`, `-rc`) `Version::is_prerelease`'s default heuristic checks
230    // for, so the default would misclassify a real Bundler prerelease as stable. `prerelease`
231    // is the registry's own flag for this version — trust it instead (#313).
232    fn is_prerelease(&self) -> bool {
233        self.prerelease
234    }
235
236    fn published_at(&self) -> Option<deps_core::PublishTime> {
237        self.published_at
238    }
239
240    fn as_any(&self) -> &dyn std::any::Any {
241        self
242    }
243}
244
245impl deps_core::Metadata for GemInfo {
246    fn name(&self) -> &deps_core::PackageName {
247        &self.name
248    }
249
250    fn description(&self) -> Option<&str> {
251        self.info.as_deref()
252    }
253
254    fn repository(&self) -> Option<&str> {
255        self.source_code_uri.as_deref()
256    }
257
258    fn documentation(&self) -> Option<&str> {
259        self.documentation_uri.as_deref()
260    }
261
262    fn latest_version(&self) -> &deps_core::ConcreteVersion {
263        &self.version
264    }
265
266    fn as_any(&self) -> &dyn std::any::Any {
267        self
268    }
269}
270
271// Implement Registry trait for trait object support
272impl deps_core::Registry for RubyGemsRegistry {
273    fn get_versions<'a>(
274        &'a self,
275        name: &'a deps_core::PackageName,
276    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
277        Box::pin(async move {
278            let versions = self.get_versions(name.as_str()).await?;
279            Ok(versions
280                .into_iter()
281                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
282                .collect())
283        })
284    }
285
286    fn get_latest_matching<'a>(
287        &'a self,
288        name: &'a deps_core::PackageName,
289        req: &'a deps_core::VersionReq,
290    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
291        Box::pin(async move {
292            let version = self
293                .get_latest_matching(name.as_str(), req.as_str())
294                .await?;
295            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
296        })
297    }
298
299    fn search<'a>(
300        &'a self,
301        query: &'a str,
302        limit: usize,
303    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
304        Box::pin(async move {
305            let results = self.search(query, limit).await?;
306            Ok(results
307                .into_iter()
308                .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
309                .collect())
310        })
311    }
312
313    fn select_latest_matching(
314        &self,
315        versions: &[Box<dyn deps_core::Version>],
316        req: &deps_core::VersionReq,
317    ) -> Option<usize> {
318        versions.iter().position(|v| {
319            version_matches_requirement(v.version_string().as_str(), req.as_str())
320                && !v.removal_status().blocks_resolution()
321        })
322    }
323
324    // RubyGems' `versions.json` (the sole data source here) omits yanked
325    // versions from the response entirely instead of flagging them with a
326    // `yanked` key — confirmed live against rest-client's known-yanked
327    // versions, which are absent from the JSON array but shown yanked on the
328    // HTML versions page. `VersionEntry.yanked` above is therefore
329    // permanently `false`, so the yanked-diagnostic path must be told this
330    // registry cannot answer rather than silently reporting "not yanked"
331    // (#298).
332    fn reports_yanked(&self) -> bool {
333        false
334    }
335
336    fn as_any(&self) -> &dyn Any {
337        self
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    #[test]
346    fn test_gem_url() {
347        assert_eq!(gem_url("rails"), "https://rubygems.org/gems/rails");
348        assert_eq!(gem_url("nokogiri"), "https://rubygems.org/gems/nokogiri");
349    }
350
351    #[test]
352    fn test_gem_url_special_chars() {
353        assert_eq!(
354            gem_url("rspec-rails"),
355            "https://rubygems.org/gems/rspec-rails"
356        );
357        assert_eq!(
358            gem_url("activerecord-import"),
359            "https://rubygems.org/gems/activerecord-import"
360        );
361    }
362
363    #[test]
364    fn test_gem_url_encodes_malicious_name() {
365        let url = gem_url("evil](https://evil.example)[pkg");
366        assert!(!url.contains('('));
367        assert!(!url.contains(')'));
368        assert!(!url.contains('['));
369        assert!(!url.contains(']'));
370    }
371
372    #[test]
373    fn test_gem_url_encodes_newline_autolink_and_percent() {
374        let url = gem_url("evil\n<https://evil%zz.example>");
375        assert!(!url.contains('\n'));
376        assert!(!url.contains('<'));
377        assert!(!url.contains('>'));
378        assert!(url.contains("%25"));
379    }
380
381    #[test]
382    fn test_gem_url_empty_name() {
383        assert_eq!(gem_url(""), "https://rubygems.org/gems/");
384    }
385
386    #[test]
387    fn test_parse_versions_response() {
388        let json = r#"[
389            {"number": "7.0.8", "prerelease": false, "yanked": false, "platform": "ruby"},
390            {"number": "7.0.7", "prerelease": false, "yanked": false, "platform": "ruby"},
391            {"number": "7.1.0.beta1", "prerelease": true, "yanked": false, "platform": "ruby"}
392        ]"#;
393
394        let versions = parse_versions_response(json.as_bytes(), "rails").unwrap();
395        assert_eq!(versions.len(), 3);
396        assert!(versions[0].prerelease); // 7.1.0.beta1 should be sorted first due to higher major
397    }
398
399    #[test]
400    fn test_parse_versions_response_with_yanked() {
401        let json = r#"[
402            {"number": "1.0.0", "prerelease": false, "yanked": true, "platform": "ruby"},
403            {"number": "0.9.0", "prerelease": false, "yanked": false, "platform": "ruby"}
404        ]"#;
405
406        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
407        assert_eq!(versions.len(), 2);
408        assert!(versions[0].yanked);
409        assert!(!versions[1].yanked);
410    }
411
412    #[test]
413    fn test_parse_versions_response_with_created_at() {
414        let json = r#"[
415            {"number": "1.0.0", "prerelease": false, "yanked": false, "created_at": "2024-01-15T10:30:00Z", "platform": "ruby"}
416        ]"#;
417
418        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
419        assert_eq!(versions.len(), 1);
420        assert_eq!(
421            versions[0].published_at,
422            deps_core::PublishTime::parse_rfc3339("2024-01-15T10:30:00Z")
423        );
424    }
425
426    #[test]
427    fn test_parse_versions_response_without_created_at() {
428        let json = r#"[
429            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "ruby"}
430        ]"#;
431
432        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
433        assert_eq!(versions.len(), 1);
434        assert!(versions[0].published_at.is_none());
435    }
436
437    #[test]
438    fn test_parse_versions_response_with_malformed_created_at() {
439        let json = r#"[
440            {"number": "1.0.0", "prerelease": false, "yanked": false, "created_at": "not-a-timestamp", "platform": "ruby"}
441        ]"#;
442
443        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
444        assert_eq!(versions.len(), 1);
445        assert!(
446            versions[0].published_at.is_none(),
447            "malformed created_at degrades to None, not an error"
448        );
449    }
450
451    #[test]
452    fn test_parse_versions_response_default_platform() {
453        let json = r#"[
454            {"number": "1.0.0", "prerelease": false, "yanked": false}
455        ]"#;
456
457        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
458        assert_eq!(versions.len(), 1);
459        assert_eq!(versions[0].platform, "ruby");
460    }
461
462    #[test]
463    fn test_parse_versions_response_sorting() {
464        let json = r#"[
465            {"number": "1.0.0", "prerelease": false, "yanked": false},
466            {"number": "2.0.0", "prerelease": false, "yanked": false},
467            {"number": "1.5.0", "prerelease": false, "yanked": false}
468        ]"#;
469
470        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
471        assert_eq!(versions[0].number, "2.0.0");
472        assert_eq!(versions[1].number, "1.5.0");
473        assert_eq!(versions[2].number, "1.0.0");
474    }
475
476    #[test]
477    fn test_parse_versions_response_dedups_platform_duplicates() {
478        let json = r#"[
479            {"number": "1.19.4", "prerelease": false, "yanked": false, "platform": "x86_64-linux"},
480            {"number": "1.19.4", "prerelease": false, "yanked": false, "platform": "ruby"},
481            {"number": "1.19.4", "prerelease": false, "yanked": false, "platform": "x64-mingw32"},
482            {"number": "1.19.3", "prerelease": false, "yanked": false, "platform": "java"}
483        ]"#;
484
485        let versions = parse_versions_response(json.as_bytes(), "nokogiri").unwrap();
486        assert_eq!(versions.len(), 2);
487        assert_eq!(versions[0].number, "1.19.4");
488        assert_eq!(
489            versions[0].platform, "ruby",
490            "dedup should prefer the ruby platform entry"
491        );
492        assert_eq!(versions[1].number, "1.19.3");
493        assert_eq!(versions[1].platform, "java");
494    }
495
496    #[test]
497    fn test_parse_versions_response_dedup_without_ruby_platform() {
498        let json = r#"[
499            {"number": "2.0.0", "prerelease": false, "yanked": false, "platform": "x86-mswin32"},
500            {"number": "2.0.0", "prerelease": false, "yanked": false, "platform": "java"}
501        ]"#;
502
503        let versions = parse_versions_response(json.as_bytes(), "grpc").unwrap();
504        assert_eq!(versions.len(), 1);
505        assert_eq!(versions[0].number, "2.0.0");
506    }
507
508    #[test]
509    fn test_parse_versions_response_single_version_across_all_platforms() {
510        // A gem with exactly one released version, shipped as prebuilt gems for every
511        // supported platform plus the generic `ruby` variant: dedup must collapse all
512        // of these down to the single distinct version, not zero or several.
513        let json = r#"[
514            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "x86_64-linux"},
515            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "x86_64-darwin"},
516            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "x64-mingw32"},
517            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "java"},
518            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "ruby"}
519        ]"#;
520
521        let versions = parse_versions_response(json.as_bytes(), "grpc").unwrap();
522        assert_eq!(
523            versions.len(),
524            1,
525            "five platform entries for one version number must dedup to one distinct version"
526        );
527        assert_eq!(versions[0].platform, "ruby");
528    }
529
530    #[test]
531    fn test_parse_versions_response_dedup_is_order_deterministic_across_runs() {
532        // `compare_versions` (#323) correctly orders "3.7.0" above its own "pre1"/"pre2"
533        // prereleases rather than tying them, so this input no longer exercises a tie. Use
534        // a shape that still ties post-#323 instead — "1.0" and "1.0.0" zero-pad to equal —
535        // to keep covering the dedup step's first-seen-order preservation, which the later
536        // stable sort's tie-break depends on staying deterministic across hovers.
537        // Collecting via `HashMap::into_values` would randomize that order per-process
538        // instead. Run repeatedly to catch that regression, since a HashMap's random seed
539        // is fixed for the life of one process and a single run could pass by chance.
540        let json = r#"[
541            {"number": "1.0", "prerelease": false, "yanked": false, "platform": "ruby"},
542            {"number": "2.0.0", "prerelease": false, "yanked": false, "platform": "ruby"},
543            {"number": "1.0.0", "prerelease": false, "yanked": false, "platform": "ruby"}
544        ]"#;
545
546        for _ in 0..20 {
547            let versions = parse_versions_response(json.as_bytes(), "mime-types").unwrap();
548            assert_eq!(versions.len(), 3);
549            assert_eq!(versions[0].number, "2.0.0");
550            assert_eq!(versions[1].number, "1.0");
551            assert_eq!(versions[2].number, "1.0.0");
552        }
553    }
554
555    #[test]
556    fn test_parse_versions_response_orders_dot_notation_prerelease_below_stable() {
557        // Regression test for #323, replacing this test's old expectation (kept as
558        // `..._dedup_is_order_deterministic_across_runs` above): before the fix, mime-types'
559        // real "3.7.0"/"3.7.0.pre1"/"3.7.0.pre2" tied under `compare_versions` and this sort
560        // only preserved JSON input order by accident. It must now sort by actual version
561        // precedence.
562        let json = r#"[
563            {"number": "3.7.0.pre2", "prerelease": true, "yanked": false, "platform": "ruby"},
564            {"number": "3.7.0.pre1", "prerelease": true, "yanked": false, "platform": "ruby"},
565            {"number": "3.7.0", "prerelease": false, "yanked": false, "platform": "ruby"}
566        ]"#;
567
568        let versions = parse_versions_response(json.as_bytes(), "mime-types").unwrap();
569        assert_eq!(versions.len(), 3);
570        assert_eq!(versions[0].number, "3.7.0");
571        assert_eq!(versions[1].number, "3.7.0.pre2");
572        assert_eq!(versions[2].number, "3.7.0.pre1");
573    }
574
575    #[test]
576    fn test_parse_versions_response_empty() {
577        let json = r"[]";
578        let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
579        assert!(versions.is_empty());
580    }
581
582    #[test]
583    fn test_parse_search_response() {
584        let json = r#"[
585            {"name": "rails", "info": "Ruby on Rails", "version": "7.0.8", "downloads": 500000000},
586            {"name": "railties", "info": "Core", "version": "7.0.8", "downloads": 100000000}
587        ]"#;
588
589        let results = parse_search_response(json.as_bytes()).unwrap();
590        assert_eq!(results.len(), 2);
591        assert_eq!(results[0].name, "rails");
592        assert_eq!(results[0].info, Some("Ruby on Rails".to_string()));
593        assert_eq!(results[0].version, "7.0.8");
594        assert_eq!(results[0].downloads, 500_000_000);
595    }
596
597    #[test]
598    fn test_parse_search_response_minimal() {
599        let json = r#"[
600            {"name": "test", "version": "1.0.0"}
601        ]"#;
602
603        let results = parse_search_response(json.as_bytes()).unwrap();
604        assert_eq!(results.len(), 1);
605        assert_eq!(results[0].name, "test");
606        assert!(results[0].info.is_none());
607        assert_eq!(results[0].downloads, 0);
608    }
609
610    #[test]
611    fn test_parse_search_response_empty() {
612        let json = r"[]";
613        let results = parse_search_response(json.as_bytes()).unwrap();
614        assert!(results.is_empty());
615    }
616
617    #[test]
618    fn test_parse_gem_info_full() {
619        let json = r#"{
620            "name": "rails",
621            "info": "Full-stack web application framework",
622            "version": "7.0.8",
623            "homepage_uri": "https://rubyonrails.org",
624            "source_code_uri": "https://github.com/rails/rails",
625            "documentation_uri": "https://api.rubyonrails.org",
626            "licenses": ["MIT"],
627            "authors": "David Heinemeier Hansson",
628            "downloads": 500000000
629        }"#;
630
631        let info = parse_gem_info(json.as_bytes()).unwrap();
632        assert_eq!(info.name, "rails");
633        assert_eq!(
634            info.info,
635            Some("Full-stack web application framework".to_string())
636        );
637        assert_eq!(info.version, "7.0.8");
638        assert_eq!(
639            info.homepage_uri,
640            Some("https://rubyonrails.org".to_string())
641        );
642        assert_eq!(
643            info.source_code_uri,
644            Some("https://github.com/rails/rails".to_string())
645        );
646        assert_eq!(
647            info.documentation_uri,
648            Some("https://api.rubyonrails.org".to_string())
649        );
650        assert_eq!(info.licenses, vec!["MIT"]);
651        assert_eq!(info.authors, Some("David Heinemeier Hansson".to_string()));
652        assert_eq!(info.downloads, 500_000_000);
653    }
654
655    #[test]
656    fn test_parse_gem_info_minimal() {
657        let json = r#"{
658            "name": "minimal",
659            "version": "0.1.0"
660        }"#;
661
662        let info = parse_gem_info(json.as_bytes()).unwrap();
663        assert_eq!(info.name, "minimal");
664        assert_eq!(info.version, "0.1.0");
665        assert!(info.info.is_none());
666        assert!(info.homepage_uri.is_none());
667        assert!(info.source_code_uri.is_none());
668        assert!(info.documentation_uri.is_none());
669        assert!(info.licenses.is_empty());
670        assert!(info.authors.is_none());
671        assert_eq!(info.downloads, 0);
672    }
673
674    #[test]
675    fn test_parse_gem_info_with_multiple_licenses() {
676        let json = r#"{
677            "name": "test",
678            "version": "1.0.0",
679            "licenses": ["MIT", "Apache-2.0", "BSD-3-Clause"]
680        }"#;
681
682        let info = parse_gem_info(json.as_bytes()).unwrap();
683        assert_eq!(info.licenses.len(), 3);
684        assert!(info.licenses.contains(&"MIT".to_string()));
685        assert!(info.licenses.contains(&"Apache-2.0".to_string()));
686    }
687
688    #[tokio::test]
689    async fn test_registry_creation() {
690        let cache = Arc::new(HttpCache::new());
691        let _registry = RubyGemsRegistry::new(cache);
692    }
693
694    /// #349: `foo?callback=evil` must not inject a query string into the
695    /// `versions.json` request URL — the raw `?` has to be percent-encoded, not left to
696    /// reach the request as a literal query separator. Asserted on the *parsed* URL
697    /// (`url::Url::parse`), not the raw format-string output: a `!url.contains('?')`
698    /// check alone would pass even while a decoded round trip still produced a real
699    /// query component at the transport layer.
700    #[test]
701    fn test_get_versions_url_encodes_query_injection() {
702        let name = "foo?callback=evil";
703        let url = versions_url(name);
704        let parsed = url::Url::parse(&url).unwrap();
705        assert!(parsed.query().is_none());
706        assert_eq!(parsed.path_segments().unwrap().count(), 4);
707        let last = parsed.path_segments().unwrap().next_back().unwrap();
708        assert_eq!(
709            urlencoding::decode(last.strip_suffix(".json").unwrap()).unwrap(),
710            name
711        );
712    }
713
714    /// #349: same query-injection guard for `get_gem_info`'s `gems/{name}.json` URL.
715    #[test]
716    fn test_get_gem_info_url_encodes_query_injection() {
717        let name = "foo?callback=evil";
718        let url = gem_info_url(name);
719        let parsed = url::Url::parse(&url).unwrap();
720        assert!(parsed.query().is_none());
721        assert_eq!(parsed.path_segments().unwrap().count(), 4);
722        let last = parsed.path_segments().unwrap().next_back().unwrap();
723        assert_eq!(
724            urlencoding::decode(last.strip_suffix(".json").unwrap()).unwrap(),
725            name
726        );
727    }
728
729    /// #365 regression sweep: exercises the real production `versions_url`/`gem_info_url`
730    /// sinks against the shared adversarial input set. Both are safe by construction — the
731    /// literal `.json` suffix is glued directly onto the encoded name with no `/`
732    /// separator, so a bare `.`/`..` can never become an exact dot-segment — but this
733    /// guards that property against a future regression (e.g. a format string change that
734    /// inserts a separator before `.json`).
735    #[test]
736    fn test_versions_and_gem_info_url_dot_segment_sweep() {
737        deps_core::test_util::assert_dot_segment_gated_or_contained(
738            |seg| Some(versions_url(seg)),
739            "rubygems.org",
740            "/api/v1/versions/",
741        );
742        deps_core::test_util::assert_dot_segment_gated_or_contained(
743            |seg| Some(gem_info_url(seg)),
744            "rubygems.org",
745            "/api/v1/gems/",
746        );
747    }
748
749    #[test]
750    fn test_version_trait() {
751        use deps_core::Version;
752
753        let version = BundlerVersion {
754            number: "1.0.0".into(),
755            prerelease: false,
756            yanked: true,
757            published_at: None,
758            platform: "ruby".into(),
759        };
760
761        assert_eq!(version.version_string(), "1.0.0");
762        assert!(version.removal_status().blocks_resolution());
763        assert!(version.features().is_empty());
764    }
765
766    #[test]
767    fn test_version_trait_is_prerelease_uses_registry_flag_not_hyphen_heuristic() {
768        use deps_core::Version;
769
770        // RubyGems flags prereleases with dot notation, not deps-core's default
771        // hyphen-based heuristic (`-beta`, `-rc`, ...).
772        let prerelease = BundlerVersion {
773            number: "7.1.0.beta1".into(),
774            prerelease: true,
775            yanked: false,
776            published_at: None,
777            platform: "ruby".into(),
778        };
779        assert!(
780            prerelease.is_prerelease(),
781            "the registry's own prerelease flag must be trusted, not the hyphen heuristic"
782        );
783
784        let stable = BundlerVersion {
785            number: "7.1.0".into(),
786            prerelease: false,
787            yanked: false,
788            published_at: None,
789            platform: "ruby".into(),
790        };
791        assert!(!stable.is_prerelease());
792    }
793
794    #[test]
795    fn test_find_latest_stable_skips_dot_notation_bundler_prerelease() {
796        use deps_core::{Version, find_latest_stable};
797
798        let versions: Vec<Box<dyn Version>> = vec![
799            Box::new(BundlerVersion {
800                number: "7.1.0.beta1".into(),
801                prerelease: true,
802                yanked: false,
803                published_at: None,
804                platform: "ruby".into(),
805            }),
806            Box::new(BundlerVersion {
807                number: "7.0.8".into(),
808                prerelease: false,
809                yanked: false,
810                published_at: None,
811                platform: "ruby".into(),
812            }),
813        ];
814
815        let latest = find_latest_stable(&versions);
816        assert_eq!(
817            latest.map(|v| v.version_string().as_str()),
818            Some("7.0.8"),
819            "find_latest_stable (which #313's hover fix depends on) must skip the dot-notation prerelease"
820        );
821    }
822
823    #[test]
824    fn test_metadata_trait() {
825        use deps_core::Metadata;
826
827        let gem = GemInfo {
828            name: "test".into(),
829            info: Some("A test gem".into()),
830            homepage_uri: None,
831            source_code_uri: Some("https://github.com/test/test".into()),
832            documentation_uri: Some("https://docs.test.com".into()),
833            version: "1.0.0".into(),
834            licenses: vec![],
835            authors: None,
836            downloads: 0,
837        };
838
839        assert_eq!(gem.name(), "test");
840        assert_eq!(gem.description(), Some("A test gem"));
841        assert_eq!(gem.repository(), Some("https://github.com/test/test"));
842        assert_eq!(gem.documentation(), Some("https://docs.test.com"));
843        assert_eq!(gem.latest_version(), "1.0.0");
844    }
845
846    #[test]
847    fn test_metadata_trait_empty_optionals() {
848        use deps_core::Metadata;
849
850        let gem = GemInfo {
851            name: "empty".into(),
852            info: None,
853            homepage_uri: None,
854            source_code_uri: None,
855            documentation_uri: None,
856            version: "0.1.0".into(),
857            licenses: vec![],
858            authors: None,
859            downloads: 0,
860        };
861
862        assert!(gem.description().is_none());
863        assert!(gem.repository().is_none());
864        assert!(gem.documentation().is_none());
865    }
866
867    #[test]
868    fn test_registry_reports_yanked_false() {
869        use deps_core::Registry;
870
871        let cache = Arc::new(HttpCache::new());
872        let registry = RubyGemsRegistry::new(cache);
873
874        assert!(!registry.reports_yanked());
875    }
876
877    #[test]
878    fn test_registry_as_any() {
879        use deps_core::Registry;
880
881        let cache = Arc::new(HttpCache::new());
882        let registry = RubyGemsRegistry::new(cache);
883
884        let any = registry.as_any();
885        assert!(any.is::<RubyGemsRegistry>());
886        assert!(any.downcast_ref::<RubyGemsRegistry>().is_some());
887    }
888
889    #[test]
890    fn test_default_platform_function() {
891        assert_eq!(default_platform(), "ruby");
892    }
893
894    #[test]
895    fn test_select_latest_matching_not_default_none() {
896        use deps_core::{Registry, VersionReq};
897
898        let cache = Arc::new(HttpCache::new());
899        let registry = RubyGemsRegistry::new(cache);
900        let versions: Vec<Box<dyn deps_core::Version>> = vec![
901            Box::new(BundlerVersion {
902                number: "2.0.0".into(),
903                prerelease: false,
904                yanked: true,
905                published_at: None,
906                platform: "ruby".into(),
907            }),
908            Box::new(BundlerVersion {
909                number: "1.0.0".into(),
910                prerelease: false,
911                yanked: false,
912                published_at: None,
913                platform: "ruby".into(),
914            }),
915        ];
916        let req = VersionReq::new("*");
917        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
918    }
919
920    #[tokio::test]
921    #[ignore] // Requires network access
922    async fn test_fetch_real_rails_versions() {
923        let cache = Arc::new(HttpCache::new());
924        let registry = RubyGemsRegistry::new(cache);
925        let versions = registry.get_versions("rails").await.unwrap();
926
927        assert!(!versions.is_empty());
928        assert!(versions.iter().any(|v| v.number.as_str().starts_with("7.")));
929    }
930}