versions/version.rs
1//! Types and logic for handling general [`Version`]s.
2
3use crate::{Chunk, Chunks, Error, MChunk, Mess, Release, Sep};
4use nom::character::complete::char;
5use nom::combinator::opt;
6use nom::{IResult, Parser};
7use std::cmp::Ordering;
8use std::cmp::Ordering::{Equal, Greater, Less};
9use std::hash::Hash;
10use std::str::FromStr;
11
12#[cfg(feature = "serde")]
13use serde::{Deserialize, Serialize};
14
15/// A version number with decent structure and comparison logic.
16///
17/// This is a *descriptive* scheme, meaning that it encapsulates the most
18/// common, unconscious patterns that developers use when assigning version
19/// numbers to their software. If not [`crate::SemVer`], most version numbers
20/// found in the wild will parse as a `Version`. These generally conform to the
21/// `x.x.x-x` pattern, and may optionally have an *epoch*.
22///
23/// # Epochs
24///
25/// Epochs are prefixes marked by a colon, like in `1:2.3.4`. When comparing two
26/// `Version` values, epochs take precedent. So `2:1.0.0 > 1:9.9.9`. If one of
27/// the given `Version`s has no epoch, its epoch is assumed to be `0`.
28///
29/// # Examples
30///
31/// ```
32/// use versions::{SemVer, Version};
33///
34/// // None of these are SemVer, but can still be parsed and compared.
35/// let vers = vec!["0.25-2", "8.u51-1", "20150826-1", "1:2.3.4"];
36///
37/// for v in vers {
38/// assert!(SemVer::new(v).is_none());
39/// assert!(Version::new(v).is_some());
40/// }
41/// ```
42
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44#[derive(Debug, PartialEq, Eq, Hash, Clone, Default)]
45pub struct Version {
46 /// An optional prefix that marks that some paradigm shift in versioning has
47 /// occurred between releases of some software.
48 pub epoch: Option<u32>,
49 /// The main sections of the `Version`. Unlike [`crate::SemVer`], these
50 /// sections are allowed to contain letters.
51 pub chunks: Chunks,
52 /// This either indicates a prerelease like [`crate::SemVer`], or a
53 /// "release" revision for software packages. In the latter case, a version
54 /// like `1.2.3-2` implies that the software itself hasn't changed, but that
55 /// this is the second bundling/release (etc.) of that particular package.
56 pub release: Option<Release>,
57 /// Some extra metadata that doesn't factor into comparison.
58 pub meta: Option<String>,
59}
60
61impl Version {
62 /// Parse a `Version` from some input.
63 pub fn new<S>(s: S) -> Option<Version>
64 where
65 S: AsRef<str>,
66 {
67 match Version::parse(s.as_ref()) {
68 Ok(("", v)) => Some(v),
69 _ => None,
70 }
71 }
72
73 /// Try to extract a position from the `Version` as a nice integer, as if it
74 /// were a [`crate::SemVer`].
75 ///
76 /// ```
77 /// use versions::Version;
78 ///
79 /// let mess = Version::new("1:2.a.4.5.6.7-r1").unwrap();
80 /// assert_eq!(Some(2), mess.nth(0));
81 /// assert_eq!(None, mess.nth(1));
82 /// assert_eq!(Some(4), mess.nth(2));
83 /// ```
84 pub fn nth(&self, n: usize) -> Option<u32> {
85 self.chunks.0.get(n).and_then(Chunk::single_digit)
86 }
87
88 /// Like `nth`, but pulls a number even if it was followed by letters.
89 pub fn nth_lenient(&self, n: usize) -> Option<u32> {
90 self.chunks.0.get(n).and_then(Chunk::single_digit_lenient)
91 }
92
93 /// A lossless conversion from `Version` to [`Mess`].
94 ///
95 /// ```
96 /// use versions::Version;
97 ///
98 /// let orig = "1:1.2.3-r1";
99 /// let mess = Version::new(orig).unwrap().to_mess();
100 ///
101 /// assert_eq!(orig, format!("{}", mess));
102 /// ```
103 pub fn to_mess(&self) -> Mess {
104 match self.epoch {
105 None => self.to_mess_continued(),
106 Some(e) => {
107 let chunks = vec![MChunk::Digits(e, e.to_string())];
108 let next = Some((Sep::Colon, Box::new(self.to_mess_continued())));
109 Mess { chunks, next }
110 }
111 }
112 }
113
114 /// Convert to a `Mess` without considering the epoch.
115 fn to_mess_continued(&self) -> Mess {
116 let chunks = self.chunks.0.iter().map(|c| c.mchunk()).collect();
117 let next = self.release.as_ref().map(|cs| {
118 let chunks = cs.0.iter().map(|c| c.mchunk()).collect();
119 (Sep::Hyphen, Box::new(Mess { chunks, next: None }))
120 });
121 Mess { chunks, next }
122 }
123
124 /// If we're lucky, we can pull specific numbers out of both inputs and
125 /// accomplish the comparison without extra allocations.
126 pub(crate) fn cmp_mess(&self, other: &Mess) -> Ordering {
127 match self.epoch {
128 Some(e) if e > 0 && other.chunks.len() == 1 => match &other.next {
129 // A near-nonsense case where a `Mess` is comprised of a single
130 // digit and nothing else. In this case its epoch would be
131 // considered 0.
132 None => Greater,
133 Some((Sep::Colon, m)) => match other.nth(0) {
134 // The Mess's epoch is a letter, etc.
135 None => Greater,
136 Some(me) => match e.cmp(&me) {
137 Equal => Version::cmp_mess_continued(self, m),
138 ord => ord,
139 },
140 },
141 // Similar nonsense, where the Mess had a single *something*
142 // before some non-colon separator. We then consider the epoch
143 // to be 0.
144 Some(_) => Greater,
145 },
146 // The `Version` has an epoch but the `Mess` doesn't. Or if it does,
147 // it's malformed.
148 Some(e) if e > 0 => Greater,
149 _ => Version::cmp_mess_continued(self, other),
150 }
151 }
152
153 /// It's assumed the epoch check has already been done, and we're comparing
154 /// the main parts of each version now.
155 fn cmp_mess_continued(&self, other: &Mess) -> Ordering {
156 (0..)
157 .find_map(
158 |n| match self.nth(n).and_then(|x| other.nth(n).map(|y| x.cmp(&y))) {
159 // Sane values can't be extracted from one or both of the
160 // arguments.
161 None => Some(self.to_mess().cmp(other)),
162 Some(Greater) => Some(Greater),
163 Some(Less) => Some(Less),
164 // Continue to the next position.
165 Some(Equal) => None,
166 },
167 )
168 .unwrap_or_else(|| self.to_mess().cmp(other))
169 }
170
171 /// The raw `nom` parser for [`Version`]. Feel free to use this in
172 /// combination with other general `nom` parsers.
173 pub fn parse(i: &str) -> IResult<&str, Version> {
174 let (i, epoch) = opt(Version::epoch).parse(i)?;
175 let (i, chunks) = Chunks::parse(i)?;
176 let (i, release) = opt(Release::parse).parse(i)?;
177 let (i, meta) = opt(crate::parsers::meta).parse(i)?;
178
179 let v = Version {
180 epoch,
181 chunks,
182 meta,
183 release,
184 };
185
186 Ok((i, v))
187 }
188
189 fn epoch(i: &str) -> IResult<&str, u32> {
190 let (i, epoch) = crate::parsers::unsigned(i)?;
191 let (i, _) = char(':')(i)?;
192
193 Ok((i, epoch))
194 }
195
196 pub(crate) fn matches_tilde(&self, other: &Version) -> bool {
197 if self.chunks.0.len() != other.chunks.0.len() {
198 false
199 } else {
200 // Compare all but the final chunk.
201 let inits_equal = self
202 .chunks
203 .0
204 .iter()
205 .rev()
206 .skip(1)
207 .rev()
208 .zip(other.chunks.0.iter().rev().skip(1).rev())
209 .all(|(a, b)| a == b);
210
211 let last_good = match (self.chunks.0.last(), other.chunks.0.last()) {
212 // TODO: Do our best with strings. Right now, the alpha patch version can be "less" than the
213 // first one and this will still be true
214 (Some(Chunk::Alphanum(_)), Some(Chunk::Alphanum(_))) => true,
215 (Some(Chunk::Numeric(n1)), Some(Chunk::Numeric(n2))) => n2 >= n1,
216 _ => false,
217 };
218
219 inits_equal && last_good
220 }
221 }
222
223 // TODO 2024-01-11 Refactor this to be more functional-style.
224 pub(crate) fn matches_caret(&self, other: &Version) -> bool {
225 let mut got_first_nonzero = false;
226
227 for (v1_chunk, v2_chunk) in self.chunks.0.iter().zip(other.chunks.0.iter()) {
228 if !got_first_nonzero {
229 if !v1_chunk.single_digit().is_some_and(|n| n == 0) {
230 got_first_nonzero = true;
231
232 if v1_chunk != v2_chunk {
233 return false;
234 }
235 }
236 } else if v2_chunk.cmp_lenient(v1_chunk).is_lt() {
237 return false;
238 }
239 }
240
241 true
242 }
243}
244
245impl PartialOrd for Version {
246 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
247 Some(self.cmp(other))
248 }
249}
250
251impl Ord for Version {
252 /// If two epochs are equal, we need to compare their actual version
253 /// numbers. Otherwise, the comparison of the epochs is the only thing that
254 /// matters.
255 fn cmp(&self, other: &Self) -> Ordering {
256 let ae = self.epoch.unwrap_or(0);
257 let be = other.epoch.unwrap_or(0);
258 match ae.cmp(&be) {
259 Equal => match self.chunks.cmp(&other.chunks) {
260 Equal => self.release.cmp(&other.release),
261 ord => ord,
262 },
263 ord => ord,
264 }
265 }
266}
267
268impl std::fmt::Display for Version {
269 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
270 if let Some(e) = self.epoch {
271 write!(f, "{}:", e)?;
272 }
273
274 write!(f, "{}", self.chunks)?;
275
276 if let Some(r) = &self.release {
277 write!(f, "-{}", r)?;
278 }
279
280 if let Some(m) = &self.meta {
281 write!(f, "+{}", m)?;
282 }
283
284 Ok(())
285 }
286}
287
288impl FromStr for Version {
289 type Err = Error;
290
291 fn from_str(s: &str) -> Result<Self, Self::Err> {
292 Version::new(s).ok_or_else(|| Error::IllegalVersion(s.to_string()))
293 }
294}
295
296impl TryFrom<&str> for Version {
297 type Error = Error;
298
299 /// ```
300 /// use versions::Version;
301 ///
302 /// let orig = "1.2.3.4";
303 /// let prsd: Version = orig.try_into().unwrap();
304 /// assert_eq!(orig, prsd.to_string());
305 /// ```
306 fn try_from(value: &str) -> Result<Self, Self::Error> {
307 Version::from_str(value)
308 }
309}