Skip to main content

gem_audit/version/
gem_version.rs

1use std::cmp::Ordering;
2use std::fmt;
3use thiserror::Error;
4
5/// A segment of a gem version string.
6///
7/// Each segment is either an integer (e.g., `1`, `42`) or a string (e.g., `"alpha"`, `"rc"`).
8/// When comparing: integer segments are always greater than string segments.
9#[derive(Debug, Clone, Eq, PartialEq)]
10pub enum Segment {
11    Numeric(u64),
12    String(String),
13}
14
15impl PartialOrd for Segment {
16    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
17        Some(self.cmp(other))
18    }
19}
20
21impl Ord for Segment {
22    fn cmp(&self, other: &Self) -> Ordering {
23        match (self, other) {
24            (Segment::Numeric(a), Segment::Numeric(b)) => a.cmp(b),
25            (Segment::String(a), Segment::String(b)) => a.cmp(b),
26            // Integer is always greater than string
27            (Segment::Numeric(_), Segment::String(_)) => Ordering::Greater,
28            (Segment::String(_), Segment::Numeric(_)) => Ordering::Less,
29        }
30    }
31}
32
33impl fmt::Display for Segment {
34    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35        match self {
36            Segment::Numeric(n) => write!(f, "{}", n),
37            Segment::String(s) => write!(f, "{}", s),
38        }
39    }
40}
41
42/// Represents a RubyGems version with full semantic compatibility.
43///
44/// Implements the same parsing and comparison rules as Ruby's `Gem::Version`:
45/// - Dot-separated segments (numeric and string)
46/// - Trailing zeros are ignored in comparisons (`1.0 == 1.0.0`)
47/// - String segments denote pre-release versions
48/// - Pre-release versions sort before their release counterparts
49///
50/// # Examples
51/// ```
52/// use gem_audit::version::Version;
53///
54/// let v1 = Version::parse("1.2.3").unwrap();
55/// let v2 = Version::parse("1.2.4").unwrap();
56/// assert!(v1 < v2);
57///
58/// let pre = Version::parse("1.0.0.alpha").unwrap();
59/// let release = Version::parse("1.0.0").unwrap();
60/// assert!(pre < release);
61/// ```
62#[derive(Debug, Clone, Eq)]
63pub struct Version {
64    segments: Vec<Segment>,
65    original: String,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Error)]
69pub enum VersionError {
70    #[error("empty version string")]
71    Empty,
72    #[error("invalid character in version: '{0}'")]
73    InvalidCharacter(char),
74    #[error("invalid version format: '{0}'")]
75    InvalidFormat(String),
76}
77
78impl Version {
79    /// Parse a version string into a `Version`.
80    ///
81    /// Follows RubyGems parsing rules:
82    /// - Segments are split by `.`
83    /// - Within each segment, numeric and alphabetic parts are separated
84    /// - Leading zeros in numeric segments are stripped
85    /// - Hyphens are converted to `.pre.`
86    /// - Empty string becomes version "0"
87    pub fn parse(input: &str) -> Result<Self, VersionError> {
88        let input = input.trim();
89
90        if input.is_empty() {
91            return Ok(Version {
92                segments: vec![Segment::Numeric(0)],
93                original: "0".to_string(),
94            });
95        }
96
97        // Validate characters: only allow alphanumeric, dots, and hyphens
98        for c in input.chars() {
99            if !c.is_ascii_alphanumeric() && c != '.' && c != '-' {
100                return Err(VersionError::InvalidCharacter(c));
101            }
102        }
103
104        // Replace hyphens with .pre.
105        let normalized = input.replace('-', ".pre.");
106
107        let segments = parse_segments(&normalized)?;
108
109        if segments.is_empty() {
110            return Err(VersionError::InvalidFormat(input.to_string()));
111        }
112
113        Ok(Version {
114            segments,
115            original: input.to_string(),
116        })
117    }
118
119    /// Returns true if this version is a pre-release.
120    ///
121    /// A version is pre-release if any of its segments is a string.
122    pub fn is_prerelease(&self) -> bool {
123        self.segments
124            .iter()
125            .any(|s| matches!(s, Segment::String(_)))
126    }
127
128    /// Bump this version: drop the last segment and increment the new last segment.
129    ///
130    /// This is equivalent to Ruby's `Gem::Version#bump`:
131    /// - `1.2.3.bump()` -> `1.3`
132    /// - `1.0.bump()` -> `2`
133    /// - `1.bump()` -> `2`
134    pub fn bump(&self) -> Version {
135        // Ruby's Gem::Version#bump algorithm:
136        // 1. Copy segments
137        // 2. Pop while any segment is a String (remove all string segments from end)
138        // 3. Pop one more if more than 1 segment remains
139        // 4. Increment the last segment
140        let mut new_segments = self.segments.clone();
141
142        // Remove trailing string segments
143        while new_segments
144            .last()
145            .is_some_and(|s| matches!(s, Segment::String(_)))
146        {
147            new_segments.pop();
148        }
149
150        // Drop the last numeric segment if more than one remains
151        if new_segments.len() > 1 {
152            new_segments.pop();
153        }
154
155        // Increment the last segment
156        if let Some(Segment::Numeric(n)) = new_segments.last_mut() {
157            *n += 1;
158        }
159
160        if new_segments.is_empty() {
161            new_segments.push(Segment::Numeric(1));
162        }
163
164        let original = new_segments
165            .iter()
166            .map(|s| s.to_string())
167            .collect::<Vec<_>>()
168            .join(".");
169
170        Version {
171            segments: new_segments,
172            original,
173        }
174    }
175
176    /// Returns the segments of this version.
177    pub fn segments(&self) -> &[Segment] {
178        &self.segments
179    }
180}
181
182/// Parse the normalized version string into segments.
183fn parse_segments(input: &str) -> Result<Vec<Segment>, VersionError> {
184    let mut segments = Vec::new();
185
186    for part in input.split('.') {
187        if part.is_empty() {
188            continue;
189        }
190
191        // Within each dot-separated part, split numeric and alphabetic runs.
192        // e.g., "3rc4" -> [Numeric(3), String("rc"), Numeric(4)]
193        let mut chars = part.chars().peekable();
194        while chars.peek().is_some() {
195            let first = *chars.peek().unwrap();
196            if first.is_ascii_digit() {
197                let mut num_str = String::new();
198                while let Some(&c) = chars.peek() {
199                    if c.is_ascii_digit() {
200                        num_str.push(c);
201                        chars.next();
202                    } else {
203                        break;
204                    }
205                }
206                let n: u64 = num_str.parse().map_err(|_| {
207                    VersionError::InvalidFormat(format!("numeric overflow: {}", num_str))
208                })?;
209                segments.push(Segment::Numeric(n));
210            } else if first.is_ascii_alphabetic() {
211                let mut s = String::new();
212                while let Some(&c) = chars.peek() {
213                    if c.is_ascii_alphabetic() {
214                        s.push(c);
215                        chars.next();
216                    } else {
217                        break;
218                    }
219                }
220                segments.push(Segment::String(s));
221            } else {
222                return Err(VersionError::InvalidCharacter(first));
223            }
224        }
225    }
226
227    Ok(segments)
228}
229
230impl PartialEq for Version {
231    fn eq(&self, other: &Self) -> bool {
232        self.cmp(other) == Ordering::Equal
233    }
234}
235
236impl PartialOrd for Version {
237    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
238        Some(self.cmp(other))
239    }
240}
241
242impl Ord for Version {
243    fn cmp(&self, other: &Self) -> Ordering {
244        let a = &self.segments;
245        let b = &other.segments;
246        let max_len = a.len().max(b.len());
247
248        for i in 0..max_len {
249            let seg_a = a.get(i);
250            let seg_b = b.get(i);
251
252            let ord = match (seg_a, seg_b) {
253                (Some(sa), Some(sb)) => sa.cmp(sb),
254                // Missing segment is implicitly 0
255                (Some(sa), None) => sa.cmp(&Segment::Numeric(0)),
256                (None, Some(sb)) => Segment::Numeric(0).cmp(sb),
257                (None, None) => Ordering::Equal,
258            };
259
260            if ord != Ordering::Equal {
261                return ord;
262            }
263        }
264
265        Ordering::Equal
266    }
267}
268
269impl fmt::Display for Version {
270    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
271        write!(f, "{}", self.original)
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    // ========== Parsing Tests ==========
280
281    #[test]
282    fn parse_simple_version() {
283        let v = Version::parse("1.2.3").unwrap();
284        assert_eq!(
285            v.segments,
286            vec![
287                Segment::Numeric(1),
288                Segment::Numeric(2),
289                Segment::Numeric(3)
290            ]
291        );
292    }
293
294    #[test]
295    fn parse_single_segment() {
296        let v = Version::parse("5").unwrap();
297        assert_eq!(v.segments, vec![Segment::Numeric(5)]);
298    }
299
300    #[test]
301    fn parse_with_leading_zeros() {
302        let v = Version::parse("01.02.03").unwrap();
303        assert_eq!(
304            v.segments,
305            vec![
306                Segment::Numeric(1),
307                Segment::Numeric(2),
308                Segment::Numeric(3)
309            ]
310        );
311    }
312
313    #[test]
314    fn parse_prerelease_with_dot() {
315        let v = Version::parse("1.0.0.alpha").unwrap();
316        assert_eq!(
317            v.segments,
318            vec![
319                Segment::Numeric(1),
320                Segment::Numeric(0),
321                Segment::Numeric(0),
322                Segment::String("alpha".to_string()),
323            ]
324        );
325        assert!(v.is_prerelease());
326    }
327
328    #[test]
329    fn parse_prerelease_inline() {
330        let v = Version::parse("1.0.0rc1").unwrap();
331        assert_eq!(
332            v.segments,
333            vec![
334                Segment::Numeric(1),
335                Segment::Numeric(0),
336                Segment::Numeric(0),
337                Segment::String("rc".to_string()),
338                Segment::Numeric(1),
339            ]
340        );
341    }
342
343    #[test]
344    fn parse_prerelease_with_hyphen() {
345        let v = Version::parse("1.0.0-rc1").unwrap();
346        assert_eq!(
347            v.segments,
348            vec![
349                Segment::Numeric(1),
350                Segment::Numeric(0),
351                Segment::Numeric(0),
352                Segment::String("pre".to_string()),
353                Segment::String("rc".to_string()),
354                Segment::Numeric(1),
355            ]
356        );
357    }
358
359    #[test]
360    fn parse_empty_string() {
361        let v = Version::parse("").unwrap();
362        assert_eq!(v.segments, vec![Segment::Numeric(0)]);
363    }
364
365    #[test]
366    fn parse_invalid_character() {
367        assert!(Version::parse("1.0+build").is_err());
368        assert!(Version::parse("1.0_pre1").is_err());
369    }
370
371    // ========== Comparison Tests ==========
372
373    #[test]
374    fn compare_simple_versions() {
375        let v1 = Version::parse("1.0.0").unwrap();
376        let v2 = Version::parse("1.0.1").unwrap();
377        assert!(v1 < v2);
378    }
379
380    #[test]
381    fn compare_major_versions() {
382        let v1 = Version::parse("1.0.0").unwrap();
383        let v2 = Version::parse("2.0.0").unwrap();
384        assert!(v1 < v2);
385    }
386
387    #[test]
388    fn trailing_zeros_are_equal() {
389        let v1 = Version::parse("1.0").unwrap();
390        let v2 = Version::parse("1.0.0").unwrap();
391        let v3 = Version::parse("1.0.0.0").unwrap();
392        assert_eq!(v1, v2);
393        assert_eq!(v2, v3);
394        assert_eq!(v1, v3);
395    }
396
397    #[test]
398    fn single_segment_equals_with_trailing_zeros() {
399        let v1 = Version::parse("1").unwrap();
400        let v2 = Version::parse("1.0").unwrap();
401        assert_eq!(v1, v2);
402    }
403
404    #[test]
405    fn prerelease_less_than_release() {
406        let pre = Version::parse("1.0.0.alpha").unwrap();
407        let rel = Version::parse("1.0.0").unwrap();
408        assert!(pre < rel);
409    }
410
411    #[test]
412    fn prerelease_inline_less_than_release() {
413        let pre = Version::parse("1.0.0a").unwrap();
414        let rel = Version::parse("1.0.0").unwrap();
415        assert!(pre < rel);
416    }
417
418    #[test]
419    fn prerelease_ordering() {
420        let alpha = Version::parse("1.0.0.alpha").unwrap();
421        let beta = Version::parse("1.0.0.beta").unwrap();
422        let rc = Version::parse("1.0.0.rc").unwrap();
423        let release = Version::parse("1.0.0").unwrap();
424
425        assert!(alpha < beta);
426        assert!(beta < rc);
427        assert!(rc < release);
428    }
429
430    #[test]
431    fn prerelease_a_b_ordering() {
432        let a = Version::parse("1.0.0a").unwrap();
433        let b = Version::parse("1.0.0b").unwrap();
434        assert!(a < b);
435    }
436
437    #[test]
438    fn string_segment_less_than_integer() {
439        // Integer segments are always greater than string segments
440        let with_str = Version::parse("1.0.0.alpha").unwrap();
441        let without = Version::parse("1.0.0").unwrap();
442        assert!(with_str < without);
443
444        let with_str2 = Version::parse("1.0.0a").unwrap();
445        assert!(with_str2 < without);
446    }
447
448    #[test]
449    fn compare_different_lengths() {
450        let short = Version::parse("1.0").unwrap();
451        let long = Version::parse("1.0.1").unwrap();
452        assert!(short < long);
453    }
454
455    #[test]
456    fn compare_year_based_versions() {
457        let v1 = Version::parse("2020.1.1").unwrap();
458        let v2 = Version::parse("2021.1.1").unwrap();
459        assert!(v1 < v2);
460    }
461
462    // ========== Pre-release Detection ==========
463
464    #[test]
465    fn not_prerelease() {
466        let v = Version::parse("1.2.3").unwrap();
467        assert!(!v.is_prerelease());
468    }
469
470    #[test]
471    fn is_prerelease_with_alpha() {
472        let v = Version::parse("1.0.0.alpha").unwrap();
473        assert!(v.is_prerelease());
474    }
475
476    #[test]
477    fn is_prerelease_inline() {
478        let v = Version::parse("1.0.0rc1").unwrap();
479        assert!(v.is_prerelease());
480    }
481
482    #[test]
483    fn four_segment_numeric_not_prerelease() {
484        let v = Version::parse("1.0.0.1").unwrap();
485        assert!(!v.is_prerelease());
486    }
487
488    // ========== Bump Tests ==========
489
490    #[test]
491    fn bump_three_segments() {
492        let v = Version::parse("1.2.3").unwrap();
493        let bumped = v.bump();
494        assert_eq!(bumped, Version::parse("1.3").unwrap());
495    }
496
497    #[test]
498    fn bump_two_segments() {
499        let v = Version::parse("1.0").unwrap();
500        let bumped = v.bump();
501        assert_eq!(bumped, Version::parse("2").unwrap());
502    }
503
504    #[test]
505    fn bump_single_segment() {
506        let v = Version::parse("1").unwrap();
507        let bumped = v.bump();
508        assert_eq!(bumped, Version::parse("2").unwrap());
509    }
510
511    #[test]
512    fn bump_four_segments() {
513        let v = Version::parse("1.2.3.4").unwrap();
514        let bumped = v.bump();
515        assert_eq!(bumped, Version::parse("1.2.4").unwrap());
516    }
517
518    #[test]
519    fn bump_with_trailing_zeros() {
520        // Ruby: Gem::Version.new("1.0.0").bump => "1.1"
521        // Segments [1,0,0] → pop last → [1,0] → increment → [1,1]
522        let v = Version::parse("1.0.0").unwrap();
523        let bumped = v.bump();
524        assert_eq!(bumped, Version::parse("1.1").unwrap());
525    }
526
527    // ========== Display ==========
528
529    #[test]
530    fn display_preserves_original() {
531        let v = Version::parse("1.2.3").unwrap();
532        assert_eq!(v.to_string(), "1.2.3");
533    }
534
535    #[test]
536    fn display_prerelease() {
537        let v = Version::parse("1.0.0.alpha").unwrap();
538        assert_eq!(v.to_string(), "1.0.0.alpha");
539    }
540}