Skip to main content

composer_wire/
minify.rs

1//! The Composer v2 "minified" repository-metadata algorithm.
2//!
3//! Packagist serves each `/p2/<vendor>/<name>.json` document in minified
4//! form (`"minified": "composer/2.0"`): the first version entry is fully
5//! expanded and every later entry is a sparse diff against the running
6//! accumulation of the entries before it. A value of the literal string
7//! `"__unset"` removes an inherited key (NOT JSON `null`, which would set
8//! the key to null). [`expand_versions`] materializes a minified list;
9//! [`minify_versions`] is its exact inverse.
10//!
11//! Mirrors Composer's `Composer\MetadataMinifier\MetadataMinifier`.
12
13use serde_json::{Map, Value};
14
15/// The `minified` marker value Packagist sets for the diff format.
16pub const MINIFIED_MARKER: &str = "composer/2.0";
17
18/// Deletion sentinel inside a minified diff entry. A key whose value is
19/// exactly this string is removed from the accumulator on expansion.
20pub const UNSET: &str = "__unset";
21
22/// Expand a minified `composer/2.0` version list (newest-first) into a
23/// list of fully-materialized version objects.
24///
25/// Each entry is layered onto a running accumulator: present keys
26/// overwrite, [`UNSET`] removes, absent keys inherit. The returned list
27/// has one fully-expanded object per input entry, in the same order.
28#[must_use]
29pub fn expand_versions(versions: Vec<Map<String, Value>>) -> Vec<Map<String, Value>> {
30    let mut acc: Map<String, Value> = Map::new();
31    let mut out = Vec::with_capacity(versions.len());
32    for diff in versions {
33        apply_diff(&mut acc, diff);
34        out.push(acc.clone());
35    }
36    out
37}
38
39/// Minify a fully-materialized version list (newest-first) into the
40/// `composer/2.0` sparse-diff form. Inverse of [`expand_versions`]:
41/// `expand_versions(minify_versions(v)) == v` for any well-formed `v`
42/// (compared as key/value sets — key *order* is not preserved).
43///
44/// The first entry is emitted whole; each later entry carries only the
45/// keys that changed versus the entry before it, plus an [`UNSET`]
46/// sentinel for every key the previous entry had that this one drops.
47#[must_use]
48pub fn minify_versions(versions: &[Map<String, Value>]) -> Vec<Map<String, Value>> {
49    let mut out: Vec<Map<String, Value>> = Vec::with_capacity(versions.len());
50    for (i, cur) in versions.iter().enumerate() {
51        if i == 0 {
52            out.push(cur.clone());
53            continue;
54        }
55        let prev = &versions[i - 1];
56        let mut diff = Map::new();
57        // Changed or newly-added keys (relative to the previous entry).
58        for (k, v) in cur {
59            if prev.get(k) != Some(v) {
60                diff.insert(k.clone(), v.clone());
61            }
62        }
63        // Keys present in the previous entry but gone in this one → unset.
64        for k in prev.keys() {
65            if !cur.contains_key(k) {
66                diff.insert(k.clone(), Value::String(UNSET.to_owned()));
67            }
68        }
69        out.push(diff);
70    }
71    out
72}
73
74/// Apply one minified-diff entry onto the running accumulator. A value of
75/// [`UNSET`] removes the key; anything else (including JSON `null`)
76/// overwrites verbatim.
77fn apply_diff(acc: &mut Map<String, Value>, diff: Map<String, Value>) {
78    for (k, v) in diff {
79        if v.as_str() == Some(UNSET) {
80            acc.remove(&k);
81        } else {
82            acc.insert(k, v);
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use serde_json::json;
91
92    fn obj(v: Value) -> Map<String, Value> {
93        match v {
94            Value::Object(m) => m,
95            _ => panic!("expected a JSON object"),
96        }
97    }
98
99    #[test]
100    fn expand_inherits_and_overrides() {
101        let versions = vec![
102            obj(json!({"name":"a/b","version":"2.0.0","require":{"php":">=8"}})),
103            obj(json!({"version":"1.0.0"})),
104        ];
105        let expanded = expand_versions(versions);
106        assert_eq!(expanded[1]["name"], json!("a/b"));
107        assert_eq!(expanded[1]["require"]["php"], json!(">=8"));
108        assert_eq!(expanded[1]["version"], json!("1.0.0"));
109    }
110
111    #[test]
112    fn minify_is_inverse_of_expand() {
113        // A fully-materialized version list (what a server holds).
114        let full = vec![
115            obj(json!({"name":"a/b","version":"3.0.0","type":"library","require":{"php":">=8.1"},"dist":{"type":"zip","url":"u3"}})),
116            obj(json!({"name":"a/b","version":"2.0.0","type":"library","require":{"php":">=8.1"},"dist":{"type":"zip","url":"u2"}})),
117            obj(json!({"name":"a/b","version":"1.0.0","type":"library","require":{"php":">=7.4"},"dist":{"type":"zip","url":"u1"}})),
118        ];
119        let mini = minify_versions(&full);
120        // The second entry is sparse: name/type/require unchanged, omitted.
121        assert!(!mini[1].contains_key("name"));
122        assert!(!mini[1].contains_key("type"));
123        assert!(!mini[1].contains_key("require"));
124        assert!(mini[1].contains_key("version"));
125        // The third entry's require changed, so it is carried.
126        assert!(mini[2].contains_key("require"));
127        // And it round-trips back to the originals (order-independent eq).
128        assert_eq!(expand_versions(mini), full);
129    }
130
131    #[test]
132    fn minify_emits_unset_for_dropped_keys() {
133        let full = vec![
134            obj(json!({"name":"a/b","version":"2.0.0","require":{"php":">=8"}})),
135            obj(json!({"name":"a/b","version":"1.0.0"})), // dropped `require`
136        ];
137        let mini = minify_versions(&full);
138        assert_eq!(mini[1]["require"], json!("__unset"));
139        assert_eq!(expand_versions(mini), full);
140    }
141
142    #[test]
143    fn unset_in_input_removes_key() {
144        let versions = vec![
145            obj(json!({"name":"a/b","version":"2.0.0","require":{"php":">=8"}})),
146            obj(json!({"version":"1.0.0","require":"__unset"})),
147        ];
148        let expanded = expand_versions(versions);
149        assert!(expanded[1].get("require").is_none());
150        assert_eq!(expanded[1]["name"], json!("a/b"));
151    }
152}