ianheggie/cruisecontrol.rb

View on GitHub
lib/command_line.rb

Summary

Maintainability
B
6 hrs
Test Coverage
require 'English'

# borrowed (with modifications) from the RSCM project
module CommandLine

  QUOTE_REPLACEMENT = (Platform.family == "mswin32") ? '"' : '\\"'
  LESS_THAN_REPLACEMENT = (Platform.family == "mswin32") ? '<' : '\\<'

  class OptionError < StandardError; end
  class ExecutionError < StandardError
    attr_reader :cmd, :dir, :exitstatus, :stderr
    def initialize(cmd, full_cmd, dir, exitstatus, stderr)
      @cmd, @full_cmd, @dir, @exitstatus, @stderr = cmd, full_cmd, dir, exitstatus, stderr
    end

    def to_s
      "\ndir : #{@dir}\n" +
      "command : #{@full_cmd}\n" +
      "exitstatus: #{@exitstatus}\n" +
      (@stderr ? "STDERR TAIL START\n#{@stderr}\nSTDERR TAIL END\n" : '')
    end
  end

  # Executes +cmd+.
  # If the +:stdout+ and +:stderr+ options are specified, a line consisting
  # of a prompt (including +cmd+) will be appended to the respective output streams will be appended
  # to those files, followed by the output itself. Example:
  #
  #   CommandLine.execute("echo hello world", {:stdout => "stdout.log", :stderr => "stderr.log"})
  #
  # will result in the following being written to stdout.log:
  #
  #   /Users/aslakhellesoy/scm/buildpatterns/repos/damagecontrol/trunk aslakhellesoy$ echo hello world
  #   hello world
  #
  # -and to stderr.log:
  #   /Users/aslakhellesoy/scm/buildpatterns/repos/damagecontrol/trunk aslakhellesoy$ echo hello world
  #
  # If a block is passed, the stdout io will be yielded to it (as with IO.popen). In this case the output
  # will not be written to the stdout file (even if it's specified):
  #
  #   /Users/aslakhellesoy/scm/buildpatterns/repos/damagecontrol/trunk aslakhellesoy$ echo hello world
  #   [output captured and therefore not logged]
  #
  # If the exitstatus of the command is different from the value specified by the +:exitstatus+ option
  # (which defaults to 0) then an ExecutionError is raised, its message containing the last 400 bytes of stderr
  # (provided +:stderr+ was specified)
  #
  # You can also specify the +:dir+ option, which will cause the command to be executed in that directory
  # (default is current directory).
  #
  # You can also specify a hash of environment variables in +:env+, which will add additional environment variables
  # to the default environment.
  #
  # Finally, you can specify several commands within one by separating them with '&&' (as you would in a shell).
  # This will result in several lines to be appended to the log (as if you had executed the commands separately).
  #
  # See the unit test for more examples.
  def execute(cmd, options={}, &proc)
    raise "Can't have newline in cmd" if cmd =~ /\n/
    options = {
        :dir => Dir.pwd,
        :env => {},
        :mode => 'r',
        :exitstatus => 0 }.merge(options)

    options[:stdout] = escape(File.expand_path(options[:stdout])) if options[:stdout]
    options[:stderr] = escape(File.expand_path(options[:stderr])) if options[:stderr]

    Dir.chdir(options[:dir]) do
      return e(cmd, options, &proc)
    end
  end
  module_function :execute

  private

  def e(cmd, options, &proc)
    full_cmd = full_cmd(cmd, options, &proc)
    
    options[:env].each{|k,v| ENV[k]=v}
    begin
      CruiseControl::Log.debug "#{Platform.prompt} #{format_for_printing(cmd)}" if options[:stdout].nil?
      result = IO.popen(full_cmd, options[:mode]) do |io|
        if proc
          proc.call(io)
        else
          io.each_line do |line|
            STDOUT.puts line if options[:stdout].nil?
          end
        end
      end
      exit_status = $CHILD_STATUS
      raise "$CHILD_STATUS is nil" unless exit_status
      verify_exit_code(exit_status, cmd, full_cmd, options)
      return result
    rescue Errno::ENOENT => e
      if options[:stderr]
        File.open(options[:stderr], "a") {|io| io.write(e.message)}
      else
        STDERR.puts e.message
        STDERR.puts e.backtrace.map { |line| "    #{line}" }
      end
      raise ExecutionError.new(cmd, full_cmd, options[:dir], nil, e.message)
    end
  end
  module_function :e

  def full_cmd(cmd, options, &proc)
    stdout_opt, stderr_opt = redirects(options)

    capture_info_command = (block_given? && options[:stdout]) ?
        "echo '[output captured and therefore not logged]' >> #{options[:stdout]} && " :
        ''

    stdout_prompt_command = options[:stdout] ?
                              "echo #{Platform.prompt} #{escape_and_concatenate(cmd)} >> #{options[:stdout]} && " :
                              ''

    stderr_prompt_command = options[:stderr] && options[:stderr] != options[:stdout] ?
                              "echo #{Platform.prompt} #{escape_and_concatenate(cmd)} >> #{options[:stderr]} && " :
                              ''

    cmd = escape_and_concatenate(cmd) unless cmd.is_a? String

    redirected_command = block_given? ? "#{cmd} #{stderr_opt}" : "#{cmd} #{stdout_opt} #{stderr_opt}"

    stdout_prompt_command + capture_info_command + stderr_prompt_command + redirected_command
  end
  module_function :full_cmd

  def verify_exit_code(exit_status, cmd, full_cmd, options)
    if exit_status.exitstatus != options[:exitstatus]
      if options[:stderr]
        if File.exist?(options[:stderr])
          File.open(options[:stderr]) do |errio|
            begin
              errio.seek(-1200, IO::SEEK_END)
            rescue Errno::EINVAL
              # ignore - it just means we didn't have 400 bytes.
            end
            error_message = errio.read
          end
        else
          error_message = "#{options[:stderr]} doesn't exist"
        end
      else
        error_message = nil
      end
      raise ExecutionError.new(cmd, full_cmd, options[:dir] || Dir.pwd, exit_status.exitstatus, error_message)
    end
  end
  module_function :verify_exit_code

  def redirects(options)
    stdout_opt = options[:stdout] ? ">> #{options[:stdout]}" : ""

    # redirecting stderr to stdout if they are the same file avoids a file lock conflict
    stderr_opt =
        case(options[:stderr])
        when nil then ''
        when options[:stdout] then '2>&1'
        else "2>> #{options[:stderr]}"
        end

    # let's hope that nobody has slashes in directory names on their win32 file system
    if Platform.family == 'mswin32'
      stdout_opt.gsub!('/', '\\')
      stderr_opt.gsub!('/', '\\')
    end

    [stdout_opt, stderr_opt]
  end
  module_function :redirects
  
  def escape_and_concatenate(cmd)
    if cmd.is_a?(String)
      escape(cmd)
    else
      cmd.map { |item| escape(item) }.join(' ')
    end
  end
  module_function :escape_and_concatenate

  def escape(item)
    item = item.to_s
    
    if Platform.family == 'mswin32'
      escaped_characters = /\\|&|\||>|<|\^/
      escape_symbol = '^'
      quote_argument = (item =~ /\s/) 
    else
      escaped_characters = /"|'|<|>| |&|\||\(|\)|\\|;/
      escape_symbol = '\\'
      quote_argument_with_spaces = false
    end
    escaped_value = item.to_s.gsub(escaped_characters) { |match| "#{escape_symbol}#{match}" }
    if quote_argument
      '"' + escaped_value + '"'
    else
      escaped_value
    end
  end
  module_function :escape

  # command can be a string or an array
  def format_for_printing(command)
    if command.is_a? String
      command
    else
      command.join(' ')
    end
  end
  module_function :format_for_printing
end