use crate::types::{ComposerPackage, ComposerVersion};
use deps_core::{DepsError, HttpCache, Result};
use serde::Deserialize;
use std::any::Any;
use std::sync::Arc;
const PACKAGIST_BASE: &str = "https://repo.packagist.org";
const PACKAGIST_SEARCH: &str = "https://packagist.org/search.json";
#[derive(Clone)]
pub struct PackagistRegistry {
cache: Arc<HttpCache>,
}
impl PackagistRegistry {
pub const fn new(cache: Arc<HttpCache>) -> Self {
Self { cache }
}
pub async fn get_versions(&self, name: &str) -> Result<Vec<ComposerVersion>> {
let url = if let Some((vendor, package)) = name.split_once('/') {
format!(
"{PACKAGIST_BASE}/p2/{}/{}.json",
urlencoding::encode(vendor),
urlencoding::encode(package)
)
} else {
format!("{PACKAGIST_BASE}/p2/{}.json", urlencoding::encode(name))
};
let data = self.cache.get_cached(&url).await?;
parse_package_metadata(name, &data)
}
pub async fn get_latest_matching(
&self,
name: &str,
req_str: &str,
) -> Result<Option<ComposerVersion>> {
let versions = self.get_versions(name).await?;
let formatter = crate::formatter::ComposerFormatter;
use deps_core::lsp_helpers::EcosystemFormatter;
Ok(versions
.into_iter()
.find(|v| !v.abandoned && formatter.version_satisfies_requirement(&v.version, req_str)))
}
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<ComposerPackage>> {
let url = format!(
"{}?q={}&per_page={}",
PACKAGIST_SEARCH,
urlencoding::encode(query),
limit
);
let data = self.cache.get_cached(&url).await?;
parse_search_response(&data)
}
}
#[derive(Deserialize)]
struct PackagistResponse {
packages: std::collections::HashMap<String, Vec<MinifiedVersion>>,
}
#[derive(Deserialize, Clone, Default)]
struct MinifiedVersion {
version: Option<String>,
version_normalized: Option<String>,
abandoned: Option<serde_json::Value>,
}
fn expand_minified_versions(entries: Vec<MinifiedVersion>) -> Vec<ComposerVersion> {
let mut result = Vec::new();
let mut current = MinifiedVersion::default();
for entry in entries {
if entry.version.is_some() {
current.version = entry.version;
}
if entry.version_normalized.is_some() {
current.version_normalized = entry.version_normalized;
}
if entry.abandoned.is_some() {
current.abandoned = entry.abandoned;
}
let Some(ref version) = current.version else {
continue;
};
if version.starts_with("dev-") || version.ends_with("-dev") {
continue;
}
let abandoned = current
.abandoned
.as_ref()
.is_some_and(|v| v.as_bool() == Some(true) || v.is_string());
result.push(ComposerVersion {
version: version.clone(),
version_normalized: current
.version_normalized
.clone()
.unwrap_or_else(|| version.clone()),
abandoned,
});
}
result
}
fn parse_package_metadata(name: &str, data: &[u8]) -> Result<Vec<ComposerVersion>> {
let response: PackagistResponse = serde_json::from_slice(data).map_err(DepsError::Json)?;
let key = name.to_lowercase();
let entries = response.packages.get(&key).cloned().unwrap_or_default();
Ok(expand_minified_versions(entries))
}
#[derive(Deserialize)]
struct SearchResponse {
results: Vec<SearchResult>,
}
#[derive(Deserialize)]
struct SearchResult {
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
repository: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
version: Option<String>,
}
fn parse_search_response(data: &[u8]) -> Result<Vec<ComposerPackage>> {
let response: SearchResponse = serde_json::from_slice(data).map_err(DepsError::Json)?;
Ok(response
.results
.into_iter()
.map(|r| ComposerPackage {
name: r.name,
description: r.description,
repository: r.repository,
homepage: r.url,
latest_version: r.version.unwrap_or_default(),
})
.collect())
}
impl deps_core::Registry for PackagistRegistry {
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 packages = self.search(query, limit).await?;
Ok(packages
.into_iter()
.map(|p| Box::new(p) as Box<dyn deps_core::Metadata>)
.collect())
})
}
fn package_url(&self, name: &str) -> String {
format!("https://packagist.org/packages/{name}")
}
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_expand_minified_versions_basic() {
let entries = vec![
MinifiedVersion {
version: Some("3.0.0".into()),
version_normalized: Some("3.0.0.0".into()),
abandoned: None,
},
MinifiedVersion {
version: Some("2.0.0".into()),
version_normalized: Some("2.0.0.0".into()),
abandoned: None,
},
];
let versions = expand_minified_versions(entries);
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].version, "3.0.0");
assert_eq!(versions[1].version, "2.0.0");
assert!(!versions[0].abandoned);
}
#[test]
fn test_expand_minified_versions_field_inheritance() {
let entries = vec![
MinifiedVersion {
version: Some("3.0.0".into()),
version_normalized: Some("3.0.0.0".into()),
abandoned: None,
},
MinifiedVersion {
version: Some("2.9.0".into()),
version_normalized: None, abandoned: None,
},
];
let versions = expand_minified_versions(entries);
assert_eq!(versions.len(), 2);
assert_eq!(versions[1].version, "2.9.0");
assert_eq!(versions[1].version_normalized, "3.0.0.0"); }
#[test]
fn test_expand_minified_versions_filters_dev() {
let entries = vec![
MinifiedVersion {
version: Some("3.0.0".into()),
version_normalized: Some("3.0.0.0".into()),
abandoned: None,
},
MinifiedVersion {
version: Some("dev-main".into()),
version_normalized: None,
abandoned: None,
},
MinifiedVersion {
version: Some("2.0.0-dev".into()),
version_normalized: None,
abandoned: None,
},
];
let versions = expand_minified_versions(entries);
assert_eq!(versions.len(), 1);
assert_eq!(versions[0].version, "3.0.0");
}
#[test]
fn test_expand_minified_versions_abandoned() {
let entries = vec![MinifiedVersion {
version: Some("3.0.0".into()),
version_normalized: Some("3.0.0.0".into()),
abandoned: Some(serde_json::Value::String("Use other/package".into())),
}];
let versions = expand_minified_versions(entries);
assert_eq!(versions.len(), 1);
assert!(versions[0].abandoned);
}
#[test]
fn test_parse_search_response() {
let json = r#"{
"results": [
{
"name": "symfony/console",
"description": "Symfony Console Component",
"version": "6.0.0",
"url": "https://packagist.org/packages/symfony/console",
"repository": "https://github.com/symfony/console"
}
],
"total": 1
}"#;
let packages = parse_search_response(json.as_bytes()).unwrap();
assert_eq!(packages.len(), 1);
let pkg = &packages[0];
assert_eq!(pkg.name, "symfony/console");
assert_eq!(pkg.description, Some("Symfony Console Component".into()));
assert_eq!(pkg.latest_version, "6.0.0");
}
#[test]
fn test_parse_package_metadata() {
let json = r#"{
"packages": {
"monolog/monolog": [
{
"version": "3.0.0",
"version_normalized": "3.0.0.0",
"abandoned": null
},
{
"version": "2.0.0",
"version_normalized": "2.0.0.0"
}
]
}
}"#;
let versions = parse_package_metadata("monolog/monolog", json.as_bytes()).unwrap();
assert_eq!(versions.len(), 2);
assert_eq!(versions[0].version, "3.0.0");
}
#[tokio::test]
#[ignore]
async fn test_fetch_real_monolog_versions() {
let cache = Arc::new(HttpCache::new());
let registry = PackagistRegistry::new(cache);
let versions = registry.get_versions("monolog/monolog").await.unwrap();
assert!(!versions.is_empty());
assert!(versions.iter().any(|v| v.version.starts_with("3.")));
}
#[tokio::test]
#[ignore]
async fn test_search_real() {
let cache = Arc::new(HttpCache::new());
let registry = PackagistRegistry::new(cache);
let results = registry.search("symfony", 5).await.unwrap();
assert!(!results.is_empty());
}
}