pr_01m47d15m3e54sn21z27rpy5n9/crates/scan/src/version.rs
| 1 | //! Ordering versions well enough to pick an upgrade: semver, Cargo, Go's |
| 2 | //! `v1.2.3`, Python's `1.2.post1` and the like. |
| 3 | |
| 4 | use std::cmp::Ordering; |
| 5 | |
| 6 | #[derive(Debug, PartialEq, Eq)] |
| 7 | enum Part { |
| 8 | Number(u64), |
| 9 | Text(String), |
| 10 | } |
| 11 | |
| 12 | /// The release and the pre-release of a version, each split into numbers |
| 13 | /// and words. `+build` metadata is ignored, as semver says. |
| 14 | fn parts(version: &str) -> (Vec<Part>, Vec<Part>) { |
| 15 | let version = version.trim().trim_start_matches(['v', 'V', '=']); |
| 16 | let version = version.split('+').next().unwrap_or(version); |
| 17 | let (release, pre) = match version.split_once('-') { |
| 18 | Some((release, pre)) => (release, Some(pre)), |
| 19 | None => (version, None), |
| 20 | }; |
| 21 | let split = |text: &str| -> Vec<Part> { |
| 22 | let mut out = Vec::new(); |
| 23 | let mut current = String::new(); |
| 24 | let mut digits = false; |
| 25 | let flush = |current: &mut String, digits: bool, out: &mut Vec<Part>| { |
| 26 | if current.is_empty() { |
| 27 | return; |
| 28 | } |
| 29 | out.push(if digits { |
| 30 | Part::Number(current.parse().unwrap_or(u64::MAX)) |
| 31 | } else { |
| 32 | Part::Text(current.to_lowercase()) |
| 33 | }); |
| 34 | current.clear(); |
| 35 | }; |
| 36 | for c in text.chars() { |
| 37 | if c.is_ascii_alphanumeric() { |
| 38 | let is_digit = c.is_ascii_digit(); |
| 39 | if !current.is_empty() && is_digit != digits { |
| 40 | flush(&mut current, digits, &mut out); |
| 41 | } |
| 42 | digits = is_digit; |
| 43 | current.push(c); |
| 44 | } else { |
| 45 | flush(&mut current, digits, &mut out); |
| 46 | } |
| 47 | } |
| 48 | flush(&mut current, digits, &mut out); |
| 49 | out |
| 50 | }; |
| 51 | let mut release = split(release); |
| 52 | // A Python pre-release is written into the release: `2.0.0rc1`. |
| 53 | let mut pre_parts = pre.map(split).unwrap_or_default(); |
| 54 | if let Some(at) = release.iter().position(|part| matches!(part, Part::Text(text) if is_pre_word(text))) { |
| 55 | let mut tail = release.split_off(at); |
| 56 | tail.append(&mut pre_parts); |
| 57 | pre_parts = tail; |
| 58 | } |
| 59 | (release, pre_parts) |
| 60 | } |
| 61 | |
| 62 | fn is_pre_word(text: &str) -> bool { |
| 63 | matches!(text, "a" | "b" | "c" | "rc" | "alpha" | "beta" | "pre" | "dev" | "preview") |
| 64 | } |
| 65 | |
| 66 | fn compare_parts(a: &[Part], b: &[Part]) -> Ordering { |
| 67 | let length = a.len().max(b.len()); |
| 68 | for index in 0..length { |
| 69 | let ordering = match (a.get(index), b.get(index)) { |
| 70 | (Some(Part::Number(x)), Some(Part::Number(y))) => x.cmp(y), |
| 71 | (Some(Part::Text(x)), Some(Part::Text(y))) => x.cmp(y), |
| 72 | (Some(Part::Number(_)), Some(Part::Text(_))) => Ordering::Greater, |
| 73 | (Some(Part::Text(_)), Some(Part::Number(_))) => Ordering::Less, |
| 74 | // 1.2 == 1.2.0 |
| 75 | (Some(Part::Number(x)), None) => x.cmp(&0), |
| 76 | (None, Some(Part::Number(y))) => 0.cmp(y), |
| 77 | // 1.2.post1 > 1.2 |
| 78 | (Some(Part::Text(_)), None) => Ordering::Greater, |
| 79 | (None, Some(Part::Text(_))) => Ordering::Less, |
| 80 | (None, None) => Ordering::Equal, |
| 81 | }; |
| 82 | if ordering != Ordering::Equal { |
| 83 | return ordering; |
| 84 | } |
| 85 | } |
| 86 | Ordering::Equal |
| 87 | } |
| 88 | |
| 89 | /// Orders two versions. A pre-release comes before its release. |
| 90 | pub fn compare(a: &str, b: &str) -> Ordering { |
| 91 | let (a_release, a_pre) = parts(a); |
| 92 | let (b_release, b_pre) = parts(b); |
| 93 | compare_parts(&a_release, &b_release).then_with(|| match (a_pre.is_empty(), b_pre.is_empty()) { |
| 94 | (true, true) => Ordering::Equal, |
| 95 | (true, false) => Ordering::Greater, |
| 96 | (false, true) => Ordering::Less, |
| 97 | (false, false) => compare_parts(&a_pre, &b_pre), |
| 98 | }) |
| 99 | } |
| 100 | |
| 101 | #[cfg(test)] |
| 102 | mod tests { |
| 103 | use super::compare; |
| 104 | use std::cmp::Ordering::*; |
| 105 | |
| 106 | #[test] |
| 107 | fn versions_are_ordered() { |
| 108 | for (a, b, expected) in [ |
| 109 | ("1.2.3", "1.2.10", Less), |
| 110 | ("v1.9.0", "v1.10.0", Less), |
| 111 | ("2.0.0", "2.0.0-rc.1", Greater), |
| 112 | ("1.0.0-alpha", "1.0.0-beta", Less), |
| 113 | ("1.2", "1.2.0", Equal), |
| 114 | ("4.17.21", "4.17.21", Equal), |
| 115 | ("2.0.0rc1", "2.0.0", Less), |
| 116 | ("1.2.post1", "1.2", Greater), |
| 117 | ("0.10.0", "0.9.9", Greater), |
| 118 | ("1.0.0+build.5", "1.0.0", Equal), |
| 119 | ] { |
| 120 | assert_eq!(compare(a, b), expected, "{a} vs {b}"); |
| 121 | } |
| 122 | } |
| 123 | } |