Skip to main content

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