1use crate::download;
5use semver::{Version, VersionReq};
6use serde_json::Value;
7
8pub 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#[derive(Debug, Clone)]
23pub struct Resolved {
24 pub name: String,
25 pub version: Version,
26 pub tarball_url: String,
27}
28
29impl Registry {
30 pub fn npm() -> Self {
32 Self::default()
33 }
34
35 pub fn with_base_url(base_url: impl Into<String>) -> Self {
37 Self {
38 base_url: base_url.into(),
39 }
40 }
41
42 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 pub fn packument(&self, name: &str) -> Result<Value, Box<dyn std::error::Error>> {
51 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 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 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 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; }
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
156fn 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
181pub 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
195fn 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 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 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 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 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 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}