#!/usr/local/bin/ruby34
#
# svnshell.rb : a Ruby-based shell interface for cruising 'round in
#               the filesystem.
#
# Usage: ruby svnshell.rb REPOS_PATH, where REPOS_PATH is a path to
# a repository on your local filesystem.
#
# NOTE: This program requires the Ruby readline extension.
# See http://wiki.rubyonrails.com/rails/show/ReadlineLibrary
# for details on how to install readline for Ruby.
#
######################################################################
#    Licensed to the Apache Software Foundation (ASF) under one
#    or more contributor license agreements.  See the NOTICE file
#    distributed with this work for additional information
#    regarding copyright ownership.  The ASF licenses this file
#    to you under the Apache License, Version 2.0 (the
#    "License"); you may not use this file except in compliance
#    with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#    Unless required by applicable law or agreed to in writing,
#    software distributed under the License is distributed on an
#    "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
#    KIND, either express or implied.  See the License for the
#    specific language governing permissions and limitations
#    under the License.
######################################################################
#

require "readline"
require "shellwords"

require "svn/fs"
require "svn/core"
require "svn/repos"

# SvnShell: a Ruby-based shell interface for cruising 'round in
#           the filesystem.
class SvnShell

  # A list of potential commands. This list is populated by
  # the 'method_added' function (see below).
  WORDS = []

  # Check for methods that start with "do_"
  # and list them as potential commands
  class << self
    def method_added(name)
      if /^do_(.*)$/ =~ name.to_s
        WORDS << $1
      end
    end
  end

  # Constructor for SvnShell
  #
  # path: The path to a Subversion repository
  def initialize(path)
    @repos_path = path
    @path = "/"
    self.rev = youngest_rev
    @exited = false
  end

  # Run the shell
  def run

    # While the user hasn't typed 'exit' and there is still input to be read
    while !@exited and buf = Readline.readline(prompt, true)

      # Parse the command line into a single command and arguments
      cmd, *args = Shellwords.shellwords(buf)

      # Skip empty lines
      next if /\A\s*\z/ =~ cmd.to_s

      # Open a new connection to the repo
      @fs = Svn::Repos.open(@repos_path).fs
      setup_root

      # Execute the specified command
      dispatch(cmd, *args)

      # Find a path that exists in the current revision
      @path = find_available_path

      # Close the connection to the repo
      @root.close

    end
  end

  # Private functions
  private

  # Get the current prompt string
  def prompt

    # Gather data for the prompt string
    if rev_mode?
      mode = "rev"
      info = @rev
    else
      mode = "txn"
      info = @txn
    end

    # Return the prompt string
    "<#{mode}: #{info} #{@path}>$ "
  end

  # Dispatch a command to the appropriate do_* subroutine
  def dispatch(cmd, *args)

    # Dispatch cmd to the appropriate do_* function
    if respond_to?("do_#{cmd}", true)
      begin
        __send__("do_#{cmd}", *args)
      rescue ArgumentError
        # puts $!.message
        # puts $@
        puts("Invalid argument for #{cmd}: #{args.join(' ')}")
      end
    else
      puts("Unknown subcommand: #{cmd}")
      puts("Try one of these commands: ", WORDS.sort.join(" "))
    end
  end

  # Output the contents of a file from the repository
  def do_cat(path)

    # Normalize the path to an absolute path
    normalized_path = normalize_path(path)

    # Check what type of node exists at the specified path
    case @root.check_path(normalized_path)
    when Svn::Core::NODE_NONE
      puts "Path '#{normalized_path}' does not exist."
    when Svn::Core::NODE_DIR
      puts "Path '#{normalized_path}' is not a file."
    else
      # Output the file to standard out
      @root.file_contents(normalized_path) do |stream|
        puts stream.read(@root.file_length(normalized_path))
      end
    end
  end

  # Set the current directory
  def do_cd(path="/")

    # Normalize the path to an absolute path
    normalized_path = normalize_path(path)

    # If it's a valid directory, then set the directory
    if @root.check_path(normalized_path) == Svn::Core::NODE_DIR
      @path = normalized_path
    else
      puts "Path '#{normalized_path}' is not a valid filesystem directory."
    end
  end

  # List the contents of the current directory or provided paths
  def do_ls(*paths)

    # Default to listing the contents of the current directory
    paths << @path if paths.empty?

    # Foreach path
    paths.each do |path|

      # Normalize the path to an absolute path
      normalized_path = normalize_path(path)

      # Is it a directory or file?
      case @root.check_path(normalized_path)
      when Svn::Core::NODE_DIR

        # Output the contents of the directory
        parent = normalized_path
        entries = @root.dir_entries(parent)

      when Svn::Core::NODE_FILE

        # Split the path into directory and filename components
        parts = path_to_parts(normalized_path)
        name = parts.pop
        parent = parts_to_path(parts)

        # Output the filename
        puts "#{parent}:#{name}"

        # Double check that the file exists
        # inside the parent directory
        parent_entries = @root.dir_entries(parent)
        if parent_entries[name].nil?
          # Hmm. We found the file, but it doesn't exist inside
          # the parent directory. That's a bit unusual.
          puts "No directory entry found for '#{normalized_path}'"
          next
        else
          # Save the path so it can be output in detail
          entries = {name => parent_entries[name]}
        end
      else
        # Path is not a directory or a file,
        # so it must not exist
        puts "Path '#{normalized_path}' not found."
        next
      end

      # Output a detailed listing of the files we found
      puts "   REV   AUTHOR  NODE-REV-ID     SIZE              DATE NAME"
      puts "-" * 76

      # For each entry we found...
      entries.keys.sort.each do |entry|

        # Calculate the full path to the directory entry
        fullpath = parent + '/' + entry
        if @root.dir?(fullpath)
          # If it's a directory, output an extra slash
          size = ''
          name = entry + '/'
        else
          # If it's a file, output the size of the file
          size = @root.file_length(fullpath).to_i.to_s
          name = entry
        end

        # Output the entry
        node_id = entries[entry].id.to_s
        created_rev = @root.node_created_rev(fullpath)
        author = @fs.prop(Svn::Core::PROP_REVISION_AUTHOR, created_rev).to_s
        date = @fs.prop(Svn::Core::PROP_REVISION_DATE, created_rev)
        args = [
          created_rev, author[0,8],
          node_id, size, date.strftime("%b %d %H:%M(%Z)"), name
        ]
        puts "%6s %8s <%10s> %8s %17s %s" % args

      end
    end
  end

  # List all currently open transactions available for browsing
  def do_lstxns

    # Get a sorted list of open transactions
    txns = @fs.transactions
    txns.sort
    counter = 0

    # Output the open transactions
    txns.each do |txn|
      counter = counter + 1
      puts "%8s  " % txn

      # Every six transactions, output an extra newline
      if counter == 6
        puts
        counter = 0
      end
    end
    puts
  end

  # Output the properties of a particular path
  def do_pcat(path=nil)

    # Default to the current directory
    catpath = path ? normalize_path(path) : @path

    # Make sure that the specified path exists
    if @root.check_path(catpath) == Svn::Core::NODE_NONE
      puts "Path '#{catpath}' does not exist."
      return
    end

    # Get the list of properties
    plist = @root.node_proplist(catpath)
    return if plist.nil?

    # Output each property
    plist.each do |key, value|
      puts "K #{key.size}"
      puts key
      puts "P #{value.size}"
      puts value
    end

    # That's all folks!
    puts 'PROPS-END'

  end

  # Set the current revision to view
  def do_setrev(rev)

    # Make sure the specified revision exists
    begin
      @fs.root(Integer(rev)).close
    rescue Svn::Error
      puts "Error setting the revision to '#{rev}': #{$!.message}"
      return
    end

    # Set the revision
    self.rev = Integer(rev)

  end

  # Open an existing transaction to view
  def do_settxn(name)

    # Make sure the specified transaction exists
    begin
      txn = @fs.open_txn(name)
      txn.root.close
    rescue Svn::Error
      puts "Error setting the transaction to '#{name}': #{$!.message}"
      return
    end

    # Set the transaction
    self.txn = name

  end

  # List the youngest revision available for browsing
  def do_youngest
    rev = @fs.youngest_rev
    puts rev
  end

  # Exit this program
  def do_exit
    @exited = true
  end

  # Find the youngest revision
  def youngest_rev
    Svn::Repos.open(@repos_path).fs.youngest_rev
  end

  # Set the current revision
  def rev=(new_value)
    @rev = new_value
    @txn = nil
    reset_root
  end

  # Set the current transaction
  def txn=(new_value)
    @txn = new_value
    reset_root
  end

  # Check whether we are in 'revision-mode'
  def rev_mode?
    @txn.nil?
  end

  # Close the current root and setup a new one
  def reset_root
    if @root
      @root.close
      setup_root
    end
  end

  # Setup a new root
  def setup_root
    if rev_mode?
      @root = @fs.root(@rev)
    else
      @root = @fs.open_txn(name).root
    end
  end

  # Convert a path into its component parts
  def path_to_parts(path)
    path.split(/\/+/)
  end

  # Join the component parts of a path into a string
  def parts_to_path(parts)
    normalized_parts = parts.reject{|part| part.empty?}
    "/#{normalized_parts.join('/')}"
  end

  # Convert a path to a normalized, absolute path
  def normalize_path(path)

    # Convert the path to an absolute path
    if path[0,1] != "/" and @path != "/"
      path = "#{@path}/#{path}"
    end

    # Split the path into its component parts
    parts = path_to_parts(path)

    # Build a list of the normalized parts of the path
    normalized_parts = []
    parts.each do |part|
      case part
      when "."
        # ignore
      when ".."
        normalized_parts.pop
      else
        normalized_parts << part
      end
    end

    # Join the normalized parts together into a string
    parts_to_path(normalized_parts)

  end

  # Find the parent directory of a specified path
  def parent_dir(path)
    normalize_path("#{path}/..")
  end

  # Try to land on the specified path as a directory.
  # If the specified path does not exist, look for
  # an ancestor path that does exist.
  def find_available_path(path=@path)
    if @root.check_path(path) == Svn::Core::NODE_DIR
      path
    else
      find_available_path(parent_dir(path))
    end
  end

end


# Autocomplete commands
Readline.completion_proc = Proc.new do |word|
  SvnShell::WORDS.grep(/^#{Regexp.quote(word)}/)
end

# Output usage information if necessary
if ARGV.size != 1
  puts "Usage: #{$0} REPOS_PATH"
  exit(1)
end

# Create a new SvnShell with the command-line arguments and run it
SvnShell.new(ARGV.shift).run
