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
79/// Pick the newest version in a packument's `versions` map that satisfies `req`,
80/// returning it with the `dist.tarball` URL the registry advertises (if any).
81/// Factored out for unit testing without network access.
82fn select_version(doc: &Value, req: &VersionReq) -> Option<(Version, Option<String>)> {
83    let versions = doc.get("versions")?.as_object()?;
84    let mut best: Option<(Version, Option<String>)> = None;
85    for (ver_str, meta) in versions {
86        let Ok(ver) = Version::parse(ver_str) else {
87            continue;
88        };
89        if !req.matches(&ver) {
90            continue;
91        }
92        if best.as_ref().map(|(b, _)| ver > *b).unwrap_or(true) {
93            let tarball = meta
94                .get("dist")
95                .and_then(|d| d.get("tarball"))
96                .and_then(|t| t.as_str())
97                .map(str::to_string);
98            best = Some((ver, tarball));
99        }
100    }
101    best
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use serde_json::json;
108
109    #[test]
110    fn tarball_url_handles_scoped_and_unscoped() {
111        let reg = Registry::npm();
112        assert_eq!(
113            reg.tarball_url("lit", "3.3.3"),
114            "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz"
115        );
116        assert_eq!(
117            reg.tarball_url("@lit/context", "1.1.6"),
118            "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz"
119        );
120    }
121
122    #[test]
123    fn select_version_picks_newest_matching() {
124        let doc = json!({
125            "versions": {
126                "3.1.0": { "dist": { "tarball": "https://r/lit-3.1.0.tgz" } },
127                "3.3.3": { "dist": { "tarball": "https://r/lit-3.3.3.tgz" } },
128                "4.0.0": { "dist": { "tarball": "https://r/lit-4.0.0.tgz" } },
129                "2.9.9": {}
130            }
131        });
132        let (ver, tarball) = select_version(&doc, &"^3".parse().unwrap()).unwrap();
133        assert_eq!(ver, Version::parse("3.3.3").unwrap());
134        assert_eq!(tarball.as_deref(), Some("https://r/lit-3.3.3.tgz"));
135    }
136
137    #[test]
138    fn select_version_none_when_no_match() {
139        let doc = json!({ "versions": { "1.0.0": {}, "2.0.0": {} } });
140        assert!(select_version(&doc, &"^5".parse().unwrap()).is_none());
141    }
142}