Skip to main content

vivacity_core/
version.rs

1//! Subset of Composer versioning needed by the platform check: numeric
2//! versions `X[.Y[.Z[.W]]]` with an optional stability suffix (`-dev`,
3//! `-alpha.N`, `-beta.N`, `-RC.N`, `-patch.N`), compared like
4//! `composer/semver` (4-component normalisation, dev < alpha < beta < RC <
5//! stable < patch). Branches (`dev-master`, `1.x-dev`) are outside this
6//! subset: `parse` returns an error and the caller treats the package as
7//! out of scope rather than guessing.
8//!
9//! Parity is held by the differential tests against
10//! `Composer\Semver\Semver::satisfies` (tests/oracle_semver.rs).
11
12use std::cmp::Ordering;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
15pub enum Stability {
16    Dev,
17    Alpha,
18    Beta,
19    Rc,
20    Stable,
21    Patch,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct Version {
26    pub parts: [u64; 4],
27    pub stability: Stability,
28    /// Pre-release number (`-beta2` -> 2), 0 if absent.
29    pub pre_number: u64,
30}
31
32#[derive(Debug, thiserror::Error, PartialEq, Eq)]
33#[error("version outside the supported subset: {0:?}")]
34pub struct UnsupportedVersion(pub String);
35
36impl Version {
37    pub fn parse(input: &str) -> Result<Self, UnsupportedVersion> {
38        let s = input.trim();
39        let s = s
40            .strip_prefix('v')
41            .or_else(|| s.strip_prefix('V'))
42            .unwrap_or(s);
43        if s.is_empty() {
44            return Err(UnsupportedVersion(input.to_owned()));
45        }
46
47        // Split off the stability suffix: `1.2.3-beta2`, `1.2.3beta2`, `1.2.3-dev`.
48        let (num, suffix) = split_stability(s);
49        let (stability, pre_number) = parse_stability(suffix, input)?;
50
51        let mut parts = [0u64; 4];
52        let mut n = 0usize;
53        for piece in num.split('.') {
54            if n >= 4 || piece.is_empty() || !piece.bytes().all(|b| b.is_ascii_digit()) {
55                return Err(UnsupportedVersion(input.to_owned()));
56            }
57            parts[n] = piece
58                .parse()
59                .map_err(|_| UnsupportedVersion(input.to_owned()))?;
60            n += 1;
61        }
62        if n == 0 {
63            return Err(UnsupportedVersion(input.to_owned()));
64        }
65        Ok(Version {
66            parts,
67            stability,
68            pre_number,
69        })
70    }
71}
72
73/// Splits `1.2.3-beta2` / `1.2.3beta2` / `1.2.3_RC1` into (numeric, suffix).
74fn split_stability(s: &str) -> (&str, &str) {
75    match s.find(|c: char| !(c.is_ascii_digit() || c == '.')) {
76        Some(i) => {
77            let suffix = &s[i..];
78            (&s[..i], suffix.trim_start_matches(['-', '_', '.']))
79        }
80        None => (s, ""),
81    }
82}
83
84fn parse_stability(suffix: &str, original: &str) -> Result<(Stability, u64), UnsupportedVersion> {
85    if suffix.is_empty() {
86        return Ok((Stability::Stable, 0));
87    }
88    let lower = suffix.to_ascii_lowercase();
89    let (word, digits) = match lower.find(|c: char| c.is_ascii_digit()) {
90        Some(i) => (&lower[..i], &lower[i..]),
91        None => (lower.as_str(), ""),
92    };
93    let word = word.trim_end_matches(['-', '_', '.']);
94    let stability = match word {
95        "dev" => Stability::Dev,
96        "alpha" | "a" => Stability::Alpha,
97        "beta" | "b" => Stability::Beta,
98        "rc" => Stability::Rc,
99        "patch" | "pl" | "p" => Stability::Patch,
100        "stable" | "" => Stability::Stable,
101        _ => return Err(UnsupportedVersion(original.to_owned())),
102    };
103    let pre_number = if digits.is_empty() {
104        0
105    } else {
106        digits
107            .parse()
108            .map_err(|_| UnsupportedVersion(original.to_owned()))?
109    };
110    Ok((stability, pre_number))
111}
112
113impl PartialOrd for Version {
114    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
115        Some(self.cmp(other))
116    }
117}
118
119impl Ord for Version {
120    fn cmp(&self, other: &Self) -> Ordering {
121        self.parts
122            .cmp(&other.parts)
123            .then(self.stability.cmp(&other.stability))
124            .then(self.pre_number.cmp(&other.pre_number))
125    }
126}
127
128/// Composer's "pretty -> normalized" normalisation (VersionParser::normalize),
129/// for the subset met in locks: numeric versions (-> 4 components + canonical
130/// suffix), `dev-*` branches (unchanged) and numeric branches `N.x-dev`
131/// (x -> 9999999, padded to 4 components).
132/// Parity held by tests/oracle_normalize.rs.
133pub fn normalize_pretty(input: &str) -> Result<String, UnsupportedVersion> {
134    let s = input.trim();
135    // master/trunk/default (with or without `dev-`) -> default branch.
136    let lower = s.to_ascii_lowercase();
137    let bare = lower.strip_prefix("dev-").unwrap_or(&lower);
138    if matches!(bare, "master" | "trunk" | "default") {
139        return Ok("9999999-dev".to_owned());
140    }
141    if let Some(rest) = s.strip_prefix("dev-") {
142        if rest.is_empty() {
143            return Err(UnsupportedVersion(input.to_owned()));
144        }
145        return Ok(format!("dev-{rest}"));
146    }
147    let stripped = s
148        .strip_prefix('v')
149        .or_else(|| s.strip_prefix('V'))
150        .unwrap_or(s);
151
152    // Numeric branch `1.2.x-dev` / `1.x-dev`.
153    if let Some(stem) = stripped
154        .strip_suffix(".x-dev")
155        .or_else(|| stripped.strip_suffix(".X-dev"))
156    {
157        let mut parts: Vec<u64> = Vec::new();
158        for piece in stem.split('.') {
159            if piece.is_empty() || !piece.bytes().all(|b| b.is_ascii_digit()) || parts.len() >= 3 {
160                return Err(UnsupportedVersion(input.to_owned()));
161            }
162            parts.push(
163                piece
164                    .parse()
165                    .map_err(|_| UnsupportedVersion(input.to_owned()))?,
166            );
167        }
168        let mut out: Vec<String> = parts.iter().map(u64::to_string).collect();
169        while out.len() < 4 {
170            out.push("9999999".to_owned());
171        }
172        return Ok(format!("{}-dev", out.join(".")));
173    }
174
175    let (num, suffix) = split_stability(stripped);
176    let (stability, pre_number) = parse_stability(suffix, input)?;
177    let mut count = 0usize;
178    let mut parts = [0u64; 4];
179    for piece in num.split('.') {
180        if count >= 4 || piece.is_empty() || !piece.bytes().all(|b| b.is_ascii_digit()) {
181            return Err(UnsupportedVersion(input.to_owned()));
182        }
183        parts[count] = piece
184            .parse()
185            .map_err(|_| UnsupportedVersion(input.to_owned()))?;
186        count += 1;
187    }
188    if count == 0 {
189        return Err(UnsupportedVersion(input.to_owned()));
190    }
191    let base = format!("{}.{}.{}.{}", parts[0], parts[1], parts[2], parts[3]);
192    let word = match stability {
193        Stability::Stable => return Ok(base),
194        Stability::Dev => "dev",
195        Stability::Alpha => "alpha",
196        Stability::Beta => "beta",
197        Stability::Rc => "RC",
198        Stability::Patch => "patch",
199    };
200    // Suffixes without a number stay bare (`-alpha`), else the number is appended.
201    let had_number = suffix.chars().any(|c| c.is_ascii_digit());
202    if had_number {
203        Ok(format!("{base}-{word}{pre_number}"))
204    } else {
205        Ok(format!("{base}-{word}"))
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    fn v(s: &str) -> Version {
214        Version::parse(s).expect(s)
215    }
216
217    #[test]
218    fn parses_and_orders() {
219        assert_eq!(v("8.5.10").parts, [8, 5, 10, 0]);
220        assert_eq!(v("v1.2").parts, [1, 2, 0, 0]);
221        assert!(v("8.1") < v("8.1.1"));
222        assert!(v("7.4.33") < v("8.0.0"));
223        assert!(v("1.0.0-dev") < v("1.0.0-alpha1"));
224        assert!(v("1.0.0-alpha2") < v("1.0.0-beta1"));
225        assert!(v("1.0.0-RC1") < v("1.0.0"));
226        assert!(v("1.0.0") < v("1.0.0-patch1"));
227        assert!(v("1.0.0-beta1") < v("1.0.0-beta2"));
228        assert_eq!(v("1.0"), v("1.0.0.0"));
229    }
230
231    #[test]
232    fn rejects_out_of_subset() {
233        for s in ["dev-master", "1.x-dev", "abc", "", "1.2.3.4.5", "1.2-foo"] {
234            assert!(Version::parse(s).is_err(), "{s} should have been rejected");
235        }
236    }
237}