featurevisor 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +722 -0
- data/bin/cli.rb +142 -0
- data/bin/commands/assess_distribution.rb +236 -0
- data/bin/commands/benchmark.rb +274 -0
- data/bin/commands/test.rb +793 -0
- data/bin/commands.rb +10 -0
- data/bin/featurevisor +18 -0
- data/lib/featurevisor/bucketer.rb +95 -0
- data/lib/featurevisor/child_instance.rb +311 -0
- data/lib/featurevisor/compare_versions.rb +126 -0
- data/lib/featurevisor/conditions.rb +152 -0
- data/lib/featurevisor/datafile_reader.rb +350 -0
- data/lib/featurevisor/emitter.rb +60 -0
- data/lib/featurevisor/evaluate.rb +818 -0
- data/lib/featurevisor/events.rb +76 -0
- data/lib/featurevisor/hooks.rb +159 -0
- data/lib/featurevisor/instance.rb +463 -0
- data/lib/featurevisor/logger.rb +150 -0
- data/lib/featurevisor/murmurhash.rb +69 -0
- data/lib/featurevisor/version.rb +3 -0
- data/lib/featurevisor.rb +17 -0
- metadata +89 -0
data/bin/commands.rb
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
require_relative "commands/test"
|
|
2
|
+
require_relative "commands/benchmark"
|
|
3
|
+
require_relative "commands/assess_distribution"
|
|
4
|
+
|
|
5
|
+
module FeaturevisorCLI
|
|
6
|
+
module Commands
|
|
7
|
+
# This module serves as a namespace for all CLI commands
|
|
8
|
+
# Individual command classes are defined in separate files
|
|
9
|
+
end
|
|
10
|
+
end
|
data/bin/featurevisor
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
|
|
3
|
+
# CLI for the featurevisor gem
|
|
4
|
+
# Usage: featurevisor [command] [options]
|
|
5
|
+
|
|
6
|
+
# Add the lib directory to the load path so we can require the featurevisor gem
|
|
7
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), "..", "lib"))
|
|
8
|
+
|
|
9
|
+
require "featurevisor"
|
|
10
|
+
require_relative "cli"
|
|
11
|
+
|
|
12
|
+
if ARGV.empty?
|
|
13
|
+
FeaturevisorCLI.show_help
|
|
14
|
+
exit 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Run the CLI with the provided arguments
|
|
18
|
+
FeaturevisorCLI.run(ARGV)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Featurevisor
|
|
4
|
+
# Bucketer module for handling feature flag bucketing
|
|
5
|
+
module Bucketer
|
|
6
|
+
# Maximum bucketed number (100% * 1000 to include three decimal places)
|
|
7
|
+
MAX_BUCKETED_NUMBER = 100_000
|
|
8
|
+
|
|
9
|
+
# Hash seed for consistent bucketing
|
|
10
|
+
HASH_SEED = 1
|
|
11
|
+
|
|
12
|
+
# Maximum hash value for 32-bit integers
|
|
13
|
+
MAX_HASH_VALUE = 2**32
|
|
14
|
+
|
|
15
|
+
# Default separator for bucket keys
|
|
16
|
+
DEFAULT_BUCKET_KEY_SEPARATOR = "."
|
|
17
|
+
|
|
18
|
+
# Get bucketed number from a bucket key
|
|
19
|
+
# @param bucket_key [String] The bucket key to hash
|
|
20
|
+
# @return [Integer] Bucket value between 0 and 100000
|
|
21
|
+
def self.get_bucketed_number(bucket_key)
|
|
22
|
+
hash_value = Featurevisor.murmur_hash_v3(bucket_key, HASH_SEED)
|
|
23
|
+
ratio = hash_value.to_f / MAX_HASH_VALUE
|
|
24
|
+
|
|
25
|
+
(ratio * MAX_BUCKETED_NUMBER).floor
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get bucket key from feature configuration and context
|
|
29
|
+
# @param options [Hash] Options hash containing:
|
|
30
|
+
# - feature_key [String] The feature key
|
|
31
|
+
# - bucket_by [String, Array<String>, Hash] Bucketing strategy
|
|
32
|
+
# - context [Hash] User context
|
|
33
|
+
# - logger [Logger] Logger instance
|
|
34
|
+
# @return [String] The bucket key
|
|
35
|
+
# @raise [StandardError] If bucket_by is invalid
|
|
36
|
+
def self.get_bucket_key(options)
|
|
37
|
+
feature_key = options[:feature_key]
|
|
38
|
+
bucket_by = options[:bucket_by]
|
|
39
|
+
context = options[:context]
|
|
40
|
+
logger = options[:logger]
|
|
41
|
+
|
|
42
|
+
type, attribute_keys = parse_bucket_by(bucket_by, logger, feature_key)
|
|
43
|
+
|
|
44
|
+
bucket_key = build_bucket_key(attribute_keys, context, type, feature_key)
|
|
45
|
+
|
|
46
|
+
bucket_key.join(DEFAULT_BUCKET_KEY_SEPARATOR)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Parse bucket_by configuration to determine type and attribute keys
|
|
52
|
+
# @param bucket_by [String, Array<String>, Hash] Bucketing strategy
|
|
53
|
+
# @param logger [Logger] Logger instance
|
|
54
|
+
# @param feature_key [String] Feature key for error logging
|
|
55
|
+
# @return [Array] Tuple of [type, attribute_keys]
|
|
56
|
+
def self.parse_bucket_by(bucket_by, logger, feature_key)
|
|
57
|
+
if bucket_by.is_a?(String)
|
|
58
|
+
["plain", [bucket_by]]
|
|
59
|
+
elsif bucket_by.is_a?(Array)
|
|
60
|
+
["and", bucket_by]
|
|
61
|
+
elsif bucket_by.is_a?(Hash) && bucket_by[:or].is_a?(Array)
|
|
62
|
+
["or", bucket_by[:or]]
|
|
63
|
+
else
|
|
64
|
+
logger.error("invalid bucketBy", { feature_key: feature_key, bucket_by: bucket_by })
|
|
65
|
+
raise StandardError, "invalid bucketBy"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Build bucket key array from attribute keys and context
|
|
70
|
+
# @param attribute_keys [Array<String>] Array of attribute keys
|
|
71
|
+
# @param context [Hash] User context
|
|
72
|
+
# @param type [String] Bucketing type ("plain", "and", "or")
|
|
73
|
+
# @param feature_key [String] Feature key to append
|
|
74
|
+
# @return [Array] Array of bucket key components
|
|
75
|
+
def self.build_bucket_key(attribute_keys, context, type, feature_key)
|
|
76
|
+
bucket_key = []
|
|
77
|
+
|
|
78
|
+
attribute_keys.each do |attribute_key|
|
|
79
|
+
attribute_value = Featurevisor::Conditions.get_value_from_context(context, attribute_key)
|
|
80
|
+
|
|
81
|
+
next if attribute_value.nil?
|
|
82
|
+
|
|
83
|
+
if type == "plain" || type == "and"
|
|
84
|
+
bucket_key << attribute_value
|
|
85
|
+
elsif type == "or" && bucket_key.empty?
|
|
86
|
+
# For "or" type, only take the first available value
|
|
87
|
+
bucket_key << attribute_value
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
bucket_key << feature_key
|
|
92
|
+
bucket_key
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Featurevisor
|
|
4
|
+
# Child instance class for managing child contexts and sticky features
|
|
5
|
+
class ChildInstance
|
|
6
|
+
attr_reader :parent, :context, :sticky, :emitter
|
|
7
|
+
|
|
8
|
+
# Initialize a new child instance
|
|
9
|
+
# @param options [Hash] Child instance options
|
|
10
|
+
# @option options [Instance] :parent Parent instance
|
|
11
|
+
# @option options [Hash] :context Child context
|
|
12
|
+
# @option options [Hash] :sticky Child sticky features
|
|
13
|
+
def initialize(options)
|
|
14
|
+
@parent = options[:parent]
|
|
15
|
+
@context = options[:context] || {}
|
|
16
|
+
@sticky = options[:sticky] || {}
|
|
17
|
+
@emitter = Featurevisor::Emitter.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Subscribe to an event
|
|
21
|
+
# @param event_name [String] Event name
|
|
22
|
+
# @param callback [Proc] Callback function
|
|
23
|
+
# @return [Proc] Unsubscribe function
|
|
24
|
+
def on(event_name, callback = nil, &block)
|
|
25
|
+
callback = block if block_given?
|
|
26
|
+
|
|
27
|
+
if event_name == "context_set" || event_name == "sticky_set"
|
|
28
|
+
@emitter.on(event_name, callback)
|
|
29
|
+
else
|
|
30
|
+
@parent.on(event_name, callback)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Close the child instance
|
|
35
|
+
def close
|
|
36
|
+
@emitter.clear_all
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Set context
|
|
40
|
+
# @param context [Hash] Context to set
|
|
41
|
+
# @param replace [Boolean] Whether to replace existing context
|
|
42
|
+
def set_context(context, replace = false)
|
|
43
|
+
if replace
|
|
44
|
+
@context = context
|
|
45
|
+
else
|
|
46
|
+
@context = { **@context, **context }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@emitter.trigger("context_set", {
|
|
50
|
+
context: @context,
|
|
51
|
+
replaced: replace
|
|
52
|
+
})
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Get context
|
|
56
|
+
# @param context [Hash, nil] Additional context to merge
|
|
57
|
+
# @return [Hash] Merged context
|
|
58
|
+
def get_context(context = nil)
|
|
59
|
+
@parent.get_context({
|
|
60
|
+
**@context,
|
|
61
|
+
**(context || {})
|
|
62
|
+
})
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Set sticky features
|
|
66
|
+
# @param sticky [Hash] Sticky features
|
|
67
|
+
# @param replace [Boolean] Whether to replace existing sticky features
|
|
68
|
+
def set_sticky(sticky, replace = false)
|
|
69
|
+
previous_sticky_features = @sticky || {}
|
|
70
|
+
|
|
71
|
+
if replace
|
|
72
|
+
@sticky = sticky
|
|
73
|
+
else
|
|
74
|
+
@sticky = {
|
|
75
|
+
**@sticky,
|
|
76
|
+
**sticky
|
|
77
|
+
}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
params = Featurevisor::Events.get_params_for_sticky_set_event(previous_sticky_features, @sticky, replace)
|
|
81
|
+
@emitter.trigger("sticky_set", params)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if a feature is enabled
|
|
85
|
+
# @param feature_key [String] Feature key
|
|
86
|
+
# @param context [Hash] Context
|
|
87
|
+
# @param options [Hash] Override options
|
|
88
|
+
# @return [Boolean] True if feature is enabled
|
|
89
|
+
def is_enabled(feature_key, context = {}, options = {})
|
|
90
|
+
@parent.is_enabled(
|
|
91
|
+
feature_key,
|
|
92
|
+
{
|
|
93
|
+
**@context,
|
|
94
|
+
**context
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
sticky: @sticky,
|
|
98
|
+
**options
|
|
99
|
+
}
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Get variation value
|
|
104
|
+
# @param feature_key [String] Feature key
|
|
105
|
+
# @param context [Hash] Context
|
|
106
|
+
# @param options [Hash] Override options
|
|
107
|
+
# @return [String, nil] Variation value or nil
|
|
108
|
+
def get_variation(feature_key, context = {}, options = {})
|
|
109
|
+
@parent.get_variation(
|
|
110
|
+
feature_key,
|
|
111
|
+
{
|
|
112
|
+
**@context,
|
|
113
|
+
**context
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
sticky: @sticky,
|
|
117
|
+
**options
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get variable value
|
|
123
|
+
# @param feature_key [String] Feature key
|
|
124
|
+
# @param variable_key [String] Variable key
|
|
125
|
+
# @param context [Hash] Context
|
|
126
|
+
# @param options [Hash] Override options
|
|
127
|
+
# @return [Object, nil] Variable value or nil
|
|
128
|
+
def get_variable(feature_key, variable_key, context = {}, options = {})
|
|
129
|
+
@parent.get_variable(
|
|
130
|
+
feature_key,
|
|
131
|
+
variable_key,
|
|
132
|
+
{
|
|
133
|
+
**@context,
|
|
134
|
+
**context
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
sticky: @sticky,
|
|
138
|
+
**options
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Get variable as boolean
|
|
144
|
+
# @param feature_key [String] Feature key
|
|
145
|
+
# @param variable_key [String] Variable key
|
|
146
|
+
# @param context [Hash] Context
|
|
147
|
+
# @param options [Hash] Override options
|
|
148
|
+
# @return [Boolean, nil] Boolean value or nil
|
|
149
|
+
def get_variable_boolean(feature_key, variable_key, context = {}, options = {})
|
|
150
|
+
@parent.get_variable_boolean(
|
|
151
|
+
feature_key,
|
|
152
|
+
variable_key,
|
|
153
|
+
{
|
|
154
|
+
**@context,
|
|
155
|
+
**context
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
sticky: @sticky,
|
|
159
|
+
**options
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Get variable as string
|
|
165
|
+
# @param feature_key [String] Feature key
|
|
166
|
+
# @param variable_key [String] Variable key
|
|
167
|
+
# @param context [Hash] Context
|
|
168
|
+
# @param options [Hash] Override options
|
|
169
|
+
# @return [String, nil] String value or nil
|
|
170
|
+
def get_variable_string(feature_key, variable_key, context = {}, options = {})
|
|
171
|
+
@parent.get_variable_string(
|
|
172
|
+
feature_key,
|
|
173
|
+
variable_key,
|
|
174
|
+
{
|
|
175
|
+
**@context,
|
|
176
|
+
**context
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
sticky: @sticky,
|
|
180
|
+
**options
|
|
181
|
+
}
|
|
182
|
+
)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Get variable as integer
|
|
186
|
+
# @param feature_key [String] Feature key
|
|
187
|
+
# @param variable_key [String] Variable key
|
|
188
|
+
# @param context [Hash] Context
|
|
189
|
+
# @param options [Hash] Override options
|
|
190
|
+
# @return [Integer, nil] Integer value or nil
|
|
191
|
+
def get_variable_integer(feature_key, variable_key, context = {}, options = {})
|
|
192
|
+
@parent.get_variable_integer(
|
|
193
|
+
feature_key,
|
|
194
|
+
variable_key,
|
|
195
|
+
{
|
|
196
|
+
**@context,
|
|
197
|
+
**context
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
sticky: @sticky,
|
|
201
|
+
**options
|
|
202
|
+
}
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Get variable as double
|
|
207
|
+
# @param feature_key [String] Feature key
|
|
208
|
+
# @param variable_key [String] Variable key
|
|
209
|
+
# @param context [Hash] Context
|
|
210
|
+
# @param options [Hash] Override options
|
|
211
|
+
# @return [Float, nil] Float value or nil
|
|
212
|
+
def get_variable_double(feature_key, variable_key, context = {}, options = {})
|
|
213
|
+
@parent.get_variable_double(
|
|
214
|
+
feature_key,
|
|
215
|
+
variable_key,
|
|
216
|
+
{
|
|
217
|
+
**@context,
|
|
218
|
+
**context
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
sticky: @sticky,
|
|
222
|
+
**options
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Get variable as array
|
|
228
|
+
# @param feature_key [String] Feature key
|
|
229
|
+
# @param variable_key [String] Variable key
|
|
230
|
+
# @param context [Hash] Context
|
|
231
|
+
# @param options [Hash] Override options
|
|
232
|
+
# @return [Array, nil] Array value or nil
|
|
233
|
+
def get_variable_array(feature_key, variable_key, context = {}, options = {})
|
|
234
|
+
@parent.get_variable_array(
|
|
235
|
+
feature_key,
|
|
236
|
+
variable_key,
|
|
237
|
+
{
|
|
238
|
+
**@context,
|
|
239
|
+
**context
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
sticky: @sticky,
|
|
243
|
+
**options
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Get variable as object
|
|
249
|
+
# @param feature_key [String] Feature key
|
|
250
|
+
# @param variable_key [String] Variable key
|
|
251
|
+
# @param context [Hash] Context
|
|
252
|
+
# @param options [Hash] Override options
|
|
253
|
+
# @return [Hash, nil] Object value or nil
|
|
254
|
+
def get_variable_object(feature_key, variable_key, context = {}, options = {})
|
|
255
|
+
@parent.get_variable_object(
|
|
256
|
+
feature_key,
|
|
257
|
+
variable_key,
|
|
258
|
+
{
|
|
259
|
+
**@context,
|
|
260
|
+
**context
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
sticky: @sticky,
|
|
264
|
+
**options
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Get variable as JSON
|
|
270
|
+
# @param feature_key [String] Feature key
|
|
271
|
+
# @param variable_key [String] Variable key
|
|
272
|
+
# @param context [Hash] Context
|
|
273
|
+
# @param options [Hash] Override options
|
|
274
|
+
# @return [Object, nil] JSON value or nil
|
|
275
|
+
def get_variable_json(feature_key, variable_key, context = {}, options = {})
|
|
276
|
+
@parent.get_variable_json(
|
|
277
|
+
feature_key,
|
|
278
|
+
variable_key,
|
|
279
|
+
{
|
|
280
|
+
**@context,
|
|
281
|
+
**context
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
sticky: @sticky,
|
|
285
|
+
**options
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Get all evaluations
|
|
291
|
+
# @param context [Hash] Context
|
|
292
|
+
# @param feature_keys [Array<String>] Feature keys to evaluate
|
|
293
|
+
# @param options [Hash] Override options
|
|
294
|
+
# @return [Hash] All evaluations
|
|
295
|
+
def get_all_evaluations(context = {}, feature_keys = [], options = {})
|
|
296
|
+
@parent.get_all_evaluations(
|
|
297
|
+
{
|
|
298
|
+
**@context,
|
|
299
|
+
**context
|
|
300
|
+
},
|
|
301
|
+
feature_keys,
|
|
302
|
+
{
|
|
303
|
+
sticky: @sticky,
|
|
304
|
+
**options
|
|
305
|
+
}
|
|
306
|
+
)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
end
|
|
311
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Featurevisor
|
|
4
|
+
# Semantic version comparison functionality
|
|
5
|
+
# Original: https://github.com/omichelsen/compare-versions
|
|
6
|
+
# Ported from TypeScript to Ruby
|
|
7
|
+
|
|
8
|
+
# Regular expression for semantic version parsing
|
|
9
|
+
SEMVER_REGEX = /^[v^~<>=]*?(\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+)(?:\.([x*]|\d+))?(?:-([\da-z\-]+(?:\.[\da-z\-]+)*))?(?:\+[\da-z\-]+(?:\.[\da-z\-]+)*)?)?)?$/i
|
|
10
|
+
|
|
11
|
+
# Validates and parses a version string
|
|
12
|
+
# @param version [String] Version string to validate and parse
|
|
13
|
+
# @return [Array<String>] Array of version segments
|
|
14
|
+
# @raise [TypeError] If version is not a string
|
|
15
|
+
# @raise [ArgumentError] If version is not valid semver
|
|
16
|
+
def self.validate_and_parse(version)
|
|
17
|
+
unless version.is_a?(String)
|
|
18
|
+
raise TypeError, "Invalid argument expected string"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
match = version.match(SEMVER_REGEX)
|
|
22
|
+
unless match
|
|
23
|
+
raise ArgumentError, "Invalid argument not valid semver ('#{version}' received)"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Remove the first element (full match) and return the rest
|
|
27
|
+
match.to_a[1..-1]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Checks if a string is a wildcard
|
|
31
|
+
# @param s [String] String to check
|
|
32
|
+
# @return [Boolean] True if wildcard
|
|
33
|
+
def self.wildcard?(s)
|
|
34
|
+
s == "*" || s.downcase == "x"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Forces types to be the same for comparison
|
|
38
|
+
# @param a [String, Integer] First value
|
|
39
|
+
# @param b [String, Integer] Second value
|
|
40
|
+
# @return [Array] Array with both values converted to same type
|
|
41
|
+
def self.force_type(a, b)
|
|
42
|
+
if a.is_a?(Integer) != b.is_a?(Integer)
|
|
43
|
+
[a.to_s, b.to_s]
|
|
44
|
+
else
|
|
45
|
+
[a, b]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Tries to parse a string as an integer
|
|
50
|
+
# @param v [String] String to parse
|
|
51
|
+
# @return [Integer, String] Parsed integer or original string
|
|
52
|
+
def self.try_parse(v)
|
|
53
|
+
Integer(v, 10)
|
|
54
|
+
rescue ArgumentError
|
|
55
|
+
v
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Compares two strings for version comparison
|
|
59
|
+
# @param a [String] First string
|
|
60
|
+
# @param b [String] Second string
|
|
61
|
+
# @return [Integer] -1 if a < b, 0 if equal, 1 if a > b
|
|
62
|
+
def self.compare_strings(a, b)
|
|
63
|
+
return 0 if wildcard?(a) || wildcard?(b)
|
|
64
|
+
|
|
65
|
+
ap, bp = force_type(try_parse(a), try_parse(b))
|
|
66
|
+
|
|
67
|
+
if ap > bp
|
|
68
|
+
1
|
|
69
|
+
elsif ap < bp
|
|
70
|
+
-1
|
|
71
|
+
else
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Compares version segments
|
|
77
|
+
# @param a [Array<String>, MatchData] First version segments
|
|
78
|
+
# @param b [Array<String>, MatchData] Second version segments
|
|
79
|
+
# @return [Integer] -1 if a < b, 0 if equal, 1 if a > b
|
|
80
|
+
def self.compare_segments(a, b)
|
|
81
|
+
# Convert to arrays if needed
|
|
82
|
+
a_array = a.is_a?(MatchData) ? a.to_a[1..-1] : a.to_a
|
|
83
|
+
b_array = b.is_a?(MatchData) ? b.to_a[1..-1] : b.to_a
|
|
84
|
+
|
|
85
|
+
max_length = [a_array.length, b_array.length].max
|
|
86
|
+
|
|
87
|
+
(0...max_length).each do |i|
|
|
88
|
+
a_val = a_array[i] || "0"
|
|
89
|
+
b_val = b_array[i] || "0"
|
|
90
|
+
|
|
91
|
+
result = compare_strings(a_val, b_val)
|
|
92
|
+
return result unless result == 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Compares two version strings
|
|
99
|
+
# @param v1 [String] First version string
|
|
100
|
+
# @param v2 [String] Second version string
|
|
101
|
+
# @return [Integer] -1 if v1 < v2, 0 if equal, 1 if v1 > v2
|
|
102
|
+
# @raise [TypeError] If either version is not a string
|
|
103
|
+
# @raise [ArgumentError] If either version is not valid semver
|
|
104
|
+
def self.compare_versions(v1, v2)
|
|
105
|
+
# Validate input and split into segments
|
|
106
|
+
n1 = validate_and_parse(v1)
|
|
107
|
+
n2 = validate_and_parse(v2)
|
|
108
|
+
|
|
109
|
+
# Pop off the patch
|
|
110
|
+
p1 = n1.pop
|
|
111
|
+
p2 = n2.pop
|
|
112
|
+
|
|
113
|
+
# Validate numbers
|
|
114
|
+
r = compare_segments(n1, n2)
|
|
115
|
+
return r unless r == 0
|
|
116
|
+
|
|
117
|
+
# Validate pre-release
|
|
118
|
+
if p1 && p2
|
|
119
|
+
compare_segments(p1.split("."), p2.split("."))
|
|
120
|
+
elsif p1 || p2
|
|
121
|
+
p1 ? -1 : 1
|
|
122
|
+
else
|
|
123
|
+
0
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|