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 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
165impl 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 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}