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(
116                json!({"name":"a/b","version":"3.0.0","type":"library","require":{"php":">=8.1"},"dist":{"type":"zip","url":"u3"}}),
117            ),
118            obj(
119                json!({"name":"a/b","version":"2.0.0","type":"library","require":{"php":">=8.1"},"dist":{"type":"zip","url":"u2"}}),
120            ),
121            obj(
122                json!({"name":"a/b","version":"1.0.0","type":"library","require":{"php":">=7.4"},"dist":{"type":"zip","url":"u1"}}),
123            ),
124        ];
125        let mini = minify_versions(&full);
126        // The second entry is sparse: name/type/require unchanged, omitted.
127        assert!(!mini[1].contains_key("name"));
128        assert!(!mini[1].contains_key("type"));
129        assert!(!mini[1].contains_key("require"));
130        assert!(mini[1].contains_key("version"));
131        // The third entry's require changed, so it is carried.
132        assert!(mini[2].contains_key("require"));
133        // And it round-trips back to the originals (order-independent eq).
134        assert_eq!(expand_versions(mini), full);
135    }
136
137    #[test]
138    fn minify_emits_unset_for_dropped_keys() {
139        let full = vec![
140            obj(json!({"name":"a/b","version":"2.0.0","require":{"php":">=8"}})),
141            obj(json!({"name":"a/b","version":"1.0.0"})), // dropped `require`
142        ];
143        let mini = minify_versions(&full);
144        assert_eq!(mini[1]["require"], json!("__unset"));
145        assert_eq!(expand_versions(mini), full);
146    }
147
148    #[test]
149    fn unset_in_input_removes_key() {
150        let versions = vec![
151            obj(json!({"name":"a/b","version":"2.0.0","require":{"php":">=8"}})),
152            obj(json!({"version":"1.0.0","require":"__unset"})),
153        ];
154        let expanded = expand_versions(versions);
155        assert!(expanded[1].get("require").is_none());
156        assert_eq!(expanded[1]["name"], json!("a/b"));
157    }
158}