politrunc.rb

Windows 版の Polipo の -x オプションは使えない。

http://ely.ath.cx/~piranha/software/polipo_trimcache/

なのでここの polipo-trimcache という Python 製のコマンドを移植してみる。
名前は POLIpo cache TRUNCate ってことで。いまいちしっくりこないけど。

require 'singleton'
require 'optparse'

module Cache
  def self.transverse(root, precise_expiry = false, at_verbose = nil, at_debug = nil)
    transverser = Transverser.new(precise_expiry, at_verbose, at_debug)
    transverser.transverse(root)
    return transverser.objects, transverser.directories
  end
  
  class Transverser
    def initialize(precise_expiry = false, at_verbose = nil, at_debug = nil)
      @precise_expiry = precise_expiry
      @at_verbose     = at_verbose
      @at_debug       = at_debug
      @directories = []
      @objects     = []
      @stack = []
    end
    
    attr_reader :directories
    attr_reader :objects
    
    def verbose(message)
      @at_verbose.call(message) if @at_verbose
    end
    
    def debug(message)
      @at_debug.call(message) if @at_debug
    end
    
    def transverse(root, parent = nil)
      @stack.push(parent)
      debug "Entering directory: #{root}"
      (Dir.entries(root) - ['.', '..']).each do |ent|
        path = File.join(root, ent)
        if ent.start_with?('.')
          transverse_event_dotfile(path)
        else
          __send__("transverse_event_#{File.ftype(path)}", path)
        end
      end
    ensure
      @stack.pop
    end
    
    def add_as_child(obj)
      parent = @stack.last and parent.children.push(obj)
    end
    
    def transverse_event_file(path)
      file = Object.new(path, @stack.last, @precise_expiry)
      add_as_child(file)
      objects << file
    end
    
    def transverse_event_directory(path)
      dir = Directory.new(path, @stack.last)
      add_as_child(dir)
      directories << dir
      transverse(path, dir)
    end
    
    def transverse_event_else(path)
      verbose "Skipping special file: #{path}"
    end
    
    alias transverse_event_dotfile transverse_event_else
    
    ["characterSpecial", "blockSpecial", "fifo", "link", "socket"].each do |ftype|
      alias_method "transverse_event_#{ftype}", :transverse_event_else
    end
    
    def transverse_event_unknown(path)
      raise Exception, "[BUG] must not happen, File.ftype() returns `unknown'"
    end
  end
  
  class Entry
    def initialize(path, parent = nil)
      @path     = path
      @parent   = parent
    end
    
    attr_reader :path
    attr_reader :parent
    attr_reader :size
    
    def each_ancestor
      return enum_for(__method__) unless block_given?
      child, parent = self, self.parent
      while parent
        yield(parent, child)
        child, parent = parent, parent.parent
      end
    end
  end
  
  class Directory < Entry
    def initialize(path, parent = nil)
      super
      @size     = File.size(path)
      @children = []
    end
    
    attr_reader :children
    
    def entries
      Dir.entries(path) - ['.', '..']
    end
    
    def remove
      Dir.rmdir path
    end
  end
  
  class Object < Entry
    def initialize(path, parent = nil, precise_expiry = false)
      super(path, parent)
      stat = File.lstat(path)
      @size        = stat.blocks ? stat.blocks * 512 : stat.size
      @last_access = precise_expiry ? (get_precise_access_time(path) || 0) : stat.mtime
    end
    
    attr_reader :last_access
    
    def remove
      File.unlink path
    end
    
    def disinherite
      parent.children.delete(self) if parent
    end
  end
end

BYTE_QUANTIFIERS = {}
%w[K M G T P E Z Y].inject(1){|r, i|
  BYTE_QUANTIFIERS[i] = r * 1024
}

def parse_rest_args(cache_dir, amount)
  qua = 1
  num = amount
  if /(#{Regexp.union(*BYTE_QUANTIFIERS.keys)})B?\Z/io =~ amount
    qua = BYTE_QUANTIFIERS[$1]
    num = $`
  end
  size = (Float(num) * qua).ceil
  return cache_dir, size
end

# incomplete
def to_disk_size(n)
  n
end

# incomplete
def to_interval_description(sec)
  sec
end


force_p          = false
dryrun_p         = false
precise_expiry_p = false
verbose_p        = false
debug_p          = false

cache_dir   = nil
target_size = nil

me = File.basename($0, '.rb')
echo = lambda{|msg| $stderr.puts "#{me}: #{msg}" }
verbose = lambda{|msg| echo.call(msg) if verbose_p }
debug   = lambda{|msg| echo.call(msg) if debug_p }

o = OptionParser.new
o.on '-f', '--force' do
  force_p = true
end
o.on '-n', '--no-op' do
  dryrun_p = true
end
o.on '--dryrun' do
  dryrun_p = true
end
o.on '-p', '--precise-expiry' do
  precise_expiry_p = true
end
o.on '-V', '--verbose' do
  verbose_p = true
end
o.on '-D', '--debug' do
  debug_p = true
end
o.parse!(ARGV)
cache_dir, target_size = parse_rest_args(*ARGV)

verbose["Transversing cache..."]
objs, dirs = Cache.transverse(cache_dir, precise_expiry_p, verbose, debug)
verbose["Sorting and counting..."]
objs = objs.sort_by{|obj| obj.last_access }
total_size = [objs, dirs].map{|list| list.inject(0){|r, i| r + i.size } }.inject(:+)
verbose["Cache is currently #{to_disk_size(total_size)}B (#{objs.size} files, #{dirs.size} directories)."]

verbose["Trimming..."]
n_file_rm = 0
n_dir_rm = 0
latest_trimmed = nil
until objs.empty? || total_size <= target_size
  victim = objs.pop
  if dryrun_p
    puts victim.path
  else
    debug["Removing file: #{victim.path}"]
    victim.remove
  end
  total_size -= victim.size
  n_file_rm += 1
  latest_trimmed = victim.last_access
  victim.each_ancestor do |parent, child|
    parent.children.delete(child)
    break unless parent.children.empty?
    break if parent.entries.empty?
    if dryrun_p
      debug["Removing directory [NOOP]: #{parent.path}"]
    else
      debug["Removing directory: #{parent.path}"]
      parent.remove
    end
    total_size -= parent.size
    dirs.delete(parent)
    n_dir_rm += 1
  end
end

verbose["Cache trimmed (-#{n_file_rm} files, -#{n_dir_rm} dirs) to #{to_disk_size(total_size)}B (#{objs.size} files, #{dirs.size} directories)."]
verbose["Latest file removed was #{to_interval_description(Time.now - latest_trimmed)} old"] if latest_trimmed