Skip to main content

npm_utils/
registry.rs

1//! npm registry interaction: tarball URLs, package metadata, and version
2//! resolution against a semver range.
3
4use crate::download;
5use semver::{Version, VersionReq};
6use serde_json::Value;
7
8/// An npm-compatible registry. Defaults to the public registry.
9pub struct Registry {
10    pub base_url: String,
11}
12
13impl Default for Registry {
14    fn default() -> Self {
15        Self {
16            base_url: "https://registry.npmjs.org".to_string(),
17        }
18    }
19}
20
21/// A resolved package version: the exact version plus the tarball to fetch.
22#[derive(Debug, Clone)]
23pub struct Resolved {
24    pub name: String,
25    pub version: Version,
26    pub tarball_url: String,
27}
28
29impl Registry {
30    /// The public npm registry (`https://registry.npmjs.org`).
31    pub fn npm() -> Self {
32        Self::default()
33    }
34
35    /// A registry at a custom base URL (e.g. a private mirror).
36    pub fn with_base_url(base_url: impl Into<String>) -> Self {
37        Self {
38            base_url: base_url.into(),
39        }
40    }
41
42    /// Conventional tarball URL for an exact `version`. Handles scoped names:
43    /// `@scope/pkg` → `<base>/@scope/pkg/-/pkg-<version>.tgz`.
44    pub fn tarball_url(&self, name: &str, version: &str) -> String {
45        let unscoped = name.rsplit('/').next().unwrap_or(name);
46        format!("{}/{}/-/{}-{}.tgz", self.base_url, name, unscoped, version)
47    }
48
49    /// Fetch the package metadata document ("packument").
50    pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error>> {
51        // Scoped names are URL-encoded in the path: `@scope/pkg` → `@scope%2fpkg`.
52        let encoded = match name.strip_prefix('@') {
53            Some(rest) => format!("@{}", rest.replacen('/', "%2f", 1)),
54            None => name.to_string(),
55        };
56        let url = format!("{}/{}", self.base_url, encoded);
57        let bytes = download::fetch(&url)?;
58        Ok(serde_json::from_slice(&bytes)?)
59    }
60
61    /// Resolve the newest published version of `name` matching `req`.
62    pub fn resolve(
63        &self,
64        name: &str,
65        req: &VersionReq,
66    ) -> Result<Resolved, Box<dyn std::error::Error>> {
67        let doc = self.packument(name)?;
68        let (version, tarball) = select_version(&doc, req)
69            .ok_or_else(|| format!("no published version of {name} matches {req}"))?;
70        let tarball_url = tarball.unwrap_or_else(|| self.tarball_url(name, &version.to_string()));
71        Ok(Resolved {
72            name: name.to_string(),
73            version,
74            tarball_url,
75        })
76    }
77
78    /// Resolve the transitive dependency graph of `roots` into a **flat** set — one
79    /// version per package name (the npm v3+ `node_modules` layout). Each package's
80    /// `dependencies` are read straight from the registry metadata (no tarball
81    /// extraction), every child resolved to its newest matching version, and the set
82    /// de-duplicated by name. Cyclic graphs terminate (a name is resolved once).
83    /// Returns the packages sorted by name.
84    ///
85    /// MVP limitation: a single version per package name. Two *incompatible*
86    /// requirements on the same package — a genuine conflict npm would resolve by
87    /// nesting — is reported as an error rather than silently mis-resolved.
88    pub fn resolve_tree(
89        &self,
90        roots: &[(String, VersionReq)],
91    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error>> {
92        self.resolve_tree_from(roots, |name| self.packument(name))
93    }
94
95    /// [`resolve_tree`](Self::resolve_tree) with an injectable packument source, so the
96    /// graph walk can be unit-tested without the network.
97    fn resolve_tree_from<F>(
98        &self,
99        roots: &[(String, VersionReq)],
100        mut get_packument: F,
101    ) -> Result<Vec<Resolved>, Box<dyn std::error::Error>>
102    where
103        F: FnMut(&str) -> Result<Value, Box<dyn std::error::Error>>,
104    {
105        use std::collections::{HashMap, VecDeque};
106        let mut packuments: HashMap<String, Value> = HashMap::new();
107        let mut resolved: HashMap<String, Resolved> = HashMap::new();
108        let mut queue: VecDeque<(String, VersionReq)> = roots.iter().cloned().collect();
109
110        while let Some((name, req)) = queue.pop_front() {
111            if let Some(existing) = resolved.get(&name) {
112                if req.matches(&existing.version) {
113                    continue; // already resolved to a satisfying version — dedup
114                }
115                return Err(format!(
116                    "version conflict for `{name}`: resolved {} but also required `{req}` \
117                     (flat node_modules install resolves one version per package)",
118                    existing.version
119                )
120                .into());
121            }
122            if !packuments.contains_key(&name) {
123                let doc = get_packument(&name)?;
124                packuments.insert(name.clone(), doc);
125            }
126            let doc = &packuments[&name];
127            let (version, tarball) = select_version(doc, &req)
128                .ok_or_else(|| format!("no published version of {name} matches {req}"))?;
129            let deps = dependencies_of(doc, &version);
130            let tarball_url =
131                tarball.unwrap_or_else(|| self.tarball_url(&name, &version.to_string()));
132            for (dep_name, dep_spec) in deps {
133                let dep_req = version_req(&dep_spec).map_err(|e| {
134                    format!(
135                        "{name}@{version} dependency `{dep_name}`: unsupported version \
136                         {dep_spec:?}: {e}"
137                    )
138                })?;
139                queue.push_back((dep_name, dep_req));
140            }
141            resolved.insert(
142                name.clone(),
143                Resolved {
144                    name,
145                    version,
146                    tarball_url,
147                },
148            );
149        }
150        let mut out: Vec<Resolved> = resolved.into_values().collect();
151        out.sort_by(|a, b| a.name.cmp(&b.name));
152        Ok(out)
153    }
154}
155
156/// Pick the newest version in a packument's `versions` map that satisfies `req`,
157/// returning it with the `dist.tarball` URL the registry advertises (if any).
158/// Factored out for unit testing without network access.
159fn select_version(doc: &Value, req: &VersionReq) -> Option<(Version, Option<String>)> {
160    let versions = doc.get("versions")?.as_object()?;
161    let mut best: Option<(Version, Option<String>)> = None;
162    for (ver_str, meta) in versions {
163        let Ok(ver) = Version::parse(ver_str) else {
164            continue;
165        };
166        if !req.matches(&ver) {
167            continue;
168        }
169        if best.as_ref().map(|(b, _)| ver > *b).unwrap_or(true) {
170            let tarball = meta
171                .get("dist")
172                .and_then(|d| d.get("tarball"))
173                .and_then(|t| t.as_str())
174                .map(str::to_string);
175            best = Some((ver, tarball));
176        }
177    }
178    best
179}
180
181/// Convert an npm dependency spec into a semver [`VersionReq`], npm-faithfully: a bare
182/// full version (`"1.2.3"`) is an **exact** pin (`=1.2.3`); `"*"`, empty, `"x"` and
183/// `"latest"` mean any; range syntax (`^`, `~`, `>=`, …) parses as written.
184pub fn version_req(spec: &str) -> Result<VersionReq, semver::Error> {
185    let spec = spec.trim();
186    if spec.is_empty() || spec == "*" || spec == "x" || spec == "latest" {
187        return Ok(VersionReq::STAR);
188    }
189    if Version::parse(spec).is_ok() {
190        return VersionReq::parse(&format!("={spec}"));
191    }
192    VersionReq::parse(spec)
193}
194
195/// The `dependencies` of a specific version, read from a packument, as `(name, spec)`
196/// pairs. The full packument carries each version's `dependencies` inline, so the
197/// transitive walk discovers children without extracting any tarball.
198fn dependencies_of(doc: &Value, version: &Version) -> Vec<(String, String)> {
199    doc.get("versions")
200        .and_then(|v| v.get(version.to_string()))
201        .and_then(|meta| meta.get("dependencies"))
202        .and_then(|d| d.as_object())
203        .map(|map| {
204            map.iter()
205                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
206                .collect()
207        })
208        .unwrap_or_default()
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use serde_json::json;
215
216    #[test]
217    fn tarball_url_handles_scoped_and_unscoped() {
218        let reg = Registry::npm();
219        assert_eq!(
220            reg.tarball_url("lit", "3.3.3"),
221            "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz"
222        );
223        assert_eq!(
224            reg.tarball_url("@lit/context", "1.1.6"),
225            "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz"
226        );
227    }
228
229    #[test]
230    fn select_version_picks_newest_matching() {
231        let doc = json!({
232            "versions": {
233                "3.1.0": { "dist": { "tarball": "https://r/lit-3.1.0.tgz" } },
234                "3.3.3": { "dist": { "tarball": "https://r/lit-3.3.3.tgz" } },
235                "4.0.0": { "dist": { "tarball": "https://r/lit-4.0.0.tgz" } },
236                "2.9.9": {}
237            }
238        });
239        let (ver, tarball) = select_version(&doc, &"^3".parse().unwrap()).unwrap();
240        assert_eq!(ver, Version::parse("3.3.3").unwrap());
241        assert_eq!(tarball.as_deref(), Some("https://r/lit-3.3.3.tgz"));
242    }
243
244    #[test]
245    fn select_version_none_when_no_match() {
246        let doc = json!({ "versions": { "1.0.0": {}, "2.0.0": {} } });
247        assert!(select_version(&doc, &"^5".parse().unwrap()).is_none());
248    }
249
250    #[test]
251    fn version_req_pins_bare_versions_and_parses_ranges() {
252        assert_eq!(version_req("1.2.3").unwrap(), "=1.2.3".parse().unwrap());
253        assert_eq!(version_req("^3.0.0").unwrap(), "^3.0.0".parse().unwrap());
254        assert_eq!(version_req("*").unwrap(), VersionReq::STAR);
255        assert_eq!(version_req("").unwrap(), VersionReq::STAR);
256        // A bare version matches ONLY itself — npm's exact-pin semantics.
257        let exact = version_req("1.2.3").unwrap();
258        assert!(exact.matches(&Version::parse("1.2.3").unwrap()));
259        assert!(!exact.matches(&Version::parse("1.2.4").unwrap()));
260    }
261
262    /// A one-version packument carrying a `dependencies` map, mirroring the registry's
263    /// shape, so the graph walk can be exercised without the network.
264    fn packument_with(version: &str, deps: &[(&str, &str)]) -> Value {
265        let dep_map: serde_json::Map<String, Value> = deps
266            .iter()
267            .map(|(n, s)| (n.to_string(), json!(*s)))
268            .collect();
269        let mut versions = serde_json::Map::new();
270        versions.insert(
271            version.to_string(),
272            json!({
273                "dist": { "tarball": format!("https://r/{version}.tgz") },
274                "dependencies": Value::Object(dep_map),
275            }),
276        );
277        json!({ "versions": Value::Object(versions) })
278    }
279
280    #[test]
281    fn resolve_tree_walks_transitively_dedups_and_handles_cycles() {
282        // a@1 → {b ^1, c ^1}; b@1 → {c ^1} (shared); c@1 → {a ^1} (cycle back to root).
283        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
284        pkgs.insert(
285            "a".into(),
286            packument_with("1.0.0", &[("b", "^1"), ("c", "^1")]),
287        );
288        pkgs.insert("b".into(), packument_with("1.2.0", &[("c", "^1")]));
289        pkgs.insert("c".into(), packument_with("1.5.0", &[("a", "^1")]));
290
291        let roots = vec![("a".to_string(), "^1".parse().unwrap())];
292        let resolved = Registry::npm()
293            .resolve_tree_from(&roots, |name| {
294                pkgs.get(name)
295                    .cloned()
296                    .ok_or_else(|| format!("no packument for {name}").into())
297            })
298            .unwrap();
299
300        // Each of a, b, c resolved exactly once (cycle + shared dep deduped), sorted by name.
301        let names: Vec<&str> = resolved.iter().map(|r| r.name.as_str()).collect();
302        assert_eq!(names, ["a", "b", "c"]);
303        let ver = |n: &str| {
304            resolved
305                .iter()
306                .find(|r| r.name == n)
307                .unwrap()
308                .version
309                .to_string()
310        };
311        assert_eq!(ver("b"), "1.2.0");
312        assert_eq!(ver("c"), "1.5.0");
313    }
314
315    #[test]
316    fn resolve_tree_errors_on_version_conflict() {
317        // root requires x ^1; root also requires y, and y requires x ^2 → incompatible.
318        let mut pkgs: std::collections::HashMap<String, Value> = std::collections::HashMap::new();
319        pkgs.insert(
320            "x".into(),
321            json!({ "versions": {
322                "1.0.0": { "dist": { "tarball": "https://r/x1.tgz" } },
323                "2.0.0": { "dist": { "tarball": "https://r/x2.tgz" } }
324            }}),
325        );
326        pkgs.insert("y".into(), packument_with("1.0.0", &[("x", "^2")]));
327
328        let roots = vec![
329            ("x".to_string(), "^1".parse().unwrap()),
330            ("y".to_string(), "^1".parse().unwrap()),
331        ];
332        let err = Registry::npm()
333            .resolve_tree_from(&roots, |name| {
334                pkgs.get(name)
335                    .cloned()
336                    .ok_or_else(|| format!("no packument for {name}").into())
337            })
338            .unwrap_err();
339        assert!(err.to_string().contains("version conflict"), "got: {err}");
340    }
341}