winget_types/shared/version/
mod.rs

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    /// The original version string, used for display and serialization
22    raw: CompactString,
23    /// The split parts of a version, used for ordering and equality
24    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 mut trimmed = input.as_ref().trim();
32
33        // If there is a digit before the separator, or no separators, trim off all leading
34        // non-digit characters
35        if let Some(digit_pos) = trimmed.find(|char: char| char.is_ascii_digit()) {
36            if trimmed
37                .find('.')
38                .is_none_or(|separator_pos| digit_pos < separator_pos)
39            {
40                trimmed = &trimmed[digit_pos..];
41            }
42        }
43
44        // Split the version into parts by the separator `.`
45        let mut parts = trimmed
46            .split(Self::SEPARATOR)
47            .map(VersionPart::from)
48            .collect::<SmallVec<[_; 6]>>();
49
50        // Remove all trailing `.0`
51        if let Some(pos) = parts.iter().rposition(|part| !part.is_droppable()) {
52            parts.truncate(pos + 1);
53        } else {
54            parts.clear();
55        }
56
57        Self {
58            raw: CompactString::from(trimmed),
59            parts,
60        }
61    }
62
63    /// Returns true if the version matches `latest` (case-insensitive).
64    ///
65    /// The latest version is always the greatest of any versions.
66    ///
67    /// # Examples
68    ///
69    /// ```
70    /// use winget_types::Version;
71    ///
72    /// assert!(Version::new("latest").is_latest());
73    /// assert!(Version::new("LATEST").is_latest());
74    /// assert!(!Version::new("1.2.3").is_latest());
75    ///
76    /// assert!(Version::new("latest") > Version::new("999.999.999"));
77    /// ```
78    #[must_use]
79    #[inline]
80    pub fn is_latest(&self) -> bool {
81        const LATEST: &str = "latest";
82
83        self.raw.eq_ignore_ascii_case(LATEST)
84    }
85
86    /// Returns true if the version matches `unknown` (case-insensitive).
87    ///
88    /// An unknown version is always the minimum of any versions.
89    ///
90    /// # Examples
91    ///
92    /// ```
93    /// use winget_types::Version;
94    ///
95    /// assert!(Version::new("unknown").is_unknown());
96    /// assert!(Version::new("UNKNOWN").is_unknown());
97    /// assert!(!Version::new("1.2.3").is_unknown());
98    ///
99    /// assert!(Version::new("unknown") < Version::new("0"));
100    /// ```
101    #[must_use]
102    #[inline]
103    pub fn is_unknown(&self) -> bool {
104        const UNKNOWN: &str = "unknown";
105
106        self.raw.eq_ignore_ascii_case(UNKNOWN)
107    }
108
109    /// Extracts a string slice containing the entire `Version`.
110    #[must_use]
111    #[inline]
112    pub fn as_str(&self) -> &str {
113        self.raw.as_str()
114    }
115
116    /// Finds the closest version to this version from a given list of versions.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use winget_types::Version;
122    ///
123    /// let versions = [Version::new("1.2.5"), Version::new("1.2.0")];
124    ///
125    /// let version = Version::new("1.2.3");
126    ///
127    /// assert_eq!(version.closest(&versions).map(Version::as_str), Some("1.2.5"));
128    /// ```
129    pub fn closest<'iter, I, T>(&self, versions: I) -> Option<&'iter T>
130    where
131        I: IntoIterator<Item = &'iter T>,
132        &'iter T: Into<&'iter Self>,
133    {
134        #[derive(PartialEq, Eq, PartialOrd, Ord)]
135        struct DistanceKey<'supplement> {
136            // Prefer versions that diverge later
137            length_score: usize,
138            // Prefer smaller numerical differences
139            numerical_difference: u64,
140            // Prefer higher versions
141            total_order: Ordering,
142            // Reverse order: prefer higher supplements lexicographically
143            supplement_order: Reverse<&'supplement str>,
144        }
145
146        let default_part = &VersionPart::DEFAULT;
147
148        // Find the version with the minimum 'distance'
149        versions.into_iter().min_by_key(|&other| {
150            self.parts
151                .iter()
152                .zip_longest(other.into().parts.iter())
153                .map(|pair| match pair {
154                    EitherOrBoth::Both(part, other_part) => (part, other_part),
155                    EitherOrBoth::Left(part) => (part, default_part),
156                    EitherOrBoth::Right(other_part) => (default_part, other_part),
157                })
158                .enumerate()
159                .find_map(|(index, (part, other_part))| {
160                    (part != other_part).then(|| DistanceKey {
161                        length_score: !index,
162                        numerical_difference: part.number.abs_diff(other_part.number),
163                        total_order: part.cmp(other_part),
164                        supplement_order: Reverse(other_part.supplement.as_str()),
165                    })
166                })
167                .unwrap_or(DistanceKey {
168                    length_score: 0,
169                    numerical_difference: 0,
170                    total_order: Ordering::Equal,
171                    supplement_order: Reverse(""),
172                })
173        })
174    }
175}
176
177impl AsRef<str> for Version {
178    #[inline]
179    fn as_ref(&self) -> &str {
180        self.as_str()
181    }
182}
183
184impl fmt::Display for Version {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        self.raw.fmt(f)
187    }
188}
189
190impl FromStr for Version {
191    type Err = Infallible;
192
193    fn from_str(s: &str) -> Result<Self, Self::Err> {
194        Ok(Self::new(s))
195    }
196}
197
198impl From<&str> for Version {
199    #[inline]
200    fn from(s: &str) -> Self {
201        Self::new(s)
202    }
203}
204
205impl From<String> for Version {
206    #[inline]
207    fn from(s: String) -> Self {
208        Self::new(s)
209    }
210}
211
212impl From<&String> for Version {
213    #[inline]
214    fn from(s: &String) -> Self {
215        Self::new(s)
216    }
217}
218
219impl From<Cow<'_, str>> for Version {
220    #[inline]
221    fn from(s: Cow<'_, str>) -> Self {
222        Self::new(s)
223    }
224}
225
226impl PartialEq for Version {
227    fn eq(&self, other: &Self) -> bool {
228        (self.is_latest() && other.is_latest())
229            || (self.is_unknown() && other.is_unknown())
230            || self.parts.eq(&other.parts)
231    }
232}
233
234impl Hash for Version {
235    fn hash<H: Hasher>(&self, state: &mut H) {
236        self.parts.hash(state);
237    }
238}
239
240impl PartialOrd for Version {
241    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
242        Some(self.cmp(other))
243    }
244}
245
246impl Ord for Version {
247    fn cmp(&self, other: &Self) -> Ordering {
248        match (self.is_latest(), other.is_latest()) {
249            (true, true) => Ordering::Equal,
250            (true, false) => Ordering::Greater,
251            (false, true) => Ordering::Less,
252            (false, false) => match (self.is_unknown(), other.is_unknown()) {
253                (true, true) => Ordering::Equal,
254                (true, false) => Ordering::Less,
255                (false, true) => Ordering::Greater,
256                (false, false) => self
257                    .parts
258                    .iter()
259                    .zip_longest(&other.parts)
260                    .map(|pair| match pair {
261                        EitherOrBoth::Both(part, other_part) => part.cmp(other_part),
262                        EitherOrBoth::Left(part) => part.cmp(&VersionPart::DEFAULT),
263                        EitherOrBoth::Right(other_part) => VersionPart::DEFAULT.cmp(other_part),
264                    })
265                    .find(|&ordering| ordering != Ordering::Equal)
266                    .unwrap_or(Ordering::Equal),
267            },
268        }
269    }
270}
271
272#[cfg(feature = "serde")]
273impl serde::Serialize for Version {
274    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
275    where
276        S: serde::Serializer,
277    {
278        self.as_str().serialize(serializer)
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use alloc::vec::Vec;
285    use core::cmp::Ordering;
286
287    use rstest::rstest;
288
289    use super::Version;
290
291    #[rstest]
292    #[case("1.0", "1.0.0")]
293    #[case("1.2.00.3", "1.2.0.3")]
294    #[case("1.2.003.4", "1.2.3.4")]
295    #[case("01.02.03.04", "1.2.3.4")]
296    #[case("1.2.03-beta", "1.2.3-beta")]
297    #[case("1.0", "1.0 ")]
298    #[case("1.0", "1. 0")]
299    #[case("1.0", "1.0.")]
300    #[case("1.0", "Version 1.0")]
301    #[case("2.4.2", "v2.4.2")]
302    #[case("foo1", "bar1")]
303    #[case("latest", "LATEST")]
304    #[case("unknown", "UNKNOWN")]
305    fn version_equality(#[case] left: &str, #[case] right: &str) {
306        let left = Version::new(left);
307        let right = Version::new(right);
308        assert_eq!(left, right);
309        assert_eq!(left.cmp(&right), Ordering::Equal);
310    }
311
312    #[rstest]
313    #[case("1", "2")]
314    #[case("1.2-rc", "1.2")]
315    #[case("1.0-rc", "1.0")]
316    #[case("1.0.0-rc", "1")]
317    #[case("22.0.0-rc.1", "22.0.0")]
318    #[case("22.0.0-rc.1", "22.0.0.1")]
319    #[case("22.0.0-rc.1", "22.0.0.1-rc")]
320    #[case("22.0.0-rc.1", "22.0.0-rc.1.1")]
321    #[case("22.0.0-rc.1.1", "22.0.0-rc.1.2")]
322    #[case("22.0.0-rc.1.2", "22.0.0-rc.2")]
323    #[case("v0.0.1", "0.0.2")]
324    #[case("v0.0.1", "v0.0.2")]
325    #[case("1.a2", "1.b1")]
326    #[case("alpha", "beta")]
327    #[case("99999.99999.99999", "latest")]
328    #[case("unknown", "1.2.3")]
329    #[case("unknown", "latest")]
330    fn version_comparison_and_inequality(#[case] left: Version, #[case] right: Version) {
331        assert!(left < right);
332        assert!(right > left);
333        assert_ne!(left, right)
334    }
335
336    #[rstest]
337    #[case("1", "2")]
338    #[case("1-rc", "1")]
339    #[case("1-a2", "1-b1")]
340    #[case("alpha", "beta")]
341    fn version_part_comparison(#[case] left: Version, #[case] right: Version) {
342        assert!(left < right);
343        assert!(right > left);
344    }
345
346    #[test]
347    fn version_hash() {
348        use core::hash::BuildHasher;
349
350        use rustc_hash::FxBuildHasher;
351
352        // If two keys are equal, their hashes must also be equal
353        // https://doc.rust-lang.org/std/hash/trait.Hash.html#hash-and-eq
354
355        let version1 = Version::new("1.2.3");
356        let version2 = Version::new("1.2.3.0");
357        assert_eq!(version1, version2);
358
359        assert_eq!(
360            FxBuildHasher.hash_one(version1),
361            FxBuildHasher.hash_one(version2)
362        );
363    }
364
365    #[test]
366    fn only_supplement() {
367        const ALPHA: &str = "alpha";
368
369        let version = Version::new(ALPHA);
370        assert_eq!(version.parts.len(), 1);
371        assert_eq!(version.parts[0].number, 0);
372        assert_eq!(version.parts[0].supplement, ALPHA);
373    }
374
375    #[rstest]
376    #[case("0")]
377    #[case("0.0.0")]
378    #[case("0.0.0.0.0.0.0.0")]
379    #[case("")]
380    fn only_droppable_parts(#[case] version: Version) {
381        assert_eq!(version.parts.len(), 0);
382    }
383
384    #[rstest]
385    #[case("1.2.3", &["1.0.0", "0.9.0", "1.5.6.3", "1.3.2"], "1.3.2")]
386    #[case("10.20.30", &["10.20.29", "10.20.31", "10.20.40"], "10.20.31")]
387    #[case("5.5.5", &["5.5.50", "5.5.0", "5.5.10"], "5.5.10")]
388    #[case("3.0.0", &["3.0.0-beta", "3.0.0-alpha.1", "3.0.0-rc.1"], "3.0.0-rc.1")]
389    #[case("2.1.0-beta", &["2.1.0-alpha", "2.1.0-beta.2", "2.1.0"], "2.1.0-beta.2")]
390    #[case("1.5.0", &["1.0.0", "2.0.0"], "1.0.0")]
391    #[case("3.3.3", &["1.1.1", "5.5.5"], "5.5.5")]
392    #[case("3.3.3", &["5.5.5", "1.1.1"], "5.5.5")]
393    #[case("2.2.2", &["2.2.2", "2.2.2", "2.2.3"], "2.2.2")]
394    #[case("0.0.2", &["0.0.1", "0.0.3", "0.2.0"], "0.0.3")]
395    #[case("999.999.999", &["999.999.998", "1000.0.0"], "999.999.998")]
396    fn closest_version(#[case] version: &str, #[case] versions: &[&str], #[case] expected: &str) {
397        let versions = versions.into_iter().map(Version::new).collect::<Vec<_>>();
398        assert_eq!(
399            Version::new(version).closest(&versions),
400            Some(&Version::new(expected))
401        );
402    }
403}