Skip to main content

upstream_rs/models/common/
version.rs

1use std::fmt;
2
3use anyhow::{Result, bail};
4use serde::{Deserialize, Serialize};
5
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7pub struct Version {
8    pub major: u32,
9    pub minor: u32,
10    pub patch: u32,
11    pub is_prerelease: bool,
12}
13
14impl Version {
15    pub fn new(major: u32, minor: u32, patch: u32, is_prerelease: bool) -> Self {
16        Self {
17            major,
18            minor,
19            patch,
20            is_prerelease,
21        }
22    }
23
24    pub fn is_newer_than(&self, other: &Version) -> bool {
25        if self.major != other.major {
26            return self.major > other.major;
27        }
28        if self.minor != other.minor {
29            return self.minor > other.minor;
30        }
31        if self.patch != other.patch {
32            return self.patch > other.patch;
33        }
34        if self.is_prerelease != other.is_prerelease {
35            return !self.is_prerelease;
36        }
37
38        false
39    }
40
41    pub fn is_unknown(&self) -> bool {
42        self.major == 0 && self.minor == 0 && self.patch == 0 && !self.is_prerelease
43    }
44
45    pub fn parse(s: &str) -> Result<Self> {
46        if s.trim().is_empty() {
47            bail!("Cannot parse empty version",);
48        }
49
50        let parts: Vec<&str> = s.split('.').collect();
51
52        if parts.is_empty() || parts.len() > 3 {
53            bail!("Invalid version format",);
54        }
55
56        let major = match parts[0].parse::<u32>() {
57            Ok(v) => v,
58            Err(_) => bail!("Invalid major"),
59        };
60
61        let minor = if parts.len() > 1 {
62            match parts[1].parse::<u32>() {
63                Ok(v) => v,
64                Err(_) => bail!("Invalid minor"),
65            }
66        } else {
67            0
68        };
69
70        let patch = if parts.len() > 2 {
71            match parts[2].parse::<u32>() {
72                Ok(v) => v,
73                Err(_) => bail!("Invalid patch"),
74            }
75        } else {
76            0
77        };
78
79        Ok(Version::new(major, minor, patch, false))
80    }
81
82    pub fn from_filename(s: &str) -> Result<Self> {
83        let trimmed = s.trim();
84        if trimmed.is_empty() {
85            bail!("Cannot parse empty version");
86        }
87
88        if let Some(candidate) = Self::find_triplet(trimmed) {
89            return Self::parse(&candidate);
90        }
91
92        Self::parse(trimmed)
93    }
94
95    pub fn from_tag(tag: &str) -> Result<Self> {
96        let tag = tag.trim();
97        let tag = tag
98            .strip_prefix('v')
99            .or_else(|| tag.strip_prefix('V'))
100            .unwrap_or(tag);
101
102        const PREFIXES: &[&str] = &["release-", "rel-", "ver-", "version-"];
103        let lowered = tag.to_lowercase();
104        let cleaned = PREFIXES
105            .iter()
106            .find_map(|prefix| lowered.strip_prefix(prefix).map(|_| &tag[prefix.len()..]))
107            .unwrap_or(tag);
108
109        Self::from_filename(cleaned).or_else(|_| Self::parse(cleaned))
110    }
111
112    fn find_triplet(s: &str) -> Option<String> {
113        let bytes = s.as_bytes();
114        let len = bytes.len();
115
116        let mut i = 0;
117        while i < len {
118            if !bytes[i].is_ascii_digit() {
119                i += 1;
120                continue;
121            }
122
123            let major_start = i;
124            while i < len && bytes[i].is_ascii_digit() {
125                i += 1;
126            }
127            if i >= len || bytes[i] != b'.' {
128                continue;
129            }
130
131            i += 1;
132            let minor_start = i;
133            while i < len && bytes[i].is_ascii_digit() {
134                i += 1;
135            }
136            if minor_start == i || i >= len || bytes[i] != b'.' {
137                continue;
138            }
139
140            i += 1;
141            let patch_start = i;
142            while i < len && bytes[i].is_ascii_digit() {
143                i += 1;
144            }
145            if patch_start == i {
146                continue;
147            }
148
149            return Some(s[major_start..i].to_string());
150        }
151
152        None
153    }
154}
155
156impl fmt::Display for Version {
157    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158        if self.is_prerelease {
159            write!(f, "{}.{}.{}-pre", self.major, self.minor, self.patch)
160        } else {
161            write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
162        }
163    }
164}
165
166// Implement PartialOrd and Ord for proper comparison
167impl PartialOrd for Version {
168    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
169        Some(std::cmp::Ord::cmp(self, other))
170    }
171}
172
173impl Ord for Version {
174    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
175        match self.major.cmp(&other.major) {
176            std::cmp::Ordering::Equal => {}
177            ord => return ord,
178        }
179        match self.minor.cmp(&other.minor) {
180            std::cmp::Ordering::Equal => {}
181            ord => return ord,
182        }
183        match self.patch.cmp(&other.patch) {
184            std::cmp::Ordering::Equal => {}
185            ord => return ord,
186        }
187
188        // Stable releases are "greater than" prereleases for the same version.
189        match (self.is_prerelease, other.is_prerelease) {
190            (false, true) => std::cmp::Ordering::Greater,
191            (true, false) => std::cmp::Ordering::Less,
192            _ => std::cmp::Ordering::Equal,
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::Version;
200
201    #[test]
202    fn parse_supports_short_and_full_versions() {
203        assert_eq!(
204            Version::parse("1").expect("parse 1"),
205            Version::new(1, 0, 0, false)
206        );
207        assert_eq!(
208            Version::parse("1.2").expect("parse 1.2"),
209            Version::new(1, 2, 0, false)
210        );
211        assert_eq!(
212            Version::parse("1.2.3").expect("parse 1.2.3"),
213            Version::new(1, 2, 3, false)
214        );
215    }
216
217    #[test]
218    fn parse_rejects_invalid_versions() {
219        assert!(Version::parse("").is_err());
220        assert!(Version::parse("1.2.3.4").is_err());
221        assert!(Version::parse("v1.2.3").is_err());
222        assert!(Version::parse("1.a.3").is_err());
223    }
224
225    #[test]
226    fn from_filename_extracts_triplet_when_present() {
227        let parsed = Version::from_filename("tool-v2.15.9-linux-x86_64.tar.gz")
228            .expect("version extracted from filename");
229        assert_eq!(parsed, Version::new(2, 15, 9, false));
230    }
231
232    #[test]
233    fn from_tag_handles_common_prefixes() {
234        assert_eq!(
235            Version::from_tag("v1.2.3").expect("v-prefixed tag"),
236            Version::new(1, 2, 3, false)
237        );
238        assert_eq!(
239            Version::from_tag("release-7.8.9").expect("release-prefixed tag"),
240            Version::new(7, 8, 9, false)
241        );
242        assert_eq!(
243            Version::from_tag("VERSION-10.11.12").expect("case-insensitive prefix"),
244            Version::new(10, 11, 12, false)
245        );
246    }
247
248    #[test]
249    fn comparison_prefers_stable_over_prerelease_for_same_numbers() {
250        let stable = Version::new(1, 0, 0, false);
251        let prerelease = Version::new(1, 0, 0, true);
252
253        assert!(stable > prerelease);
254        assert!(stable.is_newer_than(&prerelease));
255        assert!(!prerelease.is_newer_than(&stable));
256    }
257}