From 895e299a6f28a728f72692e4fe1a6ce0ac1ad197 Mon Sep 17 00:00:00 2001 From: timcolonel Date: Fri, 5 Jun 2015 16:22:48 +0100 Subject: [PATCH] Shell interactions --- README.md | 1 + lib/clin/option.rb | 7 +- lib/clin/shell.rb | 113 ++------------------ lib/clin/shell_interaction.rb | 34 ++++++ lib/clin/shell_interaction/choose.rb | 59 ++++++++++ lib/clin/shell_interaction/file_conflict.rb | 53 +++++++++ lib/clin/shell_interaction/yes_or_no.rb | 16 +++ spec/clin/shell_interaction/choose_spec.rb | 53 +++++++++ spec/clin/shell_spec.rb | 22 +--- 9 files changed, 234 insertions(+), 124 deletions(-) create mode 100644 lib/clin/shell_interaction.rb create mode 100644 lib/clin/shell_interaction/choose.rb create mode 100644 lib/clin/shell_interaction/file_conflict.rb create mode 100644 lib/clin/shell_interaction/yes_or_no.rb create mode 100644 spec/clin/shell_interaction/choose_spec.rb diff --git a/README.md b/README.md index 29d264d..be4f541 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![Coverage Status](https://coveralls.io/repos/timcolonel/clin/badge.svg?branch=master)](https://coveralls.io/r/timcolonel/clin?branch=master) [![Code Climate](https://codeclimate.com/github/timcolonel/clin/badges/gpa.svg)](https://codeclimate.com/github/timcolonel/clin) [![Inline docs](http://inch-ci.org/github/timcolonel/clin.svg?branch=master)](http://inch-ci.org/github/timcolonel/clin) + Clin is Command Line Interface library that provide an clean api for complex command configuration. The way Clin is design allow a command defined by the user to be called via the command line as well as directly in the code without any additional configuration ## Installation diff --git a/lib/clin/option.rb b/lib/clin/option.rb index e8b838d..9ea2a69 100644 --- a/lib/clin/option.rb +++ b/lib/clin/option.rb @@ -24,7 +24,12 @@ def parse(name, usage, &block) end attr_accessor :name, :description, :optional_argument, :block, :type, :default - attr_writer :short, :long + + # Set the short name(e.g. -v for verbose) + attr_writer :short + + # Set the long name(e.g. --verbose for verbose) + attr_writer :long # Create a new option. # @param name [String] Option name. diff --git a/lib/clin/shell.rb b/lib/clin/shell.rb index 01e2ccc..d5619c9 100644 --- a/lib/clin/shell.rb +++ b/lib/clin/shell.rb @@ -36,19 +36,8 @@ def ask(statement, default: nil) # If multiple choices start with the same initial # ONLY the first one will be able to be selected using its inital def choose(statement, choices, default: nil, allow_initials: false) - choices = convert_choices(choices) - question = prepare_question(statement, choices, default: default, initials: allow_initials) - loop do - answer = ask(question, default: default) - unless answer.nil? - choices.each do |choice, _| - if choice.casecmp(answer) == 0 || (allow_initials && choice[0].casecmp(answer[0]) == 0) - return choice - end - end - end - print_choices_help(choices, allow_initials: allow_initials) - end + Clin::ShellInteraction::Choose.new(self).run(statement, choices, + default: default, allow_initials: allow_initials) end # Expect the user the return yes or no(y/n also works) @@ -57,24 +46,14 @@ def choose(statement, choices, default: nil, allow_initials: false) # @param persist [Boolean] Add "always" to the choices. When all is selected all the following # call to yes_or_no with persist: true will return true instead of asking the user. def yes_or_no(statement, default: nil, persist: false) - options = %w(yes no) - if persist - return true if @yes_or_no_persist - options << 'always' - end - choice = choose(statement, options, default: default, allow_initials: true) - if choice == 'always' - choice = 'yes' - @yes_or_no_persist = true - end - choice == 'yes' + Clin::ShellInteraction::YesOrNo.new(self).run(statement, default: default, persist: persist) end # Yes or no question defaulted to yes # @param options [Hash] Named parameters for yes_or_no # @see #yes_or_no def yes?(statement, options = {}) - options[:default] = 'yes' + options[:default] = :yes yes_or_no(statement, **options) end @@ -82,7 +61,7 @@ def yes?(statement, options = {}) # @param options [Hash] Named parameters for yes_or_no # @see #yes_or_no def no?(statement, options = {}) - options[:default] = 'no' + options[:default] = :no yes_or_no(statement, **options) end @@ -97,28 +76,7 @@ def no?(statement, options = {}) # @param block [Block] optional block that give the new content in case of diff # @return [Boolean] If the file should be overwritten. def file_conflict(filename, default: nil, &block) - choices = file_conflict_choices - choices = choices.except(:diff) unless block_given? - return true if @override_persist - loop do - result = choose("Overwrite '#{filename}'?", choices, default: default, allow_initials: true) - case result - when :yes - return true - when :no - return false - when :always - return @override_persist = true - when :quit - puts 'Aborting...' - fail SystemExit - when :diff - show_diff(filename, block.call) - next - else - next - end - end + Clin::ShellInteraction::FileConflict.new(self).run(filename, default: default, &block) end # File conflict question defaulted to yes @@ -135,61 +93,6 @@ def keep?(filename, &block) @out.print(statement + ' ') @in.gets end - - protected def choice_message(choices, default: nil, initials: false) - choices = choices.keys.map { |x| x == default ? x.to_s.upcase : x } - msg = if initials - choices.map { |x| x[0] }.join('') - else - choices.join(',') - end - "[#{msg}]" - end - - protected def prepare_question(statement, choices, default: nil, initials: false) - question = statement.clone - question << " #{choice_message(choices, default: default, initials: initials)}" - end - - protected def print_choices_help(choices, allow_initials: false) - puts 'Choose from:' - used_initials = Set.new - choices.each do |choice, description| - suf = choice.to_s - suf += ", #{description}" unless description.blank? - line = if allow_initials && !used_initials.include?(choice[0]) - used_initials << choice[0] - " #{choice[0]} - #{suf}" - else - " #{suf}" - end - puts line - end - end - - # Convert the choices to a hash with key being the choice and value the description - protected def convert_choices(choices) - if choices.is_a? Array - Hash[*choices.map { |k| [k, ''] }.flatten] - elsif choices.is_a? Hash - choices - end - end - - protected def file_conflict_choices - {yes: 'Overwrite', - no: 'Do not Overwrite', - always: 'Override this and all the next', - quit: 'Abort!', - diff: 'Show the difference', - help: 'Show this'} - end - - protected def show_diff(old_file, new_content) - Tempfile.open(File.basename(old_file)) do |f| - f.write new_content - f.rewind - system %(#{Clin.diff_cmd} "#{old_file}" "#{f.path}") - end - end end + +require 'clin/shell_interaction' diff --git a/lib/clin/shell_interaction.rb b/lib/clin/shell_interaction.rb new file mode 100644 index 0000000..ad85b3d --- /dev/null +++ b/lib/clin/shell_interaction.rb @@ -0,0 +1,34 @@ +require 'clin' + +# Parent class for shell interaction. +class Clin::ShellInteraction + class << self + attr_accessor :persist + end + + attr_accessor :shell + + # @param shell [Clin::Shell] Shell starting the interaction. + def initialize(shell) + @shell = shell + self.class.persist ||= {} + end + + # @return [Boolean] + def persist? + self.class.persist[@shell] ||= false + end + + # Mark the current shell to persist file interaction + def persist! + self.class.persist[@shell] = persist_answer + end + + def persist_answer + true + end +end + +require 'clin/shell_interaction/file_conflict' +require 'clin/shell_interaction/yes_or_no' +require 'clin/shell_interaction/choose' diff --git a/lib/clin/shell_interaction/choose.rb b/lib/clin/shell_interaction/choose.rb new file mode 100644 index 0000000..e76a3fd --- /dev/null +++ b/lib/clin/shell_interaction/choose.rb @@ -0,0 +1,59 @@ +require 'clin' + +# Handle a choose question +class Clin::ShellInteraction::Choose < Clin::ShellInteraction + def run(statement, choices, default: nil, allow_initials: false) + choices = convert_choices(choices) + question = prepare_question(statement, choices, default: default, initials: allow_initials) + loop do + answer = @shell.ask(question, default: default) + unless answer.nil? + choices.each do |choice, _| + if choice.casecmp(answer) == 0 || (allow_initials && choice[0].casecmp(answer[0]) == 0) + return choice + end + end + end + print_choices_help(choices, allow_initials: allow_initials) + end + end + + protected def choice_message(choices, default: nil, initials: false) + choices = choices.keys.map { |x| x == default ? x.to_s.upcase : x } + msg = if initials + choices.map { |x| x[0] }.join('') + else + choices.join(',') + end + "[#{msg}]" + end + + protected def prepare_question(statement, choices, default: nil, initials: false) + "#{statement} #{choice_message(choices, default: default, initials: initials)}" + end + + # Convert the choices to a hash with key being the choice and value the description + protected def convert_choices(choices) + if choices.is_a? Array + Hash[*choices.map { |k| [k, ''] }.flatten] + elsif choices.is_a? Hash + choices + end + end + + protected def print_choices_help(choices, allow_initials: false) + puts 'Choose from:' + used_initials = Set.new + choices.each do |choice, description| + suf = choice.to_s + suf += ", #{description}" unless description.blank? + line = if allow_initials && !used_initials.include?(choice[0]) + used_initials << choice[0] + " #{choice[0]} - #{suf}" + else + " #{suf}" + end + puts line + end + end +end diff --git a/lib/clin/shell_interaction/file_conflict.rb b/lib/clin/shell_interaction/file_conflict.rb new file mode 100644 index 0000000..901fbc1 --- /dev/null +++ b/lib/clin/shell_interaction/file_conflict.rb @@ -0,0 +1,53 @@ +require 'clin' + +# Handle the file_conflict interaction with the user. +class Clin::ShellInteraction::FileConflict < Clin::ShellInteraction + def run(filename, default: nil, &block) + choices = file_conflict_choices + choices = choices.except(:diff) unless block_given? + return true if persist? + result = nil + while result.nil? + choice = @shell.choose("Overwrite '#{filename}'?", choices, + default: default, allow_initials: true) + result = handle_choice(choice, filename, &block) + end + result + end + + protected def handle_choice(choice, filename, &block) + case choice + when :yes + return true + when :no + return false + when :always + return persist! + when :quit + puts 'Aborting...' + fail SystemExit + when :diff + show_diff(filename, block.call) + return nil + else + return nil + end + end + + protected def file_conflict_choices + {yes: 'Overwrite', + no: 'Do not Overwrite', + always: 'Override this and all the next', + quit: 'Abort!', + diff: 'Show the difference', + help: 'Show this'} + end + + protected def show_diff(old_file, new_content) + Tempfile.open(File.basename(old_file)) do |f| + f.write new_content + f.rewind + system %(#{Clin.diff_cmd} "#{old_file}" "#{f.path}") + end + end +end diff --git a/lib/clin/shell_interaction/yes_or_no.rb b/lib/clin/shell_interaction/yes_or_no.rb new file mode 100644 index 0000000..5ecdc27 --- /dev/null +++ b/lib/clin/shell_interaction/yes_or_no.rb @@ -0,0 +1,16 @@ +require 'clin' + +# Handle a simple yes/no interaction +class Clin::ShellInteraction::YesOrNo < Clin::ShellInteraction + def run(statement, default: nil, persist: false) + default = default.to_sym unless default.nil? + options = [:yes, :no] + if persist + return true if persist? + options << :always + end + choice = @shell.choose(statement, options, default: default, allow_initials: true) + return persist! if choice == :always + choice == :yes + end +end diff --git a/spec/clin/shell_interaction/choose_spec.rb b/spec/clin/shell_interaction/choose_spec.rb new file mode 100644 index 0000000..818c450 --- /dev/null +++ b/spec/clin/shell_interaction/choose_spec.rb @@ -0,0 +1,53 @@ +require 'clin' + +RSpec.describe Clin::ShellInteraction::Choose do + let(:shell) { Clin::Shell.new } + subject { Clin::ShellInteraction::Choose.new(shell) } + + def expects_scan(message, *outputs) + expect(shell).to receive(:scan).with(message).and_return(*outputs).exactly(outputs.size).times + end + + describe '#choice_message' do + let(:options) { {yes: '', no: '', maybe: ''} } + it { expect(subject.send(:choice_message, options)).to eq('[yes,no,maybe]') } + it { expect(subject.send(:choice_message, options, default: :yes)).to eq('[YES,no,maybe]') } + it { expect(subject.send(:choice_message, options, default: :no)).to eq('[yes,NO,maybe]') } + it { expect(subject.send(:choice_message, options, initials: true)).to eq('[ynm]') } + it { expect(subject.send(:choice_message, options, default: :yes, initials: true)).to eq('[Ynm]') } + it { expect(subject.send(:choice_message, options, default: :maybe, initials: true)).to eq('[ynM]') } + end + + describe '#prepare_question' do + let(:options) { {yes: '', no: '', maybe: ''} } + it do + expect(subject.send(:prepare_question, 'Is it true?', options)) + .to eq('Is it true? [yes,no,maybe]') + end + end + + describe '#run' do + let(:countries) { %w(usa france germany italy) } + + it 'ask for a choice and return the user reply' do + expects_scan('Where are you from? [usa,france,germany,italy]', 'France') + expect(subject.run('Where are you from?', countries)).to eq('france') + end + + it 'ask for a choice and return the default' do + expects_scan('Where are you from? [usa,france,GERMANY,italy]', '') + expect(subject.run('Where are you from?', countries, default: 'germany')).to eq('germany') + end + + it 'ask for a choice and user can reply with initials' do + expects_scan('Where are you from? [ufgi]', 'i') + expect(subject.run('Where are you from?', countries, allow_initials: true)).to eq('italy') + end + + it 'keep asking until the answer is valid' do + expects_scan('Where are you from? [usa,france,germany,italy]', 'spain', 'russia', 'france') + expect(subject).to receive(:print_choices_help).twice + expect(subject.run('Where are you from?', countries)).to eq('france') + end + end +end diff --git a/spec/clin/shell_spec.rb b/spec/clin/shell_spec.rb index e8af552..548326a 100644 --- a/spec/clin/shell_spec.rb +++ b/spec/clin/shell_spec.rb @@ -5,16 +5,6 @@ def expects_scan(message, *outputs) expect(subject).to receive(:scan).with(message).and_return(*outputs).exactly(outputs.size).times end - describe '#choice_message' do - let(:options) { {yes: '', no: '', maybe: ''} } - it { expect(subject.send(:choice_message, options)).to eq('[yes,no,maybe]') } - it { expect(subject.send(:choice_message, options, default: :yes)).to eq('[YES,no,maybe]') } - it { expect(subject.send(:choice_message, options, default: :no)).to eq('[yes,NO,maybe]') } - it { expect(subject.send(:choice_message, options, initials: true)).to eq('[ynm]') } - it { expect(subject.send(:choice_message, options, default: :yes, initials: true)).to eq('[Ynm]') } - it { expect(subject.send(:choice_message, options, default: :maybe, initials: true)).to eq('[ynM]') } - end - describe '#ask' do it 'ask for a question and return user reply' do expects_scan('What is your name?', 'Smith') @@ -44,13 +34,8 @@ def expects_scan(message, *outputs) expects_scan('Where are you from? [ufgi]', 'i') expect(subject.choose('Where are you from?', countries, allow_initials: true)).to eq('italy') end - - it 'keep asking until the answer is valid' do - expects_scan('Where are you from? [usa,france,germany,italy]', 'spain', 'russia', 'france') - expect(subject).to receive(:print_choices_help).twice - expect(subject.choose('Where are you from?', countries)).to eq('france') - end end + describe '#yes_or_no' do it 'asks the user and returns true if the user replies y' do expects_scan('Is earth round? [yn]', 'y') @@ -142,9 +127,10 @@ def expects_scan(message, *outputs) expect { subject.overwrite?('some.txt') }.to raise_error(SystemExit) end - it 'ask the user and quit when he reply quit' do + it 'ask the user and show diff when he reply diff' do expects_scan("Overwrite 'some.txt'? [Ynaqdh]", 'd', 'y') - expect(subject).to receive(:show_diff).with('some.txt', 'new_text') + expect_any_instance_of(Clin::ShellInteraction::FileConflict) + .to receive(:show_diff).with('some.txt', 'new_text') result = subject.overwrite?('some.txt') do 'new_text' end