Skip to content

Commit 5694431

Browse files
committed
bin
1 parent 0f25614 commit 5694431

6 files changed

Lines changed: 1473 additions & 0 deletions

File tree

bin/cli.rb

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
require "optparse"
2+
require "json"
3+
require_relative "commands"
4+
5+
module FeaturevisorCLI
6+
class Options
7+
attr_accessor :command, :assertion_pattern, :context, :environment, :feature,
8+
:key_pattern, :n, :only_failures, :quiet, :variable, :variation,
9+
:verbose, :inflate, :show_datafile, :schema_version, :project_directory_path,
10+
:populate_uuid
11+
12+
def initialize
13+
@n = 1000
14+
@project_directory_path = Dir.pwd
15+
@populate_uuid = []
16+
end
17+
end
18+
19+
class Parser
20+
def self.parse(args)
21+
options = Options.new
22+
23+
if args.empty?
24+
return options
25+
end
26+
27+
options.command = args[0]
28+
remaining_args = args[1..-1]
29+
30+
OptionParser.new do |opts|
31+
opts.banner = "Usage: featurevisor [command] [options]"
32+
33+
opts.on("--assertionPattern=PATTERN", "Assertion pattern") do |v|
34+
options.assertion_pattern = v
35+
end
36+
37+
opts.on("--context=CONTEXT", "Context JSON") do |v|
38+
options.context = v
39+
end
40+
41+
opts.on("--environment=ENV", "Environment (required for benchmark)") do |v|
42+
options.environment = v
43+
end
44+
45+
opts.on("--feature=FEATURE", "Feature key (required for benchmark)") do |v|
46+
options.feature = v
47+
end
48+
49+
opts.on("--keyPattern=PATTERN", "Key pattern") do |v|
50+
options.key_pattern = v
51+
end
52+
53+
opts.on("-n", "--iterations=N", "--n=N", Integer, "Number of iterations (default: 1000)") do |v|
54+
options.n = v
55+
end
56+
57+
opts.on("--onlyFailures", "Only show failures") do
58+
options.only_failures = true
59+
end
60+
61+
opts.on("--quiet", "Quiet mode") do
62+
options.quiet = true
63+
end
64+
65+
opts.on("--variable=VARIABLE", "Variable key") do |v|
66+
options.variable = v
67+
end
68+
69+
opts.on("--variation", "Variation mode") do
70+
options.variation = true
71+
end
72+
73+
opts.on("--verbose", "Verbose mode") do
74+
options.verbose = true
75+
end
76+
77+
opts.on("--inflate=N", Integer, "Inflate mode") do |v|
78+
options.inflate = v
79+
end
80+
81+
opts.on("--showDatafile", "Show datafile content for each test") do
82+
options.show_datafile = true
83+
end
84+
85+
opts.on("--schemaVersion=VERSION", "Schema version") do |v|
86+
options.schema_version = v
87+
end
88+
89+
opts.on("--projectDirectoryPath=PATH", "Project directory path") do |v|
90+
options.project_directory_path = v
91+
end
92+
93+
opts.on("--populateUuid=KEY", "Populate UUID for attribute key") do |v|
94+
options.populate_uuid << v
95+
end
96+
97+
opts.on("-h", "--help", "Show this help message") do
98+
puts opts
99+
exit
100+
end
101+
end.parse!(remaining_args)
102+
103+
options
104+
end
105+
end
106+
107+
def self.run(args)
108+
options = Parser.parse(args)
109+
110+
case options.command
111+
when "test"
112+
Commands::Test.run(options)
113+
when "benchmark"
114+
Commands::Benchmark.run(options)
115+
when "assess-distribution"
116+
Commands::AssessDistribution.run(options)
117+
else
118+
show_help
119+
end
120+
end
121+
122+
def self.show_help
123+
puts "Featurevisor Ruby SDK CLI"
124+
puts ""
125+
puts "Usage: featurevisor [command] [options]"
126+
puts ""
127+
puts "Commands:"
128+
puts " test Run tests for features and segments"
129+
puts " benchmark Benchmark feature evaluation performance"
130+
puts " assess-distribution Assess feature distribution across contexts"
131+
puts ""
132+
puts "Learn more at https://featurevisor.com/docs/sdks/ruby/"
133+
puts ""
134+
puts "Examples:"
135+
puts " featurevisor test"
136+
puts " featurevisor test --keyPattern=pattern"
137+
puts " featurevisor benchmark --feature=myFeature --environment=dev --n=10000"
138+
puts " featurevisor assess-distribution --feature=myFeature --n=10000"
139+
puts ""
140+
puts "Note: benchmark command requires --environment and --feature options"
141+
end
142+
end

bin/commands.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
require "json"
2+
require "securerandom"
3+
require "open3"
4+
5+
module FeaturevisorCLI
6+
module Commands
7+
class AssessDistribution
8+
# UUID_LENGTHS matches the TypeScript implementation
9+
UUID_LENGTHS = [4, 2, 2, 2, 6]
10+
11+
def self.run(options)
12+
new(options).run
13+
end
14+
15+
def initialize(options)
16+
@options = options
17+
@project_path = options.project_directory_path
18+
end
19+
20+
def run
21+
# Validate required options
22+
unless @options.environment
23+
puts "Error: --environment is required for assess-distribution command"
24+
exit 1
25+
end
26+
27+
unless @options.feature
28+
puts "Error: --feature is required for assess-distribution command"
29+
exit 1
30+
end
31+
32+
puts ""
33+
puts "Assessing distribution for feature: \"#{@options.feature}\"..."
34+
puts ""
35+
36+
# Parse context if provided
37+
context = parse_context
38+
39+
# Print context information
40+
if @options.context
41+
puts "Against context: #{@options.context}"
42+
else
43+
puts "Against context: {}"
44+
end
45+
46+
puts "Running #{@options.n} times..."
47+
puts ""
48+
49+
# Build datafile
50+
datafile = build_datafile(@options.environment)
51+
52+
# Create SDK instance
53+
instance = create_instance(datafile)
54+
55+
# Check if feature has variations
56+
feature = instance.get_feature(@options.feature)
57+
has_variations = feature && feature[:variations] && feature[:variations].length > 0
58+
59+
# Initialize evaluation counters
60+
flag_evaluations = {
61+
"enabled" => 0,
62+
"disabled" => 0
63+
}
64+
variation_evaluations = {}
65+
66+
# Run evaluations
67+
@options.n.times do |i|
68+
# Create a copy of context for this iteration
69+
context_copy = context.dup
70+
71+
# Populate UUIDs if requested
72+
if @options.populate_uuid.any?
73+
@options.populate_uuid.each do |key|
74+
context_copy[key.to_sym] = generate_uuid
75+
end
76+
end
77+
78+
# Evaluate flag
79+
flag_evaluation = instance.is_enabled(@options.feature, context_copy)
80+
if flag_evaluation
81+
flag_evaluations["enabled"] += 1
82+
else
83+
flag_evaluations["disabled"] += 1
84+
end
85+
86+
# Evaluate variation if feature has variations
87+
if has_variations
88+
variation_evaluation = instance.get_variation(@options.feature, context_copy)
89+
if variation_evaluation
90+
variation_value = variation_evaluation
91+
variation_evaluations[variation_value] ||= 0
92+
variation_evaluations[variation_value] += 1
93+
end
94+
end
95+
end
96+
97+
# Print results
98+
puts "\nFlag evaluations:"
99+
print_counts(flag_evaluations, @options.n, true)
100+
101+
if has_variations
102+
puts "\nVariation evaluations:"
103+
print_counts(variation_evaluations, @options.n, true)
104+
end
105+
end
106+
107+
private
108+
109+
def parse_context
110+
if @options.context
111+
begin
112+
context = JSON.parse(@options.context)
113+
# Convert string keys to symbols for the SDK
114+
context.transform_keys(&:to_sym)
115+
rescue JSON::ParserError => e
116+
puts "Error: Invalid JSON context: #{e.message}"
117+
exit 1
118+
end
119+
else
120+
{}
121+
end
122+
end
123+
124+
def build_datafile(environment)
125+
puts "Building datafile for environment: #{environment}..."
126+
127+
# Build the command similar to Go implementation
128+
command_parts = ["cd", @project_path, "&&", "npx", "featurevisor", "build", "--environment=#{environment}", "--json"]
129+
130+
if @options.schema_version
131+
command_parts << "--schemaVersion=#{@options.schema_version}"
132+
end
133+
134+
if @options.inflate
135+
command_parts << "--inflate=#{@options.inflate}"
136+
end
137+
138+
command = command_parts.join(" ")
139+
140+
stdout, stderr, exit_status = execute_command(command)
141+
142+
if exit_status != 0
143+
puts "Error: Command failed with exit code #{exit_status}"
144+
puts "Command: #{command}"
145+
puts "Stderr: #{stderr}"
146+
exit 1
147+
end
148+
149+
begin
150+
JSON.parse(stdout)
151+
rescue JSON::ParserError => e
152+
puts "Error: Failed to parse datafile JSON: #{e.message}"
153+
exit 1
154+
end
155+
end
156+
157+
def execute_command(command)
158+
stdout, stderr, exit_status = Open3.capture3(command)
159+
[stdout, stderr, exit_status.exitstatus]
160+
end
161+
162+
def create_instance(datafile)
163+
# Convert datafile to proper format for the SDK
164+
symbolized_datafile = symbolize_keys(datafile)
165+
166+
# Create SDK instance
167+
Featurevisor.create_instance(
168+
datafile: symbolized_datafile,
169+
log_level: get_logger_level
170+
)
171+
end
172+
173+
def symbolize_keys(obj)
174+
case obj
175+
when Hash
176+
obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
177+
when Array
178+
obj.map { |item| symbolize_keys(item) }
179+
else
180+
obj
181+
end
182+
end
183+
184+
def get_logger_level
185+
if @options.verbose
186+
"debug"
187+
elsif @options.quiet
188+
"error"
189+
else
190+
"warn"
191+
end
192+
end
193+
194+
# Generate UUID string matching the TypeScript format
195+
def generate_uuid
196+
parts = UUID_LENGTHS.map do |length|
197+
SecureRandom.hex(length)
198+
end
199+
parts.join("-")
200+
end
201+
202+
# Pretty number formatting (simple implementation)
203+
def pretty_number(n)
204+
n.to_s
205+
end
206+
207+
# Pretty percentage formatting with 2 decimal places
208+
def pretty_percentage(count, total)
209+
if total == 0
210+
"0.00%"
211+
else
212+
percentage = (count.to_f / total * 100).round(2)
213+
"#{percentage}%"
214+
end
215+
end
216+
217+
# Print evaluation counts in the same format as TypeScript
218+
def print_counts(evaluations, n, sort_results = true)
219+
# Convert to entries for sorting
220+
entries = evaluations.map { |value, count| { value: value, count: count } }
221+
222+
# Sort by count descending if requested
223+
if sort_results
224+
entries.sort_by! { |entry| -entry[:count] }
225+
end
226+
227+
# Print each entry
228+
entries.each do |entry|
229+
value_str = entry[:value].to_s
230+
count = entry[:count]
231+
puts " - #{value_str}: #{pretty_number(count)} #{pretty_percentage(count, n)}"
232+
end
233+
end
234+
end
235+
end
236+
end

0 commit comments

Comments
 (0)