Skip to main content

gem_audit/lockfile/
ruby_version.rs

1/// A parsed Ruby interpreter version from a Gemfile.lock `RUBY VERSION` section.
2///
3/// Example inputs:
4/// - `"ruby 3.0.0p0"` -> engine="ruby", version="3.0.0"
5/// - `"jruby 9.3.6.0"` -> engine="jruby", version="9.3.6.0"
6/// - `"ruby 3.2.1"` -> engine="ruby", version="3.2.1"
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RubyVersion {
9    /// The Ruby engine (e.g., "ruby", "jruby", "mruby").
10    pub engine: String,
11    /// The version string with patchlevel stripped (e.g., "3.0.0").
12    pub version: String,
13}
14
15impl RubyVersion {
16    /// Parse a Ruby version string from a Gemfile.lock `RUBY VERSION` section.
17    ///
18    /// Expects format: `"<engine> <version>[p<patchlevel>]"`
19    ///
20    /// Returns `None` if the input doesn't match the expected format.
21    pub fn parse(input: &str) -> Option<Self> {
22        let trimmed = input.trim();
23        let (engine, version_raw) = trimmed.split_once(' ')?;
24        let version = strip_patchlevel(version_raw);
25
26        Some(RubyVersion {
27            engine: engine.to_string(),
28            version,
29        })
30    }
31}
32
33/// Strip the patchlevel suffix (e.g., "p0", "p219") from a Ruby version string.
34///
35/// - `"3.0.0p0"` -> `"3.0.0"`
36/// - `"2.7.6p219"` -> `"2.7.6"`
37/// - `"9.3.6.0"` -> `"9.3.6.0"` (no patchlevel)
38fn strip_patchlevel(version: &str) -> String {
39    // Ruby patchlevel format: digits followed by 'p' followed by digits at end
40    if let Some(pos) = version.rfind('p') {
41        let before = &version[..pos];
42        let after = &version[pos + 1..];
43        // Only strip if the part after 'p' is all digits (patchlevel)
44        // and the part before ends with a digit (not e.g. "pre")
45        if !after.is_empty()
46            && after.chars().all(|c| c.is_ascii_digit())
47            && before.ends_with(|c: char| c.is_ascii_digit())
48        {
49            return before.to_string();
50        }
51    }
52    version.to_string()
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58
59    #[test]
60    fn parse_ruby_with_patchlevel() {
61        let rv = RubyVersion::parse("ruby 3.0.0p0").unwrap();
62        assert_eq!(rv.engine, "ruby");
63        assert_eq!(rv.version, "3.0.0");
64    }
65
66    #[test]
67    fn parse_ruby_with_high_patchlevel() {
68        let rv = RubyVersion::parse("ruby 2.7.6p219").unwrap();
69        assert_eq!(rv.engine, "ruby");
70        assert_eq!(rv.version, "2.7.6");
71    }
72
73    #[test]
74    fn parse_ruby_without_patchlevel() {
75        let rv = RubyVersion::parse("ruby 3.2.1").unwrap();
76        assert_eq!(rv.engine, "ruby");
77        assert_eq!(rv.version, "3.2.1");
78    }
79
80    #[test]
81    fn parse_jruby() {
82        let rv = RubyVersion::parse("jruby 9.3.6.0").unwrap();
83        assert_eq!(rv.engine, "jruby");
84        assert_eq!(rv.version, "9.3.6.0");
85    }
86
87    #[test]
88    fn parse_mruby() {
89        let rv = RubyVersion::parse("mruby 3.1.0").unwrap();
90        assert_eq!(rv.engine, "mruby");
91        assert_eq!(rv.version, "3.1.0");
92    }
93
94    #[test]
95    fn parse_with_leading_whitespace() {
96        let rv = RubyVersion::parse("  ruby 3.0.0p0  ").unwrap();
97        assert_eq!(rv.engine, "ruby");
98        assert_eq!(rv.version, "3.0.0");
99    }
100
101    #[test]
102    fn parse_empty_string() {
103        assert!(RubyVersion::parse("").is_none());
104    }
105
106    #[test]
107    fn parse_no_space() {
108        assert!(RubyVersion::parse("ruby").is_none());
109    }
110
111    #[test]
112    fn parse_empty_version() {
113        assert!(RubyVersion::parse("ruby ").is_none());
114    }
115
116    #[test]
117    fn strip_patchlevel_with_p0() {
118        assert_eq!(strip_patchlevel("3.0.0p0"), "3.0.0");
119    }
120
121    #[test]
122    fn strip_patchlevel_with_p219() {
123        assert_eq!(strip_patchlevel("2.7.6p219"), "2.7.6");
124    }
125
126    #[test]
127    fn strip_patchlevel_no_patchlevel() {
128        assert_eq!(strip_patchlevel("3.2.1"), "3.2.1");
129    }
130
131    #[test]
132    fn strip_patchlevel_preserves_pre_release() {
133        // "pre" contains 'p' but it's not a patchlevel
134        assert_eq!(strip_patchlevel("1.0.0.pre1"), "1.0.0.pre1");
135    }
136}