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
79fn 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}