From d468b2fe048ef8f6fc9991c5b6d1171437eab7ce Mon Sep 17 00:00:00 2001 From: Niko Savola Date: Fri, 24 Jul 2026 10:23:37 +0300 Subject: [PATCH] Support prerelease versions in language_version Parse prerelease versions in language_version and match/resolve them, via a shared parse_prerelease_version helper: - Go: Go-style prereleases (go1.24rc1, go1.18beta1); resolve and download using the Go-native version string (not semver), and keep stable/Any requests from selecting a prerelease. - Python: PEP 440 prereleases (3.13.0rc1, 3.14.0a1), forwarded to uv. Rust already supports prereleases via rustup channels (beta/nightly). Node, Deno, .NET, and Ruby also ship prereleases but need installer-specific download handling and are left as follow-ups. Closes #1582. --- crates/prek/src/languages/golang/installer.rs | 27 ++- crates/prek/src/languages/golang/version.rs | 177 +++++++++++++++--- crates/prek/src/languages/python/python.rs | 11 +- crates/prek/src/languages/python/version.rs | 148 ++++++++++++--- crates/prek/src/languages/version.rs | 161 +++++++++++++++- 5 files changed, 474 insertions(+), 50 deletions(-) diff --git a/crates/prek/src/languages/golang/installer.rs b/crates/prek/src/languages/golang/installer.rs index 97b4a9e7a..a9d2d83fa 100644 --- a/crates/prek/src/languages/golang/installer.rs +++ b/crates/prek/src/languages/golang/installer.rs @@ -228,9 +228,11 @@ impl GoInstaller { }; let ext = if cfg!(windows) { "zip" } else { "tar.gz" }; - let filename = format!("go{version}.{os}-{arch}.{ext}"); + // go.dev uses Go-native strings (`go1.24rc1`), not semver (`go1.24.0-rc.1`). + let go_string = version.to_go_string(); + let filename = format!("go{go_string}.{os}-{arch}.{ext}"); let url = format!("https://go.dev/dl/{filename}"); - let checksum_version = version.to_string(); + let checksum_version = go_string; let target = self.root.join(version.to_string()); let download = download_artifact(&url, &filename, store, async || { @@ -360,6 +362,27 @@ mod tests { Ok(()) } + #[test] + fn finds_go_checksum_for_prerelease_release_file() -> Result<()> { + // Exercises the actual download path: the lookup key is `GoVersion::to_go_string()` + // (the Go-native tag, `go1.24rc1`), not the semver `1.24.0-rc.1`. + let releases = vec![go_release( + "go1.24rc1", + vec![go_file("go1.24rc1.linux-amd64.tar.gz", EMPTY_SHA256)], + )]; + + let version = GoVersion::from_str("go1.24rc1")?; + let digest = digest_from_go_releases( + &releases, + &version.to_go_string(), + "go1.24rc1.linux-amd64.tar.gz", + )? + .expect("expected checksum"); + + assert_eq!(digest.to_string(), EMPTY_SHA256); + Ok(()) + } + #[test] fn returns_none_when_go_release_file_is_missing() -> Result<()> { let releases = vec![go_release( diff --git a/crates/prek/src/languages/golang/version.rs b/crates/prek/src/languages/golang/version.rs index e0bebab8a..3fc5c1f60 100644 --- a/crates/prek/src/languages/golang/version.rs +++ b/crates/prek/src/languages/golang/version.rs @@ -5,9 +5,9 @@ use std::str::FromStr; use serde::Deserialize; use crate::hook::InstallInfo; -use crate::languages::version::{Error, try_into_u64_slice}; +use crate::languages::version::{Error, parse_prerelease_version, try_into_u64_slice}; -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Eq, PartialEq, Deserialize)] pub(crate) struct GoVersion(semver::Version); impl Deref for GoVersion { @@ -27,20 +27,54 @@ impl Display for GoVersion { impl FromStr for GoVersion { type Err = semver::Error; - // TODO: go1.20.0b1, go1.20.0rc1? fn from_str(s: &str) -> Result { let s = s.strip_prefix("go").unwrap_or(s).trim(); + if let Some(version) = parse_prerelease_version(s) { + if is_valid_go_prerelease(&version) { + return Ok(GoVersion(version)); + } + } + // Fall back to plain semver parsing so exotic inputs still yield a real error. This + // also rejects shapes `parse_prerelease_version` accepts but Go never publishes (a + // patch alongside a prerelease, or a non-Go label), since they aren't valid semver. semver::Version::parse(s).map(GoVersion) } } +/// Go only ever publishes patchless `beta`/`rc` prereleases (`go1.24rc1`, `go1.18beta1`); other +/// shapes `parse_prerelease_version` would otherwise accept can't be mapped to a real download. +fn is_valid_go_prerelease(version: &semver::Version) -> bool { + if version.pre.is_empty() { + return true; + } + version.patch == 0 && matches!(version.pre.as_str().split('.').next(), Some("beta" | "rc")) +} + +impl GoVersion { + /// Go-native version string (no `go` prefix), e.g. `1.24.5` or `1.24rc1`. go.dev + /// uses this, not semver's `1.24.0-rc.1`, so downloads must go through here. + pub(crate) fn to_go_string(&self) -> String { + let v = &self.0; + if !v.pre.is_empty() { + // Go writes a prerelease without the patch: `1.24.0-rc.1` -> `1.24rc1`. + let pre: String = v.pre.as_str().split('.').collect(); + format!("{}.{}{}", v.major, v.minor, pre) + } else if v.patch == 0 && v.major == 1 && v.minor <= 20 { + // Through 1.20 Go named a minor's initial release patchless (`go1.20`); 1.21 onward + // carries the patch (`go1.21.0`, `go1.24.0`), so only collapse the older ones. + format!("{}.{}", v.major, v.minor) + } else { + format!("{}.{}.{}", v.major, v.minor, v.patch) + } + } +} + /// `language_version` field of golang can be one of the following: /// `default` /// `system` /// `go` /// `go1.20` or `1.20` /// `go1.20.3` or `1.20.3` -/// `go1.20.0b1` or `1.20.0b1` /// `go1.20rc1` or `1.20rc1` /// `go1.18beta1` or `1.18beta1` /// `>= 1.20, < 1.22` @@ -50,9 +84,9 @@ pub(crate) enum GoRequest { Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), + /// An explicit prerelease request, e.g. `go1.24rc1` or `go1.18beta1`. + Prerelease(GoVersion, String), Range(semver::VersionReq, String), - // TODO: support prerelease versions like `go1.20.0b1`, `go1.20rc1` - // MajorMinorPrerelease(u64, u64, String), } impl Display for GoRequest { @@ -64,7 +98,7 @@ impl Display for GoRequest { GoRequest::MajorMinorPatch(major, minor, patch) => { write!(f, "go{major}.{minor}.{patch}") } - GoRequest::Range(_, raw) => write!(f, "{raw}"), + GoRequest::Prerelease(_, raw) | GoRequest::Range(_, raw) => write!(f, "{raw}"), } } } @@ -77,20 +111,32 @@ impl FromStr for GoRequest { return Ok(GoRequest::Any); } - // Check if it starts with "go" - parse as specific version - if let Some(version_part) = s.strip_prefix("go") { - if version_part.is_empty() { - return Ok(GoRequest::Any); + let (version_part, has_go_prefix) = match s.strip_prefix("go") { + Some(rest) => (rest, true), + None => (s, false), + }; + if has_go_prefix && version_part.is_empty() { + return Ok(GoRequest::Any); + } + + if let Ok(request) = Self::parse_version_numbers(version_part, s) { + return Ok(request); + } + + if let Some(version) = parse_prerelease_version(version_part) { + if !version.pre.is_empty() && is_valid_go_prerelease(&version) { + return Ok(GoRequest::Prerelease(GoVersion(version), s.to_string())); } + } - return Self::parse_version_numbers(version_part, s); + // A range like `>= 1.20, < 1.22`, but not `go`-prefixed (`go>=1.20` is nonsense). + if !has_go_prefix { + if let Ok(version_req) = semver::VersionReq::parse(s) { + return Ok(GoRequest::Range(version_req, s.to_string())); + } } - Self::parse_version_numbers(s, s).or_else(|_| { - semver::VersionReq::parse(s) - .map(|version_req| GoRequest::Range(version_req, s.into())) - .map_err(|_| Error::InvalidVersion(s.to_string())) - }) + Err(Error::InvalidVersion(s.to_string())) } } @@ -122,14 +168,18 @@ impl GoRequest { pub(crate) fn matches(&self, version: &GoVersion) -> bool { match self { - GoRequest::Any => true, - GoRequest::Major(major) => version.0.major == *major, + GoRequest::Any => version.0.pre.is_empty(), + GoRequest::Major(major) => version.0.pre.is_empty() && version.0.major == *major, GoRequest::MajorMinor(major, minor) => { - version.0.major == *major && version.0.minor == *minor + version.0.pre.is_empty() && version.0.major == *major && version.0.minor == *minor } GoRequest::MajorMinorPatch(major, minor, patch) => { - version.0.major == *major && version.0.minor == *minor && version.0.patch == *patch + version.0.pre.is_empty() + && version.0.major == *major + && version.0.minor == *minor + && version.0.patch == *patch } + GoRequest::Prerelease(requested, _) => version.0 == requested.0, GoRequest::Range(req, _) => req.matches(&version.0), } } @@ -167,7 +217,18 @@ mod tests { #[test] fn test_go_request_invalid() { - let invalid_cases = vec!["go1.20.3.4", "go1.beta", "invalid_version"]; + let invalid_cases = vec![ + "go1.20.3.4", + "go1.beta", + "invalid_version", + // Go never publishes a patch alongside a prerelease. + "go1.24.5rc1", + // Go only uses `beta`/`rc`, not Python-style `a`/`alpha`/`c`/`pre`/`preview`. + "go1.24a1", + "go1.24alpha1", + "go1.24c1", + "go1.24pre1", + ]; for input in invalid_cases { let req = GoRequest::from_str(input); assert!(req.is_err(), "Input: {input}"); @@ -227,4 +288,76 @@ mod tests { assert_eq!(req_str, expected, "Request: {req:?}"); } } + + #[test] + fn test_go_request_prerelease() { + let rc = GoRequest::from_str("go1.24rc1").unwrap(); + assert_eq!( + rc, + GoRequest::Prerelease( + GoVersion(semver::Version::parse("1.24.0-rc.1").unwrap()), + "go1.24rc1".to_string(), + ) + ); + assert!(matches!( + GoRequest::from_str("1.18beta1").unwrap(), + GoRequest::Prerelease(..) + )); + + // A prerelease request matches only that exact prerelease. + let rc1 = GoVersion::from_str("go1.24rc1").unwrap(); + let rc2 = GoVersion::from_str("go1.24rc2").unwrap(); + let release = GoVersion::from_str("go1.24.0").unwrap(); + assert!(rc.matches(&rc1)); + assert!(!rc.matches(&rc2)); + assert!(!rc.matches(&release)); + + // Neither a stable request nor `Any` (the default) selects a prerelease. + let stable = GoRequest::from_str("go1.24").unwrap(); + assert!(!stable.matches(&rc1)); + assert!(stable.matches(&release)); + assert!(!GoRequest::Any.matches(&rc1)); + assert!(GoRequest::Any.matches(&release)); + } + + #[test] + fn test_go_version_to_go_string() { + for input in [ + "go1.24rc1", + "1.18beta1", + "go1.24.5", + "1.20.3", + "go1.20", + "go1.24.0", + ] { + let expected = input.strip_prefix("go").unwrap_or(input); + assert_eq!(GoVersion::from_str(input).unwrap().to_go_string(), expected); + } + // Go has no `go1.20.0` (<=1.20 initial releases are patchless), but `go1.24.0` is real. + assert_eq!( + GoVersion::from_str("go1.20.0").unwrap().to_go_string(), + "1.20" + ); + } + + #[test] + fn test_go_version_prerelease_parsing() { + assert_eq!( + *GoVersion::from_str("go1.24rc1").unwrap(), + semver::Version::parse("1.24.0-rc.1").unwrap() + ); + // Numeric (not lexical) ordering, and prerelease < release. + assert!( + *GoVersion::from_str("1.24rc9").unwrap() < *GoVersion::from_str("1.24rc10").unwrap() + ); + assert!(*GoVersion::from_str("1.24rc1").unwrap() < *GoVersion::from_str("1.24.0").unwrap()); + } + + #[test] + fn go_version_rejects_non_go_prerelease_shapes() { + // A patch alongside a prerelease, and Python-style labels, are not real Go versions. + for input in ["1.24.5rc1", "1.24a1", "1.24alpha1", "1.24c1", "1.24pre1"] { + assert!(GoVersion::from_str(input).is_err(), "Input: {input}"); + } + } } diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index 7ace14e8d..e9ef0b3e0 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -53,10 +53,17 @@ async fn query_python_info(python: &Path) -> Result base_exec_prefix: PathBuf, } + // Encode the PEP 440 prerelease level+serial as a semver prerelease so a `3.13.0rc1` + // request isn't silently satisfied by a final `3.13.0` (or a different rc) interpreter. static QUERY_PYTHON_INFO: &str = indoc::indoc! {r#" import sys, json + v = sys.version_info + version = ".".join(map(str, v[:3])) + pre = {"alpha": "a", "beta": "b", "candidate": "rc"}.get(v.releaselevel) + if pre: + version += f"-{pre}.{v.serial}" info = { - "version": ".".join(map(str, sys.version_info[:3])), + "version": version, "base_exec_prefix": sys.base_exec_prefix, } print(json.dumps(info)) @@ -232,6 +239,8 @@ fn to_uv_python_request(request: &LanguageRequest) -> Option { PythonRequest::MajorMinorPatch(major, minor, patch) => { Some(format!("{major}.{minor}.{patch}")) } + // uv understands PEP 440 prerelease requests. + PythonRequest::Prerelease(_, raw) => Some(raw.clone()), PythonRequest::Range(_, raw) => Some(raw.clone()), }, _ => unreachable!(), diff --git a/crates/prek/src/languages/python/version.rs b/crates/prek/src/languages/python/version.rs index 6b4116712..4859819d0 100644 --- a/crates/prek/src/languages/python/version.rs +++ b/crates/prek/src/languages/python/version.rs @@ -3,7 +3,7 @@ use std::str::FromStr; use crate::hook::InstallInfo; -use crate::languages::version::{Error, try_into_u64_slice}; +use crate::languages::version::{Error, parse_prerelease_version, try_into_u64_slice}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum PythonRequest { @@ -11,6 +11,7 @@ pub(crate) enum PythonRequest { Major(u64), MajorMinor(u64, u64), MajorMinorPatch(u64, u64, u64), + Prerelease(semver::Version, String), Range(semver::VersionReq, String), } @@ -26,7 +27,8 @@ pub(crate) enum PythonRequest { /// - `3.12.3` /// - `>=3.12` /// - `>=3.8, <3.12` -// TODO: support version like `3.8b1`, `3.8rc2`, `python3.8t`, `python3.8-64`, `pypy3.8`. +/// - `3.13.0rc1`, `3.14.0a1` +// TODO: support `python3.8t` (free-threaded), `python3.8-64`, `pypy3.8`. impl FromStr for PythonRequest { type Err = Error; @@ -35,21 +37,33 @@ impl FromStr for PythonRequest { return Ok(Self::Any); } - // Check if it starts with "python" - parse as specific version - if let Some(version_part) = request.strip_prefix("python") { - if version_part.is_empty() { - return Ok(Self::Any); + let (version_part, has_python_prefix) = match request.strip_prefix("python") { + Some(rest) => (rest, true), + None => (request, false), + }; + if has_python_prefix && version_part.is_empty() { + return Ok(Self::Any); + } + + if let Ok(req) = Self::parse_version_numbers(version_part, request) { + return Ok(req); + } + + if let Some(version) = parse_prerelease_version(version_part) { + if !version.pre.is_empty() { + let version = normalize_prerelease_label(version); + return Ok(PythonRequest::Prerelease(version, version_part.to_string())); } + } - Self::parse_version_numbers(version_part, request) - } else { - Self::parse_version_numbers(request, request).or_else(|_| { - // Try to parse as a VersionReq (like ">= 3.12" or ">=3.8, <3.12") - semver::VersionReq::parse(request) - .map(|version_req| PythonRequest::Range(version_req, request.into())) - .map_err(|_| Error::InvalidVersion(request.to_string())) - }) + // A range like `>=3.8, <3.12`, but not `python`-prefixed. + if !has_python_prefix { + if let Ok(version_req) = semver::VersionReq::parse(request) { + return Ok(PythonRequest::Range(version_req, request.into())); + } } + + Err(Error::InvalidVersion(request.to_string())) } } @@ -78,14 +92,21 @@ impl PythonRequest { pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { let version = &install_info.language_version; match self { - PythonRequest::Any => true, - PythonRequest::Major(major) => version.major == *major, + // Stable requests never match a prerelease interpreter (`3.13.0` != `3.13.0rc1`). + PythonRequest::Any => version.pre.is_empty(), + PythonRequest::Major(major) => version.pre.is_empty() && version.major == *major, PythonRequest::MajorMinor(major, minor) => { - version.major == *major && version.minor == *minor + version.pre.is_empty() && version.major == *major && version.minor == *minor } PythonRequest::MajorMinorPatch(major, minor, patch) => { - version.major == *major && version.minor == *minor && version.patch == *patch + version.pre.is_empty() + && version.major == *major + && version.minor == *minor + && version.patch == *patch } + // Match the exact prerelease (`query_python_info` records level+serial), so an + // rc1 request is not satisfied by a final release or a different prerelease. + PythonRequest::Prerelease(req, _) => version == req, PythonRequest::Range(req, _) => req.matches(version), } } @@ -117,6 +138,27 @@ fn split_wheel_tag_version(mut version: Vec) -> Vec { version } +/// Normalize a PEP 440 prerelease alias to the label `query_python_info` records from +/// `sys.version_info.releaselevel` (`alpha`/`a` -> `a`, `beta`/`b` -> `b`, everything else +/// meaning "release candidate" -> `rc`), so `PythonRequest::Prerelease`'s exact-equality +/// check actually matches an installed interpreter instead of comparing distinct spellings. +fn normalize_prerelease_label(mut version: semver::Version) -> semver::Version { + let pre = version.pre.as_str(); + let (label, number) = pre.split_once('.').unwrap_or((pre, "")); + let canonical = match label { + "a" | "alpha" => "a", + "b" | "beta" => "b", + _ => "rc", // c, rc, pre, preview + }; + let identifier = if number.is_empty() { + canonical.to_string() + } else { + format!("{canonical}.{number}") + }; + version.pre = semver::Prerelease::new(&identifier).expect("canonical label is valid"); + version +} + #[cfg(test)] mod tests { use super::*; @@ -189,14 +231,53 @@ mod tests { assert!(PythonRequest::from_str("3..2").is_err()); assert!(PythonRequest::from_str("a3.12").is_err()); - // TODO: support - assert!(PythonRequest::from_str("3.12.3a1").is_err()); - assert!(PythonRequest::from_str("3.12.3rc1").is_err()); - assert!(PythonRequest::from_str("python3.13.2a1").is_err()); - assert!(PythonRequest::from_str("python3.13.2rc1").is_err()); + // PEP 440 prereleases parse to `Prerelease`, keeping the string for uv. + assert_eq!( + PythonRequest::from_str("3.13.0rc1").unwrap(), + PythonRequest::Prerelease( + semver::Version::parse("3.13.0-rc.1").unwrap(), + "3.13.0rc1".to_string() + ) + ); + assert!(matches!( + PythonRequest::from_str("3.14.0a1").unwrap(), + PythonRequest::Prerelease(..) + )); + assert!(matches!( + PythonRequest::from_str("python3.13.2b2").unwrap(), + PythonRequest::Prerelease(..) + )); + + // Not prereleases: `t` (free-threaded) and `-64` (architecture) suffixes. assert!(PythonRequest::from_str("python3.13.2t1").is_err()); assert!(PythonRequest::from_str("python3.13.2-64").is_err()); - assert!(PythonRequest::from_str("python3.13.2-64").is_err()); + } + + #[test] + fn prerelease_aliases_normalize_to_the_interpreter_label() { + // `c`, `alpha`, `beta` are PEP 440 aliases; `query_python_info` only ever emits + // `a`/`b`/`rc`, so the alias must normalize to match or the env is never reused. + assert_eq!( + PythonRequest::from_str("3.13.0c1").unwrap(), + PythonRequest::Prerelease( + semver::Version::parse("3.13.0-rc.1").unwrap(), + "3.13.0c1".to_string() + ) + ); + assert_eq!( + PythonRequest::from_str("3.14.0alpha2").unwrap(), + PythonRequest::Prerelease( + semver::Version::parse("3.14.0-a.2").unwrap(), + "3.14.0alpha2".to_string() + ) + ); + assert_eq!( + PythonRequest::from_str("3.12.0beta3").unwrap(), + PythonRequest::Prerelease( + semver::Version::parse("3.12.0-b.3").unwrap(), + "3.12.0beta3".to_string() + ) + ); } #[test] @@ -222,4 +303,23 @@ mod tests { Ok(()) } + + #[test] + fn prerelease_requests_match_exactly() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::create(Language::Python, None, Vec::new(), temp_dir.path())?; + install_info + .with_language_version(semver::Version::parse("3.13.0-rc.1")?) + .with_toolchain(PathBuf::from("/usr/bin/python3.13")); + + let rc1 = PythonRequest::from_str("3.13.0rc1")?; + assert!(rc1.satisfied_by(&install_info)); + + // A different prerelease, or the final release, must not reuse an rc1 env. + assert!(!PythonRequest::from_str("3.13.0rc2")?.satisfied_by(&install_info)); + assert!(!PythonRequest::MajorMinorPatch(3, 13, 0).satisfied_by(&install_info)); + + Ok(()) + } } diff --git a/crates/prek/src/languages/version.rs b/crates/prek/src/languages/version.rs index 1e14123a0..725535e95 100644 --- a/crates/prek/src/languages/version.rs +++ b/crates/prek/src/languages/version.rs @@ -116,7 +116,17 @@ impl LanguageRequest { pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { match self { - LanguageRequest::Any { .. } => true, + // A default/omitted `language_version` means a normal, stable interpreter for + // Python/Go, so it must not silently reuse a prerelease env installed for another + // hook's explicit request. `system` is exempt: it explicitly pins to whatever is on + // PATH, prerelease or not, and should stay reusable once installed. Other languages + // (e.g. Rust, where a nightly/beta toolchain can legitimately be "the default") keep + // their existing permissive behavior. + LanguageRequest::Any { system_only } => { + *system_only + || !matches!(install_info.language, Language::Python | Language::Golang) + || install_info.language_version.pre.is_empty() + } LanguageRequest::Bun(req) => req.satisfied_by(install_info), LanguageRequest::Dotnet(req) => req.satisfied_by(install_info), LanguageRequest::Deno(req) => req.satisfied_by(install_info), @@ -155,3 +165,152 @@ pub(crate) fn try_into_u64_slice(version: &str) -> Result, std::num::Pa .map(str::parse::) .collect::, _>>() } + +/// Parse a compact prerelease version (Go's `1.24rc1`, PEP 440's `3.13.0rc1`) into +/// semver: pad to `major.minor.patch` and map `rc1` -> `rc.1` so `rc.9` < `rc.10`. +pub(crate) fn parse_prerelease_version(s: &str) -> Option { + let split = s + .find(|c: char| !c.is_ascii_digit() && c != '.') + .unwrap_or(s.len()); + let (numeric, pre) = s.split_at(split); + + let mut parts = try_into_u64_slice(numeric).ok()?; + if parts.is_empty() || parts.len() > 3 { + return None; + } + while parts.len() < 3 { + parts.push(0); + } + + let pre = if pre.is_empty() { + semver::Prerelease::EMPTY + } else { + // Split the letters from the trailing number: `rc1` -> `rc` + `1`. + let digit_at = pre.find(|c: char| c.is_ascii_digit()).unwrap_or(pre.len()); + let (label, number) = pre.split_at(digit_at); + // Real prerelease labels only, so `t` (free-threaded), `-64` (arch), etc. aren't misread. + const PRERELEASE_LABELS: &[&str] = + &["a", "b", "c", "rc", "alpha", "beta", "pre", "preview"]; + // A numeric serial is required: `rc1` is valid, but bare `rc` or junk like `rc1foo` is not. + if !PRERELEASE_LABELS.contains(&label) + || number.is_empty() + || !number.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + semver::Prerelease::new(&format!("{label}.{number}")).ok()? + }; + + Some(semver::Version { + major: parts[0], + minor: parts[1], + patch: parts[2], + pre, + build: semver::BuildMetadata::EMPTY, + }) +} + +#[cfg(test)] +mod tests { + use super::{LanguageRequest, parse_prerelease_version}; + use crate::config::Language; + use crate::hook::InstallInfo; + + #[test] + fn parses_go_and_python_prereleases() { + // Go-style (no patch) and Python/PEP 440 (with patch). + assert_eq!( + parse_prerelease_version("1.24rc1").unwrap(), + semver::Version::parse("1.24.0-rc.1").unwrap() + ); + assert_eq!( + parse_prerelease_version("1.18beta1").unwrap(), + semver::Version::parse("1.18.0-beta.1").unwrap() + ); + assert_eq!( + parse_prerelease_version("3.13.0rc1").unwrap(), + semver::Version::parse("3.13.0-rc.1").unwrap() + ); + assert_eq!( + parse_prerelease_version("3.14.0a1").unwrap(), + semver::Version::parse("3.14.0-a.1").unwrap() + ); + } + + #[test] + fn pads_and_orders_correctly() { + // Plain numeric versions pad to major.minor.patch, no prerelease. + assert_eq!( + parse_prerelease_version("1.24").unwrap(), + semver::Version::parse("1.24.0").unwrap() + ); + // Numeric (not lexical) prerelease ordering, and prerelease < release. + let rc9 = parse_prerelease_version("1.24rc9").unwrap(); + let rc10 = parse_prerelease_version("1.24rc10").unwrap(); + let release = parse_prerelease_version("1.24.0").unwrap(); + assert!(rc9 < rc10); + assert!(rc9 < release); + } + + #[test] + fn rejects_non_prerelease_suffixes_and_junk() { + // `t` (free-threaded) and `-64` (architecture) are not prereleases. + assert!(parse_prerelease_version("3.13.2t1").is_none()); + assert!(parse_prerelease_version("3.13.2-64").is_none()); + // A prerelease label without a serial, or with a non-numeric serial, is not a real version. + assert!(parse_prerelease_version("1.24rc").is_none()); + assert!(parse_prerelease_version("3.14.0a").is_none()); + assert!(parse_prerelease_version("1.24rc1foo").is_none()); + // Too many numeric parts, missing numeric part, and pure junk. + assert!(parse_prerelease_version("1.2.3.4").is_none()); + assert!(parse_prerelease_version("rc1").is_none()); + assert!(parse_prerelease_version("nonsense").is_none()); + } + + #[test] + fn default_request_never_reuses_a_prerelease_env() -> anyhow::Result<()> { + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::create(Language::Python, None, Vec::new(), temp_dir.path())?; + + let any = LanguageRequest::Any { system_only: false }; + + install_info.with_language_version(semver::Version::parse("3.13.0-rc.1")?); + assert!(!any.satisfied_by(&install_info)); + + install_info.with_language_version(semver::Version::new(3, 13, 0)); + assert!(any.satisfied_by(&install_info)); + + Ok(()) + } + + #[test] + fn default_request_stays_permissive_for_other_languages() -> anyhow::Result<()> { + // Rust's "default" toolchain can legitimately be nightly/beta (e.g. pinned by + // `rust-toolchain.toml`), unlike Python/Go where "default" implies stable. + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::create(Language::Rust, None, Vec::new(), temp_dir.path())?; + install_info.with_language_version(semver::Version::parse("1.76.0-nightly")?); + + let any = LanguageRequest::Any { system_only: false }; + assert!(any.satisfied_by(&install_info)); + + Ok(()) + } + + #[test] + fn system_request_stays_permissive_for_prereleases() -> anyhow::Result<()> { + // `system` explicitly pins to whatever is on PATH; a prerelease found there should + // stay reusable, unlike an unqualified default request. + let temp_dir = tempfile::tempdir()?; + let mut install_info = + InstallInfo::create(Language::Python, None, Vec::new(), temp_dir.path())?; + install_info.with_language_version(semver::Version::parse("3.13.0-rc.1")?); + + let system = LanguageRequest::Any { system_only: true }; + assert!(system.satisfied_by(&install_info)); + + Ok(()) + } +}