diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-22 12:05:17 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-22 12:05:17 +0900 |
| commit | 92fdff257d2c64f94600ba70bf17e429d46474b2 (patch) | |
| tree | d5262aaa71f54f4445e88459468d64262c0a4dee | |
| parent | 8071553d6c706cffda6fdb4391b561329e949014 (diff) | |
| download | php-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.lock | 8 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart-metadata-minifier/Cargo.toml | 7 | ||||
| -rw-r--r-- | crates/mozart-metadata-minifier/src/lib.rs | 274 | ||||
| -rw-r--r-- | crates/mozart-registry/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/packagist.rs | 324 |
6 files changed, 595 insertions, 20 deletions
@@ -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", @@ -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] |
