1mod part;
2
3use alloc::{borrow::Cow, string::String};
4use core::{
5 cmp::{Ordering, Reverse},
6 convert::Infallible,
7 fmt,
8 hash::{Hash, Hasher},
9 str::FromStr,
10};
11
12use compact_str::CompactString;
13use itertools::{EitherOrBoth, Itertools};
14use part::VersionPart;
15use smallvec::SmallVec;
16
17#[derive(Clone, Debug, Default, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
19#[cfg_attr(feature = "serde", serde(from = "&str"))]
20pub struct Version {
21 raw: CompactString,
23 parts: SmallVec<[VersionPart; 6]>,
25}
26
27impl Version {
28 const SEPARATOR: char = '.';
29
30 pub fn new<T: AsRef<str>>(input: T) -> Self {
31 let raw_version = input.as_ref().trim();
32
33 let mut version = raw_version;
34
35 if let Some(digit_pos) = raw_version.find(|char: char| char.is_ascii_digit()) {
38 if raw_version
39 .find('.')
40 .is_none_or(|separator_pos| digit_pos < separator_pos)
41 {
42 version = &raw_version[digit_pos..];
43 }
44 }
45
46 let mut parts = version
48 .split(Self::SEPARATOR)
49 .map(VersionPart::from)
50 .collect::<SmallVec<[_; 6]>>();
51
52 if let Some(pos) = parts.iter().rposition(|part| !part.is_droppable()) {
54 parts.truncate(pos + 1);
55 } else {
56 parts.clear();
57 }
58
59 Self {
60 raw: CompactString::from(raw_version),
61 parts,
62 }
63 }
64
65 #[must_use]
81 #[inline]
82 pub fn is_latest(&self) -> bool {
83 const LATEST: &str = "latest";
84
85 self.raw.eq_ignore_ascii_case(LATEST)
86 }
87
88 #[must_use]
104 #[inline]
105 pub fn is_unknown(&self) -> bool {
106 const UNKNOWN: &str = "unknown";
107
108 self.raw.eq_ignore_ascii_case(UNKNOWN)
109 }
110
111 #[must_use]
113 #[inline]
114 pub fn as_str(&self) -> &str {
115 self.raw.as_str()
116 }
117
118 pub fn closest<'iter, I, T>(&self, versions: I) -> Option<&'iter T>
132 where
133 I: IntoIterator<Item = &'iter T>,
134 &'iter T: Into<&'iter Self>,
135 {
136 #[derive(PartialEq, Eq, PartialOrd, Ord)]
137 struct DistanceKey<'supplement> {
138 length_score: usize,
140 numerical_difference: u64,
142 total_order: Ordering,
144 supplement_order: Reverse<&'supplement str>,
146 }
147
148 let default_part = &VersionPart::DEFAULT;
149
150 versions.into_iter().min_by_key(|&other| {
152 self.parts
153 .iter()
154 .zip_longest(other.into().parts.iter())
155 .map(|pair| match pair {
156 EitherOrBoth::Both(part, other_part) => (part, other_part),
157 EitherOrBoth::Left(part) => (part, default_part),
158 EitherOrBoth::Right(other_part) => (default_part, other_part),
159 })
160 .enumerate()
161 .find_map(|(index, (part, other_part))| {
162 (part != other_part).then(|| DistanceKey {
163 length_score: !index,
164 numerical_difference: part.number.abs_diff(other_part.number),
165 total_order: part.cmp(other_part),
166 supplement_order: Reverse(other_part.supplement.as_str()),
167 })
168 })
169 .unwrap_or(DistanceKey {
170 length_score: 0,
171 numerical_difference: 0,
172 total_order: Ordering::Equal,
173 supplement_order: Reverse(""),
174 })
175 })
176 }
177}
178
179impl AsRef<str> for Version {
180 #[inline]
181 fn as_ref(&self) -> &str {
182 self.as_str()
183 }
184}
185
186impl fmt::Display for Version {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 self.raw.fmt(f)
189 }
190}
191
192impl FromStr for Version {
193 type Err = Infallible;
194
195 fn from_str(s: &str) -> Result<Self, Self::Err> {
196 Ok(Self::new(s))
197 }
198}
199
200impl From<&str> for Version {
201 #[inline]
202 fn from(s: &str) -> Self {
203 Self::new(s)
204 }
205}
206
207impl From<String> for Version {
208 #[inline]
209 fn from(s: String) -> Self {
210 Self::new(s)
211 }
212}
213
214impl From<&String> for Version {
215 #[inline]
216 fn from(s: &String) -> Self {
217 Self::new(s)
218 }
219}
220
221impl From<Cow<'_, str>> for Version {
222 #[inline]
223 fn from(s: Cow<'_, str>) -> Self {
224 Self::new(s)
225 }
226}
227
228impl PartialEq for Version {
229 fn eq(&self, other: &Self) -> bool {
230 (self.is_latest() && other.is_latest())
231 || (self.is_unknown() && other.is_unknown())
232 || self.parts.eq(&other.parts)
233 }
234}
235
236impl Hash for Version {
237 fn hash<H: Hasher>(&self, state: &mut H) {
238 self.parts.hash(state);
239 }
240}
241
242impl PartialOrd for Version {
243 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
244 Some(self.cmp(other))
245 }
246}
247
248impl Ord for Version {
249 fn cmp(&self, other: &Self) -> Ordering {
250 match (self.is_latest(), other.is_latest()) {
251 (true, true) => Ordering::Equal,
252 (true, false) => Ordering::Greater,
253 (false, true) => Ordering::Less,
254 (false, false) => match (self.is_unknown(), other.is_unknown()) {
255 (true, true) => Ordering::Equal,
256 (true, false) => Ordering::Less,
257 (false, true) => Ordering::Greater,
258 (false, false) => self
259 .parts
260 .iter()
261 .zip_longest(&other.parts)
262 .map(|pair| match pair {
263 EitherOrBoth::Both(part, other_part) => part.cmp(other_part),
264 EitherOrBoth::Left(part) => part.cmp(&VersionPart::DEFAULT),
265 EitherOrBoth::Right(other_part) => VersionPart::DEFAULT.cmp(other_part),
266 })
267 .find(|&ordering| ordering != Ordering::Equal)
268 .unwrap_or(Ordering::Equal),
269 },
270 }
271 }
272}
273
274#[cfg(feature = "serde")]
275impl serde::Serialize for Version {
276 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
277 where
278 S: serde::Serializer,
279 {
280 self.as_str().serialize(serializer)
281 }
282}
283
284#[cfg(test)]
285mod tests {
286 use alloc::vec::Vec;
287 use core::cmp::Ordering;
288
289 use rstest::rstest;
290
291 use super::Version;
292
293 #[rstest]
294 #[case("1.0", "1.0.0")]
295 #[case("1.2.00.3", "1.2.0.3")]
296 #[case("1.2.003.4", "1.2.3.4")]
297 #[case("01.02.03.04", "1.2.3.4")]
298 #[case("1.2.03-beta", "1.2.3-beta")]
299 #[case("1.0", "1.0 ")]
300 #[case("1.0", "1. 0")]
301 #[case("1.0", "1.0.")]
302 #[case("1.0", "Version 1.0")]
303 #[case("2.4.2", "v2.4.2")]
304 #[case("foo1", "bar1")]
305 #[case("latest", "LATEST")]
306 #[case("unknown", "UNKNOWN")]
307 fn version_equality(#[case] left: &str, #[case] right: &str) {
308 let left = Version::new(left);
309 let right = Version::new(right);
310 assert_eq!(left, right);
311 assert_eq!(left.cmp(&right), Ordering::Equal);
312 }
313
314 #[rstest]
315 #[case("1", "2")]
316 #[case("1.2-rc", "1.2")]
317 #[case("1.0-rc", "1.0")]
318 #[case("1.0.0-rc", "1")]
319 #[case("22.0.0-rc.1", "22.0.0")]
320 #[case("22.0.0-rc.1", "22.0.0.1")]
321 #[case("22.0.0-rc.1", "22.0.0.1-rc")]
322 #[case("22.0.0-rc.1", "22.0.0-rc.1.1")]
323 #[case("22.0.0-rc.1.1", "22.0.0-rc.1.2")]
324 #[case("22.0.0-rc.1.2", "22.0.0-rc.2")]
325 #[case("v0.0.1", "0.0.2")]
326 #[case("v0.0.1", "v0.0.2")]
327 #[case("1.a2", "1.b1")]
328 #[case("alpha", "beta")]
329 #[case("99999.99999.99999", "latest")]
330 #[case("unknown", "1.2.3")]
331 #[case("unknown", "latest")]
332 fn version_comparison_and_inequality(#[case] left: Version, #[case] right: Version) {
333 assert!(left < right);
334 assert!(right > left);
335 assert_ne!(left, right)
336 }
337
338 #[rstest]
339 #[case("1", "2")]
340 #[case("1-rc", "1")]
341 #[case("1-a2", "1-b1")]
342 #[case("alpha", "beta")]
343 fn version_part_comparison(#[case] left: Version, #[case] right: Version) {
344 assert!(left < right);
345 assert!(right > left);
346 }
347
348 #[test]
349 fn version_hash() {
350 use core::hash::BuildHasher;
351
352 use rustc_hash::FxBuildHasher;
353
354 let version1 = Version::new("1.2.3");
358 let version2 = Version::new("1.2.3.0");
359 assert_eq!(version1, version2);
360
361 assert_eq!(
362 FxBuildHasher.hash_one(version1),
363 FxBuildHasher.hash_one(version2)
364 );
365 }
366
367 #[test]
368 fn only_supplement() {
369 const ALPHA: &str = "alpha";
370
371 let version = Version::new(ALPHA);
372 assert_eq!(version.parts.len(), 1);
373 assert_eq!(version.parts[0].number, 0);
374 assert_eq!(version.parts[0].supplement, ALPHA);
375 }
376
377 #[rstest]
378 #[case("0")]
379 #[case("0.0.0")]
380 #[case("0.0.0.0.0.0.0.0")]
381 #[case("")]
382 fn only_droppable_parts(#[case] version: Version) {
383 assert_eq!(version.parts.len(), 0);
384 }
385
386 #[rstest]
387 #[case("v123")]
388 #[case("v1.2.3")]
389 #[case("1.a2")]
390 #[case("alpha")]
391 fn version_display_round_trip(#[case] raw_version: &str) {
392 use alloc::string::ToString;
393
394 assert_eq!(Version::new(raw_version).to_string(), raw_version.trim())
396 }
397
398 #[rstest]
399 #[case("1.2.3", &["1.0.0", "0.9.0", "1.5.6.3", "1.3.2"], "1.3.2")]
400 #[case("10.20.30", &["10.20.29", "10.20.31", "10.20.40"], "10.20.31")]
401 #[case("5.5.5", &["5.5.50", "5.5.0", "5.5.10"], "5.5.10")]
402 #[case("3.0.0", &["3.0.0-beta", "3.0.0-alpha.1", "3.0.0-rc.1"], "3.0.0-rc.1")]
403 #[case("2.1.0-beta", &["2.1.0-alpha", "2.1.0-beta.2", "2.1.0"], "2.1.0-beta.2")]
404 #[case("1.5.0", &["1.0.0", "2.0.0"], "1.0.0")]
405 #[case("3.3.3", &["1.1.1", "5.5.5"], "5.5.5")]
406 #[case("3.3.3", &["5.5.5", "1.1.1"], "5.5.5")]
407 #[case("2.2.2", &["2.2.2", "2.2.2", "2.2.3"], "2.2.2")]
408 #[case("0.0.2", &["0.0.1", "0.0.3", "0.2.0"], "0.0.3")]
409 #[case("999.999.999", &["999.999.998", "1000.0.0"], "999.999.998")]
410 fn closest_version(#[case] version: &str, #[case] versions: &[&str], #[case] expected: &str) {
411 let versions = versions.into_iter().map(Version::new).collect::<Vec<_>>();
412 assert_eq!(
413 Version::new(version).closest(&versions),
414 Some(&Version::new(expected))
415 );
416 }
417}