#! /usr/bin/ruby -w

# Transform a GEM to an RPM.  See gem2rpm --help for usage.
#
# This file can be distributed under the same terms as ruby itself.  
# Author: Marek Gilbert <gil (at) fooplanet (dot) com>

require 'fileutils'
require 'optparse'
require 'ostruct'

PKG_VERSION = '0.3'
PKG_RELEASE = nil

# gem2rpm supports bootstrapping rubygems itself into an rpm.  When doing
# this, Gem classes won't be available.
$gems_present = true
begin
  require 'rubygems'
  require 'rubygems/package'

  # XXX Brutal hack that's required to interrogate requirements in a
  # gem.  This allows us to get the requirements in their raw form, rather
  # than re-parsing their textual form.
  class Gem::Version::Requirement
    attr_reader :requirements
  end

rescue LoadError
  $gems_present = false
end

# The default license and groups for packages that gem2rpm builds
DEFAULT_GROUP   = 'Applications/System'
DEFAULT_LICENSE = 'Ruby License/GPL'

# The license and group for rubygems itself.
RUBYGEMS_GROUP   = 'Applications/System'
RUBYGEMS_LICENSE = 'Ruby License/GPL'

$options = nil


# Print a message if tracing.
def ptrace(msg)
  if $options.verbose
    $stderr.puts(msg)
  end
end


# Print an error message.
def perror(msg)
  $stderr.puts(msg)
end


# wrap_text takes +text+ and wraps it such that no line exeeds +col+
# characters.
def wrap_text(text, col = 80)
  if text
    text.gsub(/(.{1,#{col}})(\s+|$)\n?/, "\\1\n")
  else
    ''
  end
end


# Cheapo wrapper around various methods that actually do things that adds
# some logging when +$options.verbose+ is in effect.
module SysUtils #:nodoc:
  def cp(src, dest)
    FileUtils.cp(src, dest, :verbose => $options.verbose)
  end

  def mkdir_p(dir)
    FileUtils.mkdir_p(dir, :verbose => $options.verbose)
  end

  def rm_rf(dir)
    FileUtils.rm_rf(dir, :verbose => $options.verbose)
  end

  def run(cmd)
    ptrace(cmd)
    return system(cmd)
  end

  extend self
end


# A simple wrapper around rpmbuild's --showrc command that allows rpm to
# tell gem2rpm where to install things.
class RpmRc
  def initialize()
    IO.popen('rpmbuild --showrc', 'r') do |fd|
      read_rc(fd)
    end
  end

  # Read the rc file and define all the macros in it.
  def read_rc(fd)
    @macros = {}
    fd.each_line() do |line|
      if line =~ /^-14:\s+(\w+)\s+(\S+)/
        @macros[$1] = $2
      end
    end
  end

  # Lookup a macro by name.  Use only the name part and leave out the
  # leading '%{' and trailing '}'.  If any value exists for the macro, it is
  # evaluated such that the result will be macro-free.
  def lookup(name)
    val = @macros[name]
    return evaluate(val)
  end

  # Recursively examine the string and substitute any macros with their
  # values.
  def evaluate(value)
    return nil if value.nil?
    return value.gsub(/%\{([^}]+)\}/) { |x| lookup($1) }
  end
end


class RpmBuildOption
  attr_reader :name, :bits, :flags

  def initialize(bits, name, flags)
    @bits = bits
    @name = name
    @flags = flags
  end

  def includes(other)
    0 != @bits & other.bits
  end

  BINARIES = RpmBuildOption.new(1, 'binary', '-bb')
  SOURCES = RpmBuildOption.new(2, 'sources', '-bs')
  ALL = RpmBuildOption.new(1 + 2, 'all', '-ba')
end


# Wrapper around the rpmbuild command.
class RpmBuild
  attr_reader :rc

  def initialize()
    @rc = RpmRc.new()
  end

  def build_binary(rpm)
    SysUtils.run("rpmbuild #{$options.rpm_build_opts.flags} " +
      "#{rpm.spec_filename}")
  end

  def build_dir
    @rc.lookup('_builddir')
  end

  def rpm_dir
    @rc.lookup('_rpmdir')
  end

  def src_rpm_dir
    @rc.lookup('_srcrpmdir')
  end

  def sources_dir
    @rc.lookup('_sourcedir')
  end

  def spec_dir
    @rc.lookup('_specdir')
  end

  def rpm_filename(rpm)
    return File.join(rpm_dir, rpm.build_arch, 
      "#{rpm.name}-#{rpm.version}-#{rpm.release}.#{rpm.build_arch}.rpm")
  end

  def srpm_filename(rpm)
    return File.join(src_rpm_dir,
      "#{rpm.name}-#{rpm.version}-#{rpm.release}.src.rpm")
  end
end


# RpmSpecWriter has formatting primitives that simplify generating RPM spec
# files.
class RpmSpecWriter
  def initialize(fd)
    @fd = fd
  end

  # Write out the contents of +line+ and a newline, just like
  # +Kernel::puts+.
  def puts(line = '')
    @fd.puts(line)
  end

  # Write out an RPM header.  If the header is a multi-valued header then
  # the value is joined with ", ".  If the value is nil, nothing is written.
  def header(name, value)
    return if value.nil?
    if value.kind_of?(Array)
      return if value.length == 0

      value = value.join(', ')
    end
    puts("#{name}: #{value}")
  end

  # Declare a section and yield.
  def section(name, rest = nil)
    out = '%' + name
    if !rest.nil?
      out += ' ' + rest
    end

    puts()
    puts(out)
    yield
  end
end


# A simple abstraction for a %name section in an rpm file.  Every section
# has a %-sign, a name, possibly some text following the %name (e.g.
# %files -f %{name}.files), and a block of lines that follow.
class RpmSection
  # The name of the section
  attr_accessor :name

  # The rest of the line following the section declaration, if any.
  attr_accessor :rest

  # The block of lines that follow the section declaration after a newline.
  attr_accessor :text

  def initialize(name, rest = nil)
    @name = name
    @rest = rest
    @text = ''
  end

  # Add a line or lines to +text+.
  def <<(line)
    @text << "\n" if @text.length > 0
    @text << line
  end
end


# A generic interface to an RPM specification, consisiting of headers,
# sections, and a little bit of glue to connect to RpmSpecWriter.
class RpmSpec
  @@headers = []
  @@sections = []

  # RPM header names aren't suitable method names because Ruby wants at
  # least the initial character of the method to be lower case.  Generate
  # good ruby names from the RPM names by converting from CamelCase to
  # camel_case.
  def self.sym_name(name)
    case name
    when 'URL'
      return name.downcase

    else
      sym = name.dup
      sym.sub!(/^[A-Z]/) { |x| x.downcase }
      sym.gsub!(/[A-Z]/) { |x| '_' + x.downcase }
      return sym
    end
  end


  # Declares an RPM header.  +default_value+ can be lambda in which case a
  # new value is taken from the result of calling the lambda with no
  # arguments.
  def self.rpm_header(name, default_value = nil)
    get = sym_name(name)
    set = get + '='

    @@headers.push([name, get.intern, set.intern, default_value])
    attr_accessor(get.intern)
  end

  # Declares an RPM header whose value consists of a list of things.
  def self.rpm_list_header(name)
    default_value = lambda do
      Array.new()
    end
    rpm_header(name, default_value)
  end

  # Declares an RPM section.
  def self.rpm_section(name)
    get = sym_name(name)
    set = get + '='
    @@sections.push([name, get.intern, set.intern])
    attr_accessor(get.intern)
  end

  attr_accessor :spec_filename

  rpm_header('Name')
  rpm_header('Version')
  rpm_header('Release')
  rpm_header('Summary')
  rpm_header('License')
  rpm_header('Group')
  rpm_header('URL')

  #rpm_header('AutoReqProv')
  rpm_list_header('Conflicts')
  rpm_list_header('Provides')
  rpm_list_header('Requires')
  rpm_list_header('Obsoletes')

  rpm_list_header('Prereq')

  rpm_header('BuildArch')
  rpm_header('BuildRoot')
  rpm_list_header('BuildConflicts')
  rpm_list_header('BuildRequires')

  # The +sources+ array generates the Source0, Source1, etc headers.
  attr_reader :sources

  # The +patches+ array generates the Patch0, Patch1, etc headers.
  attr_reader :patches

  rpm_section('description')
  rpm_section('prep')
  rpm_section('build')
  rpm_section('install')
  rpm_section('clean')
  rpm_section('files')
  rpm_section('changelog')

  # Create a new RpmSpec that is intended to be written to the file
  # at +filename+.
  def initialize(filename)
    @spec_filename = filename

    @@headers.each do |name, get, set, default_value|
      if default_value.kind_of?(Proc)
        default_value = default_value.call()
      end
      self.send(set, default_value)
    end

    @sources = []
    @patches = []

    @@sections.each do |name, get, set|
      self.send(set, RpmSection.new(name))
    end
  end

  # Write the RpmSpec out to the +io+ which is already open for write.
  def write(io)
    @@headers.each do |name, get, set, |
      io.header(name, self.send(get))
    end

    @sources.each_with_index do |src, i|
      io.header('Source' + i.to_s, src)
    end

    @patches.each_with_index do |src, i|
      io.header('Source' + i.to_s, src)
    end

    @@sections.each do |name, get, set|
      sec = self.send(get)
      io.section(sec.name, sec.rest) do
        io.puts(sec.text)
      end
    end
  end
end


# Does the actual work of mapping GEM metadata to RPM metadata and
# generating RPM spec contents that will result in a successful build of the
# RPM.
class RpmTranslator
  attr_accessor :build
  attr_accessor :gem
  attr_accessor :rpm

  def initialize(build, gem_spec)
    @build = build
    @gem = gem_spec
    @rpm = RpmSpec.new(File.join(build.spec_dir, gem.name + '.spec'))
  end

  # Factory method that creates an RpmTranslator from a +build+ and a GEM
  # filename.
  def self.from_gem(build, filename)
    gem_spec = nil
    File.open(filename, 'r') do |fd|
      Gem::Package::open_from_io(fd) do |g|
        gem_spec = g.metadata
        gem_spec.loaded_from = filename
      end
    end
    rt = RpmTranslator.new(build, gem_spec)
    return rt
  end

  def add_external_requirements(options)
    # rubygems itself doesn't require rubygems, but everything else
    # does.
    @rpm.requires << "rubygems"

    # most packages require ri and rdoc to properly install
    @rpm.build_requires << 'ruby'
    @rpm.build_requires << 'rubygems'
    if @gem.has_rdoc?
      @rpm.build_requires << 'ruby-rdoc'  # or maybe /usr/bin/rdoc?
      @rpm.build_requires << 'ruby-ri'    # or maybe /usr/bin/ri?
    end
  end

  def add_requirement(name, op, ver)
    case op
    when '~>'
      @rpm.requires << "#{name} >= #{ver}"
      @rpm.requires << "#{name} < #{ver.bump}"
    else
      @rpm.requires << "#{name} #{op} #{ver}"
    end
  end

  # Translate the gem to an RPM using the given options.
  def translate_gem(options)
    name_ver = @gem.name + '-' + @gem.version.to_s
    gem_basename = File.basename(@gem.loaded_from)

    @rpm.sources << gem_basename

    @rpm.name = @gem.name
    @rpm.version = @gem.version.to_s
    @rpm.release = options.release
    @rpm.summary = @gem.summary
    @rpm.license = options.license
    @rpm.group = options.group
    @rpm.url = @gem.homepage

    if !options.arch.nil?
      @rpm.build_arch = options.arch
    else
      case @gem.platform
      when Gem::Platform::RUBY
        @rpm.build_arch = 'noarch'

      when /(i.86)-linux/
        @rpm.build_arch = 'i386'

      else
        raise "failed to convert unknown gem platform '#{@gem.platform}' to equivalent rpm BuildArch."
      end
    end

    @rpm.build_root = '%{_tmppath}/%{name}-%{version}-%{release}-root'

    # Populate dependencies.  Note that GEM can express multiple
    # requirements on a given package and its syntax for that is
    # incompatible with RPM.  Iterate over each version requirement
    # so as to build up the list correctly.
    @gem.dependencies.each do |dep|
      dep.version_requirements.requirements.each do |x|
        add_requirement(dep.name, *x)
      end
    end

    @rpm.description << wrap_text(@gem.description, 76)

    @rpm.prep << <<EOF
EOF

    @rpm.install << <<EOF
rm -rf $RPM_BUILD_ROOT
gem_file="$RPM_SOURCE_DIR/#{gem_basename}"
gem_path="$RPM_BUILD_ROOT#{Gem.path}"
usr_bin="$RPM_BUILD_ROOT#{build.rc.lookup('_bindir')}"
mkdir -p $gem_path
mkdir -p $usr_bin
/usr/bin/gem install --local --ignore-dependencies --install-dir $gem_path $gem_file
EOF

    # When running as non-root, gem wants to install into install-dir/bin.
    # We need that to end up in prefix/bin.
    @gem.executables.each do |bin|
      @rpm.install << "mv $gem_path/#{@gem.bindir}/#{bin} $usr_bin/#{bin}"
    end

    @rpm.clean << <<EOF
rm -rf $RPM_BUILD_ROOT
EOF

    @rpm.files << '%defattr(-,root,root)'

    bin_dir = build.rc.lookup('_bindir')
    @gem.executables.each do |bin|
      @rpm.files << File.join(bin_dir, bin)
    end

    @rpm.files << File.join(Gem.path, 'cache', gem_basename)
    @rpm.files << File.join(Gem.path, 'gems', name_ver)
    @rpm.files << File.join(Gem.path, 'specifications', name_ver + '.gemspec')

    if @gem.has_rdoc?
      @rpm.files << '%doc ' + File.join(Gem.path, 'doc', name_ver)
    end

    date = @gem.date.strftime('%a %b %d %Y')
    @rpm.changelog << <<EOF
* #{date} #{@gem.authors[0]} <#{gem.email}> - #{gem.version}-#{options.release}
- #{gem.name} release #{gem.version}.
EOF
  end
end

# Build a spec file for rubygems.  This is a special case because we use gem
# to build everything else.  Rubygems doesn't even include a gemspec file
# for itself anyway and it doesn't install like a gem either.
def build_rubygems_spec(build, src_file, options)
  name_ver = File.basename(src_file, '.tgz')
  version = ''
  if name_ver =~ /rubygems-(.*)/
    version = $1
  else
    raise "Failed to find a version in #{name_ver}"
  end

  # Ugh.  This information doesn't live anywhere that's accessible so just
  # include it here for the moment until something better comes along.
  rpm = RpmSpec.new(File.join(build.spec_dir, 'rubygems.spec'))
  rpm.name = 'rubygems'
  rpm.version = version
  rpm.release = options.release
  rpm.summary = 'RubyGems package manager'
  rpm.license = RUBYGEMS_LICENSE
  rpm.group = RUBYGEMS_GROUP
  rpm.url = 'http://rubyforge.org/projects/rubygems/'

  rpm.build_arch = 'noarch'
  rpm.build_root = '%{_tmppath}/%{name}-%{version}-%{release}-root'

  rpm.sources << File.basename(src_file)

  rpm.description << <<EOF
RubyGems is a package management system for Ruby applications and libraries.
RubyGems one command download makes installing Ruby software fun and
enjoyable again. (Ok, not really.)
EOF

  rpm.prep << <<EOF
%setup -q
EOF

  # Perform the install as if installing into a user directory.  Luckily it
  # appears that the RPM_BUILD_ROOT doesn't get burned into the tree
  # anywhere so it's portable back into the proper location.
  #
  # Sheer laziness says that all files in the $RPM_BUILD_ROOT must have been
  # put there by setup.rb so just find and call it done.
  rpm.install << <<EOF
rm -rf $RPM_BUILD_ROOT
gem_file="$RPM_SOURCE_DIR/#{File.basename(src_file)}"
prefix="$RPM_BUILD_ROOT#{build.rc.lookup('_prefix')}"
usr_bin="$RPM_BUILD_ROOT#{build.rc.lookup('_bindir')}"
#mkdir -p $usr_bin
export GEM_HOME=$RPM_BUILD_ROOT/usr/lib/ruby/gems/1.8
ruby setup.rb config --prefix=$prefix
ruby setup.rb setup
ruby setup.rb install
find $RPM_BUILD_ROOT -type f | sed "s|$RPM_BUILD_ROOT||" > %{name}.files
EOF

  rpm.clean << <<EOF
rm -rf $RPM_BUILD_ROOT
EOF

  rpm.files.rest = '-f %{name}.files'
  rpm.files << <<EOF
%defattr(-,root,root)
EOF

  return rpm
end

# Parse command-line options
def parse_options(argv) #:nodoc:
  options = OpenStruct.new()
  options.arch = nil
  options.group = DEFAULT_GROUP
  options.license = DEFAULT_LICENSE
  options.output_dir = nil
  options.release = '1'
  options.rpm_build_opts = RpmBuildOption::BINARIES
  options.show_gem_spec = false
  options.show_rpm_spec = false
  options.verbose = false

  op = OptionParser.new() do |op|
    op.banner = "Usage: #{$0} [options] <gem> [...]"
    op.version = PKG_VERSION
    op.release = PKG_RELEASE

    op.separator <<EOF
Converts a ruby gem file to an rpm.
EOF
    
    op.on('-a', '--arch [ARCH]',
      "RPM architecture (default is auto-detected from gem)") do |a|
      options.arch = a
    end
    
    op.on('-b', '--build [RPMS]',
      "build binary, source, or all rpms") do |r|
      case r
      when 'a', 'all'
        options.rpm_build_opts = RpmBuildOption::ALL

      when 'b', 'binary'
        options.rpm_build_opts = RpmBuildOption::BINARIES

      when 's', 'source'
        options.rpm_build_opts = RpmBuildOption::SOURCES

      else
        puts "Invalid build option '#{r}'"
        puts op.help
        exit(1)
      end
    end
    
    op.on('-g', '--group [GROUP]',
      "RPM group (default is '#{options.group}')") do |g|
      options.group = g
    end
    
    op.on('-l', '--license [LICENSE]',
      "RPM license (default is '#{options.license}')") do |l|
      options.license = l
    end

    op.on('-O', '--output-dir [DIR]',
      "Output directory") do |d|
      options.output_dir = d
    end
    
    op.on('-r', '--release [RELEASE]',
      "RPM release (default is #{options.release})") do |r|
      options.release = r
    end
    
    op.on('-G', '--show-gem',
      "Show the GEM spec file on stdout") do
      options.show_gem_spec = true
    end
    
    op.on('-R', '--show-rpm',
      "Show the RPM spec file on stdout") do
      options.show_rpm_spec = true
    end
    
    op.on('-v', '--verbose',
      "Run verbosely") do
      options.verbose = true
    end
  end
  op.parse!(argv)

  if argv.size < 1
    puts op.help
    exit(1)
  end

  return options
end


# run gem2rpm.
def main()
  $options = parse_options(ARGV)
  build = RpmBuild.new()

  src_file = ARGV[0]
  rpm = nil
  case File.basename(src_file)
  when /\.gem$/
    if !$gems_present
      perror("Cannot process #{src_file} until rubygems is itself installed")
      exit(1)
    end

    trans = RpmTranslator.from_gem(build, src_file)
    trans.translate_gem($options)
    trans.add_external_requirements($options)
    rpm = trans.rpm

    if $options.show_gem_spec
      ptrace("##### gem spec contents: #####")
      $stderr.puts trans.gem.to_ruby
      ptrace("##### end gem spec contents #####")
    end
    
  when /^rubygems-[0-9\.]*\.tgz$/
    rpm = build_rubygems_spec(build, src_file, $options)

  else
    perror("Don't know how to make an rpm from #{src_file}")
    exit(1)
  end

  # Create the spec file
  ptrace("# creating #{rpm.spec_filename}")
  File.open(rpm.spec_filename, 'w') do |fd|
    rpm_out = RpmSpecWriter.new(fd)
    rpm.write(rpm_out)
  end

  if $options.show_rpm_spec
    ptrace("##### rpm spec contents: #####")
    File.open(rpm.spec_filename, 'r') do |fd|
      fd.each_line do |line|
        $stderr.puts(line)
      end
    end
    ptrace("##### end rpm spec contents #####")
  end

  # Install files for rpmbuild and build.
  SysUtils.cp(src_file, build.sources_dir)
  build.build_binary(rpm)

  if $options.output_dir
    if $options.rpm_build_opts.includes(RpmBuildOption::BINARIES)
      SysUtils.cp(build.rpm_filename(rpm), $options.output_dir)
    end

    if $options.rpm_build_opts.includes(RpmBuildOption::SOURCES)
      SysUtils.cp(build.srpm_filename(rpm), $options.output_dir)
    end
  end
end

main()

# vim: ai et sts=2 sw=2
