#!/usr/bin/ruby
##############################################################################
# tpkg package management system
# License: MIT (http://www.opensource.org/licenses/mit-license.php)
##############################################################################

# When run from the source repository or from an unpacked copy of the
# distribution we want to find the local library, even if there's a copy
# installed on the system.  The installed copy is likely older, and the API
# may be out of sync with this executable.
$:.unshift(File.expand_path('../lib', File.dirname(__FILE__)))

require 'fileutils' # FileUtils.cp, rm, etc.
require 'tempfile'  # Tempfile
require 'optparse'
require 'shellwords'
require 'tpkg'
require 'facter'

# We don't want to just use the first gem command in the user's PATH by
# default, as that may not be a tpkg gem.  I.e. /usr/bin/gem on Mac OS X.
DEFAULT_GEM_COMMAND = "#{Tpkg::DEFAULT_BASE}/ruby-1.8/bin/gem"

# Ruby 1.8.7 and later have Dir.mktmpdir, but we support ruby 1.8.5 for
# RHEL/CentOS 5.  So this is a basic substitute.
# FIXME: consider "backport" for Dir.mktmpdir like we use in the test suite
def tempdir(basename, tmpdir=Dir::tmpdir)
  tmpfile = Tempfile.new(basename, tmpdir)
  tmpdir = tmpfile.path
  tmpfile.close!
  Dir.mkdir(tmpdir)
  tmpdir
end

# Handle command line arguments
@gems = nil
@gemver = nil
@pkgver = 1
@extradeps = {}
@nativedeps = {}
@installopts = []
@buildopts = []
@gemcmd = DEFAULT_GEM_COMMAND
@rubygemspath = nil
@extrapkgname = nil
@verbose = false

opts = OptionParser.new
opts.banner = 'Usage: gem2tpkg [options] GEMNAME1 GEMNAME2 ...'
opts.on('--version', '-v', '=VERSION', 'Version of gem to install') do |opt|
  @gemver = "-v #{opt}"
end
opts.on('--package-version', '--pv', '=PKGVER', 'Package version to use in tpkg, defaults to 1') do |opt|
  @pkgver = opt
end
opts.on('--extra-deps', '=EXTRADEPS', Array, 'Extra dependencies to add to the package') do |opt|
  # Example: --extra-deps=foo,1.0,1.9999,bar,4.5,4.5,blah,,
  while !opt.empty?
    dep = opt.shift
    # These are optional, shift will return nil if they aren't present
    # and we'll handle that properly when creating tpkg.yml
    depminver = opt.shift
    depmaxver = opt.shift
    @extradeps[dep] = {}
    @extradeps[dep][:minimum_version] = depminver
    @extradeps[dep][:maximum_version] = depmaxver
  end
end
opts.on('--native-deps', '=NATIVEDEPS', Array, 'Native dependencies to add to the package') do |opt|
  # Example: --native-deps=foo,1.0,1.9999,bar,4.5,4.5,blah,,
  while !opt.empty?
    dep = opt.shift
    # These are optional, shift will return nil if they aren't present
    # and we'll handle that properly when creating tpkg.yml
    depminver = opt.shift
    depmaxver = opt.shift
    @nativedeps[dep] = {}
    @nativedeps[dep][:minimum_version] = depminver
    @nativedeps[dep][:maximum_version] = depmaxver
  end
end
opts.on('--install-options', '=INSTALLOPTS', 'Extra options to gem install') do |opt|
  @installopts = Shellwords.shellwords(opt)
end
opts.on('--build-options', '=BUILDOPTS', 'Extra options to gem build') do |opt|
  @buildopts = Shellwords.shellwords(opt)
end
opts.on('--gem-cmd', '=GEMCMD', 'Path to gem command') do |opt|
  @gemcmd = opt
end
opts.on('--rubygems-path', '=PATH', 'Path to rubygems library') do |opt|
  @rubygemspath = opt
end
opts.on('--extra-name', '=EXTRANAME', 'Extra string to add to package name') do |opt|
  @extrapkgname = opt
end
opts.on('--verbose', 'Provides more info on what is being executed') do |opt|
  @verbose = opt
end
opts.on_tail("-h", "--help", "Show this message") do
  puts opts
  exit
end

# Allow user to specify multiple gems at the end of the command line
# arguments
leftover = opts.parse(ARGV)
@gems = leftover

# Extract a few paths from gem
@geminstallpath = nil
@gembinpath = nil
@rubypath = nil
cmd = "#{@gemcmd} environment"
puts "Executing: #{cmd}" if @verbose
IO.popen(cmd) do |pipe|
  pipe.each_line do |line|
    if line =~ /INSTALLATION DIRECTORY:\s+(\S+)/
      @geminstallpath = $1
    end
    if line =~ /EXECUTABLE DIRECTORY:\s+(\S+)/
      @gembinpath = $1
    end
    if line =~ /RUBY EXECUTABLE:\s+(\S+)/
      @rubypath = $1
    end
  end
end
if !$?.success?
  abort 'gem environment failed'
end

# Find the right rubygems library if the user didn't request a specific
# one
if !@rubygemspath
  cmd = "#{@rubypath} -e 'puts $:'"
  IO.popen(cmd) do |pipe|
    pipe.each_line do |line|
      line.chomp!
      if File.exist?(File.join(line, 'rubygems.rb'))
        @rubygemspath = line
        break
      end
    end
  end
  if !$?.success?
      abort "#{cmd} failed"
  end
end

# Require the correct rubygems based on what the user specifies
$:.unshift @rubygemspath unless @rubygemspath.nil?
require 'rubygems'

# Ask tpkg what package owns the gem executable that we're using so that
# we can add a dependency on that package to any packages we build.
@gemdep = []
# FIXME: Currently the reading of config files and whatnot is done in
# the tpkg executable.  We should probably move that into the library so
# that other utilities like this one use an alternate base if the user
# has configured one, etc.
tpkg = Tpkg.new(:base => Tpkg::DEFAULT_BASE)
tpkg.files_for_installed_packages.each do |pkgfile, fip|
  fip[:normalized].each do |file|
    if file == @gemcmd
      metadata = nil
      # FIXME: Should add a metadata_for_installed_package method to the
      # library
      tpkg.metadata_for_installed_packages.each do |metadata|
        if metadata[:filename] == pkgfile
          @gemdep << metadata[:name]
        end
      end
    end
  end
end

# Create the directory we want gem to install into
@gemdir = tempdir('gem2tpkg')
ENV['GEM_HOME'] = @gemdir

# Install the gem
geminst = [@gemcmd, 'install', '--no-rdoc', '--no-ri'] | @gems
if @gemver
  geminst << @gemver
end
# Pass through any options the user specified, might be necessary for
# finding libraries, etc.
if !@installopts.empty?
  geminst.concat(@installopts)
end
if !@buildopts.empty?
  geminst << '--'
  geminst.concat(@buildopts)
end

puts "Executing: #{geminst.join(" ")}" if @verbose
r = system(*geminst)
if !r
  abort('gem install failed')
end

# Now set GEM_PATH so that further operations (particularly `gem list`)
# only operate against the gems installed in our working directory.
ENV['GEM_PATH'] = @gemdir

@already_packaged = []
def package(gem)
  pkgfiles = []
  
  return pkgfiles if @already_packaged.include?(gem)
  
  puts "Packaging #{gem}"
  
  gemsdir = nil
  globdirs = Dir.glob(File.join(@gemdir, 'gems', "#{gem}-[0-9]*"))
  if globdirs.length == 1
    gemsdir = globdirs[0]
  else
    # I don't expect this to happen in the real world, if it does we'll
    # have to figure out a better mechanism of isolating the correct
    # directory.  I guess we could load all of the gemspecs in the
    # specifications directory until we find one with the correct gem
    # name in it.
    abort File.join(@gemdir, 'gems', "#{gem}-*") + ' is abiguous'
  end

  # gemsdir will be something like /tmp/gem2tpkg.5833.0/gems/rake-0.8.4
  # and the gemspec will be something like
  # /tmp/gem2tpkg.5833.0/specifications/rake-0.8.4.gemspec.  So we
  # use the basename of gemsdir as the core of the gemspec
  # filename.
  gemspecfile = File.join(@gemdir,
                          'specifications',
                           File.basename(gemsdir) + '.gemspec')
  
  # Load the gemspec
  gemspec = Gem::Specification.load(gemspecfile)
  
  # Package any dependencies
  # Example output:
  # Gem rails-2.3.2
  #  rake (>= 0.8.3)
  #  activesupport (= 2.3.2)
  #  activerecord (= 2.3.2)
  #  actionpack (= 2.3.2)
  #  actionmailer (= 2.3.2)
  #  activeresource (= 2.3.2)
  # Now that we're loading the gemspec we could read this out of the
  # gemspec instead, but this is already written and working so I'm
  # leaving it alone for now.
  deps = {}
  # gem dependency does something equivalent to /^gemname/ by default,
  # i.e. matches against anything that starts with the specified name.
  # We want to exactly match the gem in question.  Turns out that if you
  # pass something that looks like a regex then gem dependency will use
  # that instead.  See rubygems/commands/dependency_command.rb
  cmd = "#{@gemcmd} dependency /^#{Regexp.escape(gem)}$/"
  puts "Executing: #{cmd}" if @verbose
  IO.popen(cmd) do |pipe|
    pipe.each_line do |line|
      next if line =~ /^Gem /  # Skip header line
      next if line =~ /^\s*$/  # Skip blank lines

      # Skip development dependencies for now.  We don't need them for
      # running production code.
      next if line =~ /, development\)$/
      line.chomp!
      # Example lines:
      # These two are for the same installed state but different
      # versions of gem:
      #   activesupport (= 2.3.2)
      #   activesupport (= 2.3.2, runtime)
      depgem, operator, depver = line.split
      operator.sub!(/^\(/, '')
      depver.sub!(/\)$/, '')
      depver.sub!(/,$/, '')
      # Save the dependency info for tpkg.yml
      # http://rubygems.org/read/chapter/16
      deps[depgem] = {}
      case operator
      when '='
        deps[depgem][:minimum_version] = depver
        deps[depgem][:maximum_version] = depver
      when '!='
        # Not quite sure what to do with this one
        abort "Unknown version dependency: #{line}"
      when '>='
        deps[depgem][:minimum_version] = depver
      when '>'
        # If the gem seems to follow the standard gem version convention
        # we can achieve this by incrementing the build number
        # http://rubygems.org/read/chapter/7
        verparts = depver.split('.')
        if depver =~ /^[\d\.]+/ && verparts.length <=3
          verparts.map! { |v| v.to_i }
          # Pad with zeros if necessary
          (verparts.length...3).each { verparts << 0 }
          verparts[2] += 1
          minver = "#{verparts[0]}.#{verparts[1]}.#{verparts[3]}"
          deps[depgem][:minimum_version] = minver
        else
          # Fall back to this, which isn't exactly correct but
          # hopefully better than nothing
          warn "Can't parse depver #{depver}, falling back to approximation"
          deps[depgem][:minimum_version] = depver
        end
      when '<='
        deps[depgem][:maximum_version] = depver
      when '<'
        # If the gem seems to follow the standard gem version convention
        # we can achieve this by decrementing the build number
        # http://rubygems.org/read/chapter/7
        verparts = depver.split('.')
        if depver =~ /^[\d\.]+/ && verparts.length <=3
          verparts.map! { |v| v.to_i }
          # Pad with zeros if necessary
          (verparts.length...3).each { verparts << 0 }
          if verparts[2] == 0
            if verparts[1] == 0  # 1.0.0 -> 0.9999.9999
              verparts[0] -= 1
              verparts[1] = 9999
              verparts[2] = 9999
            else                 # 1.1.0 -> 1.0.9999
              verparts[1] -= 1
              verparts[2] = 9999
            end
          else                   # 1.1.1 -> 1.1.0
            verparts[2] -= 1
          end
          maxver = "#{verparts[0]}.#{verparts[1]}.#{verparts[3]}"
          deps[depgem][:maximum_version] = maxver
        else
          # Fall back to this, which isn't exactly correct but
          # hopefully better than nothing
          warn "Can't parse depver #{depver}, falling back to approximation"
          deps[depgem][:maximum_version] = depver
        end
      when '~>'
        # ~> 2.2 is equivalent to >= 2.2 and < 3
        # ~> 2.2.0 is equivalent to >= 2.2.0 and < 2.3.0
        deps[depgem][:minimum_version] = depver
        # '2.2.0' -> ['2', '2', '0']
        depverparts = depver.split('.')
        # ['2', '2', '0'] -> ['2', '2']
        depverparts.pop
        # ['2', '2'] -> ['2', '2', '9999']
        depverparts << '9999'
        # ['2', '2', '9999'] -> '2.2.9999'
        deps[depgem][:maximum_version] = depverparts.join('.')
      end
    end
  end
  if !$?.success?
    abort 'gem dependency failed'
  end
  
  # The directory where we make our package
  pkgdir = tempdir('gem2tpkg')
  pkgbasedir = File.join(pkgdir, "/root/#{@geminstallpath}")
  FileUtils.mkdir_p(pkgbasedir)
  pkggemdir = File.join(pkgbasedir, 'gems')
  FileUtils.mkdir_p(pkggemdir)
  pkgspecdir = File.join(pkgbasedir, 'specifications')
  FileUtils.mkdir_p(pkgspecdir)
  
  # Copy the gems directory over
  system("#{Tpkg::find_tar} -C #{File.dirname(gemsdir)} -cf - #{File.basename(gemsdir)} | #{Tpkg::find_tar} -C #{pkggemdir} -xpf -")
  
  # If a gem flags any files as executables a wrapper script is
  # created by gem for each executable in a bin directory at
  # the top level of the gem directory structure.  We want to
  # copy those wrapper scripts into the package.
  binfiles = []
  gemspec.executables.each do |exec|
    binfiles << File.join(@gemdir, 'bin', exec)
  end
  if !binfiles.empty?
    bindir = "#{pkgdir}/root/#{@gembinpath}"
    FileUtils.mkdir_p(bindir)
    binfiles.each do |binfile|
      FileUtils.cp(binfile, bindir, :preserve => true)
    end
  end
  
  # Copy over the gemspec file
  FileUtils.cp(gemspecfile, pkgspecdir, :preserve => true)
  
  pkgnamesuffix = ''
  if @extrapkgname
    pkgnamesuffix = '-' + @extrapkgname
  elsif @gemcmd != DEFAULT_GEM_COMMAND
    # If we're not using the default gem try to name the package in a way
    # that indicates the alternate ruby/gem used
    pkgnamesuffix = '-' + @gemdep.first.sub(/\W/, '')
  end
  
  # Determine if we're packaging a gem the user requested or a dependency gem.
  # If we're packaging a dependency gem we don't want to add @extradeps or
  # @nativedeps.  Say you run gem2tpkg for gem foo. You specify some extra
  # dependencies with --extra-deps and/or --native-deps. Gem foo depends on
  # gems bar and baz. The generated tpkgs for bar and baz will include those
  # extra dependencies. I don't think they should. Bar and baz may be very
  # general gems with no close relationship to foo. It seems to me if the user
  # needs bar and baz to have the same extra dependencies they should gem2tpkg
  # those separately. We shouldn't assume everything in the dependency chain
  # needs those same dependencies.
  packaging_requested_gem = false
  if @gems.include?(gem)
    packaging_requested_gem = true
  end
  
  # Add tpkg.yml
  os = nil
  arch = nil
  File.open(File.join(pkgdir, 'tpkg.yml'), 'w') do |file|

    file.puts "name: gem-#{gem}#{pkgnamesuffix}"
    file.puts "version: #{gemspec.version.to_s}"
    file.puts "package_version: #{@pkgver}"
    file.puts "description: Ruby gem for #{gem}"
    file.puts 'maintainer: gem2tpkg'
    # If the gemspec lists any extensions then the package has native
    # code and needs to be flagged as specific to the OS and architecture
    if gemspec.extensions && !gemspec.extensions.empty?
      os = Tpkg::get_os
      if os =~ /RedHat-(.*)/
        os = os + ", CentOS-#{$1}"
      elsif os =~ /CentOS-(.*)/
        os = os + ", RedHat-#{$1}"
      end
      arch = Facter['hardwaremodel'].value
      file.puts "operatingsystem: [#{os}]"
      file.puts "architecture: [#{arch}]"
    end
    if !deps.empty? ||
       (!@extradeps.empty? && packaging_requested_gem) ||
       (!@nativedeps.empty? && packaging_requested_gem) ||
       !@gemdep.empty?
      file.puts 'dependencies:'
      deps.each do |depgem, depvers|
        file.puts "  - name: gem-#{depgem}#{pkgnamesuffix}"
        if depvers[:minimum_version]
          file.puts "    minimum_version: #{depvers[:minimum_version]}"
        end
        if depvers[:maximum_version]
          file.puts "    maximum_version: #{depvers[:maximum_version]}"
        end
      end
      if packaging_requested_gem
        @extradeps.each do |extradep, depvers|
          file.puts "  - name: #{extradep}"
          if depvers[:minimum_version]
            file.puts "    minimum_version: #{depvers[:minimum_version]}"
          end
          if depvers[:maximum_version]
            file.puts "    maximum_version: #{depvers[:maximum_version]}"
          end
        end
        @nativedeps.each do |nativedep, depvers|
          file.puts "  - name: #{nativedep}"
          if depvers[:minimum_version]
            file.puts "    minimum_version: #{depvers[:minimum_version]}"
          end
          if depvers[:maximum_version]
            file.puts "    maximum_version: #{depvers[:maximum_version]}"
          end
          file.puts '    native: true'
        end
      end
      @gemdep.each do |gemdep|
        file.puts "  - name: #{gemdep}"
      end
    end
  end
  
  # Make package
  pkgfile = Tpkg::make_package(pkgdir)
  pkgfiles << pkgfile
  
  # Cleanup
  FileUtils.rm_rf(pkgdir)
  
  @already_packaged << gem
  pkgfiles
end

# Package each installed gem
pkgfiles = []
IO.popen("#{@gemcmd} list") do |pipe|
  pipe.each_line do |line|
    next if line.include?('***')  # Skip header line
    next if line =~ /^\s*$/       # Skip blank lines
    gem, version = line.split
    pkgfiles |= package(gem)
  end
end

# Tell the user what packages were created
puts 'The following packages were created:'
pkgfiles.each do |pkgfile|
  puts pkgfile
end

# Cleanup
FileUtils.rm_rf(@gemdir)

