Skip to main content

deps_composer/
registry.rs

1//! Packagist registry client.
2//!
3//! Provides access to the Packagist registry via:
4//! - Package metadata API (<https://repo.packagist.org/p2/{vendor}/{package}.json>) for version lookups
5//! - Search API (<https://packagist.org/search.json>) for package search
6//!
7//! The Packagist v2 API returns minified metadata where only the first version entry
8//! is complete. Subsequent entries contain only changed fields and must be expanded
9//! by inheriting from the previous complete entry.
10
11use 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/// Client for interacting with the Packagist registry.
21///
22/// Uses the Packagist v2 API for package metadata and search.
23/// All requests are cached via the provided HttpCache.
24#[derive(Clone)]
25pub struct PackagistRegistry {
26    cache: Arc<HttpCache>,
27}
28
29impl PackagistRegistry {
30    /// Creates a new Packagist registry client with the given HTTP cache.
31    pub const fn new(cache: Arc<HttpCache>) -> Self {
32        Self { cache }
33    }
34
35    /// Fetches all versions for a package from the Packagist v2 API.
36    ///
37    /// Filters out dev versions (starting with `dev-` or ending with `-dev`).
38    /// Returns versions in the order returned by the API (newest first).
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if the HTTP request or JSON parsing fails.
43    pub async fn get_versions(&self, name: &str) -> Result<Vec<ComposerVersion>> {
44        // Packagist names are vendor/package; encode each segment separately
45        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    /// Finds the latest non-abandoned version satisfying the given requirement.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if the HTTP request fails.
63    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    /// Searches for packages by name/keywords.
78    ///
79    /// Returns up to `limit` results sorted by relevance.
80    ///
81    /// # Errors
82    ///
83    /// Returns an error if the HTTP request or JSON parsing fails.
84    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/// Packagist v2 API response (outer wrapper).
98#[derive(Deserialize)]
99struct PackagistResponse {
100    packages: std::collections::HashMap<String, Vec<MinifiedVersion>>,
101}
102
103/// Minified version entry from Packagist v2 API.
104///
105/// The v2 API returns only the first version as complete. Subsequent entries
106/// contain only fields that changed from the previous entry.
107#[derive(Deserialize, Clone, Default)]
108struct MinifiedVersion {
109    version: Option<String>,
110    version_normalized: Option<String>,
111    abandoned: Option<serde_json::Value>,
112}
113
114/// Expands minified Packagist v2 versions using field inheritance.
115///
116/// The v2 API compresses responses: only the first entry is complete.
117/// Each subsequent entry inherits fields from the previous one and overrides
118/// only the fields that changed.
119///
120/// Dev versions (`dev-*` or `*-dev`) are filtered out.
121fn 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        // Inherit previous state, then apply overrides
127        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        // Filter dev versions
142        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
164/// Parses Packagist v2 API response JSON.
165fn 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    // Packagist uses lowercase package names as keys
169    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/// Packagist search API response.
176#[derive(Deserialize)]
177struct SearchResponse {
178    results: Vec<SearchResult>,
179}
180
181/// Individual search result.
182#[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
195/// Parses Packagist search API response.
196fn 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        // Second entry inherits version_normalized from first, only version changes
289        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, // inherited
298                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"); // inherited
306    }
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}