1use 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
14pub const RUBYGEMS_URL: &str = "https://rubygems.org/gems";
16
17pub fn gem_url(name: &str) -> String {
19 format!("{RUBYGEMS_URL}/{name}")
20}
21
22#[derive(Clone)]
24pub struct RubyGemsRegistry {
25 cache: Arc<HttpCache>,
26}
27
28impl RubyGemsRegistry {
29 pub const fn new(cache: Arc<HttpCache>) -> Self {
31 Self { cache }
32 }
33
34 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 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 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 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 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
208impl 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); }
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] 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}