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]
181 fn matches_build_optional_refinement() {
182 let no_build = Version::base(15, 4, 0);
183 let build_a = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(100) };
184 let build_b = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(200) };
185
186 assert!(no_build.matches(&build_a));
188 assert!(build_a.matches(&no_build));
189
190 assert!(build_a.matches(&build_a));
192
193 assert!(!build_a.matches(&build_b));
195 assert!(!build_b.matches(&build_a));
196
197 let other_friendly = Version { major: 15, minor: 3, patch: 0, build: NonZeroU32::new(100) };
199 assert!(!build_a.matches(&other_friendly));
200 assert!(!other_friendly.matches(&build_a));
201 assert!(!no_build.matches(&Version::base(15, 3, 0)));
202 }
203
204 #[test]
205 fn base_eq_ignores_build() {
206 let build_a = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(100) };
207 let build_b = Version { major: 15, minor: 4, patch: 0, build: NonZeroU32::new(200) };
208 assert!(build_a.base_eq(&build_b));
210 assert!(!build_a.matches(&build_b));
211 assert!(build_a.base_eq(&Version::base(15, 4, 0)));
213 assert!(!build_a.base_eq(&Version::base(15, 3, 0)));
215 }
216
217 #[cfg(feature = "parsing")]
218 #[test]
219 fn from_account_def_parses_version() {
220 let xml = r#"<root><Properties><curVersion_15_1_0_11965230></curVersion_15_1_0_11965230></Properties></root>"#;
221 let v = Version::from_account_def(xml).unwrap();
222 assert_eq!(v.major, 15);
223 assert_eq!(v.minor, 1);
224 assert_eq!(v.patch, 0);
225 assert_eq!(v.build, NonZeroU32::new(11965230));
226 }
227
228 #[cfg(feature = "parsing")]
229 #[test]
230 fn from_account_def_release_prefix_normalizes_stripped_version() {
231 let xml = r#"<root><Properties><curVersion_release_11_4_0_5624555></curVersion_release_11_4_0_5624555></Properties></root>"#;
235 let v = Version::from_account_def(xml).unwrap();
236 assert_eq!((v.major, v.minor, v.patch), (0, 11, 4));
237 assert_eq!(v.build, NonZeroU32::new(5624555));
238 }
239
240 #[cfg(feature = "parsing")]
241 #[test]
242 fn from_account_def_stripped_versions_match_replay_header() {
243 for (tag, build, want) in [
245 ("curVersion_release_11_0_0_5045210", 5045210u32, (0, 11, 0)),
246 ("curVersion_release_7_11_0_1167524", 1167524, (0, 7, 11)),
247 ("curVersion_release_10_0_0_3343484", 3343484, (0, 10, 0)),
248 ] {
249 let xml = format!("<root><{tag}></{tag}></root>");
250 let v = Version::from_account_def(&xml).unwrap();
251 assert_eq!((v.major, v.minor, v.patch), want, "tag {tag}");
252 assert_eq!(v.build, NonZeroU32::new(build), "tag {tag}");
253 }
254 }
255
256 #[cfg(feature = "parsing")]
257 #[test]
258 fn from_account_def_friendly_versions_untouched() {
259 let xml = r#"<root><curVersion_0_11_8_0_6223574></curVersion_0_11_8_0_6223574></root>"#;
262 let v = Version::from_account_def(xml).unwrap();
263 assert_eq!((v.major, v.minor, v.patch), (0, 11, 8));
264 let xml = r#"<root><curVersion_12_0_0_6775398></curVersion_12_0_0_6775398></root>"#;
265 let v = Version::from_account_def(xml).unwrap();
266 assert_eq!((v.major, v.minor, v.patch), (12, 0, 0));
267 }
268
269 #[cfg(feature = "parsing")]
270 #[test]
271 fn from_account_def_legacy_5part_version() {
272 let xml = r#"<root><Properties><curVersion_Release_0_6_13_0_296659></curVersion_Release_0_6_13_0_296659></Properties></root>"#;
273 let v = Version::from_account_def(xml).unwrap();
274 assert_eq!(v.major, 0);
275 assert_eq!(v.minor, 6);
276 assert_eq!(v.patch, 13);
277 assert_eq!(v.build, NonZeroU32::new(296659));
278 }
279
280 #[cfg(feature = "parsing")]
281 #[test]
282 fn from_account_def_returns_none_on_missing() {
283 let xml = r#"<root><Properties><someOtherNode/></Properties></root>"#;
284 assert!(Version::from_account_def(xml).is_none());
285 }
286}