use crate::types::{CargoVersion, CrateInfo};
use deps_core::{DepsError, HttpCache, Result};
use semver::{Version, VersionReq};
use serde::Deserialize;
use std::any::Any;
use std::collections::HashMap;
use std::sync::Arc;
const SPARSE_INDEX_BASE: &str = "https://index.crates.io";
const SEARCH_API_BASE: &str = "https://crates.io/api/v1";
pub const CRATES_IO_URL: &str = "https://crates.io/crates";
pub fn crate_url(name: &str) -> String {
format!("{CRATES_IO_URL}/{name}")
}
#[derive(Clone)]
pub struct CratesIoRegistry {
cache: Arc<HttpCache>,
}
impl CratesIoRegistry {
pub const fn new(cache: Arc<HttpCache>) -> Self {
Self { cache }
}
pub async fn get_versions(&self, name: &str) -> Result<Vec<CargoVersion>> {
let path = sparse_index_path(name);
let mut url = String::with_capacity(SPARSE_INDEX_BASE.len() + 1 + path.len());
url.push_str(SPARSE_INDEX_BASE);
url.push('/');
url.push_str(&path);
let data = self.cache.get_cached(&url).await?;
parse_index_json(&data, name)
}
pub async fn get_latest_matching(
&self,
name: &str,
req_str: &str,
) -> Result<Option<CargoVersion>> {
let versions = self.get_versions(name).await?;
let req = req_str
.parse::<VersionReq>()
.map_err(|e| DepsError::InvalidVersionReq(e.to_string()))?;
Ok(versions.into_iter().find(|v| {
let version = v.num.parse::<Version>().ok();
version.is_some_and(|ver| req.matches(&ver) && !v.yanked)
}))
}
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<CrateInfo>> {
let url = format!(
"{}/crates?q={}&per_page={}&sort=downloads",
SEARCH_API_BASE,
urlencoding::encode(query),
limit
);
let data = self.cache.get_cached(&url).await?;
parse_search_response(&data)
}
}
fn sparse_index_path(name: &str) -> String {
let name_lower = name.to_lowercase();
let len = name_lower.len();
match len {
1 => {
let mut path = String::with_capacity(3);
path.push_str("1/");
path.push_str(&name_lower);
path
}
2 => {
let mut path = String::with_capacity(4);
path.push_str("2/");
path.push_str(&name_lower);
path
}
3 => {
let mut path = String::with_capacity(7);
path.push_str("3/");
path.push_str(&name_lower[0..1]);
path.push('/');
path.push_str(&name_lower);
path
}
_ => {
let mut path = String::with_capacity(6 + len);
path.push_str(&name_lower[0..2]);
path.push('/');
path.push_str(&name_lower[2..4]);
path.push('/');
path.push_str(&name_lower);
path
}
}
}
#[derive(Deserialize)]
struct IndexEntry {
#[serde(rename = "vers")]
version: String,
#[serde(default)]
yanked: bool,
#[serde(default)]
features: HashMap<String, Vec<String>>,
}
fn parse_index_json(data: &[u8], _crate_name: &str) -> Result<Vec<CargoVersion>> {
let content = std::str::from_utf8(data)
.map_err(|e| DepsError::CacheError(format!("Invalid UTF-8: {e}")))?;
let mut versions_with_parsed: Vec<(CargoVersion, Version)> = content
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let entry: IndexEntry = serde_json::from_str(line).ok()?;
let parsed = entry.version.parse::<Version>().ok()?;
Some((
CargoVersion {
num: entry.version,
yanked: entry.yanked,
features: entry.features,
},
parsed,
))
})
.collect();
versions_with_parsed.sort_unstable_by(|a, b| b.1.cmp(&a.1));
Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
}
#[derive(Deserialize)]
struct SearchResponse {
crates: Vec<SearchCrate>,
}
#[derive(Deserialize)]
struct SearchCrate {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
repository: Option<String>,
#[serde(default)]
documentation: Option<String>,
max_version: String,
}
fn parse_search_response(data: &[u8]) -> Result<Vec<CrateInfo>> {
let response: SearchResponse = serde_json::from_slice(data)?;
Ok(response
.crates
.into_iter()
.map(|c| CrateInfo {
name: c.name,
description: c.description,
repository: c.repository,
documentation: c.documentation,
max_version: c.max_version,
})
.collect())
}
impl deps_core::Registry for CratesIoRegistry {
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 {
crate_url(name)
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sparse_index_path() {
assert_eq!(sparse_index_path("a"), "1/a");
assert_eq!(sparse_index_path("ab"), "2/ab");
assert_eq!(sparse_index_path("abc"), "3/a/abc");
assert_eq!(sparse_index_path("serde"), "se/rd/serde");
assert_eq!(sparse_index_path("tokio"), "to/ki/tokio");
}
#[test]
fn test_sparse_index_path_uppercase() {
assert_eq!(sparse_index_path("SERDE"), "se/rd/serde");
}
#[test]
fn test_parse_index_json() {
let json = r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}
{"name":"serde","vers":"1.0.1","yanked":false,"features":{"derive":["serde_derive"]},"deps":[]}"#;
let versions = parse_index_json(json.as_bytes(), "serde").unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].num, "1.0.1");
assert_eq!(versions[1].num, "1.0.0");
assert!(!versions[0].yanked);
}
#[test]
fn test_parse_index_json_with_yanked() {
let json = r#"{"name":"test","vers":"0.1.0","yanked":true,"features":{},"deps":[]}
{"name":"test","vers":"0.2.0","yanked":false,"features":{},"deps":[]}"#;
let versions = parse_index_json(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 2);
assert!(versions[1].yanked);
assert!(!versions[0].yanked);
}
#[test]
fn test_parse_search_response() {
let json = r#"{
"crates": [
{
"name": "serde",
"description": "A serialization framework",
"repository": "https://github.com/serde-rs/serde",
"documentation": "https://docs.rs/serde",
"max_version": "1.0.214"
}
]
}"#;
let results = parse_search_response(json.as_bytes()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "serde");
assert_eq!(results[0].max_version, "1.0.214");
}
#[tokio::test]
#[ignore]
async fn test_fetch_real_serde_versions() {
let cache = Arc::new(HttpCache::new());
let registry = CratesIoRegistry::new(cache);
let versions = registry.get_versions("serde").await.unwrap();
assert!(!versions.is_empty());
assert!(versions.iter().any(|v| v.num.starts_with("1.")));
}
#[tokio::test]
#[ignore]
async fn test_search_real() {
let cache = Arc::new(HttpCache::new());
let registry = CratesIoRegistry::new(cache);
let results = registry.search("serde", 5).await.unwrap();
assert!(!results.is_empty());
assert!(results.iter().any(|r| r.name == "serde"));
}
#[tokio::test]
#[ignore]
async fn test_get_latest_matching_real() {
let cache = Arc::new(HttpCache::new());
let registry = CratesIoRegistry::new(cache);
let latest = registry.get_latest_matching("serde", "^1.0").await.unwrap();
assert!(latest.is_some());
let version = latest.unwrap();
assert!(version.num.starts_with("1."));
assert!(!version.yanked);
}
#[test]
fn test_parse_index_json_empty() {
let json = "";
let versions = parse_index_json(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 0);
}
#[test]
fn test_parse_index_json_blank_lines() {
let json = "\n\n\n";
let versions = parse_index_json(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 0);
}
#[test]
fn test_parse_index_json_invalid_version() {
let json = r#"{"name":"test","vers":"invalid","yanked":false,"features":{},"deps":[]}"#;
let versions = parse_index_json(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 0);
}
#[test]
fn test_parse_index_json_mixed_valid_invalid() {
let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{},"deps":[]}
{"name":"test","vers":"invalid","yanked":false,"features":{},"deps":[]}
{"name":"test","vers":"2.0.0","yanked":false,"features":{},"deps":[]}"#;
let versions = parse_index_json(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].num, "2.0.0");
assert_eq!(versions[1].num, "1.0.0");
}
#[test]
fn test_parse_index_json_with_features() {
let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{"default":["std"],"std":[]},"deps":[]}"#;
let versions = parse_index_json(json.as_bytes(), "test").unwrap();
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].features.len(), 2);
assert!(versions[0].features.contains_key("default"));
assert!(versions[0].features.contains_key("std"));
}
#[test]
fn test_parse_search_response_empty() {
let json = r#"{"crates": []}"#;
let results = parse_search_response(json.as_bytes()).unwrap();
assert_eq!(results.len(), 0);
}
#[test]
fn test_parse_search_response_missing_optional_fields() {
let json = r#"{
"crates": [
{
"name": "minimal",
"max_version": "1.0.0"
}
]
}"#;
let results = parse_search_response(json.as_bytes()).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].name, "minimal");
assert_eq!(results[0].description, None);
assert_eq!(results[0].repository, None);
}
#[test]
fn test_sparse_index_path_single_char() {
assert_eq!(sparse_index_path("x"), "1/x");
assert_eq!(sparse_index_path("z"), "1/z");
}
#[test]
fn test_sparse_index_path_two_chars() {
assert_eq!(sparse_index_path("xy"), "2/xy");
assert_eq!(sparse_index_path("ab"), "2/ab");
}
#[test]
fn test_sparse_index_path_three_chars() {
assert_eq!(sparse_index_path("xyz"), "3/x/xyz");
assert_eq!(sparse_index_path("foo"), "3/f/foo");
}
#[test]
fn test_sparse_index_path_long_name() {
assert_eq!(
sparse_index_path("very-long-crate-name"),
"ve/ry/very-long-crate-name"
);
}
#[test]
fn test_sparse_index_path_numbers() {
assert_eq!(sparse_index_path("1234"), "12/34/1234");
}
#[test]
fn test_sparse_index_path_mixed_case() {
assert_eq!(sparse_index_path("MyPackage"), "my/pa/mypackage");
assert_eq!(sparse_index_path("UPPERCASE"), "up/pe/uppercase");
}
#[test]
fn test_crate_url() {
assert_eq!(crate_url("serde"), "https://crates.io/crates/serde");
assert_eq!(crate_url("tokio"), "https://crates.io/crates/tokio");
}
#[test]
fn test_crate_url_with_hyphens() {
assert_eq!(
crate_url("serde-json"),
"https://crates.io/crates/serde-json"
);
}
#[tokio::test]
async fn test_registry_creation() {
let cache = Arc::new(HttpCache::new());
let _registry = CratesIoRegistry::new(cache);
}
#[tokio::test]
async fn test_registry_clone() {
let cache = Arc::new(HttpCache::new());
let registry = CratesIoRegistry::new(cache);
let _cloned = registry;
}
}