1use crate::types::{ComposerPackage, ComposerVersion};
12use deps_core::{DepsError, HttpCache, Result};
13use serde::Deserialize;
14use std::any::Any;
15use std::sync::Arc;
16
17const PACKAGIST_BASE: &str = "https://repo.packagist.org";
18const PACKAGIST_SEARCH: &str = "https://packagist.org/search.json";
19
20#[derive(Clone)]
25pub struct PackagistRegistry {
26 cache: Arc<HttpCache>,
27}
28
29impl PackagistRegistry {
30 pub const fn new(cache: Arc<HttpCache>) -> Self {
32 Self { cache }
33 }
34
35 pub async fn get_versions(&self, name: &str) -> Result<Vec<ComposerVersion>> {
44 let url = if let Some((vendor, package)) = name.split_once('/') {
46 format!(
47 "{PACKAGIST_BASE}/p2/{}/{}.json",
48 urlencoding::encode(vendor),
49 urlencoding::encode(package)
50 )
51 } else {
52 format!("{PACKAGIST_BASE}/p2/{}.json", urlencoding::encode(name))
53 };
54 let data = self.cache.get_cached(&url).await?;
55 parse_package_metadata(name, &data)
56 }
57
58 pub async fn get_latest_matching(
64 &self,
65 name: &str,
66 req_str: &str,
67 ) -> Result<Option<ComposerVersion>> {
68 let versions = self.get_versions(name).await?;
69 let formatter = crate::formatter::ComposerFormatter;
70 use deps_core::lsp_helpers::EcosystemFormatter;
71
72 Ok(versions
73 .into_iter()
74 .find(|v| !v.abandoned && formatter.version_satisfies_requirement(&v.version, req_str)))
75 }
76
77 pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<ComposerPackage>> {
85 let url = format!(
86 "{}?q={}&per_page={}",
87 PACKAGIST_SEARCH,
88 urlencoding::encode(query),
89 limit
90 );
91
92 let data = self.cache.get_cached(&url).await?;
93 parse_search_response(&data)
94 }
95}
96
97#[derive(Deserialize)]
99struct PackagistResponse {
100 packages: std::collections::HashMap<String, Vec<MinifiedVersion>>,
101}
102
103#[derive(Deserialize, Clone, Default)]
108struct MinifiedVersion {
109 version: Option<String>,
110 version_normalized: Option<String>,
111 abandoned: Option<serde_json::Value>,
112}
113
114fn expand_minified_versions(entries: Vec<MinifiedVersion>) -> Vec<ComposerVersion> {
122 let mut result = Vec::new();
123 let mut current = MinifiedVersion::default();
124
125 for entry in entries {
126 if entry.version.is_some() {
128 current.version = entry.version;
129 }
130 if entry.version_normalized.is_some() {
131 current.version_normalized = entry.version_normalized;
132 }
133 if entry.abandoned.is_some() {
134 current.abandoned = entry.abandoned;
135 }
136
137 let Some(ref version) = current.version else {
138 continue;
139 };
140
141 if version.starts_with("dev-") || version.ends_with("-dev") {
143 continue;
144 }
145
146 let abandoned = current
147 .abandoned
148 .as_ref()
149 .is_some_and(|v| v.as_bool() == Some(true) || v.is_string());
150
151 result.push(ComposerVersion {
152 version: version.clone(),
153 version_normalized: current
154 .version_normalized
155 .clone()
156 .unwrap_or_else(|| version.clone()),
157 abandoned,
158 });
159 }
160
161 result
162}
163
164fn parse_package_metadata(name: &str, data: &[u8]) -> Result<Vec<ComposerVersion>> {
166 let response: PackagistResponse = serde_json::from_slice(data).map_err(DepsError::Json)?;
167
168 let key = name.to_lowercase();
170 let entries = response.packages.get(&key).cloned().unwrap_or_default();
171
172 Ok(expand_minified_versions(entries))
173}
174
175#[derive(Deserialize)]
177struct SearchResponse {
178 results: Vec<SearchResult>,
179}
180
181#[derive(Deserialize)]
183struct SearchResult {
184 name: String,
185 #[serde(default)]
186 description: Option<String>,
187 #[serde(default)]
188 repository: Option<String>,
189 #[serde(default)]
190 url: Option<String>,
191 #[serde(default)]
192 version: Option<String>,
193}
194
195fn parse_search_response(data: &[u8]) -> Result<Vec<ComposerPackage>> {
197 let response: SearchResponse = serde_json::from_slice(data).map_err(DepsError::Json)?;
198
199 Ok(response
200 .results
201 .into_iter()
202 .map(|r| ComposerPackage {
203 name: r.name,
204 description: r.description,
205 repository: r.repository,
206 homepage: r.url,
207 latest_version: r.version.unwrap_or_default(),
208 })
209 .collect())
210}
211
212impl deps_core::Registry for PackagistRegistry {
213 fn get_versions<'a>(
214 &'a self,
215 name: &'a str,
216 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
217 Box::pin(async move {
218 let versions = self.get_versions(name).await?;
219 Ok(versions
220 .into_iter()
221 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
222 .collect())
223 })
224 }
225
226 fn get_latest_matching<'a>(
227 &'a self,
228 name: &'a str,
229 req: &'a str,
230 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
231 Box::pin(async move {
232 let version = self.get_latest_matching(name, req).await?;
233 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
234 })
235 }
236
237 fn search<'a>(
238 &'a self,
239 query: &'a str,
240 limit: usize,
241 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
242 Box::pin(async move {
243 let packages = self.search(query, limit).await?;
244 Ok(packages
245 .into_iter()
246 .map(|p| Box::new(p) as Box<dyn deps_core::Metadata>)
247 .collect())
248 })
249 }
250
251 fn package_url(&self, name: &str) -> String {
252 format!("https://packagist.org/packages/{name}")
253 }
254
255 fn as_any(&self) -> &dyn Any {
256 self
257 }
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn test_expand_minified_versions_basic() {
266 let entries = vec![
267 MinifiedVersion {
268 version: Some("3.0.0".into()),
269 version_normalized: Some("3.0.0.0".into()),
270 abandoned: None,
271 },
272 MinifiedVersion {
273 version: Some("2.0.0".into()),
274 version_normalized: Some("2.0.0.0".into()),
275 abandoned: None,
276 },
277 ];
278
279 let versions = expand_minified_versions(entries);
280 assert_eq!(versions.len(), 2);
281 assert_eq!(versions[0].version, "3.0.0");
282 assert_eq!(versions[1].version, "2.0.0");
283 assert!(!versions[0].abandoned);
284 }
285
286 #[test]
287 fn test_expand_minified_versions_field_inheritance() {
288 let entries = vec![
290 MinifiedVersion {
291 version: Some("3.0.0".into()),
292 version_normalized: Some("3.0.0.0".into()),
293 abandoned: None,
294 },
295 MinifiedVersion {
296 version: Some("2.9.0".into()),
297 version_normalized: None, abandoned: None,
299 },
300 ];
301
302 let versions = expand_minified_versions(entries);
303 assert_eq!(versions.len(), 2);
304 assert_eq!(versions[1].version, "2.9.0");
305 assert_eq!(versions[1].version_normalized, "3.0.0.0"); }
307
308 #[test]
309 fn test_expand_minified_versions_filters_dev() {
310 let entries = vec![
311 MinifiedVersion {
312 version: Some("3.0.0".into()),
313 version_normalized: Some("3.0.0.0".into()),
314 abandoned: None,
315 },
316 MinifiedVersion {
317 version: Some("dev-main".into()),
318 version_normalized: None,
319 abandoned: None,
320 },
321 MinifiedVersion {
322 version: Some("2.0.0-dev".into()),
323 version_normalized: None,
324 abandoned: None,
325 },
326 ];
327
328 let versions = expand_minified_versions(entries);
329 assert_eq!(versions.len(), 1);
330 assert_eq!(versions[0].version, "3.0.0");
331 }
332
333 #[test]
334 fn test_expand_minified_versions_abandoned() {
335 let entries = vec![MinifiedVersion {
336 version: Some("3.0.0".into()),
337 version_normalized: Some("3.0.0.0".into()),
338 abandoned: Some(serde_json::Value::String("Use other/package".into())),
339 }];
340
341 let versions = expand_minified_versions(entries);
342 assert_eq!(versions.len(), 1);
343 assert!(versions[0].abandoned);
344 }
345
346 #[test]
347 fn test_parse_search_response() {
348 let json = r#"{
349 "results": [
350 {
351 "name": "symfony/console",
352 "description": "Symfony Console Component",
353 "version": "6.0.0",
354 "url": "https://packagist.org/packages/symfony/console",
355 "repository": "https://github.com/symfony/console"
356 }
357 ],
358 "total": 1
359}"#;
360
361 let packages = parse_search_response(json.as_bytes()).unwrap();
362 assert_eq!(packages.len(), 1);
363
364 let pkg = &packages[0];
365 assert_eq!(pkg.name, "symfony/console");
366 assert_eq!(pkg.description, Some("Symfony Console Component".into()));
367 assert_eq!(pkg.latest_version, "6.0.0");
368 }
369
370 #[test]
371 fn test_parse_package_metadata() {
372 let json = r#"{
373 "packages": {
374 "monolog/monolog": [
375 {
376 "version": "3.0.0",
377 "version_normalized": "3.0.0.0",
378 "abandoned": null
379 },
380 {
381 "version": "2.0.0",
382 "version_normalized": "2.0.0.0"
383 }
384 ]
385 }
386}"#;
387
388 let versions = parse_package_metadata("monolog/monolog", json.as_bytes()).unwrap();
389 assert_eq!(versions.len(), 2);
390 assert_eq!(versions[0].version, "3.0.0");
391 }
392
393 #[tokio::test]
394 #[ignore]
395 async fn test_fetch_real_monolog_versions() {
396 let cache = Arc::new(HttpCache::new());
397 let registry = PackagistRegistry::new(cache);
398 let versions = registry.get_versions("monolog/monolog").await.unwrap();
399
400 assert!(!versions.is_empty());
401 assert!(versions.iter().any(|v| v.version.starts_with("3.")));
402 }
403
404 #[tokio::test]
405 #[ignore]
406 async fn test_search_real() {
407 let cache = Arc::new(HttpCache::new());
408 let registry = PackagistRegistry::new(cache);
409 let results = registry.search("symfony", 5).await.unwrap();
410
411 assert!(!results.is_empty());
412 }
413}