#!/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.20 2001/09/10 09:16:47 knu Exp $
RCS_REVISION = RCS_ID.split[2]
MYNAME = File.basename($0)

require "optparse"
require "pkgtools"
require "find"
require "readline" unless defined?(Readline)

$interactive = false
$noexecute = false
$workclean = false
$distclean = 0
$libclean = false

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

def main(argv)
  usage = <<-"EOF"
usage: #{MYNAME} [-hCDDiLn]
  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 distfiles which are not referenced from any#{NEXTLINE}port in the ports tree;#{NEXTLINE}Specified twice, clean up distfiles which are not#{NEXTLINE}referenced from any port that is currently installed") {
      $distclean += 1
    }

    opts.def_option("-i", "--interactive", "Turn on interactive mode") {
      unless $noexecute
	$interactive = true
      end
    }

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

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

    begin
      opts.order!(argv)

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

      workclean if $workclean
      distclean($distclean) if $distclean.nonzero?
      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'

  if not 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.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(level)
  return if level <= 0

  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

    if level <= 1
      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
    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

  compatlibdir = File.join(localbase, 'lib/compat/pkg')

  compatlib_re = /^#{Regexp.quote(compatlibdir)}/

  ld_entry_re = %r"^\d+:-l.*\s+=>\s+((#{Regexp.quote(localbase)}|#{Regexp.quote(x11base)})/.*/lib([^/]+)\.so\.(\d+))"

  libtable = {}

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

    case line
    when /^search directories:\s*(.*)/
      libdirs = $1.split(':')
    when ld_entry_re
      path, prefix, libname, ver = $1, $2, $3, $4

      next if compatlib_re =~ path

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

  libtable.each do |libname, hash|
    size = hash.size

    next if size < 2

    pkgnames = {}

    hash.each do |ver, (path, )|
      path = read_link(path) if File.symlink?(path)

      pkgname = `#{PkgDB::CMD[:pkg_info]} -qW #{path}`.strip

      pkgnames[ver] = pkgname unless pkgname.empty?
    end

    n = size - pkgnames.size

    next if n <= 0

    vers = hash.keys.sort! { |a, b| b.to_i <=> a.to_i }

    puts "** You have multiple versions of lib#{libname} but #{n} of them are not from packages:"

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

    symlink = find_so_link(libname, libdirs)

    if symlink
      source = read_link(symlink)

      symlink_ver = nil

      hash.each do |ver, (path, prefix)|
	if path == source
	  symlink_ver = ver
	end
      end

      d, l, v = parse_so(source)	# directory, libname, version

      if d
	puts "and the symlink (#{symlink}) points to:"
	puts "\t#{l}.#{v} (#{source}) <- " + (pkgnames[symlink_ver] || "?")
      end
    end

    libs = []
    ldconfig_dirs = []

    max_ver = vers.find { |ver| pkgnames.key?(ver) }

    vers.each do |ver|
      if ver.to_i > max_ver.to_i
	puts " --> Skipping #{libname}.#{ver} because it is newer than what the packages provide"
	puts ""
	next
      end

      next if pkgnames.key?(ver)

      path, prefix = hash[ver]

      if !$interactive || prompt_yesno("Do you want to move #{File.basename(path)} to #{compatlibdir} ?", false)
	hash.delete(ver)

	if symlink && ver == symlink_ver
	  if $interactive
	    symlink_ver = choose_from_options("Change #{File.basename(symlink)} to point to which version?", hash.keys.sort.reverse, OPTIONS_SKIP)
	    case symlink_ver
	    when :abort
	      puts "Abort."
	      exit
	    when :skip
	      puts "Skipped."
	      break
	    end

	    symlink_source = hash[symlink_ver][0]

	    if symlink_source && File.exists?(symlink_source)
	      puts " --> Changing #{File.basename(symlink)} to point to #{libname}.#{symlink_ver}"
	      system('/bin/ln', '-sf', symlink_source, symlink) unless $noexecute
	    end
	  else
	    puts " --> Skipping #{libname}.#{symlink_ver} because #{File.basename(symlink)} to point to it"
	    puts ""
	    next
	  end
	end

	puts " --> Moving #{File.basename(path)} to #{compatlibdir}"
	system('/bin/mkdir', '-p', compatlibdir) unless $noexecute
	system('/bin/mv', '-i', path, compatlibdir) unless $noexecute
	puts " --> Running ldconfig -m for #{File.dirname(path)} and #{compatlibdir}"
	system('/sbin/ldconfig', '-m', File.dirname(path), compatlibdir) unless $noexecute
      end

      puts ""
    end
  end

  puts "** Please clean out #{compatlibdir} manually on occasions."
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_do(dist_dir) do
    Find.find('.') do |f|
      next if not File.file?(f)

      if f[1] == ?/
	f.slice!(0,2)

	distfiles << f
      end
    end
  end

  distfiles.sort!

  distfiles
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|
    distfiles.concat(parse_distinfo(file))
  end

  distfiles
end

def delete_file(file)
  puts 'Delete ' + 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 'Delete ' + dir

  return if $noexecute

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

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

def signal_handler(sig)
  puts "\nInterrupted."

  stty_sane

  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
