#!/usr/bin/env ruby

require 'optparse'
require 'ostruct'
require 'rubygems'
require 'yaml'
require 'pp'

class RubyGemsInfo

  HELP = %{
    RubyGems is a sophisticated package manager for Ruby.  This is a
    basic help message containing pointers to more information.

    For examples of usage, try 'gem --help-examples'.

    For a summary of all command-line options, try 'gem --help-options'.

    For detailed online information, go to http://rubygems.rubyforge.org.
    The following documents, among others, can be found in the
    "Documentation" section:
      * Quick Introduction
      * Users' Guide
      * 'gem' Command Line Reference
        -> This includes information about environment variables,
           configuration files, and more.
  }.gsub(/^    /, "")

  EXAMPLES = %{
    Some examples of 'gem' usage.

    * Install 'rake', either from local directory or remote server:
    
        gem -i rake

    * Install 'rake', only from remote server:

        gem -Ri rake

    * Install 'rake' from remote server, and run unit tests,
      generate RDocs, and install library stub:

        gem -Ri rake --run-tests --gen-rdoc --install-stub

    * Install 'rake', but only version 0.3.1, even if dependencies
      are not met, and into a specific directory:

        gem -i rake --version 0.3.1 --force --install-dir $HOME/.gems

    * List local and remote gems beginning with 'D':

        gem -l D

    * List all local, and all remote, gems:

        gem -Ll
        gem -Rl

    * Search for local and remote gems including the string 'log':

        gem -s log

    * See information about all versions of 'rake' installed:

        gem -o rake
    
    * Uninstall 'rake':

        gem -u rake

    * See information about RubyGems:
    
        gem --rubygems-info

    * See summary of all options:
    
        gem --help-options
  }.gsub(/^    /, "")

end  # class RubyGemsInfo

#
# -_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
#


def _info(message)
  STDOUT.puts message
end


def _die(message)
  STDERR.puts message
  exit!
end


#
# -_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-
#


#
# This class defines the options structure used to capture the command-line options and
# drive the operational aspect.
#
class RubyGemsOptions
  
  def initialize
    @install_dir = Gem.dir  # TODO: should this be done here?
    @prefix = ""
    @domain    = :both
    @install_stub = Config::CONFIG['sitelibdir']
  end

  # --- Operations ---
  
  # The operation the user has asked us to perform.
  attr_accessor :operation
  VALID_OPERATIONS = [:install, :uninstall, :list, :search, :info, :build, :alien, :verify, :upgrade_all]

  # Most operations require an argument (e.g. gemfile, pattern).
  attr_accessor :argument

  # --- Modifiers ---

  attr_accessor :config_file, :install_dir, :force, :gen_rdoc, :run_tests, :http_proxy, :gem_version
  attr_accessor :install_stub, :prefix

  # Specifies whether we are working in the :local or :remote domain (or +nil+ for
  # both/neither).
  attr_accessor :domain
  VALID_DOMAINS = [:local, :remote, :both]

  # --- Other attributes (not taken from command-line yet) ---
  
  attr_accessor :rdoc_args

  # --- Methods ---

  # Tells us whether this object is sensibly constructed.  Checks for valid operation and
  # domain values.
  def valid?
    VALID_OPERATIONS.include? operation and
      VALID_DOMAINS.include? domain
  end

  # Are local operations permitted?
  def local?
    domain == :both or domain == :local
  end

  # Are remote operations permitted?
  def remote?
    domain == :both or domain == :remote
  end

  def [](attr)
    self.send(attr)
  end

  def []=(attr, value)
    self.send("#{attr}=", value)
  end

end  # class RubyGemsOptions


#
# -_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-
#


#
# This class handles the mechanics of interpreting command-line arguments and deciding
# what to do.
#
class RubyGemsApp
  #
  # Determine the selected operation and the other options, and ask RubyGemsFacade to do
  # the work for us.
  #
  def run(args)
    options = _determine_options(args)
    facade = RubyGemsFacade.new(options)
    arg = options.argument
    case options.operation
    when :install
      facade.install(arg)
    when :uninstall
      facade.uninstall(arg)
    when :list
      facade.list(arg)
    when :upgrade_all
      facade.upgrade_all
    when :search
      facade.search(arg)
    when :info
      facade.info(arg)
    when :build
      facade.build(arg)
    when :alien
      facade.alien()
    when :verify
      facade.verify(arg)
    else
      _die "Invalid operation: #{options.operation}"
    end
  end

  private

  #
  # From the given argument array +args+, we return a RubyGemsOptions
  # object.  The parsing is done in <tt>_parse_options</tt>, but this
  # method also takes account of the configuration file, if there is
  # one.
  #
  def _determine_options(args)
    args = args.dup       # So we can manipulate it.
    options = RubyGemsOptions.new

    config = _get_config_data(args)
    if config
      if gem_opts = config['gem']
        # Prepend the 'gem' arguments to our current command line so they'll be read first.
        # REMOVE THIS args.unshift(gem_opts.split).flatten!
        args = gem_opts.split + args
      end
      options.rdoc_args = config['rdoc']
      if gempath = config['gempath']
        gempath = [gempath] unless gempath.is_a? Array
        # TODO: Set GEMPATH now -- how?
        options.install_dir = gempath[0]
      end
    end

    options = _parse_options(args, options)
  end

  #
  # Returns a hash of the config file data, taking the following into
  # account:
  #  - a config file may be specified on the command line
  #  - if it's specified multiple times, the last one wins 
  #  - there is a default config file location
  #
  # TODO: improve exception handling 
  #
  def _get_config_data(args)
    config_file = File.join(ENV['HOME'], ".gemrc")
    specified = false
    args.each_with_index do |arg, i|
      if arg == '--config-file'
        config_file = args[i+1]
        specified = true
      end
    end
    if File.exist?(config_file)
      return YAML.load(File.read(config_file))
    else
      _die "Config file #{config_file} not found" if specified
    end
  rescue YAML::Error # XXX: Is this the correct error?
    _die "Config file #{config_file} is not valid YAML"
  end
  
  #
  # Parse the arguments given and return a RubyGemsOptions object.
  #
  def _parse_options(args, options=nil)
    options ||= RubyGemsOptions.new
    o = options
    parser = OptionParser.new do |op|
      op.banner = <<BANNER
    
Usage: gem <action> [modifier ...]
BANNER
  
      op.separator("")
      op.separator("Operations:")
  
      op.separator("")
      op.separator("  Install/Uninstall:")
  
      op.on('-i', '--install GEM', "Install a gem from a local file or remote server") { |gem|
        o.operation = :install
        o.argument  = gem
      }
      op.on('-u', '--uninstall GEM', "Uninstall a gem") { |gem|
        o.operation = :uninstall
        o.argument  = gem
      }
      op.on('-U', '--upgrade-all', "Upgrade all currently installed gems") { 
        o.operation = :upgrade_all
      }

      op.separator("")
      op.separator("  List/Search/Info:")
  
      op.on('-l', '--list [STR]', "List all local and/or remote gems (beginning with STR)") { |str|
        o.operation = :list
        o.argument  = str
      }
      op.on('-s', '--search STR', "List local and/or remote gems containing STR") { |str|
        o.operation = :search
        o.argument  = str || ""   
      }
      op.on('-o', '--info GEM', "Display information about local/remote gem named GEM") { |gem|
        o.operation = :info
        o.argument  = gem
      }
  
      op.separator("")
      op.separator("  Build:")
  
      op.on('-b', '--build GEMSPEC', "Build a gem file from its spec") { |spec|
        o.operation = :build
        o.argument  = spec
      }
  
      op.separator("")
      op.separator("  Miscellaneous:")
      op.on('--alien', "Report 'unmanaged' or rogue files in the gem repository") {
        o.operation = :alien
      }
      op.on('--verify GEM', "Verify gem file against its internal checksum") { |gem|
        o.operation = :verify
        o.argument  = gem
      }
  
      op.separator("")
      op.separator("Modifiers:")
  
      op.on(      '--config-file FILE', "Use this config file instead of default") {}
          # We ignore config files at this stage; they should have been dealt with already.
      op.on(      '--install-dir DIR',  "Specify RubyGems installation directory") {|o.install_dir|}
      op.on(      '--[no-]install-stub [DIR]', "Install a library stub in DIR or site_ruby/1.x(default)") {|val|
	      case val 
	      when nil 
		  o.install_stub = Config::CONFIG['sitelibdir'] 
	      when false
		  o.install_stub = nil
	      else
		  o.install_stub = val
	      end
	  }
      op.on(      '--prefix DIR', "Specify root for installing gem"){|o.prefix|}
      op.on(      '--force',            "Force gem to intall, bypassing dependency checks") {|o.force|}
      op.on(      '--gen-rdoc',         "Generate RDoc documentation for the gem on install") {|o.gen_rdoc|}
      op.on(      '--run-tests',        "Run unit tests prior to installation") {|o.run_tests|}
      op.on(      '--version VERSION',  "Specify version of gem to perform operation on") {|o.gem_version|}
      op.on('-L', '--local',            "Restrict operations to the LOCAL domain") { o.domain = :local }
      op.on('-R', '--remote',           "Restrict operations to the REMOTE domain") { o.domain = :remote }
      op.on('-B', '--both',             "Allow LOCAL and REMOTE operations") { o.domain = :both }
      op.on('-P', '--[no-]http-proxy [URL]', "Use HTTP proxy for remote operations") { |proxy|
        # This will be false, nil, or a value.  false == no proxy (at all), nil == no proxy
        # specified, a value == use that value for the proxy.
        o.http_proxy = (proxy == false) ? :no_proxy : proxy
      }
  
      op.separator("")
      op.separator("Miscellaneous:")
  
      op.on('-h', '--help', "Show simple help") {
        _show_simple_help
      }
      op.on('--help-options', "Show detailed option summary") {
        _show_help(op, 0)
      }
      op.on('--help-examples', "Show examples of usage") {
        _show_examples
      }
      op.on('--rubygems-info', "Display RubyGems information and exit") {
        _show_rubygems_info()
      }
  
      op.separator("")
      message = <<-INFO
In the above information, "GEM" typically refers to the name of a gem;
all of the following are valid (perhaps in different circumstances):

      ruby-dbi
      ruby-dbi-0.20.1
      ruby-dbi-0.20.1.gem
INFO
      message.each do |line|
        op.separator(line.chomp)
      end
    end  # OptionParser.new
  
    begin
      parser.parse!(args)
    rescue => err
      puts err.message
      exit!
    end
    options = o
    _show_help(parser, 1) if options.operation.nil?
  
#     require 'pp'
#     pp options
#     puts
#     exit
    options
  end  # _parse

  def _show_help(parser, status)
    puts parser
    exit(status)
  end

  def _show_simple_help
    puts RubyGemsInfo::HELP
    exit(0)
  end

  def _show_examples
    puts RubyGemsInfo::EXAMPLES
    exit(0)
  end

  def _show_rubygems_info
    puts "RubyGems:"
    puts " - VERSION:                #{Gem::RubyGemsVersion}"
    puts " - INSTALLATION DIRECTORY: #{Gem.dir}"
    puts " - GEM PATH:"
    puts Gem.path.map { |p| "     - #{p}" }
    puts " - REMOTE SOURCES:"
    puts Gem::RemoteInstaller.new.get_cache_sources.map { |s| "     - #{s}" }
    exit
  end

end  # class RubyGemsApp



#
# -_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-
#


#
# This class performs the basic operations that the user can select on the command line.
#
class RubyGemsFacade

  # Signals that local installation will not proceed, not that it has been tried and
  # failed.  TODO: better name.
  class LocalInstallationError < StandardError; end

  # Signals that a remote operation cannot be conducted, probably due to not being
  # connected (or just not finding host).
  #
  # TODO: create a method that tests connection to the preferred gems server.  All code
  # dealing with remote operations will want this.  Failure in that method should raise
  # this error.
  class RemoteError < StandardError; end
  

  def initialize(options)
    @options = options
    @options.gem_version ||= "> 0.0.0"
  end


  #
  # Decide whether to perform a local or remote install.  Then generate RDocs if desired.
  #
  # This can only be local (argument is a path, even though it doesn't end in '.gem').
  #   gem --install /tmp/ruby-doom-0.0.7
  #
  # This can only be local (argument is not necessarily a path, but it ends in '.gem').
  #   gem --install ruby-doom-0.0.7.gem
  #
  # This could be local (gem file in current directory) or remote.  Installation is attempted in
  # that order.
  #   gem --install ruby-doom-0.0.7
  #   
  # This could be local (gem file in current directory with highest available version number) or
  # remote.  Installation is attempted in that order.
  #   gem --install ruby-doom
  #
  # Basically, our approach in this method is to attempt a local installation.  If that
  # fails, then we attempt a remote installation.  Of course, we respect --local and
  # --remote.
  #
  # Returns true or false to indicate success.  TODO: is this necessary?
  #
  def install(gem)
    if @options.local?
      begin
        _info "Attempting local installation of '#{gem}'"
        installed_gems = _install_local(gem)
      rescue LocalInstallationError => e
        _info " -> Local installation can't proceed: #{e.message}"
      rescue => e
        $stderr.puts "Error installing gem #{gem}: #{e.message}"
        return false # If a local installation actually fails, we don't try remote.
      end
    end

    if @options.remote? and installed_gems.nil?
      begin
        _info "Attempting remote installation of '#{gem}'"
        installed_gems = _install_remote(gem)
      rescue RemoteError => e
        _info " -> Remote installation can't proceed: #{e.message}"
      rescue => e
        $stderr.puts "Error installing gem #{gem}: #{e.message + e.backtrace.join("\n")}"
        return false
      end
    end

    if @options.gen_rdoc
      installed_gems.each do |gem|
        Gem::DocManager.new(gem, @options.rdoc_args).generate_rdoc
      end
      # TODO: catch exceptions and inform user that doc generation was not successful.
    end

    if @options.run_tests
      installed_gems.each do |gem|
        _run_tests(gem.name, gem.version)
      end
    end

    return true 
  end


  #
  # Uninstall the named gem.  
  # If no version is specified, the latest version will be uninstalled.
  # XXX: We should probably change this so that if no version is specified,
  #    all versions of the named gem are uninstalled.
  def uninstall(gem)
    _info "Attempting to uninstall gem '#{gem}'"
    Gem::Uninstaller.new(gem, @options.gem_version).uninstall
  end


  #
  # This lists local _and_ remote gems beginning with 'F' (case-insensitive optional argument).
  #   gem --list F
  #
  def list(str)
    _list_gems(/^#{str}/i)
  end

  #
  # Upgrades all previously installed gems
  #   gem --upgrade-all
  #
  def upgrade_all
    _upgrade_all
  end


  #
  # List local and remote gems containing the given string.
  #
  def search(str)
    _list_gems(/#{str}/i)
  end


  # 
  # The output depends on whether the named package is installed locally.
  #
  # If it is, then the normal report happens, and the remote server is queried and a brief report
  # provided to say whether there are any updates.
  #
  # If it is not, then a full remote report is provided.
  #
  def info(gem)
    if @options.local?
      gem_specs = Gem::Cache.from_installed_gems.search(gem, @options.gem_version)
      if gem_specs.size > 0
        require 'yaml'
        gem_specs.each {|spec| puts spec.to_yaml; puts "\n"}
      else
        $stderr.puts "Unkown gem #{gem}"
      end
    end

    if @options.remote?
      _info "(Remote 'info' operation is not yet implemented.)"
      # NOTE: when we do implement remote info, make sure we don't duplicate huge swabs of
      # local data.  If it's the same, just say it's the same.
    end
  end


  #
  # Build a gem from the given gemspec.  This can only be done locally :)
  #
  def build(gemspec)
    _info "Attempting to build gem spec '#{gemspec}'"
    load gemspec
    Gem::Specification.list.each do |spec|
      Gem::Builder.new(spec).build
    end
  rescue Gem::Exception => err
    puts "Operation failed: #{err}"
    puts err.backtrace if $VERBOSE
  rescue => err
    puts "Unexpected error: #{err}"
    puts "Details:"
    puts err.backtrace
    exit!
  end


  #
  # Scour the repository for alien (unmanaged) files.
  #
  def alien
    _info "Performing the 'alien' operation"  # TODO: better (or no) message.
    Gem::Validator.new.alien
  end


  #
  # Verify the given gem.
  #
  def verify(gem)
    _info "Verifying gem: '#{gem}'"
    begin
      Gem::Validator.new.verify_gem_file(gem)
    rescue => e
      $stderr.puts "#{gem} is invalid"
    end
  end

  private

  #
  # Attempt a local installation.  Returns the list of installed gems.
  # Raises an error if installation not possible or if installation
  # fails.
  #
  def _install_local(path)
    unless File.exist?(path)
      if File.exist?(path + ".gem")
        path << ".gem"
      else
        # Raising this is intended to communicate that this gem cannot
        # be locally installed.
        raise LocalInstallationError, "Unknown gem file '#{path}'"
      end
    end
    installer = Gem::Installer.new(path)
    result = installer.install(@options.force, @options.prefix+@options.install_dir,
			       @options.prefix+@options.install_stub)
    [result].flatten
  end

  #
  # Attempts a remote installation.  Returns list of installed gems.  Errors are passed
  # upstream, but there is no specific error to indicate that we are not connected to the
  # internet, unfortunately.
  #
  # TODO: make use of RemoteError, if possible. 
  #
  def _install_remote(name)
    installer = _get_remote_installer
    begin
      installed_gems = installer.install(name, @options.gem_version, @options.force,
                                         @options.prefix+@options.install_dir, 
					 @options.prefix+@options.install_stub)
    rescue Exception => e
        $stderr.puts e.to_s
        exit 1
    end
    installed_gems
  end

  def _run_tests(name, version)
    gem_specs = Gem::Cache.from_installed_gems.search(name, version.version)
    unless gem_specs[0].test_suite_file
      _info "There are no unit tests to run for #{name}-#{version}"
      return
    end
    require_gem name, "= #{version.version}"
    require gem_specs[0].test_suite_file
    suite = Test::Unit::TestSuite.new("#{name}-#{version}")
    ObjectSpace.each_object(Class) do |klass|
      suite << klass.suite if (Test::Unit::TestCase > klass)
    end
    require 'test/unit/ui/console/testrunner'
    result = Test::Unit::UI::Console::TestRunner.run(suite, Test::Unit::UI::SILENT)
    unless(result.passed?)
      print result.to_s + "...keep Gem? [Y/n] "
      answer = gets
      if(answer !~ /^y/i) then
        Gem::Uninstaller.new(name, version.version).uninstall
      end
    end
  end

  def _get_remote_installer
    Gem::RemoteInstaller.new(@options.http_proxy)
  end

  # List all gems, local and/or remote, that match the given pattern.  This provides the
  # common factor between 'list' and 'search'.
  #
  # TODO: ignore remote gems that are available locally?  Or just list them?  Or what? 
  def _list_gems(pattern)
    if @options.local?
      puts
      puts "*** LOCAL GEMS ***"
      gemlist = []
      Gem::Cache.from_installed_gems.each do |name, spec|
        gemlist << [name, spec]
      end  # FIXME: Gem::Cache.search already does this.
      _display_gems(gemlist.select { |name, spec| name =~ pattern })
    end

    if @options.remote?
      puts
      puts "*** REMOTE GEMS ***"
      installer = _get_remote_installer
      begin
        _display_gems(installer.search(pattern))
      rescue Gem::RemoteSourceException => e
        $stderr.puts e.to_s
        exit 1
      end
    end
  end
  
  # Upgrades all gems that are currently installed
  # to the newest version.
  def _upgrade_all
    @options.domain = :remote # Get gems remotely
    puts "Upgrading previously installed gems..."
    hig = highest_installed_gems = {}
    Gem::Cache.from_installed_gems.each do |name, spec|
      if hig[spec.name].nil? or hig[spec.name].version < spec.version
        hig[spec.name] = spec
      end
    end
    remote_gems = _get_remote_installer.search(//)
    gems_to_update = []
    highest_installed_gems.each do |l_name, l_spec|
      hrg = highest_remote_gem =
              remote_gems.select  { |_, spec| spec.name == l_name }.
                          map     { |_, spec| spec }.
                          sort_by { |spec| spec.version }.
                          last
      if hrg and l_spec.version < hrg.version
        gems_to_update << l_name
      end
    end
    gems_to_update.uniq.sort.each do |name|
      puts "Attempting remote upgrade of #{name}"
      _install_remote(name)
    end
    puts "All gems up to date"
  end

  # +gems+ is actually an Array: [[name, gemspec], [name, gemspec], ...]
  def _display_gems(gems)
    gem_list_with_version = {}
    gems.each do |gem|
      gem = gem[1]
      gem_list_with_version[gem.name] ||= []
      gem_list_with_version[gem.name] << gem
    end
    gem_list_with_version.sort do |first, second|
     first[0].downcase <=> second[0].downcase
    end.each do |gem_name, list_of_matching| 
     puts
     list_of_matching.sort! do |a,b|
       a.version <=> b.version
     end.reverse!
     _display_gem(gem_name, list_of_matching)
   end
  end

  def _display_gem(gem_name, list_of_matching)
    seen_versions = []
    list_of_matching.delete_if {|item|
      if(seen_versions.member?(item.version)) then
        true
      else 
        seen_versions << item.version
        false
      end
    }
    print gem_name, " ("
    print(list_of_matching.map{|gem| gem.version.to_s}.join(", "))
    puts ")"
    puts list_of_matching[0].summary.wrap(68).indent(4)
  end

end  # class RubyGemsFacade


#
# -_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
#


#
# These implementations are not idiot-proof or intended for general use.  They are to support
# the pretty-printing of lists of gems.
#
class String # :nodoc:
  # Wrap string to +n+ characters, inserting newlines and ensuring words are not broken.
  # Stolen from 'ri'. 
  def wrap(n)
    result = []
    pattern = Regexp.new("^(.{0,#{n}})[ \n]")
    work = self.dup
    while work.length > n
      if work =~ pattern
        result << $1
        work.slice!(0, $&.length)
      else
        result << work.slice!(0, n)
      end
    end
    result << work if work.length.nonzero?
    result.join("\n")
  end

  # Indent string by +n+ spaces.
  def indent(n)
    gsub(/^/, " " * n)
  end
end

#
# -_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_-_
#

# (main)

RubyGemsApp.new.run(ARGV)
