diff --git a/lib/dry/cli/command.rb b/lib/dry/cli/command.rb index 3e6a601..68a7da1 100644 --- a/lib/dry/cli/command.rb +++ b/lib/dry/cli/command.rb @@ -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 diff --git a/lib/dry/cli/errors.rb b/lib/dry/cli/errors.rb index e9ca380..7f22577 100644 --- a/lib/dry/cli/errors.rb +++ b/lib/dry/cli/errors.rb @@ -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 diff --git a/lib/dry/cli/option.rb b/lib/dry/cli/option.rb index 710de98..1720b32 100644 --- a/lib/dry/cli/option.rb +++ b/lib/dry/cli/option.rb @@ -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? @@ -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 diff --git a/lib/dry/cli/parser.rb b/lib/dry/cli/parser.rb index aac127f..f065f8c 100644 --- a/lib/dry/cli/parser.rb +++ b/lib/dry/cli/parser.rb @@ -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 @@ -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 diff --git a/spec/support/fixtures/foo b/spec/support/fixtures/foo index bf37e0f..ffacfbf 100755 --- a/spec/support/fixtures/foo +++ b/spec/support/fixtures/foo @@ -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]}" diff --git a/spec/support/fixtures/shared_commands.rb b/spec/support/fixtures/shared_commands.rb index f694cd9..81e0a1f 100644 --- a/spec/support/fixtures/shared_commands.rb +++ b/spec/support/fixtures/shared_commands.rb @@ -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]}" diff --git a/spec/support/shared_examples/commands.rb b/spec/support/shared_examples/commands.rb index 4522201..98c1d16 100644 --- a/spec/support/shared_examples/commands.rb +++ b/spec/support/shared_examples/commands.rb @@ -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"]) } diff --git a/spec/unit/dry/cli/command_spec.rb b/spec/unit/dry/cli/command_spec.rb new file mode 100644 index 0000000..99122a3 --- /dev/null +++ b/spec/unit/dry/cli/command_spec.rb @@ -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 diff --git a/spec/unit/dry/cli/option_spec.rb b/spec/unit/dry/cli/option_spec.rb new file mode 100644 index 0000000..f8e4c73 --- /dev/null +++ b/spec/unit/dry/cli/option_spec.rb @@ -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