#!/usr/bin/ruby -w
# -*- ruby -*-

# $Id: p4delta.rb,v 1.2 2003/06/18 17:59:17 jeugenepace Exp $

# p4delta: summarizes Perforce changes and executes the appropriate commands

# This program is as identical as possible to cvsdelta. Changes should be kept
# in sync

require 'getoptlong'
require 'find'

$verbose = false                # whether to spew debugging output
$stdout.sync = true             # unbuffer output
$stderr.sync = true             # unbuffer output

$PACKAGE = "p4delta"
$VERSION = "1.3.2"

require './progmet'
require './regexp'
require './file'
require './p4ignore'

## ------------------------------------------------------- 
## A file that has changed with respect to the configuration management system.
## # This can be one that has been added (a new file), changed (a previously #
## existing file), or deleted (one that has been removed).
## ------------------------------------------------------- 

class DeltaFile

  attr_accessor :adds, :changes, :deletes, :name
  
  def initialize(name)
    # in Ruby, these are primitives, not Objects, so they are not
    # referencing the same primitive value (i.e., this is just like Java)
    @adds = @changes = @deletes = 0
    @name = File.clean_name(name)
  end

  def total
    @adds + @changes + @deletes
  end

end


class ExistingFile < DeltaFile

  def symbol; "*"; end
  
end


class DeletedFile < DeltaFile

  def initialize(name)
    super
    # it would be nice to know how long the file was, i.e., many lines were
    # deleted
  end

  def symbol; "-"; end
  
end


class NewFile < DeltaFile

  def initialize(name)
    super
    @adds = IO.readlines(name).length
  end

  def symbol; "+"; end
  
end


## -------------------------------------------------------
## Processing of "diff" output, either contextual ("long form") or unified
## (traditional).
## -------------------------------------------------------

class DiffOutput

  def initialize(total, regexp)
    @total  = total
    @regexp = regexp
    @md     = nil
  end

  def match(line)
    @md = @regexp.match(line)
  end

  def update(record)
    nlines = number_of_lines
    update_record(record, nlines)
    update_record(@total, nlines)
  end

  def to_s
    self.class.to_s + " " + @regexp.source
  end

end


class NormalDiffOutput < DiffOutput

  def initialize(total, letter)
    fmt = '(\d+)(?:,(\d+))?'
    re  = Regexp.new("^" + fmt + letter + fmt)
    super(total, re)
  end

  # Returns the amount of lines that changed, based on the MatchData object
  # which is from standard diff output

  def number_of_lines
    from = diff_difference(1, 2)
    to   = diff_difference(3, 4)
    1 + [from, to].max
  end

  # Returns the difference between the two match data objects, which represent
  # diff output (3,4c4).

  def diff_difference(from, to)
    if @md[to] then @md[to].to_i - @md[from].to_i else 0 end
  end

end


class NormalDiffOutputAdd < NormalDiffOutput

  def initialize(total)
    super(total, 'a')
  end

  def update_record(rec, nlines)
    rec.adds += nlines
  end

end


class NormalDiffOutputChange < NormalDiffOutput

  def initialize(total)
    super(total, 'c')
  end

  def update_record(rec, nlines)
    rec.changes += nlines
  end

end


class NormalDiffOutputDelete < NormalDiffOutput

  def initialize(total)
    super(total, 'd')
  end

  def update_record(rec, nlines)
    rec.deletes += nlines
  end

end


class NormalDiffProcessor

  def initialize(total)
    name = self.class
    @addre = NormalDiffOutputAdd.new(total)
    @delre = NormalDiffOutputDelete.new(total)
    @chgre = NormalDiffOutputChange.new(total)
  end

  def get_tests(line)
    [ @addre, @chgre, @delre ]
  end

end



## -------------------------------------------------------
## Perforce-specific code
## -------------------------------------------------------

# A difference within a configuration management system.

class P4Delta
  include Log
  
  attr_reader :added, :changed, :deleted, :total

  def initialize(options, args)
    log ""
    
    @options = options

    log "set options."

    # sanity check.
    p4info = `p4 info 2>&1`
    log "p4info: #{p4info}"
    if p4info.index(/command\s+not\s+found/)
      $stderr.puts "ERROR: p4 executable not in path."
      exit(2)
    elsif p4info.index(/Perforce client error:/)
      if ENV['P4PORT']
        $stderr.puts "ERROR: invalid P4PORT: #{ENV['P4PORT']}"
      else
        $stderr.puts "ERROR: P4PORT environment variable not set."
      end
      exit(2)
    end

    # for showing that we're actually doing something
    @progress = if @options.quiet then nil else ProgressMeter.new(options.verbose) end

    log ""

    @ignored_patterns = IgnoredPatterns.new(".p4ignore")

    log ""

    log "args: #{args}"

    @args = if args.length > 0 then args else [ "." ] end
  end
  
  def run
    @added    = FileHash.new
    @changed  = FileHash.new
    @deleted  = FileHash.new
    @total    = DeltaFile.new("total")
    @warned   = Array.new
    @entries  = Array.new
    @entfiles = Array.new

    log ""

    diffprocessor = NormalDiffProcessor.new(@total)
    curfile = nil

    # backticks seem to work more consistenty than IO.popen, which was losing
    # lines from the diff output.

    dirs = @args.collect do |a|
      if File.is_file?(a) then File.dirname(a) else a end
    end
    
    dirs.uniq.each { |dir| @ignored_patterns.read(dir) }
    
    # directories get appended with "...", Perforce-speak for "*.*"
    log "args: #{@args}"
    
    fileargs = @args.collect { |a| File.is_file?(a) ? a : a + "/..." }

    cmd    = "p4 diff " + (if @options.force_diff then "-f " else "" end) + fileargs.join(" ") + " 2>&1"
    lines  = `#{cmd}`
    pwd    = Dir.pwd
    @cwdre = Regexp.new(pwd + "/?")

    lines.each do |line|
      @progress.tick(line) if @progress

      log "line: " + line

      if line.index(/^====.* \- (.*) ====\s*$/)
        curfile = $1
        curfile.sub!(@cwdre, "")
      else
        tests = diffprocessor.get_tests(line)
        tests.each do |re|
          if re.match(line)
            log re.to_s + ": matches: " + line
            rec = get_record(curfile)
            re.update(rec)
          else
            log re.to_s + ": not a match line: " + line
          end
        end
      end
    end

    # determine new files

    cmd      = "p4 where . 2>&1"
    dep2dir  = `#{cmd}`
    mapping  = dep2dir.split(/\s+/)
    @depot   = mapping[0]
    @dir     = mapping[-1]

    cmd      = "p4 files " + fileargs.join(" ") + " 2>&1"
    lines    = `#{cmd}`
    @p4files = Array.new

    lines.each do |line|
      line.chomp!
      next if line.index(/no such file/)
      fname, status = depot_to_local(line)
      log "fname = #{fname}"
      if status == "delete"
        log "skipping #{fname}"
      else
        log "p4file: #{fname}"
        @p4files.push(fname) 
      end
    end

    @args.each do |arg|
      if File.directory?(arg)
        dirs = Array.new
        File.find_files(arg).each do |f|
          dir = File.dirname(f)
          unless dirs.include?(dir)
            @ignored_patterns.read(dir)
            dirs.push(dir)
          end
          add_file(f)
        end
      else
        add_file(arg) 
      end
    end

    @p4files.each do |p4f|
      unless File.exists?(p4f)
        add_deleted_file(p4f)
      end
    end
    
  end

  # Converts the depot file name to a local one, removing the current working
  # directory from the front of the file name. Returns the name and the current
  # status.

  def depot_to_local(dname)
    @progress.tick(dname) if @progress

    log "depot_to_local(#{dname})"

    fname = dname.sub(/\#\d+ +\- +(add|edit|delete)?.*/, "")
    status = $1

    log "fname   = #{fname}"
    log "status  = #{status}"
    
    cmd     = "p4 where \"" + fname + "\""
    dep2dir = `#{cmd}`

    log "dep2dir = #{dep2dir}"

    depot = nil
    dir = nil

    dep2dir.split(/\n/).each do |d2d|
      mapping = d2d.split(/\s+/)
      depot   = mapping[0]
      dir     = mapping[-1]
      break if depot && dir && !depot.index(/^\-/)
    end

    log "depot   = #{depot}"
    log "dir     = #{dir}"
    log "cwdre   = #{@cwdre.source}"

    log "fname0  = #{fname}"
    fname.sub!(Regexp.escape(depot), dir)
    log "fname1  = #{fname}"
    fname.sub!(@cwdre, "")
    log "fname2  = #{fname}"
    
    [ fname, status ]
  end

  # Returns whether the given file is included in Perforce.

  def is_file_included?(file)
    @p4files.include?(file)
  end

  def add_file(file)
    fname = File.clean_name(file)
    if is_file_included?(fname)
      log "file already in entries: " + fname
    elsif @ignored_patterns.is_ignored?(fname)
      log "file is ignored: " + fname
    else
      log "adding file: " + fname
      if File.readable?(fname)
        unless @added.include?(fname)
          # don't add it twice
          @added[fname] = NewFile.new(fname)
          @total.adds += @added[fname].adds
        end
      else
        unless @warned.include?(fname)
          puts "not readable: " + fname 
          @warned.push(fname)
        end
      end
    end
  end

  def add_deleted_file(file)
    @deleted[file] = DeletedFile.new(file)
  end
  
  def get_record(file)
    @changed[file] = ExistingFile.new(file) unless @changed.include?(file)
    @changed[file]
  end

  def execute
    print "\nEXECUTING COMMANDS\n"
    
    print "\n    ADDs\n"
    if @added.length > 0
      execute_command(@added.keys, "add")
    end

    print "\n    EDITs\n"
    if @changed.length > 0
      opened = Array.new
      `p4 opened`.each do |opline|
        opline.chomp!
        fname = depot_to_local(opline)[0]
        opened.push(fname)
      end

      toedit = @changed.keys.select { |c| !opened.include?(c) }
      execute_command(toedit, "edit")
    end

    if @options.confirm
      dels = @deleted.keys.reject { |name|
        print "delete " + name + "? "
        ans = $stdin.readline
        ans.upcase[0, 1] != 'Y'
      }
    else
      dels = @deleted.keys
    end

    print "\n    DELETEs\n"
    execute_command(dels, "delete")
  end

  # TODO: fix for spaces in names
  def execute_command(names, command)
    if names.size > 0
      cmd = "p4 " + command + ' "' + names.join('" "') + '"'
      print "        ", cmd, "\n"
      system(cmd)
    else
      log "no files to " + command
    end
  end


  def print_change_summary
    puts
    printf "%-7s  %-7s  %-7s  %-7s  %s\n", "total", "added", "changed", "deleted", "file"
    printf "=======  =======  =======  =======  ====================\n"

    files = Hash.new
    [ @added, @changed, @deleted ].each do |ary|
      ary.each do |file, record| 
        files[file] = record
      end
    end

    files.sort.each do |file, record|
      print_record(record)
    end
    
    printf "-------  -------  -------  -------  --------------------\n";
    print_record(@total, "Total")
  end

  def print_record(rec, name = nil)
    name = rec.symbol + " " + rec.name unless name
    [rec.total, rec.adds, rec.changes, rec.deletes].each do |v|
      printf("%7d  ", v)
    end
    print name, "\n"
  end
  
end


syntax highlighted by Code2HTML, v. 0.9.1