# $Idaemons: /home/cvs/pkgtools/lib/pkgdb.rb,v 1.87 2002/03/26 15:16:47 knu Exp $

require 'singleton'
require 'pkgtsort'
require "pkgmisc"

class PkgDB
  include Singleton
  include Enumerable

  DB_VERSION = [:FreeBSD, 7]
  # :db_version		=> DB_VERSION
  # :pkgnames		=> list of installed packages
  # :origins		=> list of installed packages' origins
  # :mtime		=> modification time (marshalled)
  # ?pkgname		=> origin
  #   ...
  # /path/to/file	=> pkgname
  #   ...

  PKGDB_FILES = {
    :contents => '+CONTENTS',
    :comment => '+COMMENT',
    :desc => '+DESC',
    :install => '+INSTALL',
    :post_install => '+POST-INSTALL',
    :deinstall => '+DEINSTALL',
    :post_deinstall => '+POST-DEINSTALL',
    :require => '+REQUIRE',
    :required_by => '+REQUIRED_BY',
    :display => '+DISPLAY',
    :mtree => '+MTREE_DIRS',
  }

  PREFIX = '/usr/local'

  CMD = {
    :pkg_add			=> '/usr/sbin/pkg_add',
    :pkg_delete			=> '/usr/sbin/pkg_delete',
    :pkg_info			=> '/usr/sbin/pkg_info',
    :make_describe_pass1	=> "#{PREFIX}/sbin/make_describe_pass1",
    :make_describe_pass2	=> "#{PREFIX}/sbin/make_describe_pass2",
    :pkg_deinstall		=> "#{PREFIX}/sbin/pkg_deinstall",
    :pkg_fetch			=> "#{PREFIX}/sbin/pkg_fetch",
    :pkg_tarup			=> "#{PREFIX}/sbin/pkg_tarup",
    :pkg_which			=> "#{PREFIX}/sbin/pkg_which",
    :pkgdb			=> "#{PREFIX}/sbin/pkgdb",
    :portcvsweb			=> "#{PREFIX}/sbin/portcvsweb",
    :portsclean			=> "#{PREFIX}/sbin/portsclean",
  }

  class DBError < StandardError
#    def message
#      "pkgdb error"
#    end
  end

  def initialize(*args)
    setup(*args)
  end

  def setup(alt_db_dir = nil, alt_db_driver = nil)
    set_db_dir(alt_db_dir)
    set_db_driver(alt_db_driver)

    self
  end

  def db_dir()
    unless @db_dir
      set_db_dir(nil)	# initialize with the default value
    end

    @db_dir
  end

  def db_dir=(new_db_dir)
    @abs_db_dir = nil

    @db_dir = File.expand_path(new_db_dir || ENV['PKG_DBDIR'] || '/var/db/pkg')

    @db_file = File.join(@db_dir, 'pkgdb.db')
    @db_filebase = @db_file.sub(/\.db$/, '')
    close_db

    @warned = false

    @installed_pkgs = nil

    @db_dir
  end
  alias set_db_dir db_dir=

  def abs_db_dir
    unless @abs_db_dir
      dir = db_dir

      Dir.chdir(dir) {
	@abs_db_dir = Dir.pwd
      } rescue raise DBError, "Can't chdir to '#{dir}'"
    end

    @abs_db_dir
  end

  def db_driver()
    unless @db_driver
      set_db_driver(nil)	# initialize with the default value
    end

    @db_driver
  end

  def db_driver=(new_db_driver)
    begin
      case new_db_driver || ENV['PKG_DBDRIVER'] || 'bdb1_btree'
      when 'bdb_btree'
	@db_driver = :bdb_btree
      when 'bdb_hash', 'bdb'
	@db_driver = :bdb_hash
      when 'bdb1_btree', 'btree'
	@db_driver = :bdb1_btree
      when 'bdb1_hash', 'hash', 'bdb1'
	@db_driver = :bdb1_hash
      else
	@db_driver = :dbm_hash
      end

      case @db_driver
      when :bdb_btree
	next_driver = 'bdb1_btree'
	require 'bdb'
	@db_params = ["set_pagesize" => 1024, "set_cachesize" => [0, 32 * 1024, 0]]
      when :bdb_hash
	next_driver = 'bdb1_hash'
	require 'bdb'
	@db_params = ["set_pagesize" => 1024, "set_cachesize" => [0, 32 * 1024, 0]]
      when :bdb1_btree
	next_driver = 'dbm'
	require 'bdb1'
	@db_params = ["set_pagesize" => 1024, "set_cachesize" => 32 * 1024]
      when :bdb1_hash
	next_driver = 'dbm'
	require 'bdb1'
	@db_params = ["set_pagesize" => 1024, "set_cachesize" => 32 * 1024]
      else
	next_driver = nil
	require 'dbm'
      end
    rescue LoadError
      if next_driver.nil?
	raise DBError, "No driver is available!"
      end

      new_db_driver = next_driver
      retry
    end

    @db_driver
  end
  alias set_db_driver db_driver=

  def strip(path, installed_only = false)
    base = path.chomp('/')	# allow `pkgname/'

    if base.include?('/')
      dir, base = File.split(File.expand_path(base))

      if dir != db_dir && dir != abs_db_dir
	return nil
      end
    end

    if installed_only && !installed?(base)
      return nil
    end

    base
  end

  def pkgdir(pkgname)
    File.join(db_dir, pkgname)
  end

  def pkgdir?(dir)
    File.exist?(File.join(dir, PKGDB_FILES[:contents]))
  end

  def pkgfile(pkgname, filename)
    if filename.kind_of?(Symbol)
      filename = PKGDB_FILES[filename]
    end

    File.join(pkgdir(pkgname), filename)
  end

  def pkg(pkgname)
    installed?(pkgname) and PkgInfo.new(pkgname)
  rescue => e
    return nil
  end

  alias [] pkg

  def origin(pkgname)
    open_db

    @db['?' + pkgname]
  rescue => e
    raise DBError, e.message
  end

  def set_origin(pkgname, origin)
    open_db_for_update!

    @db['?' + pkgname] = origin

    close_db
  rescue => e
    raise DBError, e.message
  end

  def deorigin(origin)
    open_db

    if str = @db['?' + origin]
      str.split
    else
      nil
    end
  rescue => e
    raise DBError, e.message
  end

  def deorigin_glob(pattern)
    if /[*?\[]/ !~ pattern
      return deorigin(pattern)
    end

    open_db

    ret = []
    @db.each_key do |key|
      /^\?(.*)/ =~ key or next

      origin = $1

      PortInfo.match?(pattern, origin) or next

      if pkgnames = deorigin(origin)
	ret.concat(pkgnames)
      end
    end

    if ret.empty?
      nil
    else
      ret
    end
  rescue => e
    raise DBError, e.message
  end

  def date_db_dir
    File.mtime(db_dir) rescue Time.at(0)
  end

  def date_db_file
    if @db
      mtime = Marshal.load(@db[':mtime'])
    elsif File.exist?(@db_file)
      open_db_for_read!
      mtime = Marshal.load(@db[':mtime'])
      close_db
    else
      mtime = Time.at(0)
    end

    mtime
  rescue => e
    return Time.at(0)
  end

  def up_to_date?
    date_db_file() >= date_db_dir()
  end

  def check_db_version
    db_version = Marshal.load(@db[':db_version'])

    db_version[0] == DB_VERSION[0] && db_version[1] >= DB_VERSION[1]
  rescue => e
    return false
  end

  def check_db
    return true if up_to_date? || File.writable?(db_dir)

    if $sudo
      close_db()

      if system! PkgDB::CMD[:pkgdb], '-u'
	return true
      end
    end

    raise DBError, "The pkgdb must be updated.  Please run 'pkgdb -u' as root."
  end

  def update_db(force = false)
    if !force
      up_to_date? and return false
    end

    close_db

    prev_sync = STDERR.sync
    STDERR.sync = true

    rebuild = force || !File.exist?(@db_file)

    STDERR.printf '[%s the pkgdb <format:%s> in %s ... ',
      rebuild ? 'Rebuilding' : 'Updating', @db_driver, db_dir

    @installed_pkgs = installed_pkgs!.freeze

    if rebuild
      open_db_for_rebuild!

      new_pkgs = @installed_pkgs

      deleted_pkgs = []

      @installed_ports = []
    else
      begin
	open_db_for_update!

	s = @db[':origins']
	s.is_a?(String) or raise "origins - not a string (#{s.type})"
	@installed_ports = s.split

	s = @db[':pkgnames']
	s.is_a?(String) or raise "pkgnames - not a string (#{s.type})"
	prev_installed_pkgs = s.split

	new_pkgs = @installed_pkgs - prev_installed_pkgs
	deleted_pkgs = prev_installed_pkgs - @installed_pkgs

	db_mtime = date_db_file()

	(@installed_pkgs & prev_installed_pkgs).each do |pkg|
	  pkg_mtime = date_installed(pkg)

	  if db_mtime < pkg_mtime
	    new_pkgs << pkg
	    deleted_pkgs << pkg
	  end
	end

	deleted_pkgs.sort!
      rescue => e
	STDERR.print "#{e.message}; rebuild needed] "
	return update_db(true)
      end
    end

    STDERR.printf "- %d packages found (-%d +%d) ",
      @installed_pkgs.size, deleted_pkgs.size, new_pkgs.size

    unless deleted_pkgs.empty?
      STDERR.print '(...)'

      # NOTE: you cannot delete keys while you enumerate the database elements
      @db.select { |path, pkgs|
	path[0] == ?/ && pkgs.split.find { |pkg| deleted_pkgs.qinclude?(pkg) }
      }.each do |path, pkgs|
	pkgs = pkgs.split - deleted_pkgs

	if pkgs.empty?
	  @db.delete(path)
	else
	  @db[path] = pkgs.join(' ')
	end
      end

      deleted_pkgs.each do |pkg|
	p_key = '?' + pkg
	origin = @db[p_key]
	@db.delete(p_key)

	if !origin
	  STDERR.print "(? #{pkg})"
	  next
	end

	o_key = '?' + origin

	pkgs = @db[o_key]

	if !pkgs
	  STDERR.print "(? #{origin})"
	  next
	end

	pkgs = pkgs.split
	pkgs.delete(pkg)

	if pkgs.empty?
	  @installed_ports.delete(origin)
	  @db.delete(o_key)
	else
	  @db[o_key] = pkgs.join(' ')
	end
      end
    end

    new_pkgs.sort { |a, b|
      date_installed(a) <=> date_installed(b)
    }.each do |pkg|
      STDERR.putc ?.

      begin
	pkginfo = PkgInfo.new(pkg)

	if origin = pkginfo.origin
	  @installed_ports << origin

	  @db['?' + pkg] = origin

	  o_key = '?' + origin
	  o_val = @db[o_key]

	  if o_val
	    o_val = o_val.split << pkg
	    o_val.uniq!		# just in case
	    @db[o_key] = o_val.join(' ')
	  else
	    @db[o_key] = pkg
	  end
	end

	pkginfo.files.each do |path|
	  if @db.key?(path)
	    pkgs = @db[path].split
	    pkgs << pkg if !pkgs.include?(pkg)
	    @db[path] = pkgs.join(' ')
	  else
	    @db[path] = pkg
	  end
	end
      rescue => e
	STDERR.puts "", e.message + ": skipping..."
	next
      end
    end

    @installed_ports.uniq!
    @installed_ports.sort!

    @db[':mtime'] = Marshal.dump(Time.now)
    @db[':origins'] = @installed_ports.join(' ')
    @db[':pkgnames'] = @installed_pkgs.join(' ')
    @db[':db_version'] = Marshal.dump(DB_VERSION)

    STDERR.puts " done]"

    true
  rescue => e
    raise DBError, "#{e.message}: Cannot update the pkgdb!]"
  ensure
    close_db
  end

  def open_db_for_read!
    close_db

    case db_driver
    when :bdb_btree
      @db = BDB::Btree.open @db_file, nil, 'r', 0, *@db_params
    when :bdb_hash
      @db = BDB::Hash.open @db_file, nil, 'r', 0, *@db_params
    when :bdb1_btree
      @db = BDB1::Btree.open @db_file, 'r', 0, *@db_params
    when :bdb1_hash
      @db = BDB1::Hash.open @db_file, 'r', 0, *@db_params
    else
      @db = DBM.open(@db_filebase)
    end
  end

  def open_db_for_update!
    close_db

    case db_driver
    when :bdb_btree
      @db = BDB::Btree.open @db_file, nil, 'r+', 0664, *@db_params
    when :bdb_hash
      @db = BDB::Hash.open @db_file, nil, 'r+', 0664, *@db_params
    when :bdb1_btree
      @db = BDB1::Btree.open @db_file, 'r+', 0664, *@db_params
    when :bdb1_hash
      @db = BDB1::Hash.open @db_file, 'r+', 0664, *@db_params
    else
      @db = DBM.open(@db_filebase)
    end
  end

  def open_db_for_rebuild!
    close_db

    case db_driver
    when :bdb_btree
      @db = BDB::Btree.open @db_file, nil, 'w+', 0664, *@db_params
    when :bdb_hash
      @db = BDB::Hash.open @db_file, nil, 'w+', 0664, *@db_params
    when :bdb1_btree
      @db = BDB1::Btree.open @db_file, 'w+', 0664, *@db_params
    when :bdb1_hash
      @db = BDB1::Hash.open @db_file, 'w+', 0664, *@db_params
    else
      File.unlink(@db_file) if File.exist?(@db_file)

      @db = DBM.open(@db_filebase, 0664)
    end
  end

  def open_db
    @db and return @db

    check_db

    update_db

    retried = false

    begin
      open_db_for_read!

      check_db_version or raise TypeError, 'database version mismatch/bump detected'

      s = @db[':pkgnames']
      s.is_a?(String) or raise TypeError, "pkgnames - not a string (#{s.type})"
      @installed_pkgs = s.split

      s = @db[':origins']
      s.is_a?(String) or raise TypeError, "origins - not a string (#{s.type})"
      @installed_ports = s.split
    rescue TypeError => e
      if retried
	raise DBError, "#{e.message}: Cannot read the pkgdb!"
      end

      STDERR.print "[#{e.message}] "
      update_db(true)

      retried = true
      retry
    end

    @db
  end

  def close_db
    if @db
      @db.close
      @db = nil
    end
  end

  def which_m(path)
    which(path, true)
  end

  def which(path, m = false)
    path = File.expand_path(path)

    open_db

    if !@db.key?(path)
      nil
    else
      pkgnames = @db[path].split

      m ? pkgnames : pkgnames.last
    end
  rescue => e
    raise DBError, e.message
  end

  def date_installed(pkgname)
    installed?(pkgname) or return nil

    File.mtime(pkg_comment(pkgname)) ||
      File.mtime(pkg_descr(pkgname)) ||
      File.mtime(pkg_contents(pkgname)) rescue Time.at(0)
  end

  def installed_pkg?(pkgname)
    installed_pkgs().qinclude?(pkgname)
  end

  alias installed? installed_pkg?

  def installed_port?(pkgname)
    installed_ports().qinclude?(pkgname)
  end

  def required?(pkgname)
    file = pkg_required_by(pkgname)

    File.exist?(file) && !File.zero?(file)
  end

  def required_by(pkgname)
    filename = pkg_required_by(pkgname)

    File.exist?(filename) or return nil

    deps = {}

    File.open(filename).each_line { |line|
      line.chomp!
      deps[line] = true unless line.empty?
    }

    deps.keys
  end

  def pkgdep(pkgname)
    filename = pkg_contents(pkgname)

    File.exist?(filename) or return nil

    deps = {}

    if File.size(filename) >= 65536	# 64KB
      open("| grep ^@pkgdep #{filename}")
    else
      File.open(filename)
    end.each { |line|
      if /^@pkgdep\s+(\S+)/ =~ line
	deps[$1] = true
      end
    }

    deps.keys
  end

  PKGDB_FILES.each_key do |key|
    module_eval %{
      def pkg_#{key.to_s}(pkgname)
	pkgfile(pkgname, #{key.inspect})
      end
    }
  end

  def self.parse_date(str)
    require 'parsedate'

    ary = ParseDate.parsedate(str)
    ary.pop
    tz = ary.pop

    case tz
    when 'GMT', 'UTC'
      Time.utc(*ary)
    else
      Time.local(*ary)
    end
  rescue
    raise ArgumentError, "#{str}: date format error"
  end

  def installed_pkgs!()
    Dir.entries(db_dir).select { |pkgname| pkgdir?(pkgdir(pkgname)) }.sort
  rescue => e
    raise DBError, e.message
  end

  def installed_pkgs()
    open_db if @installed_pkgs.nil?

    @installed_pkgs
  end

  def installed_ports!()
    ary = installed_pkgs!.map { |pkgname| origin(pkgname) }
    ary.uniq!
    ary.sort!
    ary
  end

  def installed_ports()
    open_db if @installed_ports.nil?

    @installed_ports
  end

  def glob(pattern = true, want_pkg_info = true)
    list = []
    pkg = nil

    case pattern
    when String
      # shortcut
      if pkg = pkg(pattern)
	if block_given?
	  yield(want_pkg_info ? pkg : pattern)
	  return nil
	else
	  return [want_pkg_info ? pkg : pattern]
	end
      end      

      if /^([<>]=?)(.*)/ =~ pattern
	op = $1
	arg = $2

	pkgs = glob(arg)

	begin
	  if pkgs.empty?
	    base = PkgDB.parse_date(arg)
	  else
	    pkgs.size >= 2 and
	      raise ArgumentError, "#{arg}: ambiguous package specification (#{pkgs.join(', ')})"

	    base = date_installed(pkgs[0].to_s)
	  end

	  pattern = op + base.strftime('%Y-%m-%d %H:%M:%S')
	rescue => e
	  STDERR.puts e.message

	  if block_given?
	    return nil
	  else
	    return []
	  end
	end
      end
    end

    installed_pkgs().each do |pkgname|
      pkg = PkgInfo.new(pkgname)

      pkg.match?(pattern) or next

      if block_given?
	yield(want_pkg_info ? pkg : pkgname)
      else
	list.push(want_pkg_info ? pkg : pkgname)
      end
    end

    if block_given?
      nil
    else
      list
    end
  end

  def each(want_pkg_info = true, &block)
    glob(true, want_pkg_info, &block)
  end

  def tsort(pkgnames)
    t = TSort.new

    pkgnames.each do |pkgname|
      deps = pkgdep(pkgname) || []

      t.add(pkgname, *deps)
    end

    t
  end

  def sort(pkgnames)
    tsort(pkgnames).tsort! & pkgnames
  end

  def sort!(pkgnames)
    pkgnames.replace(sort(pkgnames))
  end

  def tsort_build(pkgnames)
    t = TSort.new

    pkgnames.each do |pkgname|
      pkg = pkg(pkgname) or next

      # use package dependencies..
      deps = pkgdep(pkgname) || []

      if origin = pkg.origin
	# ..and ports dependencies
	PortsDB.instance.all_depends_list(origin).each do |o|
	  if bdeps = deorigin(o)
	    deps.concat(bdeps)
	  end
	end
      end

      t.add(pkgname, *deps)
    end

    t
  end

  def sort_build(pkgnames)
    tsort_build(pkgnames).tsort! & pkgnames
  end

  def sort_build!(pkgnames)
    pkgnames.replace(sort_build(pkgnames))
  end

  def recurse(pkgname, recurse_down = false, recurse_up = false, sanity_check = false)
    list = []

    if recurse_up || sanity_check and deps = pkgdep(pkgname)
      deps.each do |name|
	installed?(name) or
	  raise DBError,
	    format("Stale dependency: %s --> %s -- manually run 'pkgdb -F' to fix%s.",
		   pkgname, name,
		   recurse_up ? ' (-O disallowed when -R is given)' : ', or specify -O to force')

	list << name if recurse_up
      end
    end

    list << pkgname

    if recurse_down || sanity_check and deps = required_by(pkgname)
      deps.each do |name|
	installed?(name) or
	  raise DBError,
	  format("Stale dependency: %s <-- %s -- manually run 'pkgdb -F' to fix%s.",
		 pkgname, name,
		 recurse_down ? ' (-O disallowed when -r is given)' : ', or specify -O to force')

	list << name if recurse_down
      end
    end

    list
  end
end
