Skip to main content

osdk_core/version/
mod.rs

1//! Version request / resolution types shared across backends.
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use crate::error::{Error, Result};
7
8pub mod resolver;
9
10/// What the user asked for, before resolution against remote versions.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum VersionSpec {
13    /// Newest stable version.
14    Latest,
15    /// Latest LTS, optionally a named line (e.g. `lts/iron`).
16    Lts(Option<String>),
17    /// A prefix match, e.g. `20` matches `20.x.y`, `20.11` matches `20.11.z`.
18    Prefix(String),
19    /// An exact version, e.g. `20.11.1`.
20    Exact(String),
21    /// A semver requirement from structured project metadata.
22    Range(String),
23    /// Use whatever is already on PATH (no management).
24    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        // A fully-specified semver (x.y.z, possibly with pre/build) is exact;
44        // anything shorter is treated as a prefix.
45        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/// A parsed `tool@spec` request with optional backend-specific options
100/// (e.g. java distribution, rust profile).
101#[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    /// Parse and canonicalize `tool[option=value]@selector`. Dynamic namespace
110    /// subjects and inline options are validated through their central schema.
111    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/// A resolved concrete version, ready to install/activate.
122#[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
145/// Select the best matching version string from a candidate list for a spec.
146///
147/// `candidates` should be sorted ascending (oldest first). `is_stable` and
148/// `is_lts` help resolve `latest`/`lts`. Returns the chosen version.
149pub 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            // match versions whose dotted components start with the prefix
179            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/// Metadata about a single installable version.
257#[derive(Debug, Clone)]
258pub struct VersionInfo {
259    pub version: String,
260    pub stable: bool,
261    /// LTS line name if this is an LTS release (e.g. `iron`), else None.
262    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}