upstream_rs/models/common/
version.rs1use 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
166impl 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 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}