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.
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