Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
48 changes: 48 additions & 0 deletions lib/dry/cli/command.rb
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,54 @@ def self.option(name, options = {})
@options << Option.new(name, options)
end

# Mutually exclusive options (aka options that cannot be used together)
#
# @param options [Array] options that cannot be used together
#
# @since 1.3.0
#
# @example Basic usage
# require "dry/cli"
#
# class Greetings < Dry::CLI::Command
# mutually_exclusive_options [
# [:english, {desc: "Chooses English", type: :flag}],
# [:spanish, {desc: "Chooses Spanish", type: :flag}],
# [:portuguese, {desc: "Chooses Portuguese", type: :flag}]
# ]
#
# def call(options)
# if options.key?(:english)
# puts "Good morning"
# elsif options.key?(:spanish)
# puts "Buenos días"
# elsif options.key?(:portuguese)
# puts "Bom dia"
# end
# end
# end
#
# # $ foo greetings --english
# # Good morning
#
# # $ foo greetings --english --spanish
# # ERROR: "foo greetings" was called with arguments "--english --spanish"
def self.mutually_exclusive_options(opts)
names = opts.map { _1[0] }

opts.each do |o|
current_name, current_opts = o
current_opts ||= {}

current_opts.merge!(
{
conflicts_with: names.reject { _1 == current_name }
}
)
option(current_name, current_opts)
end
end

# @since 0.1.0
# @api private
def self.params
Expand Down
4 changes: 4 additions & 0 deletions lib/dry/cli/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ class CLI
class Error < StandardError
end

# @since 1.3.0
class InvalidOptionCombination < Error
end

# @since 0.2.1
class UnknownCommandError < Error
# @since 0.2.1
Expand Down
15 changes: 15 additions & 0 deletions lib/dry/cli/option.rb
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ def description_name
options[:label] || name.upcase
end

# @since 1.3.0
# @api private
def conflicts_with
options[:conflicts_with] || []
end

# @since 0.1.0
# @api private
def argument?
Expand Down Expand Up @@ -121,6 +127,15 @@ def alias_names
.map { |name| name.size == 1 ? "-#{name}" : "--#{name}" }
.map { |name| boolean? || flag? ? name : "#{name} VALUE" }
end

# @since 1.3.0
# @api private
def conflicts_with?(opt)
candidates = conflicts_with
return false if candidates.empty?

candidates.include?(opt)
end
end

# Command line argument
Expand Down
5 changes: 4 additions & 1 deletion lib/dry/cli/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ def self.call(command, arguments, prog_name)
OptionParser.new do |opts|
command.options.each do |option|
opts.on(*option.parser_options) do |value|
conflict_found = parsed_options.keys.find { option.conflicts_with?(_1) }
raise InvalidOptionCombination if conflict_found

parsed_options[option.name.to_sym] = value
end
end
Expand All @@ -31,7 +34,7 @@ def self.call(command, arguments, prog_name)

parsed_options = command.default_params.merge(parsed_options)
parse_required_params(command, arguments, prog_name, parsed_options)
rescue ::OptionParser::ParseError
rescue ::OptionParser::ParseError, InvalidOptionCombination
Result.failure("ERROR: \"#{prog_name}\" was called with arguments \"#{original_arguments.join(" ")}\"") # rubocop:disable Layout/LineLength
end

Expand Down
5 changes: 4 additions & 1 deletion spec/support/fixtures/foo
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,10 @@ module Foo
class Greeting < Dry::CLI::Command
argument :response, default: "Hello World"

option :person
mutually_exclusive_options [
[:person],
[:alien, {desc: "Choose an alien", type: :string}]
]

def call(response:, **options)
puts "response: #{response}, person: #{options[:person]}"
Expand Down
5 changes: 4 additions & 1 deletion spec/support/fixtures/shared_commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,10 @@ def call(*)
class Greeting < Dry::CLI::Command
argument :response, default: "Hello World"

option :person
mutually_exclusive_options [
[:person],
[:alien, {desc: "Choose an alien", type: :string}]
]

def call(response:, **options)
puts "response: #{response}, person: #{options[:person]}"
Expand Down
5 changes: 5 additions & 0 deletions spec/support/shared_examples/commands.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
expect(output).to eq("generate secret - app: web\n")
end

it "fails when using options that conflict" do
error = capture_error { cli.call(arguments: %w[greeting hello --person=Gustavo --alien=Orion]) }
expect(error).to eq("ERROR: \"rspec greeting\" was called with arguments \"hello --person=Gustavo --alien=Orion\"\n")
end

context "works with params" do
it "without params" do
output = capture_output { cli.call(arguments: ["server"]) }
Expand Down
25 changes: 25 additions & 0 deletions spec/unit/dry/cli/command_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

RSpec.describe "Command" do
describe "#self.mutually_exclusive_options" do
class MutuallyExclusiveOpts < Dry::CLI::Command
mutually_exclusive_options [
[:steps, {desc: "Number of versions to rollback"}],
[:version, {desc: "The target version of the rollback (see `foo db version`)"}]
]

def call(**); end
end

it "defines mutually exclusive options" do
c = MutuallyExclusiveOpts.new

opts = c.options
expect(opts.size).to eq(2)
expect(opts[0].name).to eq(:steps)
expect(opts[0].conflicts_with).to eq([:version])
expect(opts[1].name).to eq(:version)
expect(opts[1].conflicts_with).to eq([:steps])
end
end
end
21 changes: 21 additions & 0 deletions spec/unit/dry/cli/option_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

RSpec.describe "Option" do
describe "#conflicts_with?" do
it "returns if the option/argument conflicts with the option/argument passed as the argument" do
opt = Dry::CLI::Option.new(:opt, {conflicts_with: %i[arg]})
arg = Dry::CLI::Argument.new(:arg, {conflicts_with: %i[a b]})
without_conflicts = Dry::CLI::Argument.new(:arg2)

expect(opt.conflicts_with?(:arg)).to eq(true)
expect(opt.conflicts_with?(:a)).to eq(false)
expect(arg.conflicts_with?(:a)).to eq(true)
expect(arg.conflicts_with?(:b)).to eq(true)
expect(arg.conflicts_with?(:opt)).to eq(false)
expect(without_conflicts.conflicts_with?(:opt)).to eq(false)
expect(without_conflicts.conflicts_with?(:arg)).to eq(false)
expect(without_conflicts.conflicts_with?(:a)).to eq(false)
expect(without_conflicts.conflicts_with?(:b)).to eq(false)
end
end
end