#!/usr/bin/env ruby
# -*- ruby -*-
#
# Copyright (c) 2000-2001 Akinori MUSHA
#
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 1. Redistributions of source code must retain the above copyright
#    notice, this list of conditions and the following disclaimer.
# 2. Redistributions in binary form must reproduce the above copyright
#    notice, this list of conditions and the following disclaimer in the
#    documentation and/or other materials provided with the distribution.
#
# THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
# ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
# OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
# HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
# SUCH DAMAGE.
#

RCS_ID = %q$Idaemons: /home/cvs/pkgtools/portsclean,v 1.11 2001/08/21 10:57:26 knu Exp $
RCS_REVISION = RCS_ID.split[2]
MYNAME = File.basename($0)

require "optparse"
require "pkgtools"
require "find"

$interactive = false
$noexecute = false

COLUMNSIZE = 20
NEXTLINE = "\n%*s" % [5 + COLUMNSIZE, '']

def main(argv)
  usage = <<-"EOF"
usage: #{MYNAME} [-hCDiLn]
  EOF

  banner = <<-"EOF"
#{MYNAME} rev.#{RCS_REVISION}

#{usage}
  EOF

  OptionParser.new(banner, COLUMNSIZE) do |opts|
    opts.def_option("-h", "--help", "Show this message") {
      print opts
      exit 0
    }

    opts.def_option("-C", "--workclean", "Clean up working directories") {
      |$workclean|
    }

    opts.def_option("-D", "--distclean", "Clean up unreferenced distfiles") {
      |$distclean|
    }

    opts.def_option("-i", "--interactive", "Turn on interactive mode") {
      |$interactive|
    }

    opts.def_option("-L", "--libclean", "Clean up old shared libraries (not implemented yet)") {
      |$libclean|
    }

    opts.def_option("-n", "--noexecute", "Do not actually delete files") {
      |$noexecute|
      if $noexecute
	$interactive = false
      end
    }

    begin
      opts.order!(argv)

      if !$workclean && !$distclean && !$libclean
	print opts
	exit 0
      end

      workclean if $workclean
      distclean if $distclean
      libclean if $libclean
    rescue OptionParser::ParseError => e
      STDERR.puts "#{MYNAME}: #{e}", usage
      exit 64
    end
  end

  return 0
end

def workclean()
  perm = '770'
  grp = 'wheel'

  unless File.exists?(File.join($ports_dir, 'Mk/bsd.port.mk'))
    STDERR.puts "#{MYNAME}: PORTSDIR (#{$ports_dir}) does not seem to be a ports directory."
    return false
  end

  wrkdirprefix = `cd #{$ports_dir} && make -V WRKDIRPREFIX`.strip.tr_s('/', '/').chomp('/')

  if wrkdirprefix.empty?
    puts "Cleaning #{$ports_dir}/*/*/work..."

    # Ruby's glob yields the given block everytime a new matching
    # entry is found, so it doesn't overflow, and we don't have to
    # wait until everything is found.

    Dir.chdir $ports_dir
    Dir.glob(File.join($ports_dir, "*/*/work")) do |dir|
      delete_dir dir
    end

    puts "done."
    return true
  end

  wrkdir = wrkdirprefix + $ports_dir

  puts "Cleaning #{wrkdir}..."

  if File.directory?(wrkdir)
    delete_dir wrkdir
  end

  # Dig group writable directories in advance so all wheel users can
  # build ports without the root privilege.

  system('/bin/mkdir', '-p', wrkdir)
  system('/usr/bin/chgrp', '-f', grp, wrkdir)
  system('/bin/chmod', '-f', perm, wrkdir)

  $portsdb.categories.each do |category|
    subdir = File.join(wrkdir, category)

    system('/bin/mkdir', '-p', subdir)
    system('/usr/bin/chgrp', '-f', grp, subdir)
    system('/bin/chmod', '-f', perm, subdir)
  end

  puts "done."

  true
end

def distclean()
  puts "Detecting unreferenced distfiles..."

  dist_dir = `make -f #{$ports_dir}/Mk/bsd.port.mk -V DISTDIR 2>&1`.chomp

  ports = installed_ports
  hash = categorize_ports(ports)
  categories = sorted_categories(hash)
  distfiles = scan_distfiles(dist_dir)

  catch (:done) {
    throw :done if distfiles.empty?

    ports.each do |origin|
      check_distinfo(origin).each do |file|
	distfiles.delete file
      end

      throw :done if distfiles.empty?
    end

    categories.each do |category|
      installed = hash[category]

      $portsdb.each_origin(category) do |origin|
	next if installed.include? origin

	check_distinfo(origin).each do |file|
	  distfiles.delete file
	end

	throw :done if distfiles.empty?
      end
    end
  }

  if distfiles.empty?
    puts 'no unreferenced distfiles found.'
    exit 0
  end

  distfiles.each do |file|
    file = File.join(dist_dir, file)

    delete_file file
  end
end

def libclean()
  libdirs = nil

  localbase = $portsdb.localbase
  x11base = $portsdb.x11base

  prefix_re = "(?:#{Regexp.quote(localbase)}|#{Regexp.quote(x11base)})"

  libtable = {}

  `/sbin/ldconfig -elf -r`.each do |line|
    line.strip!

    case line
    when /^search directories:\s*(.*)/
      libdirs = $1.split(':')
    when %r"^\d+:-l.*\s+=>\s+((#{prefix_re})/.*/lib([^/]+)\.so\.(\d+))"
      path, prefix, libname, ver = $1, $2, $3, $4

      hash = libtable[libname] || (libtable[libname] = {})
      hash[ver] = [path, prefix]
    end
  end

  libtable.each do |libname, hash|
    next if hash.keys.size < 2

    hash.select { |ver, (path, )|
      if File.symlink?(path)
	path = read_link(path)
      end

      pkg = `#{PkgDB::CMD[:pkg_info]} -qW #{path}`.strip
      pkg.empty? and pkg = nil

      hash[ver] << pkg

      !pkg
    }.empty? and next

    libs = hash.sort { |a, b| b[0] <=> a[0] }

    puts "You have multiple versions of lib#{libname} but some of them are not from ports:"

    hash.each do |ver, (path, prefix, pkg)|
      puts "\t#{libname}.#{ver} (#{path}) <- " + (pkg || "?")
    end

    f = find_so_link(libname, libdirs)

    if f
      s = read_link(f)

      pkg = nil

      hash.each do |ver, (path, prefix, _pkg)|
	if path == s
	  pkg = _pkg
	  break
	end
      end

      d, l, v = parse_so(s)

      if d
	puts "and the symlink (#{f}) points to:"
	puts "\t#{l}.#{v} (#{s}) <- " + (pkg || "?")
      end
    end

    puts ""
  end
end

def read_link(f)
  l = File.readlink(f)

  if l[0] != ?/
    l = File.join(File.dirname(f), l)
  end

  true while l.gsub!(%r"/([^/]+/\.)?\.(/|$)/", '/')

  l
end

def find_so_link(libname, libdirs)
  libdirs.each do |dir|
    f = File.join(dir, "lib#{libname}.so")

    if File.file?(f)
      if File.symlink?(f)
	return f
      else
	return nil
      end
    end
  end

  nil
end

def parse_so(f)
  dir, file = File.split(f)

  if /^lib(.*)\.so((?:\.\d+)*)?$/ =~ file
    return dir, $1, $2.sub(%r"^\.", "")
  end

  return nil
end

def installed_ports
  return `#{PkgDB::CMD[:pkg_info]} -aqo 2>/dev/null`.split
end

def categorize_ports(ports)
  hash = Hash.new([])

  ports.each do |origin|
    category, = origin.split('/')

    if hash.key?(category)
      hash[category] << origin
    else
      hash[category] = [origin]
    end
  end

  hash
end

def sorted_categories(hash)
  $portsdb.categories.sort { |a,b|
    hash[b].size <=> hash[a].size
  }
end

def scan_distfiles(dist_dir)
  distfiles = []

  Dir.chdir(dist_dir)
  Find.find('.') do |f|
    next if f == '.'
    next unless File.file? f

    distfiles << f[2..-1]	# chop `./' off
  end

  distfiles.sort!
end

def parse_distinfo(file)
  distfiles = []

  open(file) do |f|
    f.each do |line|
      if /^MD5 \((.*)\) = / =~ line
	distfiles << $1
      end
    end

    return distfiles
  end rescue []
end
  
def check_distinfo(origin)
  distfiles = []

  Dir.glob(File.join($ports_dir, origin, 'distinfo*')) do |file|
    files = parse_distinfo(file)

    distfiles.push(*files) unless files.empty?
  end

  distfiles
end

def delete_file(file)
  puts 'Deleting ' + file

  return if $noexecute

  return if $interactive && !prompt_yesno('OK?', true)

  File.unlink(file)
rescue => e
  STDERR.puts e
end

def delete_dir(dir)
  puts 'Deleting ' + dir

  return if $noexecute

  return if $interactive && !prompt_yesno('OK?', true)

  system('/bin/rm', '-rf', dir)
end

def signal_handler(sig)
  puts "\nInterrupted."
  $interrupt_proc.call if $interrupt_proc
  exit
end

if $0 == __FILE__
  for sig in [2, 3, 15]
    trap(sig) do
      signal_handler(sig)
    end
  end

  exit main(ARGV)
end

exit 0
