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