Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,39 @@
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)
validate_digit_count!(major_digits)
validate_digit_count!(minor_digits)
validate_digit_count!(patch_digits)
validate_digit_count!(build_digits)
validate_total_digits!(major_digits, minor_digits, patch_digits, build_digits)

@prefix = prefix.to_s
@major_digits = major_digits
@minor_digits = minor_digits
@patch_digits = patch_digits
@build_digits = build_digits
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 @@ -29,16 +45,14 @@ 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
)
# Use manual padding to avoid security risks with dynamic format strings
result = [
@prefix,
version.major.to_s.rjust(@major_digits, '0'),
version.minor.to_s.rjust(@minor_digits, '0'),
version.patch.to_s.rjust(@patch_digits, '0'),
version.build_number.to_s.rjust(@build_digits, '0'),
].join

result.gsub(/^0+/, '')
end
Expand Down Expand Up @@ -67,6 +81,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!(major_digits, minor_digits, patch_digits, build_digits)
total_digits = major_digits + minor_digits + patch_digits + build_digits

# 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(#{major_digits}) + minor(#{minor_digits}) + patch(#{patch_digits}) + build(#{build_digits}) digits")
end
end
end
end
Expand Down
143 changes: 124 additions & 19 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,15 @@
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

it 'rejects non-numeric strings' do
expect { described_class.new(prefix: 'a') }.to raise_error(/Prefix must be an integer digit/)
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/)
end

it 'rejects symbols and special characters' 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/)
expect { described_class.new(prefix: '#') }.to raise_error(/Prefix must be an integer digit/)
end
end
end
Expand Down