Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ _None_
### New Features

- Add the possibility to configure in DerivedBuildCodeFormatter a versioning prefix instead of always defaulting to 1. [#656]
- Add configurable number of digits in version components in DerivedBuildCodeFormatter. [#657]

### Bug Fixes

Expand Down
8 changes: 8 additions & 0 deletions lib/fastlane/plugin/wpmreleasetoolkit/models/app_version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,14 @@ def initialize(major, minor, patch = 0, build_number = 0)
def to_s
"#{@major}.#{@minor}.#{@patch}.#{@build_number}"
end

# Returns an array of the version components.
#
# @return [Array<Integer>] an array of the version components.
#
def components
[@major, @minor, @patch, @build_number]
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,34 @@
module Fastlane
module Wpmreleasetoolkit
module Versioning
MAX_TOTAL_DIGITS = 8

# The `DerivedBuildCodeFormatter` class is a specialized build code formatter for derived build codes.
# It takes in an AppVersion object and derives a build code from it.
class DerivedBuildCodeFormatter
# Initialize the formatter with a configurable prefix.
# Initialize the formatter with configurable prefix and digit counts.
#
# @param [String] prefix The prefix to use for the build code. Must be a single digit (0-9), or empty string / nil.
# @param [Integer] major_digits Number of digits for major version. Defaults to 2.
# @param [Integer] minor_digits Number of digits for minor version. Defaults to 2.
# @param [Integer] patch_digits Number of digits for patch version. Defaults to 2.
# @param [Integer] build_digits Number of digits for build number. Defaults to 2.
#
def initialize(prefix: nil)
def initialize(prefix: nil, major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)
prefix ||= ''
validate_prefix!(prefix)

@digit_counts = [major_digits, minor_digits, patch_digits, build_digits]
@digit_counts.each { |d| validate_digit_count!(d) }
validate_total_digits!(@digit_counts)

@prefix = prefix.to_s
end

# Calculate the next derived build code.
#
# This method derives a new build code from the given AppVersion object by concatenating the configured prefix,
# the major version, the minor version, the patch version, and the build number.
# the major version, the minor version, the patch version, and the build number with configurable digit counts.
#
# @param [AppVersion] version The AppVersion object to derive the next build code from.
#
Expand All @@ -28,19 +39,16 @@ def initialize(prefix: nil)
#
# @return [String] The formatted build code string.
#
def build_code(build_code = nil, version:)
result = format(
# The prefix is configurable to allow for additional platforms or
# extensions that could use a different digit prefix such as 2, etc.
'%<prefix>s%<major>.2i%<minor>.2i%<patch>.2i%<build_number>.2i',
prefix: @prefix,
major: version.major,
minor: version.minor,
patch: version.patch,
build_number: version.build_number
)

result.gsub(/^0+/, '')
def build_code(_build_code = nil, version:)
formatted_components = version.components.zip(@digit_counts).map do |value, width|
comp = value.to_s.rjust(width, '0')
if comp.length > width
UI.user_error!("Version component value (#{value}) exceeds maximum allowed width of #{width} characters. " \
"Consider increasing the corresponding `*_digits` parameter of your `#{self.class.name}`")
end
comp
end
[@prefix, *formatted_components].join.gsub(/^0+/, '')
end

private
Expand All @@ -67,6 +75,35 @@ def validate_prefix!(prefix)

UI.user_error!("Prefix must be an integer digit (0-9) or empty string, got: '#{prefix_str}'")
end

# Validates that the digit count is a valid positive integer within reasonable limits.
#
# @param [Integer] digit_count The digit count to validate
#
# @raise [StandardError] If the digit count is invalid
#
def validate_digit_count!(digit_count)
# Check if it's an integer
unless digit_count.is_a?(Integer)
UI.user_error!("Digit count must be an integer, got: #{digit_count.class}")
end

return if digit_count.between?(1, 3)

UI.user_error!("Digit count must be between 1 and 3 digits, got: #{digit_count}")
end

# Validates that the total number of digits (excluding prefix) doesn't exceed the maximum for multiplatform compatibility.
#
def validate_total_digits!(digits_list)
total_digits = digits_list.sum

# Limit total digits to 8 (excluding prefix)
return if total_digits <= MAX_TOTAL_DIGITS

UI.user_error!("Total digit count (#{total_digits}) exceeds maximum allowed (#{MAX_TOTAL_DIGITS}). " \
"Current config: major(#{digits_list[0]}) + minor(#{digits_list[1]}) + patch(#{digits_list[2]}) + build(#{digits_list[3]}) digits")
end
end
end
end
Expand Down
215 changes: 198 additions & 17 deletions spec/derived_build_code_formatter_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,14 @@
end

context 'with explicit prefix' do
it 'derives the build code with prefix "1"' do
version = Fastlane::Models::AppVersion.new(1, 2, 3, 4)
formatter = described_class.new(prefix: '1')
build_code_string = formatter.build_code(version: version)
expect(build_code_string.to_s).to eq('101020304')
end

it 'derives the build code with prefix "2"' do
version = Fastlane::Models::AppVersion.new(1, 2, 3, 4)
formatter = described_class.new(prefix: '2')
build_code_string = formatter.build_code(version: version)
expect(build_code_string.to_s).to eq('201020304')
end

it 'derives the build code with prefix "0"' do
it 'derives the build code with prefix "0" and trims leading zeros' do
version = Fastlane::Models::AppVersion.new(12, 34, 56, 78)
formatter = described_class.new(prefix: '0')
build_code_string = formatter.build_code(version: version)
Expand Down Expand Up @@ -81,6 +74,125 @@
end
end

describe 'configurable digit counts' do
context 'with custom digit counts' do
it 'uses 1 digit for each component' do
version = Fastlane::Models::AppVersion.new(1, 2, 3, 4)
formatter = described_class.new(prefix: '1', major_digits: 1, minor_digits: 1, patch_digits: 1, build_digits: 1)
build_code_string = formatter.build_code(version: version)
expect(build_code_string.to_s).to eq('11234')
end

it 'uses 2 digits for each component and pads with zeros' do
# Test both large numbers and zero-padding in one test
version_large = Fastlane::Models::AppVersion.new(12, 34, 56, 78)
formatter = described_class.new(prefix: '2', major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)
expect(formatter.build_code(version: version_large).to_s).to eq('212345678')

# Test zero-padding with smaller numbers
version_small = Fastlane::Models::AppVersion.new(1, 2, 3, 4)
expect(formatter.build_code(version: version_small).to_s).to eq('201020304')
end

it 'uses mixed digit counts' do
version = Fastlane::Models::AppVersion.new(1, 23, 45, 678)
formatter = described_class.new(prefix: '', major_digits: 1, minor_digits: 2, patch_digits: 2, build_digits: 3)
build_code_string = formatter.build_code(version: version)
# 1(1 digit) + 23(2 digits) + 45(2 digits) + 678(3 digits) = "12345678"
expect(build_code_string.to_s).to eq('12345678')
end

it 'handles maximum values within digit limits' do
version = Fastlane::Models::AppVersion.new(99, 99, 99, 99)
formatter = described_class.new(prefix: '1', major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)
build_code_string = formatter.build_code(version: version)
expect(build_code_string.to_s).to eq('199999999')
end
end

context 'with empty prefix and custom digits' do
it 'trims leading zeros correctly with 1-digit major' do
version = Fastlane::Models::AppVersion.new(5, 12, 34, 56)
formatter = described_class.new(prefix: '', major_digits: 1, minor_digits: 2, patch_digits: 2, build_digits: 2)
build_code_string = formatter.build_code(version: version)
expect(build_code_string.to_s).to eq('5123456')
end

it 'trims leading zeros correctly with larger major' do
version = Fastlane::Models::AppVersion.new(7, 8, 9, 10)
formatter = described_class.new(prefix: '', major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)
build_code_string = formatter.build_code(version: version)
expect(build_code_string.to_s).to eq('7080910')
end

it 'handles edge case where all components start with zeros' do
version = Fastlane::Models::AppVersion.new(0, 1, 2, 3)
formatter = described_class.new(prefix: '', major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)
build_code_string = formatter.build_code(version: version)
# ''(empty prefix) + '00'(2 digits) + '01'(2 digits) + '02'(2 digits) + '03'(2 digits) = "00010203", trimmed to "10203"
expect(build_code_string.to_s).to eq('10203')
end
end

context 'with backward compatibility (default 2 digits)' do
it 'maintains existing behavior when no digit parameters specified' do
version = Fastlane::Models::AppVersion.new(1, 2, 3, 4)
formatter_old = described_class.new(prefix: '1')
formatter_new = described_class.new(prefix: '1', major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)

expect(formatter_old.build_code(version: version)).to eq(formatter_new.build_code(version: version))
end
end
end

describe 'digit count validation' do
context 'with valid digit counts' do
it 'accepts digit counts from 1 to 3 individually' do
(1..3).each do |count|
# Test each parameter individually with safe defaults for others that stay within 8 digit limit
expect { described_class.new(major_digits: count, minor_digits: 1, patch_digits: 1, build_digits: 1) }.not_to raise_error
expect { described_class.new(major_digits: 1, minor_digits: count, patch_digits: 1, build_digits: 1) }.not_to raise_error
expect { described_class.new(major_digits: 1, minor_digits: 1, patch_digits: count, build_digits: 1) }.not_to raise_error
expect { described_class.new(major_digits: 1, minor_digits: 1, patch_digits: 1, build_digits: count) }.not_to raise_error
end
end

it 'accepts mixed valid digit counts within 8 total digits' do
# 1 + 2 + 2 + 3 = 8 digits <= 8
expect { described_class.new(major_digits: 1, minor_digits: 2, patch_digits: 2, build_digits: 3) }.not_to raise_error
end
end

context 'with invalid digit counts' do
it 'rejects digit counts outside valid range (1-3)' do
expect { described_class.new(major_digits: 0) }.to raise_error(/Digit count must be between 1 and 3/)
expect { described_class.new(minor_digits: -1) }.to raise_error(/Digit count must be between 1 and 3/)
expect { described_class.new(patch_digits: 4) }.to raise_error(/Digit count must be between 1 and 3/)
end

it 'rejects non-integer digit counts' do
expect { described_class.new(build_digits: '3') }.to raise_error(/Digit count must be an integer, got: String/)
expect { described_class.new(major_digits: 2.5) }.to raise_error(/Digit count must be an integer, got: Float/)
expect { described_class.new(minor_digits: 1.0) }.to raise_error(/Digit count must be an integer, got: Float/)
expect { described_class.new(patch_digits: nil) }.to raise_error(/Digit count must be an integer, got: NilClass/)
end

it 'rejects configurations exceeding 8 total digits' do
# 3 + 3 + 3 + 3 = 12 digits > 8
expect { described_class.new(major_digits: 3, minor_digits: 3, patch_digits: 3, build_digits: 3) }.to raise_error(/Total digit count \(12\) exceeds maximum allowed \(8\)/)
# 2 + 2 + 2 + 3 = 9 digits > 8
expect { described_class.new(major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 3) }.to raise_error(/Total digit count \(9\) exceeds maximum allowed \(8\)/)
end

it 'accepts configurations within and at 8 total digit limit' do
# 2 + 2 + 2 + 2 = 8 digits <= 8 (default config)
expect { described_class.new }.not_to raise_error
# 2 + 2 + 2 + 2 = 8 digits = 8 (at limit)
expect { described_class.new(major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2) }.not_to raise_error
end
end
end

describe 'prefix validation' do
context 'with valid prefixes' do
it 'accepts single digits 0-9' do
Expand All @@ -99,22 +211,91 @@
end

context 'with invalid prefixes' do
it 'rejects multi-character strings' do
it 'rejects invalid prefix formats' do
# Multi-character strings and out-of-range numbers
expect { described_class.new(prefix: '12') }.to raise_error(/Prefix must be a single digit or empty string/)
end
expect { described_class.new(prefix: '10') }.to raise_error(/Prefix must be a single digit or empty string/)
expect { described_class.new(prefix: '-1') }.to raise_error(/Prefix must be a single digit or empty string/)

it 'rejects non-numeric strings' do
# Non-numeric characters
expect { described_class.new(prefix: 'a') }.to raise_error(/Prefix must be an integer digit/)
expect { described_class.new(prefix: '@') }.to raise_error(/Prefix must be an integer digit/)
end
end
end

it 'rejects numbers outside 0-9 range' do
expect { described_class.new(prefix: '10') }.to raise_error(/Prefix must be a single digit or empty string/)
expect { described_class.new(prefix: '-1') }.to raise_error(/Prefix must be a single digit or empty string/)
describe 'version component validation' do
context 'with valid version components within digit limits' do
it 'accepts version components that fit within their digit limits' do
formatter = described_class.new(major_digits: 1, minor_digits: 2, patch_digits: 2, build_digits: 3)

# Valid cases: major=9 (1 digit), minor=99 (2 digits), patch=99 (2 digits), build=999 (3 digits)
version = Fastlane::Models::AppVersion.new(9, 99, 99, 999)
expect { formatter.build_code(version: version) }.not_to raise_error
end

it 'rejects symbols and special characters' do
expect { described_class.new(prefix: '@') }.to raise_error(/Prefix must be an integer digit/)
expect { described_class.new(prefix: '#') }.to raise_error(/Prefix must be an integer digit/)
it 'accepts version components at exactly the digit limit' do
formatter = described_class.new(major_digits: 2, minor_digits: 1, patch_digits: 3, build_digits: 2)

# Edge cases: exactly at limits
version = Fastlane::Models::AppVersion.new(99, 9, 999, 99)
expect { formatter.build_code(version: version) }.not_to raise_error
end
end

context 'with invalid version components exceeding digit limits' do
it 'rejects major version exceeding digit limit' do
formatter = described_class.new(major_digits: 1, minor_digits: 2, patch_digits: 2, build_digits: 2)
version = Fastlane::Models::AppVersion.new(10, 1, 1, 1) # major=10 > max(9) for 1 digit

expect { formatter.build_code(version: version) }.to raise_error(
/Version component value \(10\) exceeds maximum allowed width of 1 characters/
)
end

it 'rejects minor version exceeding digit limit' do
formatter = described_class.new(major_digits: 2, minor_digits: 1, patch_digits: 2, build_digits: 2)
version = Fastlane::Models::AppVersion.new(1, 23, 4, 5) # minor=23 > max(9) for 1 digit

expect { formatter.build_code(version: version) }.to raise_error(
/Version component value \(23\) exceeds maximum allowed width of 1 characters/
)
end

it 'rejects patch version exceeding digit limit' do
formatter = described_class.new(major_digits: 2, minor_digits: 2, patch_digits: 1, build_digits: 2)
version = Fastlane::Models::AppVersion.new(1, 2, 34, 5) # patch=34 > max(9) for 1 digit

expect { formatter.build_code(version: version) }.to raise_error(
/Version component value \(34\) exceeds maximum allowed width of 1 characters/
)
end

it 'rejects build number exceeding digit limit' do
formatter = described_class.new(major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 1)
version = Fastlane::Models::AppVersion.new(1, 2, 3, 46) # build_number=46 > max(9) for 1 digit

expect { formatter.build_code(version: version) }.to raise_error(
/Version component value \(46\) exceeds maximum allowed width of 1 characters/
)
end

it 'provides helpful error messages with different digit limits' do
formatter = described_class.new(major_digits: 2, minor_digits: 2, patch_digits: 2, build_digits: 2)
version = Fastlane::Models::AppVersion.new(123, 4, 5, 6) # major=123 > max(99) for 2 digits

expect { formatter.build_code(version: version) }.to raise_error(
/Version component value \(123\) exceeds maximum allowed width of 2 characters.*Consider increasing the corresponding `\*_digits` parameter/
)
end

it 'validates the first component that exceeds limits' do
formatter = described_class.new(major_digits: 1, minor_digits: 1, patch_digits: 1, build_digits: 1)
version = Fastlane::Models::AppVersion.new(12, 34, 56, 78) # All exceed 1 digit limit, but major is checked first

expect { formatter.build_code(version: version) }.to raise_error(
/Version component value \(12\) exceeds maximum allowed width of 1 characters/
)
end
end
end
Expand Down