alef_core/version.rs
1//! Cross-ecosystem version-format conversions.
2//!
3//! Cargo and the polyglot package registries disagree on prerelease syntax;
4//! these helpers normalize between them so each binding manifest receives a
5//! version string that its tooling will accept.
6
7/// Convert a semver pre-release version to R / CRAN-compatible version format.
8///
9/// R's `package_version()` rejects SemVer dash-form prereleases like
10/// `4.10.0-rc.15`. CRAN convention is to encode development versions as a
11/// fourth component with a high value (9000+). This helper maps:
12/// `4.10.0` → `4.10.0` (unchanged)
13/// `4.10.0-rc.1` → `4.10.0.9001`
14/// `4.10.0-rc.15`→ `4.10.0.9015`
15///
16/// The numeric suffix preserves ordering: rc.1 < rc.15 < release.
17///
18/// # Examples
19///
20/// ```
21/// use alef_core::version::to_r_version;
22/// assert_eq!(to_r_version("1.8.0"), "1.8.0");
23/// assert_eq!(to_r_version("4.10.0-rc.1"), "4.10.0.9001");
24/// assert_eq!(to_r_version("4.10.0-rc.15"), "4.10.0.9015");
25/// assert_eq!(to_r_version("0.1.0-alpha.2"), "0.1.0.9000");
26/// ```
27pub fn to_r_version(version: &str) -> String {
28 let Some((base, pre)) = version.split_once('-') else {
29 return version.to_string();
30 };
31
32 // For rc (release candidate) prereleases, encode the RC number as an offset
33 // from 9000 so that ordering is preserved within a series (rc.1 → 9001, rc.15 → 9015).
34 // All other prerelease identifiers (alpha, beta, dev, …) map to the base value 9000.
35 let numeric_offset: u32 = if pre.starts_with("rc") {
36 pre.split('.')
37 .filter_map(|part| part.parse::<u32>().ok())
38 .next_back()
39 .unwrap_or(0)
40 } else {
41 0
42 };
43
44 format!("{base}.{}", 9000 + numeric_offset)
45}
46
47/// Convert a semver pre-release version to RubyGems canonical prerelease format.
48///
49/// RubyGems rejects the dash-form prerelease syntax that cargo uses
50/// (`Gem::Version.new("1.8.0-rc.2")` raises) and requires the `.pre.` form.
51///
52/// # Examples
53///
54/// ```
55/// use alef_core::version::to_rubygems_prerelease;
56/// assert_eq!(to_rubygems_prerelease("1.8.0"), "1.8.0");
57/// assert_eq!(to_rubygems_prerelease("1.8.0-rc.2"), "1.8.0.pre.rc.2");
58/// assert_eq!(to_rubygems_prerelease("0.1.0-alpha.2"), "0.1.0.pre.alpha.2");
59/// ```
60pub fn to_rubygems_prerelease(version: &str) -> String {
61 if let Some((base, pre)) = version.split_once('-') {
62 let normalized_pre = pre.replace(['-', '_'], ".");
63 format!("{base}.pre.{normalized_pre}")
64 } else {
65 version.to_string()
66 }
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72
73 #[test]
74 fn release_version_is_unchanged() {
75 assert_eq!(to_rubygems_prerelease("1.8.0"), "1.8.0");
76 assert_eq!(to_rubygems_prerelease("0.1.0"), "0.1.0");
77 }
78
79 #[test]
80 fn rc_prerelease_uses_pre_dot_form() {
81 assert_eq!(to_rubygems_prerelease("1.8.0-rc.2"), "1.8.0.pre.rc.2");
82 }
83
84 #[test]
85 fn alpha_and_beta_prereleases_normalize() {
86 assert_eq!(to_rubygems_prerelease("0.1.0-alpha.2"), "0.1.0.pre.alpha.2");
87 assert_eq!(to_rubygems_prerelease("0.1.0-beta.3"), "0.1.0.pre.beta.3");
88 }
89
90 #[test]
91 fn dashes_in_prerelease_become_dots() {
92 assert_eq!(to_rubygems_prerelease("1.0.0-pre-rc-2"), "1.0.0.pre.pre.rc.2");
93 }
94
95 // --- to_r_version tests ---
96
97 #[test]
98 fn r_release_version_is_unchanged() {
99 assert_eq!(to_r_version("1.8.0"), "1.8.0");
100 assert_eq!(to_r_version("0.1.0"), "0.1.0");
101 assert_eq!(to_r_version("4.10.0"), "4.10.0");
102 }
103
104 #[test]
105 fn r_rc_prerelease_gets_9000_offset() {
106 assert_eq!(to_r_version("4.10.0-rc.1"), "4.10.0.9001");
107 assert_eq!(to_r_version("4.10.0-rc.15"), "4.10.0.9015");
108 assert_eq!(to_r_version("1.8.0-rc.2"), "1.8.0.9002");
109 }
110
111 #[test]
112 fn r_alpha_without_number_gets_9000() {
113 assert_eq!(to_r_version("0.1.0-alpha"), "0.1.0.9000");
114 }
115
116 #[test]
117 fn r_alpha_with_number_uses_offset() {
118 assert_eq!(to_r_version("0.1.0-alpha.2"), "0.1.0.9000");
119 }
120}