#!/usr/pkg/bin/ruby33
#
# $Id: vaporadmin 274 2003-11-19 11:58:41Z bolzer $
# Author::  Oliver M. Bolzer (mailto:oliver@fakeroot.net)
# Copyright:: (c) Oliver M. Bolzer, 2002
# License:: Distributes under the same terms as Ruby
#
# Administrative Tool for Repository Management of Vapor

require 'vapor'
require 'vapor/repositorymgr'
require 'rexml/document'

unless REXML::Version >= "2.4.0"
  raise LoadError, "vaporadmin requires REXML >= 2.4.0"
end

begin # only load when available
  require 'termios'
rescue LoadError
end

# repository specifier is invalid
class InvalidRepositoryError < StandardError
end

# problem with XML-specification
class InvalidXMLError < StandardError
end

# an Vapor::VaporException has properly been handled
class HandledVaporException < StandardError
end


class VaporAdmin

  include Vapor

  Commands = ['init','help','add','remove','update']
  Help_Text = Hash.new
 
  # initialize with repository-specifier
  def initialize( rep_spec )
    spec = rep_spec.scan(/([\w\.]+)(:[\S]*)?@([\w\.]+)(:[\d]+)?\/(\w+)/).flatten
    
    # bark on invalid repository specifier
    if spec.empty? then
      raise InvalidRepositoryError, "invalid repository specification: #{rep_spec}" 
    end

    @username = spec[0]
    @pass     = spec[1]
    @dbhost   = spec[2]
    @dbport   = spec[3]
    @dbname   = spec[4]
     
    if !@pass.nil? then
      @pass.sub!( /^:/, '' ) 
    end
   
    if !@dbport.nil? then
      @dbport.sub!( /^:/, '' ).to_i
    end

  end # initialize()

  # wheter the password is not set yet
  def need_password?
    @pass.nil?
  end # need_password?()

  # set password
  def password=( password )
    @pass = password
  end # password=()

  # output help message
  def self.help( restargs )

    message = Array.new
    if !restargs.empty? and Help_Text.has_key?( restargs[0] ) then
      message << Help_Text[ restargs[0] ]
    else
      message << "usage: vaporadmin [repository] <command> [args]"
      message << "Type `vaporadmin help <command>' for help on a specific command."
      message << ""
      message << "All commands except 'help' require a repository specification"
      message << "in the form of <user[:pass]@dbhost[:port]/dbname>"
      message << ""
      message << "Available commands:"
      Commands.sort.each{|c| message << "   " + c }
    end

    message( message.join("\n") )
  end # self.help()

  # help message for "help"
  Help_Text['help'] = ["help: Display usage message.",
                       "usage: vaporadmin help [COMMAND]"
                      ]

  def execute( command, restargs )
    raise TypeError unless command.is_a? String
    raise TypeError unless restargs.is_a? Array

    if command == "help"
      then self.class.help( restargs )
    else
      self.send( command, restargs )
    end
  end
  
  # initialize a repository
  def init( restargs )
    db_spec = ["pg", @dbname, @dbhost, @dbport ].compact.join(':')
    mgr = nil
    begin
      mgr = RepositoryManager.new( db_spec, @username, @pass, false )
      mgr.init_repository
    rescue Vapor::RepositoryOfflineError => e
      error( "Cound not connect to repository: #{e.message}" )
      raise HandledVaporException 
    rescue Vapor::BackendInconsistentError => e
      error( "Could not initialize repository: #{e.message}" )
      raise HandledVaporException
    end
  end # init()

  # help message for "init"
  Help_Text['init'] = ["init: Initialize the Repository.",
                       "usage: vaporadmin REPOSITORY init"
                      ]

  # add one or more classes to the repository, each argument is the filename
  # of an XML file containing information about one or more classes
  def add( restargs )
    if restargs.empty?
      raise InvalidXMLError, "no file specified.\nTry `vaporadmin help add' for usage." 
    end

    # check existence of all files
    check_files( restargs )

    # connect to repository
    @mgr = connect_repository()

    # parse and process each file
    add_queue = parse_xml_files( restargs )
   
    # start adding the classes
    deferred_classes = Array.new
    class_count = 0
    @mgr.start_transaction
    message( "Attempting to add class(es) to repository:" )
    add_queue.each{|klass|
      begin
        add_class( klass )
        message( "  #{klass.name}" )
        class_count += 1
      rescue UnknownSuperclassError
        deferred_classes << klass
      end
    }

    # sort deferred_classes so that parents come before children that inherit
    # from them. The superclass is not in the repository and if it's also
    # not here, we can't know about it => error
    sorted = Array.new
    while not deferred_classes.empty? do
      klass = deferred_classes.shift
      if sorted.detect{ |k| klass.superclass == k.name } then
        sorted << klass
      elsif @mgr.class_known?( klass.superclass ) then
        sorted << klass
      elsif
         deferred_classes.detect{ |k| klass.superclass == k.name } then
         deferred_classes << klass
      else
        error( "superclass `#{klass.superclass}' of `#{klass.name}' not found, aborting." )
        raise HandledVaporException 
      end
    end

    # add sorted classes to repository
    sorted.each{|klass|
      begin
        add_class( klass )
        message( "  #{klass.name}" )
        class_count += 1
      rescue UnknownSuperclassError
        error( "superclass `#{klass.superclass}' not found anywhere, aborting" )
        raise HandledVaporException
      end
    }
 
    @mgr.commit_transaction
    message( "Added #{class_count} new classes to Repository." )

  end # add()

  Help_Text['add'] = ["add: Add one or more classes to the Repository.",
                      "usage: vaporadmin REPOSITORY add XML-FILE [XML-FILE...]"
                     ]
  
  # attempt to add class to repository using RepositoryManager#addclass()
  def add_class( klass )
    begin
      @mgr.addclass( klass )
    rescue DuplicateClassError
      error( "`#{klass.name}' already registered with Repository, aborting." )
      raise HandledVaporException
    rescue DuplicateClassError
      error( "`#{klass.name}' already registered with Repository." )
      message( "Aborting without saving changes." )
      raise HandledVaporException
    rescue InvalidMetadataError => e
      error( "while adding `#{klass.name}', #{e.message}" )
      message( "Aborting without saving changes." )
      raise HandledVaporException
    end
  end # add_class
  private :add_class

  # Remove a class an possibly all of it's subclasses from the Repository.
  def remove( restargs )
    if restargs.empty?
      error( "no class name specified." )
      raise HandledVaporException
    end
  
    # check options
    force = false
    recursive = false
    while ( restargs.first =~ /^-/ ) do
      case restargs.first
      when "-f"
        force = true
      when "-r"
        recursive = true
      when "-rf", "-fr"
        force = true
        recursive = true
      end
      restargs.shift
    end
   
    # connect to repository
    @mgr = connect_repository()

    # start transaction
    @mgr.start_transaction
    message( "Attempting to remove class(es) from repository:" )
    
    restargs.each{|klassname|
      begin
        # check if class is actually known to repository
        if !@mgr.class_known?( klassname ) then
          raise ClassNotKnownError
        end
        # confirm deletion
        confirm_message = "Really remove `#{klassname}' including all instances "
        if recursive
          confirm_message += "and subclasses "
        end
        confirm_message += "from the Repository? (y/N)"
       
        if !force then
          message( confirm_message )
          answer = STDIN.gets
          answer.chomp!
          if answer != "y" then
            next
          end
        end
        # attempt to delete
        @mgr.removeclass( klassname, recursive )
        message( "  #{klassname}" )
      rescue ClassNotKnownError      # the class can not be found
        error( "Could not find `#{klassname}' in the repository.")
        error( "Aborting without committing any deletion(s)." )
        raise HandledVaporException
      rescue ClassNotDeletableError => e # the class still has childclasses
        error( "`#{klassname}' still has subclasses: #{e.children.join(",")}\n" )
        error( "Aborting without committing any deletions(s)." )
        raise HandledVaporException 
      end
    }
    
    # commit changes
    @mgr.commit_transaction
  end #remove()
  
  Help_Text['remove'] = ["remove: Remove a class and all it's insstances from the",
                         "        Repository.",
                         "usage: vaporadmin REPOSITORY remove [-r|-f|-rf] KLASSNAME",
                         "",
                         "-r    delete recursivly, including all subclasses",
                         "-f    don't ask for conformation"
                        ]

  # Update definition of a class
  def update( restargs )
    if restargs.empty?
      raise InvalidXMLError, "no file specified.\nTry `vaporadmin help add' for usage."
    end

    # check existence of all files
    check_files( restargs )

    # connect to repository
    @mgr = connect_repository()

    # parse and process each file
    klasses = parse_xml_files( restargs )
  
    # sort queue so that superclasses come before child classes
    update_queue = Array.new
    while not klasses.empty? do
      klass = klasses.shift
      # class's superclass to be updated
      if klasses.detect{ |k| klass.superclass == k.name } then
        klasses << klass
      else
        update_queue << klass
      end
    end # while not

    # start updating classes
    @mgr.start_transaction
    message( "Attempting to update class(es) in Repository:" )
    update_queue.each{|klass|
      begin
        @mgr.updateclass( klass )
        message( "  #{klass.name}" )
      rescue InvalidMetadataError => e
        error( "while updating `#{klass.name}', #{e.message}" )
        message( "Aborting without saving changes." )
        raise HandledVaporException
      rescue ClassNotKnownError
        error ( "while updating `#{klass.name}', class not known to Repository." )
        raise HandledVaporException
      end
    }
    
    @mgr.commit_transaction
  end # update()

  Help_Text['update'] = ["update: Update one or more classes' definitions.",
                         "usage: vaporadmin REPOSITORY update XML-FILE [XML-FILE...]"
                        ]

  ################# common private methods #################
  private

  # Check the existence of files. Filenames given as Array of Strings.
  def check_files( files )
    files.each{|filename|
      if !FileTest.readable?(filename)
        raise InvalidXMLError, "ERROR: #{filename} not found."
      end
    }
  end # check_files()
  private :check_files

  # Connect to Repository. Returns a RepositoryManager
  def connect_repository
    db_spec = ["pg", @dbname, @dbhost, @dbport ].compact.join(':')
    mgr = nil
    begin
      mgr = RepositoryManager.new( db_spec, @username, @pass )
    rescue Vapor::RepositoryOfflineError, Vapor::BackendInconsistentError => e
      error( "Cound not connect to repository, #{e.message}" )
      raise HandledVaporException
    end
    return mgr
  end # connect_repository()
  private :connect_repository

  # Parse multiple XML-Files into an Array of ClassMetaData
  def parse_xml_files( files )
    klasses = Array.new 
    files.each{|filename|
      begin
        File.open( filename ){|file|
          tree = REXML::Document.new( file ).root
          klasses += process_xml( tree, filename)
        }
      rescue REXML::ParseException => e
        error( "parse error in #{filename}, #{e.message}" )
        raise HandledVaporException
      rescue InvalidXMLError => e
        error ( e.message )
        raise HandledVaporException
      end
    }
    return klasses
  end # parse_xml_files()
  private :parse_xml_files
  
  # extract all classes from a XML-Tree
  def process_xml( tree, filename, namespace = '' )

    # check top-level tag
    if tree.local_name != 'vapor' and tree.local_name != 'module' then
      raise InvalidXMLException, "expecting top-level element <vapor> but was <#{tree.local_name}> in #{filename}" 
    end

    generated_classes = Array.new
    # decend into content
    tree.each_element{|element|

      case element.local_name
      when 'class'
        if element.attributes['name'].nil? then
          raise InvalidXMLError, "mission name for <class> in #{filename}"
        end 
        # convert <class/> into ClassMetaData
        generated_classes << generate_class( element, namespace )
      when 'module'
        generated_classes += process_xml( element, filename, namespace +  element.attributes['name'] + '::' )
      else
        raise InvalidXMLError, "Unknwon XML element <#{element.local_name}> where <clss> or <module> expected in #{filename}"
      end
    }

    return generated_classes

  end # process_xml()
  private :process_xml

  # convert an <class/> into ClassMetaData while watching for Repository
  # consistency
  def generate_class( element, namespace = '' )
    raise TypeError unless element.is_a? REXML::Element
    raise TypeError unless namespace.is_a? String 

    name = namespace + element.attributes['name']
    superclass = element.attributes['superclass']
    
    klass = ClassMetaData.new( name, superclass, name.to_s )

    # collect attributes
    element.each_element('attribute'){|attr|
      aname = attr.attributes['name']
      if aname.nil? then
        raise InvalidXMLError, "missing name of attribute in definition of #{name}."
      end

      is_array = 
        case attr.attributes['is_array']
        when 'true'         then true
        when 'false', nil   then false
        else
          raise InvalidXMLError, "invalid Array specification #{attr.attributes['is_array']} for #{name}.#{aname}"
        end

      type =
        case attr.attributes['type']
        when 'String'    then ClassAttribute::String
        when 'Integer'   then ClassAttribute::Integer
        when 'Date'      then ClassAttribute::Date
        when 'Reference' then ClassAttribute::Reference
        when 'Float'     then ClassAttribute::Float
        when 'Boolean'   then ClassAttribute::Boolean
        else
          raise InvalidXMLError, "invalid type #{attr.attributes['type']} for #{name}.#{aname}"
        end
      
      klass.attributes << ClassAttribute.new( aname, type, is_array )

      indexed =
        case attr.attributes['index']
        when 'true'      then true
        when 'false',nil then false
        else
          raise InvalidXMLError, "invalid value #{attr.attributes['index']} for #{name}.#{aname}"
        end
      if indexed then
        klass.indexes << [ aname ]
      end

      unique =
        case attr.attributes['unique']
        when 'true'      then true
        when 'false',nil then false
        else
          aise InvalidXMLError, "invalid value #{attr.attributes['unique']} for #{name}.#{aname}"
        end
      if unique then
        klass.unique << [ aname ]
      end

    }

    # look for indexes
    element.each_element('index'){|index|
      index_attributes = Array.new
      index.each_element('attribute'){|attr|
        aname = attr.attributes['name']
        if aname.nil? then
          raise InvalidXMLError, "missing name of attribute in index definition of #{name}."
        end
        index_attributes << aname
      }
      klass.indexes << index_attributes
    }

    # look for uniqueness constraints
    element.each_element('unique'){|uniq|
      unique_attributes = Array.new
      uniq.each_element('attribute'){|attr|
        aname = attr.attributes['name']
        if aname.nil? then
          raise InvalidXMLError, "missing name of attribute in uniqueness constraint of #{name}."
        end
        unique_attributes << aname
      }
      klass.unique << unique_attributes
    }
    
    # done
    return klass
  end # generate_class()
  private :generate_class

end # class VaporAdmin

# output error message to standard error
def error( message )
  STDERR.write( "ERROR: " + message )
  STDERR.write( "\n" )
end # error()

# output a warning message to standard error
def warn( message )
  STDERR.write( message )
  STDERR.write( "\n" )
end

# output a normal message to standard error
def message( message )
  STDOUT.write( message )
  STDOUT.write( "\n" )
end # message()

################ main ###################

first_argument = ARGV.shift
db_spec = ''

# check if first argument is "help" or repository specifier
case first_argument
when nil    # no option given
  warn( "Try `vaporadmin help' for usage." )
  exit 1
when "help" # help requested
  VaporAdmin.help( ARGV )
  exit 0
else
  db_spec = first_argument
end

# check if command is valid
command = ARGV.shift
if !VaporAdmin::Commands.include? command then
  warn( "unknown command: `#{command}'" )
  warn( "Try `vaporadmin help' for usage." )
  exit 1
end

# escape route for "help"
if command == "help" then
  VaporAdmin.help( ARGV )
  exit 0
end

# initialize VaporAdmin
admin = nil
begin
  admin = VaporAdmin.new( db_spec )
rescue InvalidRepositoryError => e
  warn( e.message )
  warn( "Try `vaporadmin help' for usage." )
  exit 1
end

# ask user for password if needed
if admin.need_password? then
  password = nil

  # set screen property to noecho if Termios present
  if defined? Termios then
    oldattr = Termios.getattr(STDIN)
    newattr = oldattr.dup
    newattr.c_lflag &= ~Termios::ECHO
    Termios.setattr( STDIN, Termios::TCSANOW, newattr)
  else
    warn( "WARNING: Your password will be echoed on screen." )
    warn( "         Install the Termios module for Ruby to surpress screen echo." ) 
  end

  begin
    # read password
    while password.nil? do
      STDOUT << "Password: "
      password = STDIN.gets
      password.chomp!
      STDOUT << "\n"
    end
    admin.password = password
  rescue Interrupt
    exit 130  # exit code for SIGINT 
  ensure
    # restore screen echo
    if defined? Termios then
      Termios.setattr( STDIN, Termios::TCSANOW, oldattr )
    end
  end

end
# execute action
begin
  admin.execute( command, ARGV )
rescue HandledVaporException 
  exit 1
rescue Interrupt
  exit 130
end

exit 0
