diff --git a/features/cli_usage.feature b/features/cli_usage.feature index 3903b5d..a7329e0 100644 --- a/features/cli_usage.feature +++ b/features/cli_usage.feature @@ -6,5 +6,10 @@ Feature: CLI usage Then the exit status must be 64 And the output must contain exactly: """ - Usage: producer [-v] [-n] [-t host.example] recipe_file + Usage: producer [options] [recipes] + + options: + -v, --verbose enable verbose mode + -n, --dry-run enable dry run mode + -t, --target HOST target host """ diff --git a/lib/producer/core.rb b/lib/producer/core.rb index 5208076..4e1c85f 100644 --- a/lib/producer/core.rb +++ b/lib/producer/core.rb @@ -1,5 +1,6 @@ require 'etc' require 'forwardable' +require 'optparse' require 'pathname' require 'net/ssh' diff --git a/lib/producer/core/cli.rb b/lib/producer/core/cli.rb index 3e483cf..52d1993 100644 --- a/lib/producer/core/cli.rb +++ b/lib/producer/core/cli.rb @@ -3,8 +3,7 @@ module Producer class CLI ArgumentError = Class.new(::ArgumentError) - OPTIONS_USAGE = '[-v] [-n] [-t host.example]'.freeze - USAGE = "Usage: #{File.basename $0} #{OPTIONS_USAGE} recipe_file".freeze + USAGE = "Usage: #{File.basename $0} [options] [recipes]".freeze EX_USAGE = 64 EX_SOFTWARE = 70 @@ -15,8 +14,8 @@ module Producer begin cli.parse_arguments! cli.run - rescue ArgumentError - stderr.puts USAGE + rescue ArgumentError => e + stderr.puts e.message exit EX_USAGE rescue RuntimeError => e stderr.puts "#{e.class.name.split('::').last}: #{e.message}" @@ -25,40 +24,55 @@ module Producer end end - attr_reader :arguments, :env + attr_reader :arguments, :stdin, :stdout, :stderr, :env def initialize(args, stdin: $stdin, stdout: $stdout, stderr: $stderr) @arguments = args @stdin = stdin @stdout = stdout - @env = Env.new(input: stdin, output: stdout, error_output: stderr) + @stderr = stderr + @env = build_env end def parse_arguments! - @arguments = arguments.each_with_index.inject([]) do |m, (e, i)| - case e - when '-v' - env.verbose = true - when '-n' - env.dry_run = true - when '-t' - env.target = arguments.delete_at i + 1 - else - m << e - end - m - end - - fail ArgumentError unless @arguments.any? + option_parser.parse!(@arguments) + fail ArgumentError, option_parser if @arguments.empty? end def run(worker: Worker.new(@env)) - worker.process recipe.tasks + evaluate_recipes.each { |recipe| worker.process recipe.tasks } @env.cleanup end - def recipe - @recipe ||= Recipe::FileEvaluator.evaluate(@arguments.first, env) + def evaluate_recipes + @arguments.map { |e| Recipe::FileEvaluator.evaluate(e, @env) } + end + + + private + + def build_env + Env.new(input: @stdin, output: @stdout, error_output: @stderr) + end + + def option_parser + OptionParser.new do |opts| + opts.banner = USAGE + opts.separator '' + opts.separator 'options:' + + opts.on '-v', '--verbose', 'enable verbose mode' do |e| + env.verbose = true + end + + opts.on '-n', '--dry-run', 'enable dry run mode' do |e| + env.dry_run = true + end + + opts.on '-t', '--target HOST', 'target host' do |e| + env.target = e + end + end end end end diff --git a/spec/producer/core/cli_spec.rb b/spec/producer/core/cli_spec.rb index 0499e0f..c4fa56e 100644 --- a/spec/producer/core/cli_spec.rb +++ b/spec/producer/core/cli_spec.rb @@ -5,28 +5,42 @@ module Producer::Core include ExitHelpers include FixturesHelpers - let(:recipe_file) { fixture_path_for 'recipes/some_recipe.rb' } let(:options) { [] } + let(:recipe_file) { fixture_path_for 'recipes/some_recipe.rb' } let(:arguments) { [*options, recipe_file] } subject(:cli) { described_class.new(arguments) } describe '.run!' do - let(:stderr) { StringIO.new } - subject(:run!) { described_class.run! arguments, stderr: stderr } + subject(:run!) { described_class.run! arguments } - context 'when given arguments are invalid' do + it 'builds a new CLI instance with given arguments' do + expect(described_class) + .to receive(:new).with(arguments, anything).and_call_original + run! + end + + it 'parses new CLI instance arguments' do + expect_any_instance_of(described_class).to receive :parse_arguments! + run! + end + + it 'runs the new CLI instance' do + expect_any_instance_of(described_class).to receive :run + run! + end + + context 'when no recipe is given' do let(:arguments) { [] } it 'exits with a return status of 64' do - expect { run! }.to raise_error(SystemExit) do |e| - expect(e.status).to eq 64 - end + expect { described_class.run! arguments, stderr: StringIO.new } + .to raise_error(SystemExit) { |e| expect(e.status).to eq 64 } end it 'prints the usage on the error stream' do - trap_exit { run! } - expect(stderr.string).to match /\AUsage: .+/ + expect { trap_exit { run! } } + .to output(/\AUsage: .+/).to_stderr end end @@ -34,92 +48,95 @@ module Producer::Core let(:recipe_file) { fixture_path_for 'recipes/raise.rb' } it 'exits with a return status of 70' do - expect { run! }.to raise_error(SystemExit) do |e| - expect(e.status).to eq 70 - end + expect { described_class.run! arguments, stderr: StringIO.new } + .to raise_error(SystemExit) { |e| expect(e.status).to eq 70 } end it 'prints exception name and message and the error stream' do - trap_exit { run! } - expect(stderr.string).to eq "RemoteCommandExecutionError: false\n" + expect { trap_exit { run! } } + .to output("RemoteCommandExecutionError: false\n").to_stderr end end end - describe '#env' do - let(:stdin) { StringIO.new } - let(:stdout) { StringIO.new } - let(:stderr) { StringIO.new } - - subject(:cli) { described_class.new(arguments, - stdin: stdin, stdout: stdout, stderr: stderr) } - - it 'returns an env' do + describe '#initialize' do + it 'assigns an env' do expect(cli.env).to be_an Env end it 'assigns CLI stdin as the env input' do - expect(cli.env.input).to be stdin + expect(cli.env.input).to be cli.stdin end it 'assigns CLI stdout as the env output' do - expect(cli.env.output).to be stdout + expect(cli.env.output).to be cli.stdout end it 'assigns CLI stderr as the env error output' do - expect(cli.env.error_output).to be stderr + expect(cli.env.error_output).to be cli.stderr end end describe '#parse_arguments!' do - context 'with options' do - let(:options) { %w[-v -t some_host.example] } + let(:options) { %w[-v -t some_host.example] } - before { cli.parse_arguments! } + it 'removes options from arguments' do + cli.parse_arguments! + expect(cli.arguments).to eq [recipe_file] + end - it 'removes options from arguments' do - expect(cli.arguments).to eq [recipe_file] - end + context 'with verbose option' do + let(:options) { %w[-v] } - context 'verbose' do - let(:options) { %w[-v] } - - it 'enables env verbose mode' do - expect(cli.env).to be_verbose - end - end - - context 'dry run' do - let(:options) { %w[-n] } - - it 'enables env dry run mode' do - expect(cli.env).to be_dry_run - end - end - - context 'target' do - let(:options) { %w[-t some_host.example] } - - it 'assigns the given target to the env' do - expect(cli.env.target).to eq 'some_host.example' - end + it 'enables env verbose mode' do + cli.parse_arguments! + expect(cli.env).to be_verbose end end - context 'without arguments' do + context 'with dry run option' do + let(:options) { %w[-n] } + + it 'enables env dry run mode' do + cli.parse_arguments! + expect(cli.env).to be_dry_run + end + end + + context 'with target option' do + let(:options) { %w[-t some_host.example] } + + it 'assigns the given target to the env' do + cli.parse_arguments! + expect(cli.env.target).to eq 'some_host.example' + end + end + + context 'with combined options' do + let(:options) { %w[-vn]} + + it 'handles combined options' do + cli.parse_arguments! + expect(cli.env).to be_verbose.and be_dry_run + end + end + + context 'when no arguments remains after parsing' do let(:arguments) { [] } - it 'raises the argument error exception' do - expect { cli.parse_arguments! }.to raise_error described_class::ArgumentError + it 'raises an error' do + expect { cli.parse_arguments! } + .to raise_error described_class::ArgumentError end end end describe '#run' do - it 'processes recipe tasks with a worker' do + it 'processes recipes tasks with a worker' do worker = instance_spy Worker cli.run worker: worker - expect(worker).to have_received(:process).with cli.recipe.tasks + expect(worker).to have_received(:process) + .with all be_an_instance_of Task end it 'cleans up the env' do @@ -128,12 +145,9 @@ module Producer::Core end end - describe '#recipe' do - it 'returns the evaluated recipe' do - expect(cli.recipe.tasks).to match [ - an_object_having_attributes(name: :some_task), - an_object_having_attributes(name: :another_task) - ] + describe '#evaluate_recipes' do + it 'returns the evaluated recipes' do + expect(cli.evaluate_recipes).to all be_an_instance_of Recipe end end end