1use std::num::NonZeroU32;
4
5#[derive(Debug, Clone, Copy, Default, Eq, PartialEq, PartialOrd, Ord, Hash)]
6#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
7#[cfg_attr(feature = "rkyv", derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize))]
8pub struct Version {
9 pub major: u32,
10 pub minor: u32,
11 pub patch: u32,
12 pub build: Option<NonZeroU32>,
15}
16
17impl Version {
18 pub fn from_client_exe(version: &str) -> Version {
19 let parts: Vec<_> = version.split(",").collect();
20 assert!(parts.len() == 4);
21 Version {
22 major: parts[0].trim().parse::<u32>().unwrap(),
23 minor: parts[1].trim().parse::<u32>().unwrap(),
24 patch: parts[2].trim().parse::<u32>().unwrap(),
25 build: NonZeroU32::new(parts[3].trim().parse::<u32>().unwrap()),
26 }
27 }
28
29 pub fn try_from_client_exe(version: &str) -> Option<Version> {
33 let parts: Vec<_> = version.split(',').collect();
34 if parts.len() != 4 {
35 return None;
36 }
37 Some(Version {
38 major: parts[0].trim().parse::<u32>().ok()?,
39 minor: parts[1].trim().parse::<u32>().ok()?,
40 patch: parts[2].trim().parse::<u32>().ok()?,
41 build: NonZeroU32::new(parts[3].trim().parse::<u32>().ok()?),
42 })
43 }
44
45 pub fn build_number(&self) -> Option<u32> {
47 self.build.map(NonZeroU32::get)
48 }
49
50 #[cfg(feature = "parsing")]
65 pub fn from_account_def(xml: &str) -> Option<Version> {
66 let doc = roxmltree::Document::parse(xml).ok()?;
67 for node in doc.descendants() {
68 if let Some(rest) = node.tag_name().name().strip_prefix("curVersion_") {
69 let rest = rest.strip_prefix("Release_").or_else(|| rest.strip_prefix("release_")).unwrap_or(rest);
71 let parts: Vec<&str> = rest.split('_').collect();
72 let build_idx = match parts.len() {
74 4 => 3,
75 5 => 4,
76 _ => continue,
77 };
78 let (major, minor, patch) = Self::friendly_from_account_def_parts(
79 parts[0].parse().ok()?,
80 parts[1].parse().ok()?,
81 parts[2].parse().ok()?,
82 );
83 return Some(Version { major, minor, patch, build: NonZeroU32::new(parts[build_idx].parse().ok()?) });
84 }
85 }
86 None
87 }
88
89 #[cfg(feature = "parsing")]
96 fn friendly_from_account_def_parts(major: u32, minor: u32, patch: u32) -> (u32, u32, u32) {
97 if (1..=11).contains(&major) { (0, major, minor) } else { (major, minor, patch) }
98 }
99
100 pub fn to_path(&self) -> String {
101 format!("{}.{}.{}", self.major, self.minor, self.patch)
102 }
103
104 pub const fn base(major: u32, minor: u32, patch: u32) -> Version {
109 Version { major, minor, patch, build: None }
110 }
111
112 pub fn base_eq(&self, other: &Version) -> bool {
117 (self.major, self.minor, self.patch) == (other.major, other.minor, other.patch)
118 }
119
120 pub fn matches(&self, other: &Version) -> bool {
127 self.base_eq(other)
128 && match (self.build, other.build) {
129 (Some(a), Some(b)) => a == b,
130 _ => true,
131 }
132 }
133
134 pub fn is_at_least(&self, other: &Version) -> bool {
135 if self.major > other.major {
136 true
137 } else if self.major < other.major {
138 false
139 } else if self.minor > other.minor {
140 true
141 } else if self.minor < other.minor {
142 false
143 } else {
144 self.patch >= other.patch
145 }
146 }
147}
148
149#[cfg(test)]
150mod test {
151 use super::*;
152
153 fn assert_older_newer(older: Version, newer: Version) {
154 assert!(newer.is_at_least(&older));
155 assert!(newer.is_at_least(&newer));
156 assert!(!older.is_at_least(&newer));
157 }
158
159 #[test]
160 fn different_patch() {
161 let older = Version::from_client_exe("0,10,9,0");
162 let newer = Version::from_client_exe("0,10,10,0");
163 assert_older_newer(older, newer);
164 }
165
166 #[test]
167 fn different_minor() {
168 let older = Version::from_client_exe("0,10,9,0");
169 let newer = Version::from_client_exe("0,11,0,0");
170 assert_older_newer(older, newer);
171 }
172
173 #[test]
174 fn different_major() {
175 let older = Version::from_client_exe("0,11,5,0");
176 let newer = Version::from_client_exe("1,0,0,0");
177 assert_older_newer(older, newer);
178 }
179
180 #[test]
184 fn try_from_client_exe_rejects_a_malformed_version_instead_of_panicking() {
185 assert_eq!(Version::try_from_client_exe(""), None, "an empty header has no parts");
186 assert_eq!(Version::try_from_client_exe("0,10,9"), None, "three parts is not four");
187 assert_eq!(Version::try_from_client_exe("0,10,9,0,1"), None, "five parts is not four");
188 assert_eq!(Version::try_from_client_exe("0,ten,9,0"), None, "a non-numeric part is not a version");
189 assert_eq!(Version::try_from_client_exe("0,-1,9,0"), None, "a negative part is not a version");
190 }
191
192 #[test]
195 fn try_from_client_exe_reads_a_zero_build_as_no_build() {
196 let version = Version::try_from_client_exe("0,9,4,0").expect("a well-formed header parses");
197 assert_eq!((version.major, version.minor, version.patch), (0, 9, 4));
198 assert_eq!(version.build_number(), None);
199 }
200
201 #[test]
202 fn try_from_client_exe_agrees_with_the_panicking_form_on_well_formed_input() {
203 for raw in ["0,9,4,0", "0,11,4,5624555", "15,4,0,11965230"] {
204 assert_eq!(Version::try_from_client_exe(raw), Some(Version::from_client_exe(raw)), "input {raw}");
205 }
206 }
207
208 #[test]
209 fn matches_build_optional_refinement() {
210 let no_build = Version::base(15, 4, 0);
211 let build_a = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(100) };
212 let build_b = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(200) };
213
214 assert!(no_build.matches(&build_a));
216 assert!(build_a.matches(&no_build));
217
218 assert!(build_a.matches(&build_a));
220
221 assert!(!build_a.matches(&build_b));
223 assert!(!build_b.matches(&build_a));
224
225 let other_friendly = Version { major: 15, minor: 3, patch: 0, build: NonZeroU32::new(100) };
227 assert!(!build_a.matches(&other_friendly));
228 assert!(!other_friendly.matches(&build_a));
229 assert!(!no_build.matches(&Version::base(15, 3, 0)));
230 }
231
232 #[test]
233 fn base_eq_ignores_build() {
234 let build_a = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(100) };
235 let build_b = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(200) };
236 assert!(build_a.base_eq(&build_b));
238 assert!(!build_a.matches(&build_b));
239 assert!(build_a.base_eq(&Version::base(15, 4, 0)));
241 assert!(!build_a.base_eq(&Version::base(15, 3, 0)));
243 }
244
245 #[cfg(feature = "parsing")]
246 #[test]
247 fn from_account_def_parses_version() {
248 let xml = r#"<root><Properties><curVersion_15_1_0_11965230></curVersion_15_1_0_11965230></Properties></root>"#;
249 let v = Version::from_account_def(xml).unwrap();
250 assert_eq!(v.major, 15);
251 assert_eq!(v.minor, 1);
252 assert_eq!(v.patch, 0);
253 assert_eq!(v.build, NonZeroU32::new(11965230));
254 }
255
256 #[cfg(feature = "parsing")]
257 #[test]
258 fn from_account_def_release_prefix_normalizes_stripped_version() {
259 let xml = r#"<root><Properties><curVersion_release_11_4_0_5624555></curVersion_release_11_4_0_5624555></Properties></root>"#;
263 let v = Version::from_account_def(xml).unwrap();
264 assert_eq!((v.major, v.minor, v.patch), (0, 11, 4));
265 assert_eq!(v.build, NonZeroU32::new(5624555));
266 }
267
268 #[cfg(feature = "parsing")]
269 #[test]
270 fn from_account_def_stripped_versions_match_replay_header() {
271 for (tag, build, want) in [
273 ("curVersion_release_11_0_0_5045210", 5045210u32, (0, 11, 0)),
274 ("curVersion_release_7_11_0_1167524", 1167524, (0, 7, 11)),
275 ("curVersion_release_10_0_0_3343484", 3343484, (0, 10, 0)),
276 ] {
277 let xml = format!("<root><{tag}></{tag}></root>");
278 let v = Version::from_account_def(&xml).unwrap();
279 assert_eq!((v.major, v.minor, v.patch), want, "tag {tag}");
280 assert_eq!(v.build, NonZeroU32::new(build), "tag {tag}");
281 }
282 }
283
284 #[cfg(feature = "parsing")]
285 #[test]
286 fn from_account_def_friendly_versions_untouched() {
287 let xml = r#"<root><curVersion_0_11_8_0_6223574></curVersion_0_11_8_0_6223574></root>"#;
290 let v = Version::from_account_def(xml).unwrap();
291 assert_eq!((v.major, v.minor, v.patch), (0, 11, 8));
292 let xml = r#"<root><curVersion_12_0_0_6775398></curVersion_12_0_0_6775398></root>"#;
293 let v = Version::from_account_def(xml).unwrap();
294 assert_eq!((v.major, v.minor, v.patch), (12, 0, 0));
295 }
296
297 #[cfg(feature = "parsing")]
298 #[test]
299 fn from_account_def_legacy_5part_version() {
300 let xml = r#"<root><Properties><curVersion_Release_0_6_13_0_296659></curVersion_Release_0_6_13_0_296659></Properties></root>"#;
301 let v = Version::from_account_def(xml).unwrap();
302 assert_eq!(v.major, 0);
303 assert_eq!(v.minor, 6);
304 assert_eq!(v.patch, 13);
305 assert_eq!(v.build, NonZeroU32::new(296659));
306 }
307
308 #[cfg(feature = "parsing")]
309 #[test]
310 fn from_account_def_returns_none_on_missing() {
311 let xml = r#"<root><Properties><someOtherNode/></Properties></root>"#;
312 assert!(Version::from_account_def(xml).is_none());
313 }
314}