#!/usr/bin/env ruby

#
# checkin -- A Ruby script to facilitate checkins.
#

#$: << "#{File.dirname(__FILE__)}"

require 'etc'
require 'net/smtp'
#require 'io/filters'

#
# Get basic informations from environment variables.
#
class Opt

  attr_reader :news_server, :newsgroup, :news_file, :want_news,
	      :mail_server, :checkin_mailaddr, :mail_file, :want_mail,
	      :changelog_old, :changelog_add, :changelog,
	      :diffs_patch, :comment, :rcs, :pager

  def initialize
    safe_die('RCS not set (choose one SVN CVS PRCS).') if ENV['RCS'].nil?
    @rcs = Class.const_get(ENV['RCS'].upcase)
    @diffs_patch = 'diffs.patch'		# diff output file
    @comment = 'checkin.comment'		# Comment file
    @news_file = 'checkin.news'			# generated news file
    @mail_file = 'checkin.mail'			# generated mail file
    @changelog_old = 'ChangeLog.old'
    @changelog_add = 'ChangeLog.add'
    @changelog = 'ChangeLog'
    @want_news = ! ENV['CHECKIN_NEWSGROUP'].nil?
    @want_mail = ! ENV['CHECKIN_MAILADDR'].nil?
    @news_server = ENV['CHECKIN_NEWSSERVER']
    @mail_server = ENV['CHECKIN_MAILSERVER'] || 'localhost'
    @checkin_mailaddr = (ENV['CHECKIN_MAILADDR'] || '').split(/,\s+/)
    @newsgroup = (ENV['CHECKIN_NEWSGROUP'] || '').split(/,\s+/)
    @pager = ENV['PAGER'] || 'less'
    @fullname = @mail_addr = @editor = @extra = @subject = nil
  end

  # Text editor
  # Get editor from the environment, performs extra stuffs for vim.
  def editor
    if @editor.nil?
      @editor = ENV['EDITOR'] || 'emacs -nw'
      @editor += ' -o' if @editor =~ /vim/
    end
    @editor
  end
  
  # Get FULL name.
  def fullname
    if @fullname.nil?
      @fullname = ENV['FULLNAME']
      if @fullname.nil?
	@fullname = (Etc.getpwnam ENV['USER']).gecos
	$stderr.puts "Warning: FULLNAME not set, using #{@fullname} instead."
      end
    end
    @fullname
  end

  # Get mail addr.
  def mail_addr
    if @mail_addr.nil?
      @mail_addr = ENV['MAILADDR'] || ENV['EMAIL']
      if @mail_addr.nil?
	@mail_addr = "#{ENV['USER']}@#{`hostname -f`}"
	@mail_addr.chomp!
	$stderr.puts "Warning: MAILADDR not set, using #{mail_addr} instead."
      end
    end
    @mail_addr
  end

  # Get extra version information.
  def extra
    while @extra.nil? or @extra == "\n" do
      puts 'Extra version information (for mail/news subject): '
      @extra = $stdin.gets
    end
    @extra
  end

  def subject
    if @subject.nil?
      safe_die('Warning: Invalid environement variable CHECKIN_SUBJECT, ' +
	       'news/mail not posted.') unless ENV['CHECKIN_SUBJECT']
      @subject = "#{ENV['CHECKIN_SUBJECT']}"
      @subject.gsub!(/\$extra/, extra.chomp)
      @subject.gsub!(/\$version/, "#{$rcs.version}")
      puts "Final subject is: \"#{@subject}\""
    end
    @subject
  end
end


if ENV['CHECKIN_DEBUG']
  $svn = 'echo CHECKIN_DEBUG svn'
  $cvs = 'echo CHECKIN_DEBUG cvs'
  $prcs = 'echo CHECKIN_DEBUG prcs'
else
  $svn = 'svn'
  $cvs = 'cvs'
  $prcs = 'prcs'
end

class RCS
  attr_reader :version, :opt, :checkout, :diff, :status, :files

  def initialize(opt, files='')
    @opt = opt
    @files = files
  end

  def do_diff
    IO.popen self.diff
  end

  def do_checkout
    system(self.checkout)
  end

  def pager_diff
    system(self.diff + ' | ' + @opt.pager)
  end

  def pager_status
    system(self.status + ' | ' + @opt.pager)
  end

  #
  # Generates the diff and update ChangeLog.add accordingly.
  #
  def generate_diffs_and_changelog_add
    add = File.new(@opt.changelog_add, 'w')
    diffs_patch = File.new(@opt.diffs_patch, 'w')
    rcs_diff = do_diff

    # Creates ChangeLog.add with an empty entry in it.
    add.print `date +"%Y-%m-%d  #{@opt.fullname}  <#{@opt.mail_addr}>%n"`

    rcs_diff.each do |line|
      diffs_patch.print line
      if line =~ /^Index: $/
	line = rcs_diff.gets
	diffs_patch.print line
	add.puts "\t* #{line}: "
      end
      add.puts "\t* #$1: " if line =~ /^Index: (.+)$/
    end
    add.puts
    [add, diffs_patch, rcs_diff].each { |f| f.close }
  end
end

class CVS < RCS

  attr_reader :diff, :status

  def initialize(opt, files='')
    super
    @files = files

    # cvs diff options.
    # -u	Output 2 lines of unified context.
    @diff = "cvs diff -u #@files"
    @status = "cvs status #@files"
    @checkout = "cvs update #@files"
  end

  # Effectively do the checkin for CVS.
  def checkin
    system "#$cvs commit -F #{opt.changelog_add} #@files" \
      or safe_die('Cannot checkin.')
    $stderr.puts 'CVS commit: Done.'
  end
end

class PRCS < RCS
  def initialize(opt, files='')
    super
    raise ArgumentError,
          'prcs doesn\'t support partial checkin.' unless files.empty?

    # prcs options.
    # -N	Treat absent files as empty.
    # -P	Do not output changes of the project file.
    # diff options.
    # -u	Output 3 lines of unified context.
    # -w	Ignore all white space.
    @diff = "prcs diff -NP -- -uw"
  #    IO::InputFilter IO.popen("prcs diff -NP -- -uw") do |line|
  #      line.sub(/^(Index:|---|\+\+\+) [^\/]*\/ /, "#$1 ")
  #    end
  #              | sed -e 's/^\(Index:|---|\+\+\+\) [^\/]*\/ /\1 /'")
    @status = "prcs status"
    @checkout = "prcs checkout"
  end

  # Effectively do the checkin for PRCS. Also extract the version.
  def checkin
    prjfile_name = glob("*.prj")
    prjfile = File.new(prjfile_name, 'r')
    prjfile_new = File.new(prjfile_name + '.new', 'r')
    prjfile.each do |line|
      if line =~ /\(New-Version-Log ""\)/
	prjfile_new.print "(New-Version-Log \""
	changelog_add = changelog.add
	changelog_add.each { |line| prjfile_new.print line }
	prjfile_new.print "\")\n"
      else
	prjfile_new.print line
      end
    end
    prjfile_new.flush
    rename "#{prjfile_name}.new", prjfile_name
    system "#$prcs checkin 2> prcs.out" and safe_die('Cannot checkin.')
    @version = File.new('prcs.out').gets
    if (defined? @version)
      print @version
      @version.sub!(/.*([0-9]+\.[0-9]+)\.$/, $1)
      @version.chomp!; # Just a security, who knows?
    end
    File.unlink 'prcs.out'
    $stderr.puts 'PRCS checkin: Done.'
  end
end

class SVN < RCS
  def initialize(opt, files='')
    super
    @files = files

    # diff options.
    # -N	Treat absent files as empty.
    # -P	When comparing directories, treat absent files in the second
    #		directory as empty.
    # -b	Ignore changes in the amount of white space.
    # -u	Output 3 lines of unified context.
    # -w	Ignore all white space.
    @diff = "#$svn diff #@files --diff-cmd diff --extensions -NPbuw"
    #    IO::InputFilter IO.popen("prcs diff -NP -- -uw") do |line|
    #      line.sub(/^(Index:|---|\+\+\+) [^\/]*\/ /, "#$1 ")
    #    end
    #              | sed -e 's/^\(Index:|---|\+\+\+\) [^\/]*\/ /\1 /'")
    @status = "svn status #@files"
    @checkout = "#$svn update #@files"
  end


  # Effectively do the checkin for Subversion. Also extract the version.
  def checkin
    svn_out = IO.popen "#$svn commit #@files --file #{opt.changelog_add}"
    svn_out.each do |line|
      print line
      if line =~ /Committed revision ([0-9.]+)\./
	@version = $1
	return $stderr.puts('SUBVERSION commit: Done.')
      elsif line =~ /Commit failed/
	safe_die('SUBVERSION commit failed.')
      end
    end
    safe_die('Got weird output from svn, aborting.')
  end
end

#
# Display a yes/no question, and returns false for no, true for yes.
#
def yes_no_querry(question, yn)
  print "#{question}? [#{yn}]: "
  yn.gsub!(/[yn]/, '')
  loop do
    ans = $stdin.gets
    ans = yn if ans == "\n"
    return true  if ans =~ /^[yY]/
    return false if ans =~ /^[nN]/
    print "\nInvalid answer! So what: "
  end
end

#
# Backup the ChangeLog into ChangeLog.old, prepend ChangeLog.add to the
# ChangeLog.
# Provide editing facilities for the ChangeLog, then allow the user to
# write a comment and check that she wants to continue.
#
def edit_changelog_and_comments(opt)
  File.rename opt.changelog, opt.changelog_old
  changelog = File.new(opt.changelog, 'w')
  IO.foreach(opt.changelog_add) { |line| changelog.print line }
  IO.foreach(opt.changelog_old) { |line| changelog.print line }
  changelog.close

  system "#{opt.editor} #{opt.diffs_patch} #{opt.changelog}"

  # Extract the final new entry from the ChangeLog and backup it in
  # ChangeLog.add.
  add = File.new(opt.changelog_add, 'w')
  changelog = File.new(opt.changelog, 'r')
  add.print changelog.gets
  changelog.each do |line|
    break if line =~ /[0-9]{4}-[0-9]{2}-[0-9]{2}  .*  <.*>\s*$/
    add.print line
  end
  add.close

  if yes_no_querry('Add a comment', 'yN')
    system "#{opt.editor} #{opt.comment}"
  else
    if FileTest.exist?(opt.comment)
      File.truncate opt.comment, 0
    else
      system("touch #{opt.comment}")
    end
  end
end

#
# Generate the body of the mail.
#
def generate_mail(opt, to)
  mail = File.new(opt.mail_file, 'w+')
  mail.puts to
  mail.puts "From: #{opt.fullname} <#{opt.mail_addr}>"
  mail.puts "Subject: #{opt.subject}"
  mail.puts
  IO.foreach(opt.comment) { |line| mail.print line }
  mail.puts
  IO.foreach(opt.changelog_add) { |line| mail.print line }
  IO.foreach(opt.diffs_patch) { |line| mail.print line }
  mail.rewind
  mail
end


#
# Post the news.
#
def do_news(opt)
  news = generate_mail(opt, "Newsgroups: #{opt.newsgroup.join(', ')}")
  sock = TCPSocket.new(opt.news_server, 119)
  sock.puts 'post'
  news.each { |line| sock.print line.gsub(/^\./, ' .') }
  news.close
  sock.puts '.'
  sock.puts 'quit'
  sock.close
  $stderr.puts 'News: Sent.'
end


#
# Mail.
#
def do_mail(opt,
            mail = generate_mail(opt, "To: #{opt.checkin_mailaddr.join(', ')}"))
  Net::SMTP.start(opt.mail_server, 25) do |smtp|
    smtp.open_message_stream(opt.mail_addr, opt.checkin_mailaddr) do |f|
      mail.each { |line| f.print line }
    end
  end
  mail.close
  $stderr.puts 'Mail: Sent.'
end

#
# Clean temporary files.
#
def do_clean
  [$opt.comment,
   $opt.news_file,
   $opt.mail_file,
   $opt.changelog_add].each { |f| File.unlink f if FileTest.exist? f }
end

#
# Die, but backup some files and clean.
#
def safe_die(err)
  if $opt
    File.rename $opt.changelog_old,
		$opt.changelog if FileTest.exist? $opt.changelog_old

#    [$opt.news_file, $opt.mail_file].each do |f|
#      File.unlink f if FileTest.exist? f
#    end
  end
  $stderr.puts err
  exit 0
end


def main
  opt = $opt = Opt.new

  if $*.include?('--mail')
    do_mail(opt, File.new(opt.mail_file)) 
    exit
  end

  $* << 'ChangeLog' if !$*.empty? and !$*.include?('ChangeLog')
  rcs = $rcs = opt.rcs.new(opt, $*.join(' '))

  #
  # Do the checkout and update the ChangeLog if no backup files are found.
  #
  if not FileTest.exist?(opt.changelog_add) or
     not yes_no_querry('Backup files found. Use', 'Yn')

     rcs.do_checkout if yes_no_querry('Perform a checkout before checkin', 'Yn')
     rcs.generate_diffs_and_changelog_add
  end
      
  edit_changelog_and_comments(opt)

  #force subject creation.
  opt.extra

  rcs.pager_diff
  rcs.pager_status

  # Announce to the user what actions will be performed.
  puts "I will:"
  puts "\t* Checkin/Commit"
  puts "\t* Post a news on #{opt.newsgroup} " +
       "with server #{opt.news_server}" if opt.want_news
  puts "\t* Send a mail to #{opt.checkin_mailaddr.join(', ')}" if opt.want_mail
  yes_no_querry("Continue", "Yn") or safe_die('Abort.')

  # Do the checkin.
  rcs.checkin

  # Mail and news.
  do_news(opt) if opt.want_news
  do_mail(opt) if opt.want_mail
  do_clean()
end

$opt = nil

begin
  main()
rescue SystemCallError => e
  safe_die("#{e.class}: #{e.to_s}")
rescue String => e
  safe_die(e)
rescue Object => e
  safe_die(e)
end

