1use std::collections::BTreeMap;
4use std::fmt;
5
6use crate::error::{Error, Result};
7
8pub mod resolver;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum VersionSpec {
13 Latest,
15 Lts(Option<String>),
17 Prefix(String),
19 Exact(String),
21 Range(String),
23 System,
25}
26
27impl VersionSpec {
28 pub fn parse(s: &str) -> VersionSpec {
29 let s = s.trim();
30 let lower = s.to_ascii_lowercase();
31 match lower.as_str() {
32 "latest" | "current" | "stable" | "" => return VersionSpec::Latest,
33 "system" => return VersionSpec::System,
34 "lts" | "lts/*" | "lts-latest" => return VersionSpec::Lts(None),
35 _ => {}
36 }
37 if let Some(rest) = lower.strip_prefix("lts/") {
38 return VersionSpec::Lts(Some(rest.to_string()));
39 }
40 if let Some(rest) = lower.strip_prefix("lts-") {
41 return VersionSpec::Lts(Some(rest.to_string()));
42 }
43 let core = s.strip_prefix('v').unwrap_or(s);
46 if semver::Version::parse(core).is_ok() {
47 VersionSpec::Exact(core.to_string())
48 } else {
49 VersionSpec::Prefix(core.to_string())
50 }
51 }
52
53 pub fn parse_range(s: &str) -> Result<VersionSpec> {
54 npm_range_requirements(s)?
55 .first()
56 .ok_or_else(|| Error::config("empty semver range"))?;
57 Ok(VersionSpec::Range(s.trim().to_string()))
58 }
59}
60
61fn npm_range_requirements(input: &str) -> Result<Vec<semver::VersionReq>> {
62 input
63 .split("||")
64 .map(|alternative| {
65 let normalized = normalize_npm_comparators(alternative)?;
66 semver::VersionReq::parse(&normalized)
67 .map_err(|error| Error::config(format!("invalid semver range `{input}`: {error}")))
68 })
69 .collect()
70}
71
72fn normalize_npm_comparators(input: &str) -> Result<String> {
73 let input = input.trim();
74 if input.is_empty() {
75 return Err(Error::config("empty semver range alternative"));
76 }
77 let tokens: Vec<&str> = input.split_whitespace().collect();
78 if tokens.len() > 1 {
79 Ok(tokens.join(", "))
80 } else {
81 Ok(input.to_string())
82 }
83}
84
85impl fmt::Display for VersionSpec {
86 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87 match self {
88 VersionSpec::Latest => write!(f, "latest"),
89 VersionSpec::Lts(None) => write!(f, "lts"),
90 VersionSpec::Lts(Some(n)) => write!(f, "lts/{n}"),
91 VersionSpec::Prefix(p) => write!(f, "{p}"),
92 VersionSpec::Exact(v) => write!(f, "{v}"),
93 VersionSpec::Range(requirement) => write!(f, "{requirement}"),
94 VersionSpec::System => write!(f, "system"),
95 }
96 }
97}
98
99#[derive(Debug, Clone)]
102pub struct ToolRequest {
103 pub backend: String,
104 pub spec: VersionSpec,
105 pub options: BTreeMap<String, String>,
106}
107
108impl ToolRequest {
109 pub fn parse(s: &str) -> Result<ToolRequest> {
112 let parsed = crate::tool::ToolSpec::parse(s)?;
113 Ok(ToolRequest {
114 backend: parsed.id.to_string(),
115 spec: VersionSpec::parse(parsed.selector().unwrap_or_default()),
116 options: parsed.options.into_map(),
117 })
118 }
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct ToolVersion {
124 pub backend: String,
125 pub version: String,
126 pub options: BTreeMap<String, String>,
127}
128
129impl ToolVersion {
130 pub fn new(backend: impl Into<String>, version: impl Into<String>) -> ToolVersion {
131 ToolVersion {
132 backend: backend.into(),
133 version: version.into(),
134 options: BTreeMap::new(),
135 }
136 }
137}
138
139impl fmt::Display for ToolVersion {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 write!(f, "{}@{}", self.backend, self.version)
142 }
143}
144
145pub fn select_version<'a>(
150 spec: &VersionSpec,
151 candidates: &'a [VersionInfo],
152) -> Option<&'a VersionInfo> {
153 match spec {
154 VersionSpec::System => None,
155 VersionSpec::Latest => candidates.iter().rev().find(|v| v.stable),
156 VersionSpec::Lts(None) => candidates.iter().rev().find(|v| v.lts.is_some()),
157 VersionSpec::Lts(Some(line)) => candidates.iter().rev().find(|v| {
158 v.lts
159 .as_deref()
160 .map(|l| l.eq_ignore_ascii_case(line))
161 .unwrap_or(false)
162 }),
163 VersionSpec::Exact(want) => candidates.iter().find(|v| v.version == *want),
164 VersionSpec::Range(requirement) => {
165 let requirements = npm_range_requirements(requirement).ok()?;
166 candidates.iter().rev().find(|candidate| {
167 candidate.stable
168 && semver::Version::parse(candidate.version.trim_start_matches('v'))
169 .map(|version| {
170 requirements
171 .iter()
172 .any(|requirement| requirement.matches(&version))
173 })
174 .unwrap_or(false)
175 })
176 }
177 VersionSpec::Prefix(pfx) => {
178 let want = pfx.trim_end_matches('.');
180 candidates
181 .iter()
182 .rev()
183 .find(|v| version_has_prefix(&v.version, want))
184 }
185 }
186}
187
188pub fn select_version_with_prerelease<'a>(
189 spec: &VersionSpec,
190 candidates: &'a [VersionInfo],
191 policy: crate::config::PrereleasePolicy,
192) -> Option<&'a VersionInfo> {
193 use crate::config::PrereleasePolicy;
194 let exact_prerelease = matches!(
195 spec,
196 VersionSpec::Exact(version)
197 if semver::Version::parse(version)
198 .map(|version| !version.pre.is_empty())
199 .unwrap_or(false)
200 );
201 match policy {
202 PrereleasePolicy::Never if exact_prerelease => None,
203 PrereleasePolicy::Allow => match spec {
204 VersionSpec::Latest => candidates.last(),
205 VersionSpec::Prefix(prefix) => {
206 let want = prefix.trim_end_matches('.');
207 candidates
208 .iter()
209 .rev()
210 .find(|version| version_has_prefix(&version.version, want))
211 }
212 VersionSpec::Range(requirement) => {
213 let requirements = npm_range_requirements(requirement).ok()?;
214 candidates.iter().rev().find(|candidate| {
215 semver::Version::parse(candidate.version.trim_start_matches('v'))
216 .map(|version| {
217 requirements
218 .iter()
219 .any(|requirement| requirement.matches(&version))
220 })
221 .unwrap_or(false)
222 })
223 }
224 _ => select_version(spec, candidates),
225 },
226 PrereleasePolicy::Never | PrereleasePolicy::IfExplicit => {
227 if exact_prerelease {
228 select_version(spec, candidates)
229 } else {
230 let stable = candidates
231 .iter()
232 .filter(|candidate| candidate.stable)
233 .cloned()
234 .collect::<Vec<_>>();
235 let selected = select_version(spec, &stable)?;
236 candidates
237 .iter()
238 .find(|candidate| candidate.version == selected.version)
239 }
240 }
241 }
242}
243
244fn version_has_prefix(version: &str, prefix: &str) -> bool {
245 if version == prefix {
246 return true;
247 }
248 let v_parts: Vec<&str> = version.split('.').collect();
249 let p_parts: Vec<&str> = prefix.split('.').collect();
250 if p_parts.len() > v_parts.len() {
251 return false;
252 }
253 v_parts.iter().zip(p_parts.iter()).all(|(a, b)| a == b)
254}
255
256#[derive(Debug, Clone)]
258pub struct VersionInfo {
259 pub version: String,
260 pub stable: bool,
261 pub lts: Option<String>,
263}
264
265impl VersionInfo {
266 pub fn stable(version: impl Into<String>) -> VersionInfo {
267 VersionInfo {
268 version: version.into(),
269 stable: true,
270 lts: None,
271 }
272 }
273}
274
275#[cfg(test)]
276mod tests {
277 use super::*;
278
279 #[test]
280 fn parse_specs() {
281 assert_eq!(VersionSpec::parse("latest"), VersionSpec::Latest);
282 assert_eq!(VersionSpec::parse("lts"), VersionSpec::Lts(None));
283 assert_eq!(
284 VersionSpec::parse("lts/iron"),
285 VersionSpec::Lts(Some("iron".into()))
286 );
287 assert_eq!(VersionSpec::parse("20"), VersionSpec::Prefix("20".into()));
288 assert_eq!(
289 VersionSpec::parse("20.11"),
290 VersionSpec::Prefix("20.11".into())
291 );
292 assert_eq!(
293 VersionSpec::parse("v20.11.1"),
294 VersionSpec::Exact("20.11.1".into())
295 );
296 assert_eq!(VersionSpec::parse("system"), VersionSpec::System);
297 }
298
299 #[test]
300 fn parse_tool_request() {
301 let r = ToolRequest::parse("node@20").unwrap();
302 assert_eq!(r.backend, "node");
303 assert_eq!(r.spec, VersionSpec::Prefix("20".into()));
304
305 let r = ToolRequest::parse("go").unwrap();
306 assert_eq!(r.backend, "go");
307 assert_eq!(r.spec, VersionSpec::Latest);
308 }
309
310 #[test]
311 fn parse_namespaced_npm_tool_requests() {
312 let r = ToolRequest::parse("npm:prettier@3").unwrap();
313 assert_eq!(r.backend, "npm:prettier");
314 assert_eq!(r.spec, VersionSpec::Prefix("3".into()));
315
316 let r = ToolRequest::parse("npm:@antfu/ni@0.21.12").unwrap();
317 assert_eq!(r.backend, "npm:@antfu/ni");
318 assert_eq!(r.spec, VersionSpec::Exact("0.21.12".into()));
319
320 let r = ToolRequest::parse("npm:@antfu/ni").unwrap();
321 assert_eq!(r.backend, "npm:@antfu/ni");
322 assert_eq!(r.spec, VersionSpec::Latest);
323 }
324
325 #[test]
326 fn parse_namespaced_npm_tool_requests_canonicalizes_case() {
327 let r = ToolRequest::parse("npm:Prettier@3").unwrap();
328 assert_eq!(r.backend, "npm:prettier");
329 assert_eq!(r.spec, VersionSpec::Prefix("3".into()));
330
331 let r = ToolRequest::parse("npm:@Antfu/Ni").unwrap();
332 assert_eq!(r.backend, "npm:@antfu/ni");
333 assert_eq!(r.spec, VersionSpec::Latest);
334 }
335
336 #[test]
337 fn parse_inline_dynamic_options_uses_canonical_schema() {
338 let request =
339 ToolRequest::parse("npm:Prettier[installer=AUBE,allow_builds='Sharp, esbuild']@3")
340 .unwrap();
341 assert_eq!(request.backend, "npm:prettier");
342 assert_eq!(request.spec, VersionSpec::Prefix("3".into()));
343 assert_eq!(request.options["installer"], "aube");
344 assert_eq!(request.options["allow_builds"], "esbuild,sharp");
345 }
346
347 #[test]
348 fn parse_github_request_canonicalizes_id_and_preserves_selector() {
349 let request =
350 ToolRequest::parse("github:Cli/CLI.git[os=darwin,arch=amd64]@2.96.0").unwrap();
351 assert_eq!(request.backend, "github:cli/cli");
352 assert_eq!(request.spec, VersionSpec::Exact("2.96.0".into()));
353 assert_eq!(request.options["os"], "macos");
354 assert_eq!(request.options["arch"], "x64");
355 }
356
357 #[test]
358 fn parse_rejects_unknown_dynamic_namespaces_and_options_early() {
359 assert!(matches!(
360 ToolRequest::parse("pip:ripgrep@latest"),
361 Err(Error::UnknownBackend(_))
362 ));
363 assert_eq!(
364 ToolRequest::parse("cargo:ripgrep@latest").unwrap().backend,
365 "cargo:ripgrep"
366 );
367 assert!(ToolRequest::parse("npm:prettier[token=secret]@3").is_err());
368 }
369
370 #[test]
371 fn namespaced_npm_parser_keeps_bare_npm_as_the_cli_backend() {
372 let cli = ToolRequest::parse("npm").unwrap();
373 assert_eq!(cli.backend, "npm");
374 assert_eq!(cli.spec, VersionSpec::Latest);
375
376 let package = ToolRequest::parse("npm:npm").unwrap();
377 assert_eq!(package.backend, "npm:npm");
378 assert_eq!(package.spec, VersionSpec::Latest);
379 }
380
381 #[test]
382 fn rejects_invalid_namespaced_npm_requests() {
383 assert!(ToolRequest::parse("npm:").is_err());
384 assert!(ToolRequest::parse("npm:@antfu").is_err());
385 assert!(ToolRequest::parse("npm:@antfu/ni/extra").is_err());
386 assert!(ToolRequest::parse("npm:foo#bar").is_err());
387 assert!(ToolRequest::parse("npm:foo?bar").is_err());
388 assert!(ToolRequest::parse("npm:foo%2fbar").is_err());
389 assert!(ToolRequest::parse("npm:foo bar").is_err());
390 assert!(ToolRequest::parse("npm:foo\tbar").is_err());
391 assert!(ToolRequest::parse("npm:foo/bar").is_err());
392 assert!(ToolRequest::parse("npm:foo\\bar").is_err());
393 assert!(ToolRequest::parse("npm:.").is_err());
394 assert!(ToolRequest::parse("npm:..").is_err());
395 assert!(ToolRequest::parse("npm:CON").is_err());
396 assert!(ToolRequest::parse("npm:@scope/AUX").is_err());
397 assert!(ToolRequest::parse(&format!("npm:{}", "a".repeat(215))).is_err());
398 }
399
400 #[test]
401 fn accepts_safe_scoped_names_and_normalizes_case() {
402 let request = ToolRequest::parse("npm:@Scope/Package_Name-1.2").unwrap();
403 assert_eq!(request.backend, "npm:@scope/package_name-1.2");
404 assert_eq!(request.spec, VersionSpec::Latest);
405 }
406
407 #[test]
408 fn accepted_npm_names_map_to_safe_inventory_paths() {
409 let request = ToolRequest::parse("npm:@Antfu/Ni").unwrap();
410 assert_eq!(
411 crate::dirs::sanitize_tool_id(&request.backend),
412 std::path::PathBuf::from("npm/@antfu/ni")
413 );
414 }
415
416 fn vi(v: &str, stable: bool, lts: Option<&str>) -> VersionInfo {
417 VersionInfo {
418 version: v.into(),
419 stable,
420 lts: lts.map(String::from),
421 }
422 }
423
424 #[test]
425 fn select_prefix_picks_highest_match() {
426 let c = vec![
427 vi("20.10.0", true, None),
428 vi("20.11.0", true, None),
429 vi("20.11.1", true, None),
430 vi("21.0.0", true, None),
431 ];
432 let sel = select_version(&VersionSpec::Prefix("20.11".into()), &c).unwrap();
433 assert_eq!(sel.version, "20.11.1");
434 let sel = select_version(&VersionSpec::Prefix("20".into()), &c).unwrap();
435 assert_eq!(sel.version, "20.11.1");
436 }
437
438 #[test]
439 fn npm_semver_ranges_select_the_highest_stable_match() {
440 let candidates = vec![
441 vi("18.20.0", true, None),
442 vi("20.10.0", true, None),
443 vi("22.4.1", true, None),
444 vi("23.0.0-beta.1", false, None),
445 vi("24.1.0", true, None),
446 ];
447 let range = VersionSpec::parse_range(">=20 <23").unwrap();
448 assert_eq!(
449 select_version(&range, &candidates).unwrap().version,
450 "22.4.1"
451 );
452 let alternative = VersionSpec::parse_range("^18.0.0 || >=24").unwrap();
453 assert_eq!(
454 select_version(&alternative, &candidates).unwrap().version,
455 "24.1.0"
456 );
457 assert!(VersionSpec::parse_range("not a range").is_err());
458 }
459
460 #[test]
461 fn prerelease_policy_controls_implicit_and_explicit_selection() {
462 let candidates = vec![vi("1.0.0", true, None), vi("1.1.0-beta.1", false, None)];
463 assert_eq!(
464 select_version_with_prerelease(
465 &VersionSpec::Latest,
466 &candidates,
467 crate::config::PrereleasePolicy::IfExplicit,
468 )
469 .unwrap()
470 .version,
471 "1.0.0"
472 );
473 assert_eq!(
474 select_version_with_prerelease(
475 &VersionSpec::Latest,
476 &candidates,
477 crate::config::PrereleasePolicy::Allow,
478 )
479 .unwrap()
480 .version,
481 "1.1.0-beta.1"
482 );
483 assert!(select_version_with_prerelease(
484 &VersionSpec::Exact("1.1.0-beta.1".into()),
485 &candidates,
486 crate::config::PrereleasePolicy::Never,
487 )
488 .is_none());
489 }
490
491 #[test]
492 fn select_latest_and_lts() {
493 let c = vec![
494 vi("18.20.0", true, Some("hydrogen")),
495 vi("20.11.1", true, Some("iron")),
496 vi("21.6.0", true, None),
497 vi("22.0.0-nightly", false, None),
498 ];
499 assert_eq!(
500 select_version(&VersionSpec::Latest, &c).unwrap().version,
501 "21.6.0"
502 );
503 assert_eq!(
504 select_version(&VersionSpec::Lts(None), &c).unwrap().version,
505 "20.11.1"
506 );
507 assert_eq!(
508 select_version(&VersionSpec::Lts(Some("hydrogen".into())), &c)
509 .unwrap()
510 .version,
511 "18.20.0"
512 );
513 }
514}