Repository: main-branch/process_executer
Pull request #101: Allow Integer or IO as redirection sources
Author: login: jcouball
Created: 2025-03-11T19:29:14Z | Merged: 2025-03-11T20:07:01Z

Enable the use of Integer or IO objects as valid redirection sources for process execution.

Fixes #91

Linked issue #91: Exception raised when an Integer or IO are used as a redirection source
## Version

process_executer-2.0.0

## Description

When using an Integer or IO object for a redirection source by `ProcessExecuter.run`, an exception is raised. 

These work when using Process.spawn:

```Ruby
pid, status = Process.wait2(Process.spawn('echo hello', 1 => 'output.txt'))
pid, status = Process.wait2(Process.spawn('echo hello', STDOUT => 'output.txt'))
```

But fail when using ProcessExecuter.run:

```Ruby
irb(main):004> result = ProcessExecuter.run('echo hello', 1 => stdout_buffer)
/Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:139:in 'block in ProcessExecuter::Options#validate_options': undefined method 'validator' for nil (NoMethodError)

        validator = allowed_options[option].validator
                                           ^^^^^^^^^^
        from /Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:138:in 'Hash#each_key'
        from /Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:138:in 'ProcessExecuter::Options#validate_options'
        from /Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:47:in 'ProcessExecuter::Options#initialize'
        from /Users/james/github/main-branch/process_executer/lib/process_executer.rb:440:in 'Class#new'
        from /Users/james/github/main-branch/process_executer/lib/process_executer.rb:440:in 'ProcessExecuter.run_options'
        from /Users/james/github/main-branch/process_executer/lib/process_executer.rb:303:in 'ProcessExecuter.run'
        from (irb):4:in '<main>'
        from <internal:kernel>:168:in 'Kernel#loop'
        from bin/console:15:in '<main>'
irb(main):005> 
```

```
irb(main):005> result = ProcessExecuter.run('echo hello', STDOUT => stdout_buffer)
/Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:139:in 'block in ProcessExecuter::Options#validate_options': undefined method 'validator' for nil (NoMethodError)

        validator = allowed_options[option].validator
                                           ^^^^^^^^^^
        from /Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:138:in 'Hash#each_key'
        from /Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:138:in 'ProcessExecuter::Options#validate_options'
        from /Users/james/github/main-branch/process_executer/lib/process_executer/options.rb:47:in 'ProcessExecuter::Options#initialize'
        from /Users/james/github/main-branch/process_executer/lib/process_executer.rb:440:in 'Class#new'
        from /Users/james/github/main-branch/process_executer/lib/process_executer.rb:440:in 'ProcessExecuter.run_options'
        from /Users/james/github/main-branch/process_executer/lib/process_executer.rb:303:in 'ProcessExecuter.run'
        from (irb):5:in '<main>'
        from <internal:kernel>:168:in 'Kernel#loop'
        from bin/console:15:in '<main>'
irb(main):006> 
```


Diff:
diff --git a/lib/process_executer/options/base.rb b/lib/process_executer/options/base.rb
index 44c2170..0df2363 100644
--- a/lib/process_executer/options/base.rb
+++ b/lib/process_executer/options/base.rb
@@ -94,6 +94,32 @@ def to_h
         @options.dup
       end
 
+      # Iterate over each option
+      # @example
+      #   options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
+      #   options.each { |option_key, option_value| puts "#{option_key}: #{option_value}" }
+      #   # option1: value1
+      #   # option2: value2
+      #
+      # @yield [option_key, option_value]
+      # @return [void]
+      def each(&)
+        @options.each(&)
+      end
+
+      # Merge the given options into the current options
+      # @example
+      #   options = ProcessExecuter::Options.new(option1: 'value1', option2: 'value2')
+      #   options.merge!(option2: 'new_value2', option3: 'value3')
+      #   options.option2 # => 'new_value2'
+      #   options.option3 # => 'value3'
+      #
+      # @param other_options [Hash] the options to merge into the current options
+      # @return [void]
+      def merge!(**other_options)
+        @options.merge!(other_options)
+      end
+
       protected
 
       # An array of OptionDefinition objects that define the allowed options
diff --git a/lib/process_executer/options/spawn_options.rb b/lib/process_executer/options/spawn_options.rb
index bb68ae0..136e485 100644
--- a/lib/process_executer/options/spawn_options.rb
+++ b/lib/process_executer/options/spawn_options.rb
@@ -9,18 +9,13 @@ module Options
     #
     # Allow subclasses to add additional options that are not passed to `Process.spawn`
     #
-    # Valid options are those accepted by Process.spawn plus the following additions:
-    #
-    # * `:timeout_after`: the number of seconds to allow a process to run before killing it
+    # Valid options are those accepted by Process.spawn.
     #
     # @api public
     #
     class SpawnOptions < Base
       # :nocov: SimpleCov on JRuby reports hashes declared on multiple lines as not covered
       SPAWN_OPTIONS = [
-        OptionDefinition.new(:in, default: :not_set),
-        OptionDefinition.new(:out, default: :not_set),
-        OptionDefinition.new(:err, default: :not_set),
         OptionDefinition.new(:unsetenv_others, default: :not_set),
         OptionDefinition.new(:pgroup, default: :not_set),
         OptionDefinition.new(:new_pgroup, default: :not_set),
@@ -47,6 +42,66 @@ def spawn_options
         end
       end
 
+      # Determine if the given option key indicates a redirection option
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def redirection?(option_key)
+        test = ->(key) { %i[in out err].include?(key) || key.is_a?(Integer) || (key.is_a?(IO) && !key.fileno.nil?) }
+        test.call(option_key) || (option_key.is_a?(Array) && option_key.all? { |key| test.call(key) })
+      end
+
+      # Does option_key indicate a standard redirection such as stdin, stdout, or stderr
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @param symbol [:in, :out, :err] the symbol to test for
+      # @param fileno [Integer] the file descriptor number to test for
+      # @return [Boolean]
+      # @api private
+      def std_redirection?(option_key, symbol, fileno)
+        test = ->(key) { key == symbol || key == fileno || (key.is_a?(IO) && key.fileno == fileno) }
+        test.call(option_key) || (option_key.is_a?(Array) && option_key.any? { |key| test.call(key) })
+      end
+
+      # Determine if the given option key indicates a redirection option for stdout
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def stdout_redirection?(option_key) = std_redirection?(option_key, :out, 1)
+
+      # Determine the option key that indicates a redirection option for stdout
+      # @return [Symbol, Integer, IO, Array, nil] nil if not found
+      # @api private
+      def stdout_redirection_key
+        options.keys.find { |option_key| option_key if stdout_redirection?(option_key) }
+      end
+
+      # Determine the value of the redirection option for stdout
+      # @return [Object]
+      # @api private
+      def stdout_redirection_value
+        options[stdout_redirection_key]
+      end
+
+      # Determine if the given option key indicates a redirection option for stderr
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def stderr_redirection?(option_key) = std_redirection?(option_key, :err, 2)
+
+      # Determine the option key that indicates a redirection option for stderr
+      # @return [Symbol, Integer, IO, Array, nil] nil if not found
+      # @api private
+      def stderr_redirection_key
+        options.keys.find { |option_key| option_key if stderr_redirection?(option_key) }
+      end
+
+      # Determine the value of the redirection option for stderr
+      # @return [Object]
+      # @api private
+      def stderr_redirection_value
+        options[stderr_redirection_key]
+      end
+
       private
 
       # Define the allowed options
@@ -73,41 +128,15 @@ def define_options
       def include_spawn_option?(option_key, value)
         return false if value == :not_set
 
-        redirection_option?(option_key) || SPAWN_OPTIONS.any? { |o| o.name == option_key }
-      end
-
-      # Determine if the given option key indicates a non-array redirection option
-      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
-      # @return [Boolean]
-      # @api private
-      def non_array_redirection_option?(option_key)
-        %i[in out err].include?(option_key) ||
-          option_key.is_a?(Integer) ||
-          (option_key.is_a?(IO) && !option_key.fileno.nil?)
-      end
-
-      # Determine if the given option key indicates an array redirection option
-      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
-      # @return [Boolean]
-      # @api private
-      def array_redirection_option?(option_key)
-        option_key.is_a?(Array) && option_key.all? { |source| non_array_redirection_option?(source) }
-      end
-
-      # Determine if the given option key indicates a redirection option
-      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
-      # @return [Boolean]
-      # @api private
-      def redirection_option?(option_key)
-        non_array_redirection_option?(option_key) || array_redirection_option?(option_key)
+        redirection?(option_key) || SPAWN_OPTIONS.any? { |o| o.name == option_key }
       end
 
       # Spawn allows IO object and integers as options
-      # @param option [Symbol] the option to be tested
+      # @param option_key [Symbol] the option to be tested
       # @return [Boolean] true if the given option is a valid option
       # @api private
       def valid_option?(option_key)
-        super || redirection_option?(option_key)
+        super || redirection?(option_key)
       end
     end
   end
diff --git a/lib/process_executer/result.rb b/lib/process_executer/result.rb
index 74d8644..098cb05 100644
--- a/lib/process_executer/result.rb
+++ b/lib/process_executer/result.rb
@@ -121,7 +121,7 @@ def to_s
     # @return [String, nil]
     #
     def stdout
-      pipe = options.out
+      pipe = options.stdout_redirection_value
       return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
 
       pipe.destination.string
@@ -141,7 +141,7 @@ def stdout
     # @return [String, nil]
     #
     def stderr
-      pipe = options.err
+      pipe = options.stderr_redirection_value
       return nil unless pipe.is_a?(ProcessExecuter::MonitoredPipe)
 
       pipe.destination.string
diff --git a/lib/process_executer/runner.rb b/lib/process_executer/runner.rb
index a7a375d..c7b3bf5 100644
--- a/lib/process_executer/runner.rb
+++ b/lib/process_executer/runner.rb
@@ -32,12 +32,19 @@ class Runner
     # @return [ProcessExecuter::Result] The result of the completed subprocess
     #
     def call(command, options)
-      options.out = StringIO.new if options.out == :not_set
-      options.err = StringIO.new if options.err == :not_set
-
+      set_default_stdout_stderr(options)
       spawn(command, options).tap { |result| process_result(result) }
     end
 
+    # Set default values for stdout and stderr if not already set
+    # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
+    # @return [void]
+    # @api private
+    def set_default_stdout_stderr(options) # rubocop:disable Naming/AccessorMethodName
+      options.merge!(out: StringIO.new) unless options.stdout_redirection_key
+      options.merge!(err: StringIO.new) unless options.stderr_redirection_key
+    end
+
     private
 
     # Wrap the output buffers in pipes and then execute the command
@@ -53,13 +60,27 @@ def call(command, options)
     # @api private
     #
     def spawn(command, options)
-      options.out = ProcessExecuter::MonitoredPipe.new(options.out)
-      options.err = ProcessExecuter::MonitoredPipe.new(options.err)
-
+      opened_pipes = wrap_stdout_stderr(options)
       ProcessExecuter.spawn_and_wait_with_options(command, options)
     ensure
-      close_pipe(command, :stdout, options.out)
-      close_pipe(command, :stderr, options.err)
+      opened_pipes.each { |key, value| close_pipe(command, key, value) }
+    end
+
+    # Wrap the stdout and stderr redirection options with a MonitoredPipe
+    # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
+    # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
+    # @api private
+    def wrap_stdout_stderr(options)
+      opened_pipes = {}
+
+      options.each do |key, value|
+        next unless options.stdout_redirection?(key) || options.stderr_redirection?(key)
+
+        opened_pipes[key] = ProcessExecuter::MonitoredPipe.new(value)
+        options.merge!(key => opened_pipes[key])
+      end
+
+      opened_pipes
     end
 
     # Close the pipe and raise an error if the pipe raised an exception
@@ -67,9 +88,9 @@ def spawn(command, options)
     # @raise [ProcessExecuter::ProcessIOError] If an exception was raised while
     #   collecting subprocess output
     # @api private
-    def close_pipe(command, pipe_name, pipe)
+    def close_pipe(command, option_key, pipe)
       pipe.close
-      raise_pipe_error(command, pipe_name, pipe) if pipe.exception
+      raise_pipe_error(command, option_key, pipe) if pipe.exception
     end
 
     # Process the result of the command and return a ProcessExecuter::Result
@@ -118,7 +139,7 @@ def log_result(result)
     # Raise an error when there was exception while collecting the subprocess output
     #
     # @param command [Array<String>] The command that was executed
-    # @param pipe_name [Symbol] The name of the pipe that raised the exception
+    # @param option_key [Symbol] The name of the pipe that raised the exception
     # @param pipe [ProcessExecuter::MonitoredPipe] The pipe that raised the exception
     #
     # @raise [ProcessExecuter::ProcessIOError]
@@ -127,8 +148,8 @@ def log_result(result)
     #
     # @api private
     #
-    def raise_pipe_error(command, pipe_name, pipe)
-      error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{pipe_name}")
+    def raise_pipe_error(command, option_key, pipe)
+      error = ProcessExecuter::ProcessIOError.new("Pipe Exception for #{command}: #{option_key.inspect}")
       raise(error, cause: pipe.exception)
     end
   end
diff --git a/spec/process_executer/options/run_options_spec.rb b/spec/process_executer/options/run_options_spec.rb
index 58af0b4..3345ccc 100644
--- a/spec/process_executer/options/run_options_spec.rb
+++ b/spec/process_executer/options/run_options_spec.rb
@@ -12,9 +12,6 @@
     context 'when no options are given' do
       it 'should set all options to their default values' do
         expect(subject).to have_attributes(
-          in: :not_set,
-          out: :not_set,
-          err: :not_set,
           unsetenv_others: :not_set,
           pgroup: :not_set,
           new_pgroup: :not_set,
diff --git a/spec/process_executer/options/spawn_and_wait_options_spec.rb b/spec/process_executer/options/spawn_and_wait_options_spec.rb
index 1ce3b02..70e757d 100644
--- a/spec/process_executer/options/spawn_and_wait_options_spec.rb
+++ b/spec/process_executer/options/spawn_and_wait_options_spec.rb
@@ -12,9 +12,6 @@
     context 'when no options are given' do
       it 'should set all options to their default values' do
         expect(subject).to have_attributes(
-          in: :not_set,
-          out: :not_set,
-          err: :not_set,
           unsetenv_others: :not_set,
           pgroup: :not_set,
           new_pgroup: :not_set,
diff --git a/spec/process_executer/options/spawn_options_spec.rb b/spec/process_executer/options/spawn_options_spec.rb
index 172f995..fd286f8 100644
--- a/spec/process_executer/options/spawn_options_spec.rb
+++ b/spec/process_executer/options/spawn_options_spec.rb
@@ -12,9 +12,6 @@
     context 'when no options are given' do
       it 'should set all options to their default values' do
         expect(subject).to have_attributes(
-          in: :not_set,
-          out: :not_set,
-          err: :not_set,
           unsetenv_others: :not_set,
           pgroup: :not_set,
           new_pgroup: :not_set,
diff --git a/spec/process_executer_run_spec.rb b/spec/process_executer_run_spec.rb
index 623d108..ff9aba3 100644
--- a/spec/process_executer_run_spec.rb
+++ b/spec/process_executer_run_spec.rb
@@ -420,5 +420,122 @@
         end
       end
     end
+
+    describe 'capturing stdout and stderr' do
+      context "when given { out: 'stdout.txt' }" do
+        let(:command) { ruby_command "STDOUT.puts 'stdout output'" }
+        let(:options) { { out: 'stdout.txt' } }
+        it 'should capture stdout to the stdout.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stdout.txt').gsub("\r\n", "\n")).to eq("stdout output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { 1: 'stdout.txt' }" do
+        let(:command) { ruby_command "STDOUT.puts 'stdout output'" }
+        let(:options) { { 1 => 'stdout.txt' } }
+        it 'should capture stdout to the stdout.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stdout.txt').gsub("\r\n", "\n")).to eq("stdout output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { STDOUT => 'stdout.txt' }" do
+        let(:command) { ruby_command "STDOUT.puts 'stdout output'" }
+        let(:options) { { $stdout => 'stdout.txt' } }
+        it 'should capture stdout to the stdout.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stdout.txt').gsub("\r\n", "\n")).to eq("stdout output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { err: 'stderr.txt' }" do
+        let(:command) { ruby_command "STDERR.puts 'stderr output'" }
+        let(:options) { { err: 'stderr.txt' } }
+        it 'should capture stderr to the stderr.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stderr.txt').gsub("\r\n", "\n")).to eq("stderr output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { 2 => 'stderr.txt' }" do
+        let(:command) { ruby_command "STDERR.puts 'stderr output'" }
+        let(:options) { { 2 => 'stderr.txt' } }
+        it 'should capture stderr to the stderr.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stderr.txt').gsub("\r\n", "\n")).to eq("stderr output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { STDERR => 'stderr.txt' }" do
+        let(:command) { ruby_command "STDERR.puts 'stderr output'" }
+        let(:options) { { $stderr => 'stderr.txt' } }
+        it 'should capture stderr to the stderr.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stderr.txt').gsub("\r\n", "\n")).to eq("stderr output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { out: 'stdout.txt', err: 'stderr.txt' }" do
+        let(:command) { ruby_command "STDOUT.puts 'stdout output'; STDERR.puts 'stderr output'" }
+        let(:options) { { out: 'stdout.txt', err: 'stderr.txt' } }
+
+        it 'should capture stdout to the stdout.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stdout.txt').gsub("\r\n", "\n")).to eq("stdout output\n")
+            end
+          end
+        end
+
+        it 'should capture stderr to the stderr.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('stderr.txt').gsub("\r\n", "\n")).to eq("stderr output\n")
+            end
+          end
+        end
+      end
+
+      context "when given { [1, 2] => 'output.txt' }" do
+        let(:command) { ruby_command "STDOUT.puts 'stdout output'; STDERR.puts 'stderr output'" }
+        let(:options) { { [1, 2] => 'output.txt' } }
+        it 'should capture both stdout and stderr to output.txt' do
+          Dir.mktmpdir do |dir|
+            Dir.chdir(dir) do
+              ProcessExecuter.run(*command, **options)
+              expect(File.read('output.txt').gsub("\r\n", "\n")).to match(/^stdout output\n/)
+              expect(File.read('output.txt').gsub("\r\n", "\n")).to match(/^stderr output\n/)
+            end
+          end
+        end
+      end
+    end
   end
 end


Reviews:
user: login: qlty-cloud-legacy[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-11T19:34:40Z

Review comments:
diff_hunk: @@ -53,23 +60,35 @@ def call(command, options)
     # @api private
     #
     def spawn(command, options)
-      options.out = ProcessExecuter::MonitoredPipe.new(options.out)
-      options.err = ProcessExecuter::MonitoredPipe.new(options.err)
-
+      opened_pipes = wrap_stdout_stderr(options)
       ProcessExecuter.spawn_and_wait_with_options(command, options)
     ensure
-      close_pipe(command, :stdout, options.out)
-      close_pipe(command, :stderr, options.err)
+      opened_pipes.each { |key, value| close_pipe(command, key, value) }
+    end
+
+    # Wrap the stdout and stderr redirection options with a MonitoredPipe
+    # @param options [ProcessExecuter::Options::RunOptions] Options for running the command
+    # @return [Hash<Object, ProcessExecuter::MonitoredPipe>] The opened pipes (the Object is the option key)
+    # @api private
+    def wrap_stdout_stderr(options)
path: lib/process_executer/runner.rb
user: login: qlty-cloud-legacy[bot]
body: Method `wrap_stdout_stderr` has a Cognitive Complexity of 6 (exceeds 5 allowed). Consider refactoring.
created_at: 2025-03-11T19:34:39Z
author_association: NONE
diff_hunk: @@ -47,6 +42,61 @@ def spawn_options
         end
       end
 
+      # Determine if the given option key indicates a redirection option
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def redirection?(option_key)
+        test = ->(key) { %i[in out err].include?(key) || key.is_a?(Integer) || (key.is_a?(IO) && !key.fileno.nil?) }
+        test.call(option_key) || (option_key.is_a?(Array) && option_key.all? { |key| test.call(key) })
+      end
+
+      # Determine if the given option key indicates a redirection option for stdout
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def stdout_redirection?(option_key)
path: lib/process_executer/options/spawn_options.rb
user: login: qlty-cloud-legacy[bot]
body: Similar blocks of code found in 2 locations. Consider refactoring.
created_at: 2025-03-11T19:34:39Z
author_association: NONE
diff_hunk: @@ -47,6 +42,61 @@ def spawn_options
         end
       end
 
+      # Determine if the given option key indicates a redirection option
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def redirection?(option_key)
+        test = ->(key) { %i[in out err].include?(key) || key.is_a?(Integer) || (key.is_a?(IO) && !key.fileno.nil?) }
+        test.call(option_key) || (option_key.is_a?(Array) && option_key.all? { |key| test.call(key) })
+      end
+
+      # Determine if the given option key indicates a redirection option for stdout
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def stdout_redirection?(option_key)
+        test = ->(key) { key == :out || key == 1 || (key.is_a?(IO) && key.fileno == 1) }
+        test.call(option_key) || (option_key.is_a?(Array) && option_key.any? { |key| test.call(key) })
+      end
+
+      # Determine the option key that indicates a redirection option for stdout
+      # @return [Symbol, Integer, IO, Array, nil] nil if not found
+      # @api private
+      def stdout_redirection_key
+        options.keys.find { |option_key| option_key if stdout_redirection?(option_key) }
+      end
+
+      # Determine the value of the redirection option for stdout
+      # @return [Object]
+      # @api private
+      def stdout_redirection_value
+        options[stdout_redirection_key]
+      end
+
+      # Determine if the given option key indicates a redirection option for stderr
+      # @param option_key [Symbol, Integer, IO, Array] the option key to be tested
+      # @return [Boolean]
+      # @api private
+      def stderr_redirection?(option_key)
path: lib/process_executer/options/spawn_options.rb
user: login: qlty-cloud-legacy[bot]
body: Similar blocks of code found in 2 locations. Consider refactoring.
created_at: 2025-03-11T19:34:40Z
author_association: NONE

PR comments:
user: login: qlty-cloud-legacy[bot]
created_at: 2025-03-11T20:06:22Z
body: Code Climate has analyzed commit 152bda09 and detected **0 issues** on this pull request.

The test coverage on the diff in this pull request is **100.0%** (**100%** is the threshold).

This pull request will bring the total coverage in the repository to **100.0%** (**0.0%** change).

View more on [Code Climate](https://codeclimate.com/github/main-branch/process_executer/pull/101).

author_association: NONE