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    /// Increment the last numeric segment by 1.
177    ///
178    /// Unlike `bump()` which drops the last segment first, this keeps all segments
179    /// and only increments the final numeric one.
180    ///
181    /// - `1.2.3` -> `1.2.4`
182    /// - `1.0` -> `1.1`
183    /// - `2` -> `3`
184    pub fn increment_last(&self) -> Version {
185        let mut new_segments = self.segments.clone();
186
187        // Find last numeric segment and increment it
188        for seg in new_segments.iter_mut().rev() {
189            if let Segment::Numeric(n) = seg {
190                *n += 1;
191                break;
192            }
193        }
194
195        let original = new_segments
196            .iter()
197            .map(|s| s.to_string())
198            .collect::<Vec<_>>()
199            .join(".");
200
201        Version {
202            segments: new_segments,
203            original,
204        }
205    }
206
207    /// Create a version with an additional `.0` segment appended.
208    ///
209    /// - `1.2` -> `1.2.0`
210    pub fn append_zero(&self) -> Version {
211        let mut new_segments = self.segments.clone();
212        new_segments.push(Segment::Numeric(0));
213
214        let original = new_segments
215            .iter()
216            .map(|s| s.to_string())
217            .collect::<Vec<_>>()
218            .join(".");
219
220        Version {
221            segments: new_segments,
222            original,
223        }
224    }
225
226    /// Returns the segments of this version.
227    pub fn segments(&self) -> &[Segment] {
228        &self.segments
229    }
230}
231
232/// Parse the normalized version string into segments.
233fn parse_segments(input: &str) -> Result<Vec<Segment>, VersionError> {
234    let mut segments = Vec::new();
235
236    for part in input.split('.') {
237        if part.is_empty() {
238            continue;
239        }
240
241        // Within each dot-separated part, split numeric and alphabetic runs.
242        // e.g., "3rc4" -> [Numeric(3), String("rc"), Numeric(4)]
243        let mut chars = part.chars().peekable();
244        while chars.peek().is_some() {
245            let first = *chars.peek().unwrap();
246            if first.is_ascii_digit() {
247                let mut num_str = String::new();
248                while let Some(&c) = chars.peek() {
249                    if c.is_ascii_digit() {
250                        num_str.push(c);
251                        chars.next();
252                    } else {
253                        break;
254                    }
255                }
256                let n: u64 = num_str.parse().map_err(|_| {
257                    VersionError::InvalidFormat(format!("numeric overflow: {}", num_str))
258                })?;
259                segments.push(Segment::Numeric(n));
260            } else if first.is_ascii_alphabetic() {
261                let mut s = String::new();
262                while let Some(&c) = chars.peek() {
263                    if c.is_ascii_alphabetic() {
264                        s.push(c);
265                        chars.next();
266                    } else {
267                        break;
268                    }
269                }
270                segments.push(Segment::String(s));
271            } else {
272                return Err(VersionError::InvalidCharacter(first));
273            }
274        }
275    }
276
277    Ok(segments)
278}
279
280impl PartialEq for Version {
281    fn eq(&self, other: &Self) -> bool {
282        self.cmp(other) == Ordering::Equal
283    }
284}
285
286impl PartialOrd for Version {
287    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
288        Some(self.cmp(other))
289    }
290}
291
292impl Ord for Version {
293    fn cmp(&self, other: &Self) -> Ordering {
294        let a = &self.segments;
295        let b = &other.segments;
296        let max_len = a.len().max(b.len());
297
298        for i in 0..max_len {
299            let seg_a = a.get(i);
300            let seg_b = b.get(i);
301
302            let ord = match (seg_a, seg_b) {
303                (Some(sa), Some(sb)) => sa.cmp(sb),
304                // Missing segment is implicitly 0
305                (Some(sa), None) => sa.cmp(&Segment::Numeric(0)),
306                (None, Some(sb)) => Segment::Numeric(0).cmp(sb),
307                (None, None) => Ordering::Equal,
308            };
309
310            if ord != Ordering::Equal {
311                return ord;
312            }
313        }
314
315        Ordering::Equal
316    }
317}
318
319impl fmt::Display for Version {
320    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
321        write!(f, "{}", self.original)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    // ========== Parsing Tests ==========
330
331    #[test]
332    fn parse_simple_version() {
333        let v = Version::parse("1.2.3").unwrap();
334        assert_eq!(
335            v.segments,
336            vec![
337                Segment::Numeric(1),
338                Segment::Numeric(2),
339                Segment::Numeric(3)
340            ]
341        );
342    }
343
344    #[test]
345    fn parse_single_segment() {
346        let v = Version::parse("5").unwrap();
347        assert_eq!(v.segments, vec![Segment::Numeric(5)]);
348    }
349
350    #[test]
351    fn parse_with_leading_zeros() {
352        let v = Version::parse("01.02.03").unwrap();
353        assert_eq!(
354            v.segments,
355            vec![
356                Segment::Numeric(1),
357                Segment::Numeric(2),
358                Segment::Numeric(3)
359            ]
360        );
361    }
362
363    #[test]
364    fn parse_prerelease_with_dot() {
365        let v = Version::parse("1.0.0.alpha").unwrap();
366        assert_eq!(
367            v.segments,
368            vec![
369                Segment::Numeric(1),
370                Segment::Numeric(0),
371                Segment::Numeric(0),
372                Segment::String("alpha".to_string()),
373            ]
374        );
375        assert!(v.is_prerelease());
376    }
377
378    #[test]
379    fn parse_prerelease_inline() {
380        let v = Version::parse("1.0.0rc1").unwrap();
381        assert_eq!(
382            v.segments,
383            vec![
384                Segment::Numeric(1),
385                Segment::Numeric(0),
386                Segment::Numeric(0),
387                Segment::String("rc".to_string()),
388                Segment::Numeric(1),
389            ]
390        );
391    }
392
393    #[test]
394    fn parse_prerelease_with_hyphen() {
395        let v = Version::parse("1.0.0-rc1").unwrap();
396        assert_eq!(
397            v.segments,
398            vec![
399                Segment::Numeric(1),
400                Segment::Numeric(0),
401                Segment::Numeric(0),
402                Segment::String("pre".to_string()),
403                Segment::String("rc".to_string()),
404                Segment::Numeric(1),
405            ]
406        );
407    }
408
409    #[test]
410    fn parse_empty_string() {
411        let v = Version::parse("").unwrap();
412        assert_eq!(v.segments, vec![Segment::Numeric(0)]);
413    }
414
415    #[test]
416    fn parse_invalid_character() {
417        assert!(Version::parse("1.0+build").is_err());
418        assert!(Version::parse("1.0_pre1").is_err());
419    }
420
421    // ========== Comparison Tests ==========
422
423    #[test]
424    fn compare_simple_versions() {
425        let v1 = Version::parse("1.0.0").unwrap();
426        let v2 = Version::parse("1.0.1").unwrap();
427        assert!(v1 < v2);
428    }
429
430    #[test]
431    fn compare_major_versions() {
432        let v1 = Version::parse("1.0.0").unwrap();
433        let v2 = Version::parse("2.0.0").unwrap();
434        assert!(v1 < v2);
435    }
436
437    #[test]
438    fn trailing_zeros_are_equal() {
439        let v1 = Version::parse("1.0").unwrap();
440        let v2 = Version::parse("1.0.0").unwrap();
441        let v3 = Version::parse("1.0.0.0").unwrap();
442        assert_eq!(v1, v2);
443        assert_eq!(v2, v3);
444        assert_eq!(v1, v3);
445    }
446
447    #[test]
448    fn single_segment_equals_with_trailing_zeros() {
449        let v1 = Version::parse("1").unwrap();
450        let v2 = Version::parse("1.0").unwrap();
451        assert_eq!(v1, v2);
452    }
453
454    #[test]
455    fn prerelease_less_than_release() {
456        let pre = Version::parse("1.0.0.alpha").unwrap();
457        let rel = Version::parse("1.0.0").unwrap();
458        assert!(pre < rel);
459    }
460
461    #[test]
462    fn prerelease_inline_less_than_release() {
463        let pre = Version::parse("1.0.0a").unwrap();
464        let rel = Version::parse("1.0.0").unwrap();
465        assert!(pre < rel);
466    }
467
468    #[test]
469    fn prerelease_ordering() {
470        let alpha = Version::parse("1.0.0.alpha").unwrap();
471        let beta = Version::parse("1.0.0.beta").unwrap();
472        let rc = Version::parse("1.0.0.rc").unwrap();
473        let release = Version::parse("1.0.0").unwrap();
474
475        assert!(alpha < beta);
476        assert!(beta < rc);
477        assert!(rc < release);
478    }
479
480    #[test]
481    fn prerelease_a_b_ordering() {
482        let a = Version::parse("1.0.0a").unwrap();
483        let b = Version::parse("1.0.0b").unwrap();
484        assert!(a < b);
485    }
486
487    #[test]
488    fn string_segment_less_than_integer() {
489        // Integer segments are always greater than string segments
490        let with_str = Version::parse("1.0.0.alpha").unwrap();
491        let without = Version::parse("1.0.0").unwrap();
492        assert!(with_str < without);
493
494        let with_str2 = Version::parse("1.0.0a").unwrap();
495        assert!(with_str2 < without);
496    }
497
498    #[test]
499    fn compare_different_lengths() {
500        let short = Version::parse("1.0").unwrap();
501        let long = Version::parse("1.0.1").unwrap();
502        assert!(short < long);
503    }
504
505    #[test]
506    fn compare_year_based_versions() {
507        let v1 = Version::parse("2020.1.1").unwrap();
508        let v2 = Version::parse("2021.1.1").unwrap();
509        assert!(v1 < v2);
510    }
511
512    // ========== Pre-release Detection ==========
513
514    #[test]
515    fn not_prerelease() {
516        let v = Version::parse("1.2.3").unwrap();
517        assert!(!v.is_prerelease());
518    }
519
520    #[test]
521    fn is_prerelease_with_alpha() {
522        let v = Version::parse("1.0.0.alpha").unwrap();
523        assert!(v.is_prerelease());
524    }
525
526    #[test]
527    fn is_prerelease_inline() {
528        let v = Version::parse("1.0.0rc1").unwrap();
529        assert!(v.is_prerelease());
530    }
531
532    #[test]
533    fn four_segment_numeric_not_prerelease() {
534        let v = Version::parse("1.0.0.1").unwrap();
535        assert!(!v.is_prerelease());
536    }
537
538    // ========== Bump Tests ==========
539
540    #[test]
541    fn bump_three_segments() {
542        let v = Version::parse("1.2.3").unwrap();
543        let bumped = v.bump();
544        assert_eq!(bumped, Version::parse("1.3").unwrap());
545    }
546
547    #[test]
548    fn bump_two_segments() {
549        let v = Version::parse("1.0").unwrap();
550        let bumped = v.bump();
551        assert_eq!(bumped, Version::parse("2").unwrap());
552    }
553
554    #[test]
555    fn bump_single_segment() {
556        let v = Version::parse("1").unwrap();
557        let bumped = v.bump();
558        assert_eq!(bumped, Version::parse("2").unwrap());
559    }
560
561    #[test]
562    fn bump_four_segments() {
563        let v = Version::parse("1.2.3.4").unwrap();
564        let bumped = v.bump();
565        assert_eq!(bumped, Version::parse("1.2.4").unwrap());
566    }
567
568    #[test]
569    fn bump_with_trailing_zeros() {
570        // Ruby: Gem::Version.new("1.0.0").bump => "1.1"
571        // Segments [1,0,0] → pop last → [1,0] → increment → [1,1]
572        let v = Version::parse("1.0.0").unwrap();
573        let bumped = v.bump();
574        assert_eq!(bumped, Version::parse("1.1").unwrap());
575    }
576
577    // ========== Display ==========
578
579    #[test]
580    fn display_preserves_original() {
581        let v = Version::parse("1.2.3").unwrap();
582        assert_eq!(v.to_string(), "1.2.3");
583    }
584
585    #[test]
586    fn display_prerelease() {
587        let v = Version::parse("1.0.0.alpha").unwrap();
588        assert_eq!(v.to_string(), "1.0.0.alpha");
589    }
590}