|  | 
|  | 1 | +module Fastlane | 
|  | 2 | +  module Helper | 
|  | 3 | +    RC_DELIMITERS = %w[ | 
|  | 4 | +      rc | 
|  | 5 | +      beta | 
|  | 6 | +      b | 
|  | 7 | +    ].freeze | 
|  | 8 | + | 
|  | 9 | +    class Version | 
|  | 10 | +      include Comparable | 
|  | 11 | +      attr :major, :minor, :patch, :rc | 
|  | 12 | + | 
|  | 13 | +      def initialize(major:, minor:, patch: 0, rc_number: nil) | 
|  | 14 | +        @major = major | 
|  | 15 | +        @minor = minor | 
|  | 16 | +        @patch = patch | 
|  | 17 | +        @rc = rc_number | 
|  | 18 | +      end | 
|  | 19 | + | 
|  | 20 | +      # Create a new Version object based on a given string. | 
|  | 21 | +      # | 
|  | 22 | +      # Can parse a variety of different two, three, and four-segment version numbers, | 
|  | 23 | +      # including: | 
|  | 24 | +      # - x.y | 
|  | 25 | +      # - x.yrc1 | 
|  | 26 | +      # - x.y.rc1 | 
|  | 27 | +      # - x.y-rc1 | 
|  | 28 | +      # - x.y.rc.1 | 
|  | 29 | +      # - x.y-rc-1 | 
|  | 30 | +      # - x.y.z | 
|  | 31 | +      # - x.y.zrc1 | 
|  | 32 | +      # - x.y.z.rc1 | 
|  | 33 | +      # - x.y.z-rc1 | 
|  | 34 | +      # - x.y.z.rc.1 | 
|  | 35 | +      # - x.y.z-rc-1 | 
|  | 36 | +      # - Any of the above with `v` prepended | 
|  | 37 | +      # | 
|  | 38 | +      def self.create(string) | 
|  | 39 | +        string = string.downcase | 
|  | 40 | +        string = string.delete_prefix('v') if string.start_with?('v') | 
|  | 41 | + | 
|  | 42 | +        components = string | 
|  | 43 | +                     .split('.') | 
|  | 44 | +                     .map { |component| component.remove('-') } | 
|  | 45 | +                     .delete_if { |component| component == 'rc' } | 
|  | 46 | + | 
|  | 47 | +        return nil if components.length < 2 | 
|  | 48 | + | 
|  | 49 | +        # Turn RC version codes into simple versions | 
|  | 50 | +        if components.last.include? 'rc' | 
|  | 51 | +          rc_segments = VersionHelpers.rc_segments_from_string(components.last) | 
|  | 52 | +          components.delete_at(components.length - 1) | 
|  | 53 | +          components = VersionHelpers.combine_components_and_rc_segments(components, rc_segments) | 
|  | 54 | +        end | 
|  | 55 | + | 
|  | 56 | +        # Validate our work | 
|  | 57 | +        raise if components.any? { |component| !VersionHelpers.string_is_valid_int(component) } | 
|  | 58 | + | 
|  | 59 | +        # If this is a simple version string, process it early | 
|  | 60 | +        major = components.first.to_i | 
|  | 61 | +        minor = components.second.to_i | 
|  | 62 | +        patch = components.third.to_i | 
|  | 63 | + | 
|  | 64 | +        # Simple two-segment version numbers can exit here | 
|  | 65 | +        return Version.new(major: major, minor: minor) if components.length == 2 | 
|  | 66 | + | 
|  | 67 | +        # Simple three-segment version numbers can exit here | 
|  | 68 | +        return Version.new(major: major, minor: minor, patch: patch) if components.length == 3 | 
|  | 69 | + | 
|  | 70 | +        # Simple four-segment version numbers can exit here | 
|  | 71 | +        return Version.new(major: major, minor: minor, patch: patch, rc_number: components.fourth.to_i) if components.length == 4 | 
|  | 72 | +      end | 
|  | 73 | + | 
|  | 74 | +      # Create a new Version object based on a given string. | 
|  | 75 | +      # | 
|  | 76 | +      # Raises if the string is invalid | 
|  | 77 | +      def self.create!(string) | 
|  | 78 | +        version = create(string) | 
|  | 79 | +        raise "Invalid Version: #{string}" if version.nil? | 
|  | 80 | + | 
|  | 81 | +        version | 
|  | 82 | +      end | 
|  | 83 | + | 
|  | 84 | +      # Returns a formatted string suitable for use as an Android Version Name | 
|  | 85 | +      def android_version_name | 
|  | 86 | +        return [@major, @minor].join('.') if @patch.zero? && @rc.nil? | 
|  | 87 | +        return [@major, @minor, @patch].join('.') if !@patch.zero? && rc.nil? | 
|  | 88 | +        return [@major, "#{@minor}-rc-#{@rc}"].join('.') if @patch.zero? && !rc.nil? | 
|  | 89 | + | 
|  | 90 | +        return [@major, @minor, "#{@patch}-rc-#{@rc}"].join('.') | 
|  | 91 | +      end | 
|  | 92 | + | 
|  | 93 | +      # Returns a formatted string suitable for use as an Android Version Code | 
|  | 94 | +      def android_version_code(prefix: 1) | 
|  | 95 | +        [ | 
|  | 96 | +          '1', | 
|  | 97 | +          @major, | 
|  | 98 | +          format('%02d', @minor), | 
|  | 99 | +          format('%02d', @patch), | 
|  | 100 | +          format('%02d', @rc || 0), | 
|  | 101 | +        ].join | 
|  | 102 | +      end | 
|  | 103 | + | 
|  | 104 | +      # Returns a formatted string suitable for use as an iOS Version Number | 
|  | 105 | +      def ios_version_number | 
|  | 106 | +        return [@major, @minor, @patch, @rc || 0].join('.') | 
|  | 107 | +      end | 
|  | 108 | + | 
|  | 109 | +      # Returns a string suitable for comparing two version objects | 
|  | 110 | +      # | 
|  | 111 | +      # This method has no version number padding, so its likely to have collisions | 
|  | 112 | +      def raw_version_code | 
|  | 113 | +        [@major, @minor, @patch, @rc || 0].join.to_i | 
|  | 114 | +      end | 
|  | 115 | + | 
|  | 116 | +      # Is this version number a patch version? | 
|  | 117 | +      def patch? | 
|  | 118 | +        !@patch.zero? | 
|  | 119 | +      end | 
|  | 120 | + | 
|  | 121 | +      # Is this version number a prerelease version? | 
|  | 122 | +      def prerelease? | 
|  | 123 | +        !@rc.nil? | 
|  | 124 | +      end | 
|  | 125 | + | 
|  | 126 | +      # Derive the next major version from this version number | 
|  | 127 | +      def next_major_version | 
|  | 128 | +        Version.new( | 
|  | 129 | +          major: @major + 1, | 
|  | 130 | +          minor: 0 | 
|  | 131 | +        ) | 
|  | 132 | +      end | 
|  | 133 | + | 
|  | 134 | +      # Derive the next minor version from this version number | 
|  | 135 | +      def next_minor_version | 
|  | 136 | +        major = @major | 
|  | 137 | +        minor = @minor | 
|  | 138 | + | 
|  | 139 | +        if minor == 9 | 
|  | 140 | +          major += 1 | 
|  | 141 | +          minor = 0 | 
|  | 142 | +        else | 
|  | 143 | +          minor += 1 | 
|  | 144 | +        end | 
|  | 145 | + | 
|  | 146 | +        Version.new( | 
|  | 147 | +          major: major, | 
|  | 148 | +          minor: minor | 
|  | 149 | +        ) | 
|  | 150 | +      end | 
|  | 151 | + | 
|  | 152 | +      # Derive the next patch version from this version number | 
|  | 153 | +      def next_patch_version | 
|  | 154 | +        Version.new( | 
|  | 155 | +          major: @major, | 
|  | 156 | +          minor: @minor, | 
|  | 157 | +          patch: @patch + 1 | 
|  | 158 | +        ) | 
|  | 159 | +      end | 
|  | 160 | + | 
|  | 161 | +      # Derive the next rc version from this version number | 
|  | 162 | +      def next_rc_version | 
|  | 163 | +        rc = @rc | 
|  | 164 | +        rc = 0 if rc.nil? | 
|  | 165 | + | 
|  | 166 | +        Version.new( | 
|  | 167 | +          major: @major, | 
|  | 168 | +          minor: @minor, | 
|  | 169 | +          patch: @patch, | 
|  | 170 | +          rc_number: rc + 1 | 
|  | 171 | +        ) | 
|  | 172 | +      end | 
|  | 173 | + | 
|  | 174 | +      # Is this version the same as another version, just with different RC codes? | 
|  | 175 | +      def is_different_rc_of(other) | 
|  | 176 | +        return false unless other.is_a?(Version) | 
|  | 177 | + | 
|  | 178 | +        return other.major == @major && other.minor == @minor && other.patch == @patch | 
|  | 179 | +      end | 
|  | 180 | + | 
|  | 181 | +      # Is this version the same as another version, just with a different patch version? | 
|  | 182 | +      def is_different_patch_of(other) | 
|  | 183 | +        return false unless other.is_a?(Version) | 
|  | 184 | + | 
|  | 185 | +        return other.major == @major && other.minor == @minor | 
|  | 186 | +      end | 
|  | 187 | + | 
|  | 188 | +      def ==(other) | 
|  | 189 | +        return false unless other.is_a?(Version) | 
|  | 190 | + | 
|  | 191 | +        raw_version_code == other.raw_version_code | 
|  | 192 | +      end | 
|  | 193 | + | 
|  | 194 | +      def equal?(other) | 
|  | 195 | +        self == other | 
|  | 196 | +      end | 
|  | 197 | + | 
|  | 198 | +      def <=>(other) | 
|  | 199 | +        raw_version_code <=> other.raw_version_code | 
|  | 200 | +      end | 
|  | 201 | +    end | 
|  | 202 | + | 
|  | 203 | +    # A collection of helpers for the `Version.create` method that extract some of the tricky code | 
|  | 204 | +    # that's nice to be able to test in isolation – in practice, this is private API and you *probably* | 
|  | 205 | +    # don't want to use it for other things. | 
|  | 206 | +    module VersionHelpers | 
|  | 207 | +      # Determines whether the given string is a valid integer. | 
|  | 208 | +      # | 
|  | 209 | +      # Examples: | 
|  | 210 | +      # - 00  => true | 
|  | 211 | +      # - 01  => true | 
|  | 212 | +      # - 1   => true | 
|  | 213 | +      # - rc  => false | 
|  | 214 | +      # See the `version_helpers_spec` for more test cases. | 
|  | 215 | +      # | 
|  | 216 | +      # @param string String The string to check. | 
|  | 217 | +      # @return bool `true` if the given string is a valid integer. `false` if not. | 
|  | 218 | +      def self.string_is_valid_int(string) | 
|  | 219 | +        return true if string.count('0') == string.length | 
|  | 220 | + | 
|  | 221 | +        # Remove any leading zeros | 
|  | 222 | +        string = string.delete_prefix('0') | 
|  | 223 | + | 
|  | 224 | +        return string.to_i.to_s == string | 
|  | 225 | +      end | 
|  | 226 | + | 
|  | 227 | +      # Extracts all integers (delimited by anything non-integer value) from a given string | 
|  | 228 | +      # | 
|  | 229 | +      # @param string String The string to check. | 
|  | 230 | +      # @return [int] The integers contained within the string | 
|  | 231 | +      def self.extract_ints_from_string(string) | 
|  | 232 | +        string.scan(/\d+/) | 
|  | 233 | +      end | 
|  | 234 | + | 
|  | 235 | +      # Parses release candidate number (and potentially minor or patch version depending on how the | 
|  | 236 | +      # version code is formatted) from a given string. This can take a variety of forms because the | 
|  | 237 | +      # release candidate segment of a version string can be formatted in a lot of different ways. | 
|  | 238 | +      # | 
|  | 239 | +      # Examples: | 
|  | 240 | +      # - 00  =>  ['0'] | 
|  | 241 | +      # - rc1  => ['1'] | 
|  | 242 | +      # - 5rc1 => ['5','1'] | 
|  | 243 | +      # See the `version_helpers_spec` for more test cases. | 
|  | 244 | +      # | 
|  | 245 | +      # @param string String The string to parse. | 
|  | 246 | +      # @return [string] The leading and trailing digits from the version segment string | 
|  | 247 | +      def self.rc_segments_from_string(string) | 
|  | 248 | +        # If the string is all zeros, return zero | 
|  | 249 | +        return ['0'] if string.scan(/0/).length == string.length | 
|  | 250 | + | 
|  | 251 | +        extract_ints_from_string(string) | 
|  | 252 | +      end | 
|  | 253 | + | 
|  | 254 | +      # Combines the non-RC version string components with the RC segments extracted by `rc_segments_from_string`. | 
|  | 255 | +      # | 
|  | 256 | +      # Because this method needs to be able to assemble the version segments and release candidate segments into a | 
|  | 257 | +      # coherent version based on a variety of input formats, the implementation looks pretty complex, but it's covered | 
|  | 258 | +      # by a comprehensive test suite to validate that it does, in fact, work. | 
|  | 259 | +      # | 
|  | 260 | +      # Examples: | 
|  | 261 | +      # - [1.0], [1]  =>  ['1','0', '0', '1'] | 
|  | 262 | +      # - [1.0], [2,1]  =>  ['1','0', '2', '1'] | 
|  | 263 | +      # See the `version_helpers_spec` for more test cases. | 
|  | 264 | +      # | 
|  | 265 | +      # @param components [string] The version string components (without the RC segments) | 
|  | 266 | +      # @param rc_segments [string] The return value from `rc_segments_from_string` | 
|  | 267 | +      # @return [string] An array of stringified integer version components in `major.minor.patch.rc` order | 
|  | 268 | +      def self.combine_components_and_rc_segments(components, rc_segments) | 
|  | 269 | +        case true # rubocop:disable Lint/LiteralAsCondition | 
|  | 270 | +        when components.length == 1 && rc_segments.length == 2 | 
|  | 271 | +          return [components.first, rc_segments.first, '0', rc_segments.last] | 
|  | 272 | +        when components.length == 2 && rc_segments.length == 1 | 
|  | 273 | +          return [components.first, components.second, '0', rc_segments.first] | 
|  | 274 | +        when components.length == 2 && rc_segments.length == 2 | 
|  | 275 | +          return [components.first, components.second, rc_segments.first, rc_segments.last] | 
|  | 276 | +        when components.length == 3 && rc_segments.length == 1 | 
|  | 277 | +          return [components.first, components.second, components.third, rc_segments.first] | 
|  | 278 | +        end | 
|  | 279 | + | 
|  | 280 | +        raise "Invalid components: #{components.inspect} or rc_segments: #{rc_segments.inspect}" | 
|  | 281 | +      end | 
|  | 282 | +    end | 
|  | 283 | +  end | 
|  | 284 | +end | 
0 commit comments