use crate::types::{BundlerVersion, GemInfo};
use crate::version::{compare_versions, version_matches_requirement};
use deps_core::{HttpCache, Result};
use serde::Deserialize;
use std::any::Any;
use std::sync::Arc;
const RUBYGEMS_API_BASE: &str = "https://rubygems.org/api/v1";
pub const RUBYGEMS_URL: &str = "https://rubygems.org/gems";
pub fn gem_url(name: &str) -> String {
format!("{RUBYGEMS_URL}/{name}")
}
#[derive(Clone)]
pub struct RubyGemsRegistry {
cache: Arc<HttpCache>,
}
impl RubyGemsRegistry {
pub const fn new(cache: Arc<HttpCache>) -> Self {
Self { cache }
}
pub async fn get_versions(&self, name: &str) -> Result<Vec<BundlerVersion>> {
let url = format!("{}/versions/{}.json", RUBYGEMS_API_BASE, name);
let data = self.cache.get_cached(&url).await?;
parse_versions_response(&data, name)
}
pub async fn get_latest_matching(
&self,
name: &str,
req_str: &str,
) -> Result<Option<BundlerVersion>> {
let versions = self.get_versions(name).await?;
Ok(versions
.into_iter()
.find(|v| version_matches_requirement(&v.number, req_str) && !v.yanked))
}
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<GemInfo>> {
let url = format!(
"{}/search.json?query={}",
RUBYGEMS_API_BASE,
urlencoding::encode(query)
);
let data = self.cache.get_cached(&url).await?;
let gems = parse_search_response(&data)?;
Ok(gems.into_iter().take(limit).collect())
}
pub async fn get_gem_info(&self, name: &str) -> Result<GemInfo> {
let url = format!("{}/gems/{}.json", RUBYGEMS_API_BASE, name);
let data = self.cache.get_cached(&url).await?;
parse_gem_info(&data)
}
}
#[derive(Deserialize)]
struct VersionEntry {
number: String,
#[serde(default)]
prerelease: bool,
#[serde(default)]
yanked: bool,
created_at: Option<String>,
#[serde(default = "default_platform")]
platform: String,
}
fn default_platform() -> String {
"ruby".to_string()
}
fn parse_versions_response(data: &[u8], _gem_name: &str) -> Result<Vec<BundlerVersion>> {
let entries: Vec<VersionEntry> = serde_json::from_slice(data)?;
let mut versions: Vec<BundlerVersion> = entries
.into_iter()
.map(|e| BundlerVersion {
number: e.number,
prerelease: e.prerelease,
yanked: e.yanked,
created_at: e.created_at,
platform: e.platform,
})
.collect();
versions.sort_by(|a, b| compare_versions(&b.number, &a.number));
Ok(versions)
}
#[derive(Deserialize)]
struct SearchEntry {
name: String,
info: Option<String>,
version: String,
#[serde(default)]
downloads: u64,
}
fn parse_search_response(data: &[u8]) -> Result<Vec<GemInfo>> {
let entries: Vec<SearchEntry> = serde_json::from_slice(data)?;
Ok(entries
.into_iter()
.map(|e| GemInfo {
name: e.name,
info: e.info,
homepage_uri: None,
source_code_uri: None,
documentation_uri: None,
version: e.version,
licenses: vec![],
authors: None,
downloads: e.downloads,
})
.collect())
}
#[derive(Deserialize)]
struct GemInfoResponse {
name: String,
info: Option<String>,
version: String,
homepage_uri: Option<String>,
source_code_uri: Option<String>,
documentation_uri: Option<String>,
#[serde(default)]
licenses: Vec<String>,
authors: Option<String>,
#[serde(default)]
downloads: u64,
}
fn parse_gem_info(data: &[u8]) -> Result<GemInfo> {
let response: GemInfoResponse = serde_json::from_slice(data)?;
Ok(GemInfo {
name: response.name,
info: response.info,
homepage_uri: response.homepage_uri,
source_code_uri: response.source_code_uri,
documentation_uri: response.documentation_uri,
version: response.version,
licenses: response.licenses,
authors: response.authors,
downloads: response.downloads,
})
}
impl deps_core::Version for BundlerVersion {
fn version_string(&self) -> &str {
&self.number
}
fn is_yanked(&self) -> bool {
self.yanked
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl deps_core::Metadata for GemInfo {
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> Option<&str> {
self.info.as_deref()
}
fn repository(&self) -> Option<&str> {
self.source_code_uri.as_deref()
}
fn documentation(&self) -> Option<&str> {
self.documentation_uri.as_deref()
}
fn latest_version(&self) -> &str {
&self.version
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
impl deps_core::Registry for RubyGemsRegistry {
fn get_versions<'a>(
&'a self,
name: &'a str,
) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
Box::pin(async move {
let versions = self.get_versions(name).await?;
Ok(versions
.into_iter()
.map(|v| Box::new(v) as Box<dyn deps_core::Version>)
.collect())
})
}
fn get_latest_matching<'a>(
&'a self,
name: &'a str,
req: &'a str,
) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
Box::pin(async move {
let version = self.get_latest_matching(name, req).await?;
Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
})
}
fn search<'a>(
&'a self,
query: &'a str,
limit: usize,
) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
Box::pin(async move {
let results = self.search(query, limit).await?;
Ok(results
.into_iter()
.map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
.collect())
})
}
fn package_url(&self, name: &str) -> String {
gem_url(name)
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gem_url() {
assert_eq!(gem_url("rails"), "https://rubygems.org/gems/rails");
assert_eq!(gem_url("nokogiri"), "https://rubygems.org/gems/nokogiri");
}
#[test]
fn test_gem_url_special_chars() {
assert_eq!(
gem_url("rspec-rails"),
"https://rubygems.org/gems/rspec-rails"
);
assert_eq!(
gem_url("activerecord-import"),
"https://rubygems.org/gems/activerecord-import"
);
}
#[test]
fn test_parse_versions_response() {
let json = r#"[
{"number": "7.0.8", "prerelease": false, "yanked": false, "platform": "ruby"},
{"number": "7.0.7", "prerelease": false, "yanked": false, "platform": "ruby"},
{"number": "7.1.0.beta1", "prerelease": true, "yanked": false, "platform": "ruby"}
]"#;
let versions = parse_versions_response(json.as_bytes(), "rails").unwrap();
assert_eq!(versions.len(), 3);
assert!(versions[0].prerelease); }
#[test]
fn test_parse_versions_response_with_yanked() {
let json = r#"[
{"number": "1.0.0", "prerelease": false, "yanked": true, "platform": "ruby"},
{"number": "0.9.0", "prerelease": false, "yanked": false, "platform": "ruby"}
]"#;
let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 2);
assert!(versions[0].yanked);
assert!(!versions[1].yanked);
}
#[test]
fn test_parse_versions_response_with_created_at() {
let json = r#"[
{"number": "1.0.0", "prerelease": false, "yanked": false, "created_at": "2024-01-15T10:30:00Z", "platform": "ruby"}
]"#;
let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(
versions[0].created_at,
Some("2024-01-15T10:30:00Z".to_string())
);
}
#[test]
fn test_parse_versions_response_default_platform() {
let json = r#"[
{"number": "1.0.0", "prerelease": false, "yanked": false}
]"#;
let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].platform, "ruby");
}
#[test]
fn test_parse_versions_response_sorting() {
let json = r#"[
{"number": "1.0.0", "prerelease": false, "yanked": false},
{"number": "2.0.0", "prerelease": false, "yanked": false},
{"number": "1.5.0", "prerelease": false, "yanked": false}
]"#;
let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
assert_eq!(versions[0].number, "2.0.0");
assert_eq!(versions[1].number, "1.5.0");
assert_eq!(versions[2].number, "1.0.0");
}
#[test]
fn test_parse_versions_response_empty() {
let json = r"[]";
let versions = parse_versions_response(json.as_bytes(), "test").unwrap();
assert!(versions.is_empty());
}
#[test]
fn test_parse_search_response() {
let json = r#"[
{"name": "rails", "info": "Ruby on Rails", "version": "7.0.8", "downloads": 500000000},
{"name": "railties", "info": "Core", "version": "7.0.8", "downloads": 100000000}
]"#;
let results = parse_search_response(json.as_bytes()).unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].name, "rails");
assert_eq!(results[0].info, Some("Ruby on Rails".to_string()));
assert_eq!(results[0].version, "7.0.8");
assert_eq!(results[0].downloads, 500_000_000);
}
#[test]
fn test_parse_search_response_minimal() {
let json = r#"[
{"name": "test", "version": "1.0.0"}
]"#;
let results = parse_search_response(json.as_bytes()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "test");
assert!(results[0].info.is_none());
assert_eq!(results[0].downloads, 0);
}
#[test]
fn test_parse_search_response_empty() {
let json = r"[]";
let results = parse_search_response(json.as_bytes()).unwrap();
assert!(results.is_empty());
}
#[test]
fn test_parse_gem_info_full() {
let json = r#"{
"name": "rails",
"info": "Full-stack web application framework",
"version": "7.0.8",
"homepage_uri": "https://rubyonrails.org",
"source_code_uri": "https://github.com/rails/rails",
"documentation_uri": "https://api.rubyonrails.org",
"licenses": ["MIT"],
"authors": "David Heinemeier Hansson",
"downloads": 500000000
}"#;
let info = parse_gem_info(json.as_bytes()).unwrap();
assert_eq!(info.name, "rails");
assert_eq!(
info.info,
Some("Full-stack web application framework".to_string())
);
assert_eq!(info.version, "7.0.8");
assert_eq!(
info.homepage_uri,
Some("https://rubyonrails.org".to_string())
);
assert_eq!(
info.source_code_uri,
Some("https://github.com/rails/rails".to_string())
);
assert_eq!(
info.documentation_uri,
Some("https://api.rubyonrails.org".to_string())
);
assert_eq!(info.licenses, vec!["MIT"]);
assert_eq!(info.authors, Some("David Heinemeier Hansson".to_string()));
assert_eq!(info.downloads, 500_000_000);
}
#[test]
fn test_parse_gem_info_minimal() {
let json = r#"{
"name": "minimal",
"version": "0.1.0"
}"#;
let info = parse_gem_info(json.as_bytes()).unwrap();
assert_eq!(info.name, "minimal");
assert_eq!(info.version, "0.1.0");
assert!(info.info.is_none());
assert!(info.homepage_uri.is_none());
assert!(info.source_code_uri.is_none());
assert!(info.documentation_uri.is_none());
assert!(info.licenses.is_empty());
assert!(info.authors.is_none());
assert_eq!(info.downloads, 0);
}
#[test]
fn test_parse_gem_info_with_multiple_licenses() {
let json = r#"{
"name": "test",
"version": "1.0.0",
"licenses": ["MIT", "Apache-2.0", "BSD-3-Clause"]
}"#;
let info = parse_gem_info(json.as_bytes()).unwrap();
assert_eq!(info.licenses.len(), 3);
assert!(info.licenses.contains(&"MIT".to_string()));
assert!(info.licenses.contains(&"Apache-2.0".to_string()));
}
#[tokio::test]
async fn test_registry_creation() {
let cache = Arc::new(HttpCache::new());
let _registry = RubyGemsRegistry::new(cache);
}
#[test]
fn test_version_trait() {
use deps_core::Version;
let version = BundlerVersion {
number: "1.0.0".into(),
prerelease: false,
yanked: true,
created_at: None,
platform: "ruby".into(),
};
assert_eq!(version.version_string(), "1.0.0");
assert!(version.is_yanked());
assert!(version.features().is_empty());
}
#[test]
fn test_metadata_trait() {
use deps_core::Metadata;
let gem = GemInfo {
name: "test".into(),
info: Some("A test gem".into()),
homepage_uri: None,
source_code_uri: Some("https://github.com/test/test".into()),
documentation_uri: Some("https://docs.test.com".into()),
version: "1.0.0".into(),
licenses: vec![],
authors: None,
downloads: 0,
};
assert_eq!(gem.name(), "test");
assert_eq!(gem.description(), Some("A test gem"));
assert_eq!(gem.repository(), Some("https://github.com/test/test"));
assert_eq!(gem.documentation(), Some("https://docs.test.com"));
assert_eq!(gem.latest_version(), "1.0.0");
}
#[test]
fn test_metadata_trait_empty_optionals() {
use deps_core::Metadata;
let gem = GemInfo {
name: "empty".into(),
info: None,
homepage_uri: None,
source_code_uri: None,
documentation_uri: None,
version: "0.1.0".into(),
licenses: vec![],
authors: None,
downloads: 0,
};
assert!(gem.description().is_none());
assert!(gem.repository().is_none());
assert!(gem.documentation().is_none());
}
#[test]
fn test_registry_package_url() {
use deps_core::Registry;
let cache = Arc::new(HttpCache::new());
let registry = RubyGemsRegistry::new(cache);
assert_eq!(
registry.package_url("rails"),
"https://rubygems.org/gems/rails"
);
}
#[test]
fn test_registry_as_any() {
use deps_core::Registry;
let cache = Arc::new(HttpCache::new());
let registry = RubyGemsRegistry::new(cache);
let any = registry.as_any();
assert!(any.is::<RubyGemsRegistry>());
assert!(any.downcast_ref::<RubyGemsRegistry>().is_some());
}
#[test]
fn test_default_platform_function() {
assert_eq!(default_platform(), "ruby");
}
#[tokio::test]
#[ignore] async fn test_fetch_real_rails_versions() {
let cache = Arc::new(HttpCache::new());
let registry = RubyGemsRegistry::new(cache);
let versions = registry.get_versions("rails").await.unwrap();
assert!(!versions.is_empty());
assert!(versions.iter().any(|v| v.number.starts_with("7.")));
}
}