From d63f6553f01a6cdb39e6b28ab831a7327e83ba95 Mon Sep 17 00:00:00 2001 From: Albert Alef Date: Mon, 2 Feb 2026 20:35:51 -0300 Subject: [PATCH 1/4] feat: add debug mode --- examples/example1.rb | 6 +- lib/rubyshell.rb | 49 +++++++- lib/rubyshell/command.rb | 8 +- lib/rubyshell/debugger.rb | 28 +++++ lib/rubyshell/executor.rb | 29 ++++- lib/rubyshell/results/string_result.rb | 8 ++ lib/rubyshell/terminal_executor.rb | 5 +- spec/debugger_spec.rb | 161 +++++++++++++++++++++++++ spec/spec_helper.rb | 2 +- 9 files changed, 283 insertions(+), 13 deletions(-) create mode 100644 lib/rubyshell/debugger.rb create mode 100644 spec/debugger_spec.rb diff --git a/examples/example1.rb b/examples/example1.rb index 4aa2048..da4776d 100755 --- a/examples/example1.rb +++ b/examples/example1.rb @@ -1,11 +1,11 @@ #!/usr/bin/env ruby # frozen_string_literal: true -require "rubyshell" +require_relative "../lib/rubyshell" require "securerandom" -sh do - mkdir "files" +sh(debug: true) do + mkdir "-p", "files" cd "files" do 5.times do |i| diff --git a/lib/rubyshell.rb b/lib/rubyshell.rb index 1a33137..4e3d4ff 100644 --- a/lib/rubyshell.rb +++ b/lib/rubyshell.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "logger" + require_relative "rubyshell/version" require_relative "rubyshell/command" require_relative "rubyshell/chainer" @@ -12,15 +14,56 @@ require_relative "rubyshell/sanitizer" require_relative "rubyshell/parser" require_relative "rubyshell/parsers/base" +require_relative "rubyshell/debugger" + +module RubyShell + class << self + def debug=(value) + @debug_mode = !!value + end + + def debug(value = true) + previous_value = @debug_mode + + @debug_mode = value + + result = yield + + @debug_mode = previous_value + + result + end + + def debug? + @debug_mode == true + end + + attr_writer :logger + + def logger + @logger ||= Logger.new($stdout) + end + + def log_level=(level) + @log_level = level.to_s + end + + def log(text) + logger.send(@log_level || :info, text) + end + end +end module Kernel - def sh(command = nil, *args, &block) + def sh(command = nil, *args, **kwargs, &block) if command - RubyShell::Executor.send(command, *args) + RubyShell::Executor.send(command, *args, **kwargs) elsif block.nil? RubyShell::Executor else - RubyShell::Executor.class_eval(&block) + RubyShell.debug(kwargs[:debug]) do + RubyShell::Executor.class_eval(&block) + end end end end diff --git a/lib/rubyshell/command.rb b/lib/rubyshell/command.rb index 27f1208..a822aeb 100644 --- a/lib/rubyshell/command.rb +++ b/lib/rubyshell/command.rb @@ -16,11 +16,13 @@ def to_shell end def exec_command - result = RubyShell::TerminalExecutor.capture(to_shell, @options) + RubyShell::Debugger.run_wrapper(self, debug: @options[:_debug]) do + result = RubyShell::TerminalExecutor.capture(to_shell, @options) - result = RubyShell::Parser.parse(@options[:_parse], result) if @options[:_parse] + result = RubyShell::Parser.parse(@options[:_parse], result) if @options[:_parse] - result + result + end end alias exec exec_command diff --git a/lib/rubyshell/debugger.rb b/lib/rubyshell/debugger.rb new file mode 100644 index 0000000..58d4a4a --- /dev/null +++ b/lib/rubyshell/debugger.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module RubyShell + module Debugger + class << self + def run_wrapper(command, debug: nil) + if debug || RubyShell.debug? + + time_one = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + result = yield + + time_two = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + RubyShell.log("Executed: #{command.to_shell.chomp}") + RubyShell.log(" Duration: #{format("%.6f", time_two - time_one)}s") + RubyShell.log(" Pid: #{result._meta[:exit_status].pid}") + RubyShell.log(" Exit code: #{result._meta[:exit_status].to_i}") + RubyShell.log(" Stdout: #{result.to_s.inspect}") + + result + else + yield + end + end + end + end +end diff --git a/lib/rubyshell/executor.rb b/lib/rubyshell/executor.rb index 8854365..d94c594 100644 --- a/lib/rubyshell/executor.rb +++ b/lib/rubyshell/executor.rb @@ -10,8 +10,8 @@ def chain(&block) RubyShell::ChainContext.class_eval(&block).exec_commands end - def method_missing(method_name, *args) - command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args) + def method_missing(method_name, *args, **kwargs) + command = RubyShell::Command.new(method_name.to_s.gsub(/!$/, ""), *args, **kwargs) if method_name.to_s.match?(/!$/) command @@ -25,3 +25,28 @@ def respond_to_missing?(_name, _include_private) end end end +# +# # Enable for single block +# sh(debug: true) do +# mkdir("test") +# cd("test") do +# touch("file.txt") +# end +# end +# # Output: +# # [DEBUG] Executing: mkdir test +# # [DEBUG] Duration: 3ms +# # [DEBUG] Exit code: 0 +# # [DEBUG] Changing directory to: test +# # [DEBUG] Executing: touch file.txt +# # [DEBUG] Duration: 2ms +# # [DEBUG] Exit code: 0 +# +# # Enable globally +# RubyShell.debug = true +# +# # Custom logger +# RubyShell.logger = Logger.new("rubyshell.log") +# +# # Log levels +# RubyShell.log_level = :info # :debug, :info, :warn, :error diff --git a/lib/rubyshell/results/string_result.rb b/lib/rubyshell/results/string_result.rb index 0854286..59ddc70 100644 --- a/lib/rubyshell/results/string_result.rb +++ b/lib/rubyshell/results/string_result.rb @@ -3,6 +3,14 @@ module RubyShell module Results class StringResult < String + def initialize(value, **kwargs) + @_meta = kwargs.delete(:_meta) + + super(value) + end + + attr_reader :_meta + def inspect if $stdin.isatty to_s diff --git a/lib/rubyshell/terminal_executor.rb b/lib/rubyshell/terminal_executor.rb index 5146f78..fe3fabd 100644 --- a/lib/rubyshell/terminal_executor.rb +++ b/lib/rubyshell/terminal_executor.rb @@ -64,7 +64,10 @@ def self.capture(command, options) # rubocop:disable Metris/MethodLength,Metrics ) end - RubyShell::Results::StringResult.new(output.chomp) + RubyShell::Results::StringResult.new(output.chomp, _meta: { + command: command, + exit_status: status + }) end rescue StandardError => e raise e if e.is_a?(RubyShell::CommandError) diff --git a/spec/debugger_spec.rb b/spec/debugger_spec.rb new file mode 100644 index 0000000..9025119 --- /dev/null +++ b/spec/debugger_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +RSpec.describe RubyShell::Debugger do + around(:example) do |example| + Dir.mktmpdir do |dir| + Dir.chdir(dir) { example.run } + end + end + + describe ".run_wrapper" do + let(:log_output) { [] } + let(:logger) { double("Logger", info: nil) } + + before do + allow(logger).to receive(:info) { |msg| log_output << msg } + allow(RubyShell).to receive(:logger).and_return(logger) + end + + after do + RubyShell.debug = false + end + + context "when debug mode is disabled" do + def subject_method + sh.echo("hello") + end + + it "returns the command output" do + expect(subject_method).to eq("hello") + end + + it "does not log anything" do + subject_method + + expect(log_output).to be_empty + end + end + + context "when debug mode is enabled via block" do + def subject_method + sh(debug: true) { echo("hello") } + end + + it "returns the command output" do + expect(subject_method).to eq("hello") + end + + it "logs the command executed" do + subject_method + + expect(log_output).to include("Executed: echo hello") + end + + it "logs the duration" do + subject_method + + expect(log_output.find { |msg| msg.match?(/Duration: \d+\.\d+s/) }).not_to be_nil + end + + it "logs the pid" do + subject_method + + expect(log_output.find { |msg| msg.match?(/Pid: \d+/) }).not_to be_nil + end + + it "logs the exit code" do + subject_method + + expect(log_output).to include(" Exit code: 0") + end + + it "logs the stdout" do + subject_method + + expect(log_output).to include(' Stdout: "hello"') + end + end + + context "when debug mode is enabled via command option" do + def subject_method + sh.echo("hello", _debug: true) + end + + it "returns the command output" do + expect(subject_method).to eq("hello") + end + + it "logs the command executed" do + subject_method + + expect(log_output).to include("Executed: echo hello") + end + + it "logs the duration" do + subject_method + + expect(log_output.find { |msg| msg.match?(/Duration: \d+\.\d+s/) }).not_to be_nil + end + + it "logs the pid" do + subject_method + + expect(log_output.find { |msg| msg.match?(/Pid: \d+/) }).not_to be_nil + end + + it "logs the exit code" do + subject_method + + expect(log_output).to include(" Exit code: 0") + end + + it "logs the stdout" do + subject_method + + expect(log_output).to include(' Stdout: "hello"') + end + end + + context "when debug mode is enabled globally" do + before { RubyShell.debug = true } + + def subject_method + sh.echo("world") + end + + it "returns the command output" do + expect(subject_method).to eq("world") + end + + it "logs the command executed" do + subject_method + + expect(log_output).to include("Executed: echo world") + end + + it "logs the duration" do + subject_method + + expect(log_output.find { |msg| msg.match?(/Duration: \d+\.\d+s/) }).not_to be_nil + end + + it "logs the pid" do + subject_method + + expect(log_output.find { |msg| msg.match?(/Pid: \d+/) }).not_to be_nil + end + + it "logs the exit code" do + subject_method + + expect(log_output).to include(" Exit code: 0") + end + + it "logs the stdout" do + subject_method + + expect(log_output).to include(' Stdout: "world"') + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 463ddb2..1f606bb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -require_relative "../lib/rubyshell" require "tmpdir" require "debug" +require_relative "../lib/rubyshell" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure From 53b7d679eab6cadad4f9b66c5324ca2253eddafd Mon Sep 17 00:00:00 2001 From: Albert Alef Date: Mon, 2 Feb 2026 20:37:25 -0300 Subject: [PATCH 2/4] fix: update field value --- lib/rubyshell/debugger.rb | 4 ++-- lib/rubyshell/results/string_result.rb | 6 +++--- lib/rubyshell/terminal_executor.rb | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/rubyshell/debugger.rb b/lib/rubyshell/debugger.rb index 58d4a4a..0ac9f79 100644 --- a/lib/rubyshell/debugger.rb +++ b/lib/rubyshell/debugger.rb @@ -14,8 +14,8 @@ def run_wrapper(command, debug: nil) RubyShell.log("Executed: #{command.to_shell.chomp}") RubyShell.log(" Duration: #{format("%.6f", time_two - time_one)}s") - RubyShell.log(" Pid: #{result._meta[:exit_status].pid}") - RubyShell.log(" Exit code: #{result._meta[:exit_status].to_i}") + RubyShell.log(" Pid: #{result.metadata[:exit_status].pid}") + RubyShell.log(" Exit code: #{result.metadata[:exit_status].to_i}") RubyShell.log(" Stdout: #{result.to_s.inspect}") result diff --git a/lib/rubyshell/results/string_result.rb b/lib/rubyshell/results/string_result.rb index 59ddc70..cb4096d 100644 --- a/lib/rubyshell/results/string_result.rb +++ b/lib/rubyshell/results/string_result.rb @@ -4,12 +4,12 @@ module RubyShell module Results class StringResult < String def initialize(value, **kwargs) - @_meta = kwargs.delete(:_meta) + @metadata = kwargs.delete(:metadata) - super(value) + super end - attr_reader :_meta + attr_reader :metadata def inspect if $stdin.isatty diff --git a/lib/rubyshell/terminal_executor.rb b/lib/rubyshell/terminal_executor.rb index fe3fabd..a7d7a56 100644 --- a/lib/rubyshell/terminal_executor.rb +++ b/lib/rubyshell/terminal_executor.rb @@ -64,7 +64,7 @@ def self.capture(command, options) # rubocop:disable Metris/MethodLength,Metrics ) end - RubyShell::Results::StringResult.new(output.chomp, _meta: { + RubyShell::Results::StringResult.new(output.chomp, metadata: { command: command, exit_status: status }) From 3bc2cc255dc6be5fbf913a641ebcaa719eba6099 Mon Sep 17 00:00:00 2001 From: Albert Alef Date: Mon, 2 Feb 2026 20:39:08 -0300 Subject: [PATCH 3/4] fix: rubocop --- .rubocop.yml | 2 +- lib/rubyshell.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index e9a9ec8..2017bc4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -28,7 +28,7 @@ Lint/ConstantDefinitionInBlock: - "spec/spec_helper.rb" Metrics/AbcSize: - Max: 18 + Max: 25 Metrics/MethodLength: Max: 30 diff --git a/lib/rubyshell.rb b/lib/rubyshell.rb index 4e3d4ff..a126197 100644 --- a/lib/rubyshell.rb +++ b/lib/rubyshell.rb @@ -22,7 +22,7 @@ def debug=(value) @debug_mode = !!value end - def debug(value = true) + def debug(value = true) # rubocop:disable Style/OptionalBooleanParameter previous_value = @debug_mode @debug_mode = value From c6a6da881f48afd74d40ca3569ef156c9f4d977a Mon Sep 17 00:00:00 2001 From: Albert Alef Date: Mon, 2 Feb 2026 20:40:08 -0300 Subject: [PATCH 4/4] chore: remove idea comments --- lib/rubyshell/executor.rb | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/lib/rubyshell/executor.rb b/lib/rubyshell/executor.rb index d94c594..2d02afc 100644 --- a/lib/rubyshell/executor.rb +++ b/lib/rubyshell/executor.rb @@ -25,28 +25,3 @@ def respond_to_missing?(_name, _include_private) end end end -# -# # Enable for single block -# sh(debug: true) do -# mkdir("test") -# cd("test") do -# touch("file.txt") -# end -# end -# # Output: -# # [DEBUG] Executing: mkdir test -# # [DEBUG] Duration: 3ms -# # [DEBUG] Exit code: 0 -# # [DEBUG] Changing directory to: test -# # [DEBUG] Executing: touch file.txt -# # [DEBUG] Duration: 2ms -# # [DEBUG] Exit code: 0 -# -# # Enable globally -# RubyShell.debug = true -# -# # Custom logger -# RubyShell.logger = Logger.new("rubyshell.log") -# -# # Log levels -# RubyShell.log_level = :info # :debug, :info, :warn, :error