1mod part;
2
3use alloc::{borrow::Cow, string::String};
4use core::{
5 cmp::{Ordering, Reverse},
6 convert::Infallible,
7 fmt,
8 hash::{Hash, Hasher},
9 str::FromStr,
10};
11
12use compact_str::CompactString;
13use itertools::{EitherOrBoth, Itertools};
14use part::VersionPart;
15use smallvec::SmallVec;
16
17#[derive(Clone, Debug, Default, Eq)]
19#[cfg_attr(feature = "serde", derive(serde::Deserialize))]
20#[cfg_attr(feature = "serde", serde(from = "&str"))]
21pub struct Version {
22 raw: CompactString,
24 parts: SmallVec<[VersionPart; 6]>,
26}
27
28impl Version {
29 const SEPARATOR: char = '.';
30
31 pub fn new<T: AsRef<str>>(input: T) -> Self {
32 let raw_version = input.as_ref().trim();
33
34 let mut version = raw_version;
35
36 if let Some(digit_pos) = raw_version.find(|char: char| char.is_ascii_digit()) {
39 if raw_version
40 .find('.')
41 .is_none_or(|separator_pos| digit_pos < separator_pos)
42 {
43 version = &raw_version[digit_pos..];
44 }
45 }
46
47 let mut parts = version
49 .split(Self::SEPARATOR)
50 .map(VersionPart::from)
51 .collect::<SmallVec<[_; 6]>>();
52
53 if let Some(pos) = parts.iter().rposition(|part| !part.is_droppable()) {
55 parts.truncate(pos + 1);
56 } else {
57 parts.clear();
58 }
59
60 Self {
61 raw: CompactString::from(raw_version),
62 parts,
63 }
64 }
65
66 #[must_use]
82 #[inline]
83 pub fn is_latest(&self) -> bool {
84 const LATEST: &str = "latest";
85
86 self.raw.eq_ignore_ascii_case(LATEST)
87 }
88
89 #[must_use]
105 #[inline]
106 pub fn is_unknown(&self) -> bool {
107 const UNKNOWN: &str = "unknown";
108
109 self.raw.eq_ignore_ascii_case(UNKNOWN)
110 }
111
112 #[must_use]
114 #[inline]
115 pub fn as_str(&self) -> &str {
116 self.raw.as_str()
117 }
118
119 pub fn closest<'iter, I, T>(&self, versions: I) -> Option<&'iter T>
133 where
134 I: IntoIterator<Item = &'iter T>,
135 &'iter T: Into<&'iter Self>,
136 {
137 #[derive(PartialEq, Eq, PartialOrd, Ord)]
138 struct DistanceKey<'supplement> {
139 length_score: usize,
141 numerical_difference: u64,
143 total_order: Ordering,
145 supplement_order: Reverse<&'supplement str>,
147 }
148
149 let default_part = &VersionPart::DEFAULT;
150
151 versions.into_iter().min_by_key(|&other| {
153 self.parts
154 .iter()
155 .zip_longest(other.into().parts.iter())
156 .map(|pair| match pair {
157 EitherOrBoth::Both(part, other_part) => (part, other_part),
158 EitherOrBoth::Left(part) => (part, default_part),
159 EitherOrBoth::Right(other_part) => (default_part, other_part),
160 })
161 .enumerate()
162 .find_map(|(index, (part, other_part))| {
163 (part != other_part).then(|| DistanceKey {
164 length_score: !index,
165 numerical_difference: part.number.abs_diff(other_part.number),
166 total_order: part.cmp(other_part),
167 supplement_order: Reverse(other_part.supplement.as_str()),
168 })
169 })
170 .unwrap_or(DistanceKey {
171 length_score: 0,
172 numerical_difference: 0,
173 total_order: Ordering::Equal,
174 supplement_order: Reverse(""),
175 })
176 })
177 }
178}
179
180impl AsRef<str> for Version {
181 #[inline]
182 fn as_ref(&self) -> &str {
183 self.as_str()
184 }
185}
186
187impl fmt::Display for Version {
188 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189 self.raw.fmt(f)
190 }
191}
192
193impl FromStr for Version {
194 type Err = Infallible;
195
196 fn from_str(s: &str) -> Result<Self, Self::Err> {
197 Ok(Self::new(s))
198 }
199}
200
201impl From<&str> for Version {
202 #[inline]
203 fn from(s: &str) -> Self {
204 Self::new(s)
205 }
206}
207
208impl From<String> for Version {
209 #[inline]
210 fn from(s: String) -> Self {
211 Self::new(s)
212 }
213}
214
215impl From<&String> for Version {
216 #[inline]
217 fn from(s: &String) -> Self {
218 Self::new(s)
219 }
220}
221
222impl From<Cow<'_, str>> for Version {
223 #[inline]
224 fn from(s: Cow<'_, str>) -> Self {
225 Self::new(s)
226 }
227}
228
229impl PartialEq for Version {
230 fn eq(&self, other: &Self) -> bool {
231 (self.is_latest() && other.is_latest())
232 || (self.is_unknown() && other.is_unknown())
233 || self.parts.eq(&other.parts)
234 }
235}
236
237impl Hash for Version {
238 fn hash<H: Hasher>(&self, state: &mut H) {
239 self.parts.hash(state);
240 }
241}
242
243impl PartialOrd for Version {
244 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
245 Some(self.cmp(other))
246 }
247}
248
249impl Ord for Version {
250 fn cmp(&self, other: &Self) -> Ordering {
251 match (self.is_latest(), other.is_latest()) {
252 (true, true) => Ordering::Equal,
253 (true, false) => Ordering::Greater,
254 (false, true) => Ordering::Less,
255 (false, false) => match (self.is_unknown(), other.is_unknown()) {
256 (true, true) => Ordering::Equal,
257 (true, false) => Ordering::Less,
258 (false, true) => Ordering::Greater,
259 (false, false) => self
260 .parts
261 .iter()
262 .zip_longest(&other.parts)
263 .map(|pair| match pair {
264 EitherOrBoth::Both(part, other_part) => part.cmp(other_part),
265 EitherOrBoth::Left(part) => part.cmp(&VersionPart::DEFAULT),
266 EitherOrBoth::Right(other_part) => VersionPart::DEFAULT.cmp(other_part),
267 })
268 .find(|&ordering| ordering != Ordering::Equal)
269 .unwrap_or(Ordering::Equal),
270 },
271 }
272 }
273}
274
275#[cfg(feature = "serde")]
276impl serde::Serialize for Version {
277 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
278 where
279 S: serde::Serializer,
280 {
281 self.as_str().serialize(serializer)
282 }
283}
284
285#[cfg(test)]
286mod tests {
287 use alloc::vec::Vec;
288 use core::cmp::Ordering;
289
290 use rstest::rstest;
291
292 use super::Version;
293
294 #[rstest]
295 #[case("1.0", "1.0.0")]
296 #[case("1.2.00.3", "1.2.0.3")]
297 #[case("1.2.003.4", "1.2.3.4")]
298 #[case("01.02.03.04", "1.2.3.4")]
299 #[case("1.2.03-beta", "1.2.3-beta")]
300 #[case("1.0", "1.0 ")]
301 #[case("1.0", "1. 0")]
302 #[case("1.0", "1.0.")]
303 #[case("1.0", "Version 1.0")]
304 #[case("2.4.2", "v2.4.2")]
305 #[case("foo1", "bar1")]
306 #[case("latest", "LATEST")]
307 #[case("unknown", "UNKNOWN")]
308 fn version_equality(#[case] left: &str, #[case] right: &str) {
309 let left = Version::new(left);
310 let right = Version::new(right);
311 assert_eq!(left, right);
312 assert_eq!(left.cmp(&right), Ordering::Equal);
313 }
314
315 #[rstest]
316 #[case("1", "2")]
317 #[case("1.2-rc", "1.2")]
318 #[case("1.0-rc", "1.0")]
319 #[case("1.0.0-rc", "1")]
320 #[case("22.0.0-rc.1", "22.0.0")]
321 #[case("22.0.0-rc.1", "22.0.0.1")]
322 #[case("22.0.0-rc.1", "22.0.0.1-rc")]
323 #[case("22.0.0-rc.1", "22.0.0-rc.1.1")]
324 #[case("22.0.0-rc.1.1", "22.0.0-rc.1.2")]
325 #[case("22.0.0-rc.1.2", "22.0.0-rc.2")]
326 #[case("v0.0.1", "0.0.2")]
327 #[case("v0.0.1", "v0.0.2")]
328 #[case("1.a2", "1.b1")]
329 #[case("alpha", "beta")]
330 #[case("99999.99999.99999", "latest")]
331 #[case("unknown", "1.2.3")]
332 #[case("unknown", "latest")]
333 fn version_comparison_and_inequality(#[case] left: Version, #[case] right: Version) {
334 assert!(left < right);
335 assert!(right > left);
336 assert_ne!(left, right)
337 }
338
339 #[rstest]
340 #[case("1", "2")]
341 #[case("1-rc", "1")]
342 #[case("1-a2", "1-b1")]
343 #[case("alpha", "beta")]
344 fn version_part_comparison(#[case] left: Version, #[case] right: Version) {
345 assert!(left < right);
346 assert!(right > left);
347 }
348
349 #[test]
350 fn version_hash() {
351 use core::hash::BuildHasher;
352
353 use rustc_hash::FxBuildHasher;
354
355 let version1 = Version::new("1.2.3");
359 let version2 = Version::new("1.2.3.0");
360 assert_eq!(version1, version2);
361
362 assert_eq!(
363 FxBuildHasher.hash_one(version1),
364 FxBuildHasher.hash_one(version2)
365 );
366 }
367
368 #[test]
369 fn only_supplement() {
370 const ALPHA: &str = "alpha";
371
372 let version = Version::new(ALPHA);
373 assert_eq!(version.parts.len(), 1);
374 assert_eq!(version.parts[0].number, 0);
375 assert_eq!(version.parts[0].supplement, ALPHA);
376 }
377
378 #[rstest]
379 #[case("0")]
380 #[case("0.0.0")]
381 #[case("0.0.0.0.0.0.0.0")]
382 #[case("")]
383 fn only_droppable_parts(#[case] version: Version) {
384 assert_eq!(version.parts.len(), 0);
385 }
386
387 #[rstest]
388 #[case("v123")]
389 #[case("v1.2.3")]
390 #[case("1.a2")]
391 #[case("alpha")]
392 fn version_display_round_trip(#[case] raw_version: &str) {
393 use alloc::string::ToString;
394
395 assert_eq!(Version::new(raw_version).to_string(), raw_version.trim())
397 }
398
399 #[rstest]
400 #[case("1.2.3", &["1.0.0", "0.9.0", "1.5.6.3", "1.3.2"], "1.3.2")]
401 #[case("10.20.30", &["10.20.29", "10.20.31", "10.20.40"], "10.20.31")]
402 #[case("5.5.5", &["5.5.50", "5.5.0", "5.5.10"], "5.5.10")]
403 #[case("3.0.0", &["3.0.0-beta", "3.0.0-alpha.1", "3.0.0-rc.1"], "3.0.0-rc.1")]
404 #[case("2.1.0-beta", &["2.1.0-alpha", "2.1.0-beta.2", "2.1.0"], "2.1.0-beta.2")]
405 #[case("1.5.0", &["1.0.0", "2.0.0"], "1.0.0")]
406 #[case("3.3.3", &["1.1.1", "5.5.5"], "5.5.5")]
407 #[case("3.3.3", &["5.5.5", "1.1.1"], "5.5.5")]
408 #[case("2.2.2", &["2.2.2", "2.2.2", "2.2.3"], "2.2.2")]
409 #[case("0.0.2", &["0.0.1", "0.0.3", "0.2.0"], "0.0.3")]
410 #[case("999.999.999", &["999.999.998", "1000.0.0"], "999.999.998")]
411 fn closest_version(#[case] version: &str, #[case] versions: &[&str], #[case] expected: &str) {
412 let versions = versions.into_iter().map(Version::new).collect::<Vec<_>>();
413 assert_eq!(
414 Version::new(version).closest(&versions),
415 Some(&Version::new(expected))
416 );
417 }
418}