Skip to main content

composer_wire/
document.rs

1//! Composer v2 repository wire documents: the per-package `p2/*.json`
2//! ([`PackageDocument`]) and the root `packages.json` ([`RootManifest`]).
3
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::collections::BTreeMap;
7
8use crate::minify::{MINIFIED_MARKER, expand_versions, minify_versions};
9
10/// A `/p2/<vendor>/<name>.json` document: a map (usually single-entry,
11/// keyed by `vendor/name`) of version lists. When
12/// [`minified`](Self::minified) is `Some("composer/2.0")`, the version
13/// lists are sparse diffs (see [`crate::minify`]); otherwise each entry
14/// stands alone.
15#[derive(Debug, Clone, Default, Serialize, Deserialize)]
16pub struct PackageDocument {
17    /// The minified-format marker. `Some("composer/2.0")` means the
18    /// `packages` version lists are diffs; any other value (or `None`)
19    /// means they stand alone.
20    #[serde(skip_serializing_if = "Option::is_none", default)]
21    pub minified: Option<String>,
22    /// `vendor/name` → version list (Packagist orders newest-first).
23    /// Each version is a raw object so the minify/expand pass can run
24    /// before any typed deserialization.
25    #[serde(default)]
26    pub packages: BTreeMap<String, Vec<Map<String, Value>>>,
27}
28
29impl PackageDocument {
30    /// Parse a `/p2/` document from JSON bytes.
31    pub fn parse(bytes: &[u8]) -> Result<Self, serde_json::Error> {
32        serde_json::from_slice(bytes)
33    }
34
35    /// Serialize to compact JSON bytes.
36    pub fn to_vec(&self) -> Result<Vec<u8>, serde_json::Error> {
37        serde_json::to_vec(self)
38    }
39
40    /// Build a non-minified document: each version stands alone.
41    #[must_use]
42    pub fn flat(packages: BTreeMap<String, Vec<Map<String, Value>>>) -> Self {
43        Self {
44            minified: None,
45            packages,
46        }
47    }
48
49    /// Build a minified `composer/2.0` document from fully-materialized
50    /// version lists, applying [`minify_versions`] to each package.
51    #[must_use]
52    pub fn minified(packages: BTreeMap<String, Vec<Map<String, Value>>>) -> Self {
53        let packages = packages
54            .into_iter()
55            .map(|(name, versions)| (name, minify_versions(&versions)))
56            .collect();
57        Self {
58            minified: Some(MINIFIED_MARKER.to_owned()),
59            packages,
60        }
61    }
62
63    /// Expand into fully-materialized version objects, honoring the
64    /// [`minified`](Self::minified) marker. A document whose marker is
65    /// anything other than `composer/2.0` is returned as-is (each entry
66    /// already stands alone) — defensive against a future format bump.
67    #[must_use]
68    pub fn expand(self) -> BTreeMap<String, Vec<Map<String, Value>>> {
69        let is_minified = self.minified.as_deref() == Some(MINIFIED_MARKER);
70        self.packages
71            .into_iter()
72            .map(|(name, versions)| {
73                let expanded = if is_minified {
74                    expand_versions(versions)
75                } else {
76                    versions
77                };
78                (name, expanded)
79            })
80            .collect()
81    }
82}
83
84/// The root `packages.json` of a Composer v2 repository. Tells a client
85/// where to fetch per-package metadata ([`metadata_url`](Self::metadata_url),
86/// a template with a `%package%` placeholder) and, optionally, which
87/// packages exist. Unknown root keys round-trip through
88/// [`extra`](Self::extra).
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
90pub struct RootManifest {
91    /// `metadata-url` — the per-package metadata template, e.g.
92    /// `https://repo.test/p2/%package%.json`.
93    #[serde(
94        rename = "metadata-url",
95        skip_serializing_if = "Option::is_none",
96        default
97    )]
98    pub metadata_url: Option<String>,
99    /// `available-packages` — the full list of package names served.
100    #[serde(
101        rename = "available-packages",
102        skip_serializing_if = "Option::is_none",
103        default
104    )]
105    pub available_packages: Option<Vec<String>>,
106    /// `available-package-patterns` — wildcard patterns for served names.
107    #[serde(
108        rename = "available-package-patterns",
109        skip_serializing_if = "Option::is_none",
110        default
111    )]
112    pub available_package_patterns: Option<Vec<String>>,
113    /// `providers-url` — the legacy v1 provider template, if advertised.
114    #[serde(
115        rename = "providers-url",
116        skip_serializing_if = "Option::is_none",
117        default
118    )]
119    pub providers_url: Option<String>,
120    /// Any other root keys (e.g. `notify-batch`, `search`, `mirrors`,
121    /// `provider-includes`), preserved verbatim on round-trip.
122    #[serde(flatten)]
123    pub extra: Map<String, Value>,
124}
125
126impl RootManifest {
127    /// The v2 `metadata-url` template for a repository served at
128    /// `base_url`: `<base>/p2/%package%.json` (trailing slash trimmed).
129    #[must_use]
130    pub fn metadata_template(base_url: &str) -> String {
131        format!("{}/p2/%package%.json", base_url.trim_end_matches('/'))
132    }
133
134    /// Build a minimal v2 root manifest: a `metadata-url` for `base_url`
135    /// plus an `available-packages` list.
136    #[must_use]
137    pub fn v2(base_url: &str, available_packages: Vec<String>) -> Self {
138        Self {
139            metadata_url: Some(Self::metadata_template(base_url)),
140            available_packages: Some(available_packages),
141            ..Default::default()
142        }
143    }
144
145    /// Parse a root `packages.json` from JSON bytes.
146    pub fn parse(bytes: &[u8]) -> Result<Self, serde_json::Error> {
147        serde_json::from_slice(bytes)
148    }
149
150    /// Serialize to compact JSON bytes.
151    pub fn to_vec(&self) -> Result<Vec<u8>, serde_json::Error> {
152        serde_json::to_vec(self)
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    #[test]
162    fn parses_a_single_fully_expanded_version() {
163        let body = br#"{ "packages": { "monolog/monolog": [
164            {"name":"monolog/monolog","version":"3.0.0","require":{"php":">=8.1"}}
165        ] } }"#;
166        let expanded = PackageDocument::parse(body).unwrap().expand();
167        let v = &expanded["monolog/monolog"];
168        assert_eq!(v.len(), 1);
169        assert_eq!(v[0]["version"], json!("3.0.0"));
170        assert_eq!(v[0]["require"]["php"], json!(">=8.1"));
171    }
172
173    #[test]
174    fn expands_minified_composer_2_0_inheritance() {
175        let body = br#"{ "minified":"composer/2.0", "packages": { "acme/foo": [
176            {"name":"acme/foo","version":"3.0.0","type":"library","dist":{"type":"zip","url":"https://e/3.0.0.zip"},"require":{"php":">=8.1"}},
177            {"version":"2.5.0","dist":{"type":"zip","url":"https://e/2.5.0.zip"}},
178            {"version":"2.0.0","dist":{"type":"zip","url":"https://e/2.0.0.zip"},"require":{"php":">=7.4"},"require-dev":{"phpunit/phpunit":"^9"}}
179        ] } }"#;
180        let expanded = PackageDocument::parse(body).unwrap().expand();
181        let v = &expanded["acme/foo"];
182        assert_eq!(v.len(), 3);
183        assert_eq!(v[0]["version"], json!("3.0.0"));
184        // v1 inherits name + type + require; overrides version + dist.
185        assert_eq!(v[1]["version"], json!("2.5.0"));
186        assert_eq!(v[1]["name"], json!("acme/foo"));
187        assert_eq!(v[1]["type"], json!("library"));
188        assert_eq!(v[1]["require"]["php"], json!(">=8.1"));
189        assert_eq!(v[1]["dist"]["url"], json!("https://e/2.5.0.zip"));
190        // v2 overrides require, adds require-dev, still inherits name+type.
191        assert_eq!(v[2]["require"]["php"], json!(">=7.4"));
192        assert_eq!(v[2]["require-dev"]["phpunit/phpunit"], json!("^9"));
193        assert_eq!(v[2]["name"], json!("acme/foo"));
194    }
195
196    #[test]
197    fn unset_sentinel_resets_inherited_key() {
198        let body = br#"{ "minified":"composer/2.0", "packages": { "acme/bar": [
199            {"name":"acme/bar","version":"2.0.0","require":{"php":">=8.0"}},
200            {"version":"1.0.0","require":"__unset"}
201        ] } }"#;
202        let expanded = PackageDocument::parse(body).unwrap().expand();
203        let v = &expanded["acme/bar"];
204        assert_eq!(v[1]["version"], json!("1.0.0"));
205        assert!(v[1].get("require").is_none());
206        assert_eq!(v[1]["name"], json!("acme/bar"));
207    }
208
209    #[test]
210    fn non_minified_response_is_returned_as_is() {
211        let body = br#"{ "packages": { "acme/baz": [
212            {"name":"acme/baz","version":"2.0.0","require":{"php":">=8.0"}},
213            {"name":"acme/baz","version":"1.0.0"}
214        ] } }"#;
215        let expanded = PackageDocument::parse(body).unwrap().expand();
216        let v = &expanded["acme/baz"];
217        assert_eq!(v[0]["require"]["php"], json!(">=8.0"));
218        assert!(v[1].get("require").is_none());
219    }
220
221    #[test]
222    fn unknown_minified_marker_is_treated_as_non_minified() {
223        let body = br#"{ "minified":"composer/2.1", "packages": { "acme/qux": [
224            {"name":"acme/qux","version":"2.0.0"}
225        ] } }"#;
226        let expanded = PackageDocument::parse(body).unwrap().expand();
227        assert_eq!(expanded["acme/qux"].len(), 1);
228        assert_eq!(expanded["acme/qux"][0]["version"], json!("2.0.0"));
229    }
230
231    #[test]
232    fn empty_packages_map_parses() {
233        let body = br#"{"minified":"composer/2.0","packages":{}}"#;
234        let expanded = PackageDocument::parse(body).unwrap().expand();
235        assert!(expanded.is_empty());
236    }
237
238    #[test]
239    fn malformed_json_errors() {
240        assert!(PackageDocument::parse(br"{not json").is_err());
241    }
242
243    #[test]
244    fn minified_constructor_round_trips_through_expand() {
245        let mut packages = BTreeMap::new();
246        packages.insert(
247            "acme/foo".to_owned(),
248            vec![
249                match json!({"name":"acme/foo","version":"2.0.0","require":{"php":">=8"}}) {
250                    Value::Object(m) => m,
251                    _ => unreachable!(),
252                },
253                match json!({"name":"acme/foo","version":"1.0.0","require":{"php":">=8"}}) {
254                    Value::Object(m) => m,
255                    _ => unreachable!(),
256                },
257            ],
258        );
259        let doc = PackageDocument::minified(packages.clone());
260        assert_eq!(doc.minified.as_deref(), Some("composer/2.0"));
261        // The serialized-then-parsed document expands back to the input.
262        let bytes = doc.to_vec().unwrap();
263        let expanded = PackageDocument::parse(&bytes).unwrap().expand();
264        assert_eq!(expanded, packages);
265    }
266
267    #[test]
268    fn root_manifest_v2_render_and_round_trip() {
269        let root = RootManifest::v2(
270            "https://r.test/",
271            vec!["acme/widget".to_owned(), "acme/gadget".to_owned()],
272        );
273        assert_eq!(
274            root.metadata_url.as_deref(),
275            Some("https://r.test/p2/%package%.json")
276        );
277        let back = RootManifest::parse(&root.to_vec().unwrap()).unwrap();
278        assert_eq!(back.metadata_url, root.metadata_url);
279        assert_eq!(back.available_packages, root.available_packages);
280    }
281
282    #[test]
283    fn root_manifest_preserves_unknown_fields() {
284        let body = br#"{"metadata-url":"/p2/%package%.json","notify-batch":"https://r/notify"}"#;
285        let root = RootManifest::parse(body).unwrap();
286        assert_eq!(root.metadata_url.as_deref(), Some("/p2/%package%.json"));
287        assert_eq!(root.extra["notify-batch"], json!("https://r/notify"));
288        let back = RootManifest::parse(&root.to_vec().unwrap()).unwrap();
289        assert_eq!(back.extra["notify-batch"], json!("https://r/notify"));
290    }
291}