1use 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 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 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
73fn 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
128pub fn normalize_pretty(input: &str) -> Result<String, UnsupportedVersion> {
134 let s = input.trim();
135 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 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 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}