aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-22 12:05:17 +0900
committernsfisis <nsfisis@gmail.com>2026-02-22 12:05:17 +0900
commit92fdff257d2c64f94600ba70bf17e429d46474b2 (patch)
treed5262aaa71f54f4445e88459468d64262c0a4dee
parent8071553d6c706cffda6fdb4391b561329e949014 (diff)
downloadphp-mozart-92fdff257d2c64f94600ba70bf17e429d46474b2.tar.gz
php-mozart-92fdff257d2c64f94600ba70bf17e429d46474b2.tar.zst
php-mozart-92fdff257d2c64f94600ba70bf17e429d46474b2.zip
feat(metadata-minifier): add mozart-metadata-minifier crate
Port composer/metadata-minifier to Rust as an independent workspace crate. Implements expand() and minify() for Packagist's delta-encoded version metadata. Update mozart-registry to use the new crate for transparent minified response handling, and add __unset sentinel support to PackagistVersion deserialization. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--Cargo.lock8
-rw-r--r--Cargo.toml1
-rw-r--r--crates/mozart-metadata-minifier/Cargo.toml7
-rw-r--r--crates/mozart-metadata-minifier/src/lib.rs274
-rw-r--r--crates/mozart-registry/Cargo.toml1
-rw-r--r--crates/mozart-registry/src/packagist.rs324
6 files changed, 595 insertions, 20 deletions
diff --git a/Cargo.lock b/Cargo.lock
index e32d4fd..5daa259 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1138,6 +1138,13 @@ dependencies = [
]
[[package]]
+name = "mozart-metadata-minifier"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+]
+
+[[package]]
name = "mozart-registry"
version = "0.1.0"
dependencies = [
@@ -1147,6 +1154,7 @@ dependencies = [
"md5",
"mozart-constraint",
"mozart-core",
+ "mozart-metadata-minifier",
"pubgrub",
"reqwest",
"serde",
diff --git a/Cargo.toml b/Cargo.toml
index d197d11..9359601 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ edition = "2024"
mozart-constraint = { path = "crates/mozart-constraint" }
mozart-core = { path = "crates/mozart-core" }
mozart-archiver = { path = "crates/mozart-archiver" }
+mozart-metadata-minifier = { path = "crates/mozart-metadata-minifier" }
mozart-registry = { path = "crates/mozart-registry" }
mozart-autoload = { path = "crates/mozart-autoload" }
anyhow = "1.0.102"
diff --git a/crates/mozart-metadata-minifier/Cargo.toml b/crates/mozart-metadata-minifier/Cargo.toml
new file mode 100644
index 0000000..49534e2
--- /dev/null
+++ b/crates/mozart-metadata-minifier/Cargo.toml
@@ -0,0 +1,7 @@
+[package]
+name = "mozart-metadata-minifier"
+version.workspace = true
+edition.workspace = true
+
+[dependencies]
+serde_json.workspace = true
diff --git a/crates/mozart-metadata-minifier/src/lib.rs b/crates/mozart-metadata-minifier/src/lib.rs
new file mode 100644
index 0000000..cf88138
--- /dev/null
+++ b/crates/mozart-metadata-minifier/src/lib.rs
@@ -0,0 +1,274 @@
+//! Metadata minification and expansion for Composer-compatible package
+//! repositories.
+//!
+//! This crate is a Rust port of the PHP
+//! [`composer/metadata-minifier`](https://packagist.org/packages/composer/metadata-minifier)
+//! library. It operates on raw JSON values (`serde_json::Value`) so that it
+//! stays independent of any particular package struct definition.
+//!
+//! # Minified format
+//!
+//! A minified version list is a JSON array of objects where:
+//!
+//! * The **first** entry carries all fields in full.
+//! * Each **subsequent** entry only contains the fields that *differ* from the
+//! previous expanded entry.
+//! * The sentinel string `"__unset"` marks a field as *deleted*.
+//!
+//! A response that uses this encoding contains a top-level key
+//! `"minified": "composer/2.0"`.
+
+use serde_json::{Map, Value};
+
+/// The sentinel value used by Composer's metadata minifier to mark a deleted
+/// field.
+const UNSET_SENTINEL: &str = "__unset";
+
+/// Expand a minified version list back to full form.
+///
+/// Each entry in the returned list is a self-contained JSON object with all
+/// fields present (i.e. no diff encoding).
+///
+/// If the input is empty the output is empty. If the input contains only one
+/// entry it is returned as-is.
+pub fn expand(versions: &[Value]) -> Vec<Value> {
+ let mut expanded: Vec<Value> = Vec::with_capacity(versions.len());
+
+ let Some((first, rest)) = versions.split_first() else {
+ return expanded;
+ };
+
+ let Some(mut state) = first.as_object().cloned() else {
+ expanded.push(first.clone());
+ return expanded;
+ };
+
+ expanded.push(Value::Object(state.clone()));
+
+ for diff in rest {
+ let Some(diff_map) = diff.as_object() else {
+ expanded.push(diff.clone());
+ continue;
+ };
+
+ for (key, val) in diff_map {
+ if val.as_str() == Some(UNSET_SENTINEL) {
+ state.remove(key.as_str());
+ } else {
+ state.insert(key.clone(), val.clone());
+ }
+ }
+
+ expanded.push(Value::Object(state.clone()));
+ }
+
+ expanded
+}
+
+/// Minify a list of fully-expanded version objects into diff form.
+///
+/// The first entry is emitted in full. Each subsequent entry only contains
+/// fields that changed compared to the previous one plus `"__unset"` markers
+/// for fields that were removed.
+pub fn minify(versions: &[Value]) -> Vec<Value> {
+ let mut minified: Vec<Value> = Vec::with_capacity(versions.len());
+
+ let Some((first, rest)) = versions.split_first() else {
+ return minified;
+ };
+
+ let Some(mut last_known) = first.as_object().cloned() else {
+ minified.push(first.clone());
+ return minified;
+ };
+
+ minified.push(Value::Object(last_known.clone()));
+
+ for version in rest {
+ let Some(current) = version.as_object() else {
+ minified.push(version.clone());
+ continue;
+ };
+
+ let mut diff = Map::new();
+
+ // Add changed or new fields.
+ for (key, val) in current {
+ match last_known.get(key) {
+ Some(prev) if prev == val => {} // unchanged — omit
+ _ => {
+ diff.insert(key.clone(), val.clone());
+ last_known.insert(key.clone(), val.clone());
+ }
+ }
+ }
+
+ // Mark deleted fields.
+ let removed: Vec<String> = last_known
+ .keys()
+ .filter(|k| !current.contains_key(k.as_str()))
+ .cloned()
+ .collect();
+ for key in &removed {
+ diff.insert(key.clone(), Value::String(UNSET_SENTINEL.to_string()));
+ last_known.remove(key.as_str());
+ }
+
+ minified.push(Value::Object(diff));
+ }
+
+ minified
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ /// Mirrors the canonical Composer MetadataMinifierTest.
+ #[test]
+ fn expand_matches_composer_test() {
+ let minified = vec![
+ json!({
+ "name": "foo/bar",
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "type": "library",
+ "scripts": {"foo": ["bar"]},
+ "license": ["MIT"]
+ }),
+ json!({
+ "version": "1.2.0",
+ "version_normalized": "1.2.0.0",
+ "license": ["GPL"],
+ "homepage": "https://example.org",
+ "scripts": "__unset"
+ }),
+ json!({
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "homepage": "__unset"
+ }),
+ ];
+
+ let expanded = expand(&minified);
+ assert_eq!(expanded.len(), 3);
+
+ // Version 2.0.0 — unchanged.
+ assert_eq!(expanded[0]["name"], "foo/bar");
+ assert_eq!(expanded[0]["version"], "2.0.0");
+ assert_eq!(expanded[0]["type"], "library");
+ assert_eq!(expanded[0]["scripts"], json!({"foo": ["bar"]}));
+ assert_eq!(expanded[0]["license"], json!(["MIT"]));
+
+ // Version 1.2.0 — inherits name, type; license changed; homepage
+ // added; scripts removed.
+ assert_eq!(expanded[1]["name"], "foo/bar");
+ assert_eq!(expanded[1]["version"], "1.2.0");
+ assert_eq!(expanded[1]["type"], "library");
+ assert_eq!(expanded[1]["license"], json!(["GPL"]));
+ assert_eq!(expanded[1]["homepage"], "https://example.org");
+ assert!(expanded[1].get("scripts").is_none());
+
+ // Version 1.0.0 — inherits from 1.2.0; homepage removed.
+ assert_eq!(expanded[2]["name"], "foo/bar");
+ assert_eq!(expanded[2]["version"], "1.0.0");
+ assert_eq!(expanded[2]["type"], "library");
+ assert_eq!(expanded[2]["license"], json!(["GPL"]));
+ assert!(expanded[2].get("homepage").is_none());
+ assert!(expanded[2].get("scripts").is_none());
+ }
+
+ /// Mirrors the canonical Composer MetadataMinifierTest.
+ #[test]
+ fn minify_matches_composer_test() {
+ let full = vec![
+ json!({
+ "name": "foo/bar",
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "type": "library",
+ "scripts": {"foo": ["bar"]},
+ "license": ["MIT"]
+ }),
+ json!({
+ "name": "foo/bar",
+ "version": "1.2.0",
+ "version_normalized": "1.2.0.0",
+ "type": "library",
+ "license": ["GPL"],
+ "homepage": "https://example.org"
+ }),
+ json!({
+ "name": "foo/bar",
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "type": "library",
+ "license": ["GPL"]
+ }),
+ ];
+
+ let minified = minify(&full);
+ assert_eq!(minified.len(), 3);
+
+ // First entry — unchanged.
+ assert_eq!(minified[0], full[0]);
+
+ // Second entry — only diffs.
+ let diff1 = minified[1].as_object().unwrap();
+ assert_eq!(diff1["version"], "1.2.0");
+ assert_eq!(diff1["version_normalized"], "1.2.0.0");
+ assert_eq!(diff1["license"], json!(["GPL"]));
+ assert_eq!(diff1["homepage"], "https://example.org");
+ assert_eq!(diff1["scripts"], "__unset");
+ assert!(!diff1.contains_key("name"));
+ assert!(!diff1.contains_key("type"));
+
+ // Third entry — only diffs.
+ let diff2 = minified[2].as_object().unwrap();
+ assert_eq!(diff2["version"], "1.0.0");
+ assert_eq!(diff2["version_normalized"], "1.0.0.0");
+ assert_eq!(diff2["homepage"], "__unset");
+ assert!(!diff2.contains_key("name"));
+ assert!(!diff2.contains_key("type"));
+ assert!(!diff2.contains_key("license"));
+ }
+
+ #[test]
+ fn roundtrip_expand_minify() {
+ let full = vec![
+ json!({"name": "a/b", "version": "2.0.0", "require": {"php": ">=8.0"}}),
+ json!({"name": "a/b", "version": "1.0.0", "require": {"php": ">=7.4"}}),
+ ];
+
+ let minified = minify(&full);
+ let expanded = expand(&minified);
+ assert_eq!(expanded, full);
+ }
+
+ #[test]
+ fn expand_empty() {
+ assert!(expand(&[]).is_empty());
+ }
+
+ #[test]
+ fn minify_empty() {
+ assert!(minify(&[]).is_empty());
+ }
+
+ #[test]
+ fn expand_single_version() {
+ let versions = vec![json!({"name": "a/b", "version": "1.0.0"})];
+ let expanded = expand(&versions);
+ assert_eq!(expanded.len(), 1);
+ assert_eq!(expanded[0], versions[0]);
+ }
+
+ #[test]
+ fn minify_single_version() {
+ let versions = vec![json!({"name": "a/b", "version": "1.0.0"})];
+ let minified = minify(&versions);
+ assert_eq!(minified.len(), 1);
+ assert_eq!(minified[0], versions[0]);
+ }
+}
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml
index b388438..8672708 100644
--- a/crates/mozart-registry/Cargo.toml
+++ b/crates/mozart-registry/Cargo.toml
@@ -6,6 +6,7 @@ edition.workspace = true
[dependencies]
mozart-constraint.workspace = true
mozart-core.workspace = true
+mozart-metadata-minifier.workspace = true
anyhow.workspace = true
filetime.workspace = true
flate2.workspace = true
diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs
index 95d02ad..e851955 100644
--- a/crates/mozart-registry/src/packagist.rs
+++ b/crates/mozart-registry/src/packagist.rs
@@ -1,7 +1,38 @@
use crate::cache::Cache;
+use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
+/// Deserialize a field that may contain the Packagist minifier sentinel `"__unset"`.
+///
+/// Packagist's metadata minifier (see `composer/metadata-minifier`) encodes
+/// deleted fields as the literal string `"__unset"` in version diffs. When we
+/// encounter this sentinel we treat the field as absent (`None` / default).
+fn deserialize_unset_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
+where
+ D: Deserializer<'de>,
+ T: serde::de::DeserializeOwned,
+{
+ let value = serde_json::Value::deserialize(deserializer)?;
+ if value.as_str() == Some("__unset") {
+ return Ok(None);
+ }
+ serde_json::from_value(value).map_err(serde::de::Error::custom)
+}
+
+/// Like [`deserialize_unset_as_none`] but returns a default `T` instead of `Option`.
+fn deserialize_unset_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
+where
+ D: Deserializer<'de>,
+ T: serde::de::DeserializeOwned + Default,
+{
+ let value = serde_json::Value::deserialize(deserializer)?;
+ if value.as_str() == Some("__unset") {
+ return Ok(T::default());
+ }
+ serde_json::from_value(value).map_err(serde::de::Error::custom)
+}
+
#[derive(Debug, Clone, Deserialize)]
pub struct PackagistDist {
#[serde(rename = "type")]
@@ -23,50 +54,62 @@ pub struct PackagistSource {
pub struct PackagistVersion {
pub version: String,
pub version_normalized: String,
- #[serde(default)]
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
pub require: BTreeMap<String, String>,
- #[serde(default)]
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
pub replace: BTreeMap<String, String>,
- #[serde(default)]
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
pub provide: BTreeMap<String, String>,
- #[serde(default)]
+ #[serde(default, deserialize_with = "deserialize_unset_as_default")]
pub conflict: BTreeMap<String, String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub dist: Option<PackagistDist>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub source: Option<PackagistSource>,
- #[serde(rename = "require-dev", default)]
+ #[serde(rename = "require-dev", default, deserialize_with = "deserialize_unset_as_default")]
pub require_dev: BTreeMap<String, String>,
- #[serde(default)]
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub suggest: Option<BTreeMap<String, String>>,
- #[serde(rename = "type")]
+ #[serde(rename = "type", default, deserialize_with = "deserialize_unset_as_none")]
pub package_type: Option<String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub autoload: Option<serde_json::Value>,
- #[serde(rename = "autoload-dev")]
+ #[serde(rename = "autoload-dev", default, deserialize_with = "deserialize_unset_as_none")]
pub autoload_dev: Option<serde_json::Value>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub license: Option<Vec<String>>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub description: Option<String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub homepage: Option<String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub keywords: Option<Vec<String>>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub authors: Option<Vec<serde_json::Value>>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub support: Option<serde_json::Value>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub funding: Option<Vec<serde_json::Value>>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub time: Option<String>,
+ #[serde(default, deserialize_with = "deserialize_unset_as_none")]
pub extra: Option<serde_json::Value>,
- #[serde(rename = "notification-url")]
+ #[serde(rename = "notification-url", default, deserialize_with = "deserialize_unset_as_none")]
pub notification_url: Option<String>,
}
@@ -107,20 +150,48 @@ impl PackagistVersion {
/// Parse a Packagist p2 API JSON response.
///
-/// The response format is: `{"packages": {"vendor/package": [...]}}`.
+/// The response format is:
+/// ```json
+/// {
+/// "packages": {"vendor/package": [...]},
+/// "minified": "composer/2.0" // optional
+/// }
+/// ```
+///
+/// When the `"minified"` key is present the version list is delta-encoded by
+/// Composer's `MetadataMinifier`. This function transparently expands the
+/// minified data before deserializing into [`PackagistVersion`] structs.
pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> {
- #[derive(Deserialize)]
- struct P2Response {
- packages: BTreeMap<String, Vec<PackagistVersion>>,
- }
+ let raw: serde_json::Value = serde_json::from_str(json)?;
- let response: P2Response = serde_json::from_str(json)?;
- response
- .packages
+ // Check whether the response is minified.
+ let is_minified = raw
+ .get("minified")
+ .and_then(|v| v.as_str())
+ .is_some_and(|s| s == "composer/2.0");
+
+ // Extract the version array for the requested package.
+ let versions_value = raw
+ .get("packages")
+ .and_then(|p| p.get(package_name))
+ .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))?;
+
+ let versions_array = versions_value
+ .as_array()
+ .ok_or_else(|| anyhow::anyhow!("Expected array for package \"{package_name}\""))?;
+
+ // Expand minified diffs into full version objects if necessary.
+ let versions: Vec<serde_json::Value> = if is_minified {
+ mozart_metadata_minifier::expand(versions_array)
+ } else {
+ versions_array.clone()
+ };
+
+ // Deserialize the (possibly expanded) version objects.
+ versions
.into_iter()
- .find(|(key, _)| key == package_name)
- .map(|(_, versions)| versions)
- .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))
+ .map(|v| serde_json::from_value(v).map_err(Into::into))
+ .collect()
}
/// Fetch package version metadata from the Packagist p2 API.
@@ -540,6 +611,219 @@ mod tests {
assert!(aliases.is_empty());
}
+ // ──────────── __unset sentinel handling ────────────────────────────────
+
+ #[test]
+ fn parse_p2_response_unset_fields() {
+ // Packagist metadata minifier uses "__unset" to mark deleted fields.
+ let json = r#"{
+ "packages": {
+ "test/pkg": [
+ {
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "require": {"php": ">=8.1"},
+ "license": ["MIT"],
+ "keywords": ["framework"],
+ "authors": [{"name": "Alice"}],
+ "funding": [{"type": "github", "url": "https://github.com/sponsors/alice"}]
+ },
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "license": "__unset",
+ "keywords": "__unset",
+ "authors": "__unset",
+ "funding": "__unset",
+ "require": "__unset",
+ "homepage": "__unset",
+ "description": "__unset",
+ "extra": "__unset",
+ "suggest": "__unset"
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "test/pkg").unwrap();
+ assert_eq!(versions.len(), 2);
+
+ // First version has normal values
+ assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
+ assert_eq!(versions[0].keywords.as_ref().unwrap(), &["framework"]);
+
+ // Second version has __unset → treated as absent
+ assert!(versions[1].license.is_none());
+ assert!(versions[1].keywords.is_none());
+ assert!(versions[1].authors.is_none());
+ assert!(versions[1].funding.is_none());
+ assert!(versions[1].require.is_empty());
+ assert!(versions[1].homepage.is_none());
+ assert!(versions[1].description.is_none());
+ assert!(versions[1].extra.is_none());
+ assert!(versions[1].suggest.is_none());
+ }
+
+ // ──────────── minified metadata expansion ──────────────────────────────
+
+ #[test]
+ fn parse_p2_response_minified_expand() {
+ // Mirrors the Composer MetadataMinifierTest: 3 versions where only
+ // the first carries all fields and subsequent entries are diffs.
+ let json = r#"{
+ "packages": {
+ "foo/bar": [
+ {
+ "name": "foo/bar",
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "type": "library",
+ "license": ["MIT"],
+ "require": {"php": ">=8.1"},
+ "description": "A great package"
+ },
+ {
+ "version": "1.2.0",
+ "version_normalized": "1.2.0.0",
+ "license": ["GPL"],
+ "homepage": "https://example.org"
+ },
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "homepage": "__unset"
+ }
+ ]
+ },
+ "minified": "composer/2.0"
+ }"#;
+
+ let versions = parse_p2_response(json, "foo/bar").unwrap();
+ assert_eq!(versions.len(), 3);
+
+ // Version 2.0.0 — full data (first entry).
+ assert_eq!(versions[0].version, "2.0.0");
+ assert_eq!(versions[0].package_type.as_deref(), Some("library"));
+ assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
+ assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
+ assert_eq!(versions[0].description.as_deref(), Some("A great package"));
+ assert!(versions[0].homepage.is_none());
+
+ // Version 1.2.0 — inherits name, type, require, description from 2.0.0;
+ // license changed to GPL; homepage added.
+ assert_eq!(versions[1].version, "1.2.0");
+ assert_eq!(versions[1].package_type.as_deref(), Some("library"));
+ assert_eq!(versions[1].license.as_ref().unwrap(), &["GPL"]);
+ assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1");
+ assert_eq!(versions[1].description.as_deref(), Some("A great package"));
+ assert_eq!(versions[1].homepage.as_deref(), Some("https://example.org"));
+
+ // Version 1.0.0 — inherits everything from 1.2.0 except homepage
+ // which is __unset (deleted).
+ assert_eq!(versions[2].version, "1.0.0");
+ assert_eq!(versions[2].package_type.as_deref(), Some("library"));
+ assert_eq!(versions[2].license.as_ref().unwrap(), &["GPL"]);
+ assert_eq!(versions[2].require.get("php").unwrap(), ">=8.1");
+ assert_eq!(versions[2].description.as_deref(), Some("A great package"));
+ assert!(versions[2].homepage.is_none());
+ }
+
+ #[test]
+ fn parse_p2_response_not_minified_no_inheritance() {
+ // Without "minified" key, each version stands alone — no inheritance.
+ let json = r#"{
+ "packages": {
+ "foo/bar": [
+ {
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "license": ["MIT"],
+ "description": "A great package"
+ },
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0"
+ }
+ ]
+ }
+ }"#;
+
+ let versions = parse_p2_response(json, "foo/bar").unwrap();
+ assert_eq!(versions.len(), 2);
+
+ assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
+ assert_eq!(versions[0].description.as_deref(), Some("A great package"));
+
+ // Without minified flag, version 1.0.0 does NOT inherit from 2.0.0.
+ assert!(versions[1].license.is_none());
+ assert!(versions[1].description.is_none());
+ }
+
+ #[test]
+ fn parse_p2_response_minified_single_version() {
+ // Edge case: minified response with only one version.
+ let json = r#"{
+ "packages": {
+ "foo/bar": [
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "license": ["MIT"]
+ }
+ ]
+ },
+ "minified": "composer/2.0"
+ }"#;
+
+ let versions = parse_p2_response(json, "foo/bar").unwrap();
+ assert_eq!(versions.len(), 1);
+ assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
+ }
+
+ #[test]
+ fn parse_p2_response_minified_empty_versions() {
+ let json = r#"{
+ "packages": {
+ "foo/bar": []
+ },
+ "minified": "composer/2.0"
+ }"#;
+
+ let versions = parse_p2_response(json, "foo/bar").unwrap();
+ assert!(versions.is_empty());
+ }
+
+ #[test]
+ fn parse_p2_response_minified_map_fields_inherited() {
+ // Verify BTreeMap fields (require, replace, etc.) are inherited.
+ let json = r#"{
+ "packages": {
+ "foo/bar": [
+ {
+ "version": "2.0.0",
+ "version_normalized": "2.0.0.0",
+ "require": {"php": ">=8.1", "ext-json": "*"},
+ "replace": {"foo/old": "self.version"}
+ },
+ {
+ "version": "1.0.0",
+ "version_normalized": "1.0.0.0",
+ "replace": "__unset"
+ }
+ ]
+ },
+ "minified": "composer/2.0"
+ }"#;
+
+ let versions = parse_p2_response(json, "foo/bar").unwrap();
+ assert_eq!(versions.len(), 2);
+
+ // Version 1.0.0 inherits require from 2.0.0, replace is unset.
+ assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1");
+ assert_eq!(versions[1].require.get("ext-json").unwrap(), "*");
+ assert!(versions[1].replace.is_empty());
+ }
+
// ──────────── SecurityAdvisory parsing tests ─────────────────────────────
#[test]