# $Id: Slack.pm 189 2008-04-21 00:52:56Z sundell $
# vim:sw=2
# vim600:fdm=marker
# Copyright (C) 2004-2008 Alan Sundell <alan@sundell.net>
# All Rights Reserved.  This program comes with ABSOLUTELY NO WARRANTY.
# See the file COPYING for details.

package Slack;

require 5.006;
use strict;
use Carp qw(cluck confess croak);
use File::Find;
use POSIX qw(WIFEXITED WEXITSTATUS WIFSIGNALED WTERMSIG);

use base qw(Exporter);
use vars qw($VERSION @EXPORT @EXPORT_OK $DEFAULT_CONFIG_FILE $DEFAULT_IGNORE_FILE);
$VERSION = '0.15.3';
@EXPORT    = qw();
@EXPORT_OK = qw();

$DEFAULT_CONFIG_FILE = '/etc/slack/slack.conf';
$DEFAULT_IGNORE_FILE = '/etc/slack/slackignore';

my $term;

my @default_options = (
    'help|h|?',
    'version',
    'verbose|v+',
    'quiet',
    'config|C=s',
    'ignore|I=s',
    'source|s=s',
    'rsh|e=s',
    'cache|c=s',
    'stage|t=s',
    'root|r=s',
    'dry-run|n',
    'backup|b',
    'backup-dir=s',
    'hostname|H=s',
);

sub default_usage ($) {
  my ($synopsis) = @_;
  return <<EOF;
Usage: $synopsis

Options:
  -h, -?, --help
      Print this help message and exit.

  --version
      Print the version number and exit.

  -v, --verbose
      Be verbose.

  --quiet
      Don't be verbose (Overrides previous uses of --verbose)

  -C, --config  FILE
      Use this config file instead of '$DEFAULT_CONFIG_FILE'.

  -I, --ignore FILE
      Use this slackignore file instead of '$DEFAULT_IGNORE_FILE'.

  -s, --source  DIR
      Source for slack files

  -e, --rsh  COMMAND
      Remote shell for rsync

  -c, --cache  DIR
      Local cache directory for slack files

  -t, --stage  DIR
      Local staging directory for slack files

  -r, --root  DIR
      Root destination for slack files

  -n, --dry-run
      Don't write any files to disk -- just report what would have been done.

  -b, --backup
      Make backups of existing files in ROOT that are overwritten.

  --backup-dir  DIR
      Put backups into this directory.

  -H, --hostname  HOST
      Pretend to be running on HOST, instead of the name given by
        gethostname(2).
EOF
}
# Read options from a config file.  Arguments:
#       file    => config file to read
#       opthash => hashref in which to store the options
#       verbose => whether to be verbose
sub read_config (%) {
  my %arg = @_;
  my ($config_fh);
  local $_;

  confess "Slack::read_config: no config file given"
    if not defined $arg{file};
  $arg{opthash} = {}
    if not defined $arg{opthash};

  open($config_fh, '<', $arg{file})
    or confess "Could not open config file '$arg{file}': $!";

  # Make this into a hash so we can quickly see if we're looking
  # for a particular option
  my %looking_for;
  if (ref $arg{options} eq 'ARRAY') {
    %looking_for = map { $_ => 1 } @{$arg{options}};
  }

  while(<$config_fh>) {
    chomp;
    s/#.*//; # delete comments
    s/\s+$//; # delete trailing spaces
    next if m/^$/; # skip empty lines

    if (m/^[A-Z_]+=\S+/) {
      my ($key, $value) = split(/=/, $_, 2);
      $key =~ tr/A-Z_/a-z-/;
      # Only set options we're looking for
      next if (%looking_for and not $looking_for{$key});
      # Don't set options that are already set
      next if defined $arg{opthash}->{$key};

      $arg{verbose} and print STDERR "Slack::read_config: Setting '$key' to '$value'\n";
      $arg{opthash}->{$key} = $value;
    } else {
      cluck "Slack::read_config: Garbage line '$_' in '$arg{file}' line $. ignored";
    }
  }

  close($config_fh)
    or confess "Slack::read_config: Could not close config file: $!";

  # The verbose option is treated specially in so many places that
  # we need to make sure it's defined.
  $arg{opthash}->{verbose} ||= 0;

  return $arg{opthash};
}

# Just get the exit code from a command that failed.
# croaks if anything weird happened.
sub get_system_exit (@) {
  my @command = @_;

  if (WIFEXITED($?)) {
    my $exit = WEXITSTATUS($?);
    return $exit if $exit;
  }
  if (WIFSIGNALED($?)) {
    my $sig = WTERMSIG($?);
    croak "'@command' caught sig $sig";
  }
  if ($!) {
    croak "Syserr on system '@command': $!";
  }
  croak "Unknown error on '@command'";
}

sub check_system_exit (@) {
  my @command = @_;
  my $exit = get_system_exit(@command);
  # Exit is non-zero if get_system_exit() didn't croak.
  croak "'@command' exited $exit";
}

# get options from the command line and the config file
# Arguments
#       opthash => hashref in which to store options
#       usage   => usage statement
#       required_options => arrayref of options to require -- an exception
#               will be thrown if these options are not defined
#       command_line_hash => store options specified on the command line here
sub get_options {
  my %arg = @_;
  use Getopt::Long;
  Getopt::Long::Configure('bundling');

  if (not defined $arg{opthash}) {
    $arg{opthash} = {};
  }

  if (not defined $arg{usage}) {
    $arg{usage} = default_usage($0);
  }

  my @extra_options = ();  # extra arguments to getoptions
  if (defined $arg{command_line_options}) {
    @extra_options = @{$arg{command_line_options}};
  }

  # Make a --quiet function that turns off verbosity
  $arg{opthash}->{quiet} = sub { $arg{opthash}->{verbose} = 0; };

  unless (GetOptions($arg{opthash},
                    @default_options,
                    @extra_options,
                    )) {
    print STDERR $arg{usage};
    exit 1;
  }
  if ($arg{opthash}->{help}) {
    print $arg{usage};
    exit 0;
  }

  if ($arg{opthash}->{version}) {
    print "slack version $VERSION\n";
    exit 0;
  }

  # Get rid of the quiet handler
  delete $arg{opthash}->{quiet};

  # If we've been given a hashref, save our options there at this
  # stage, so the caller can see what was passed on the command line.
  # Unfortunately, perl has no .replace function, so we iterate.
  if (ref $arg{command_line_hash} eq 'HASH') {
    while (my ($k, $v) = each %{$arg{opthash}}) {
      $arg{command_line_hash}->{$k} = $v;
    }
  }

  # Use the default config file
  if (not defined $arg{opthash}->{config}) {
    $arg{opthash}->{config} = $DEFAULT_CONFIG_FILE;
  }

  # Use the default ignore file
  if (not defined $arg{opthash}->{ignore}) {
    $arg{opthash}->{ignore} = $DEFAULT_IGNORE_FILE;
  }

  # We need to decide whether to be verbose about reading the config file
  # Currently we just do it if global verbosity > 2
  my $verbose_config = 0;
  if (defined $arg{opthash}->{verbose}
      and $arg{opthash}->{verbose} > 2) {
    $verbose_config = 1;
  }

  # Read options from the config file, passing along the options we've
  # gotten so far
  read_config(
      file => $arg{opthash}->{config},
      opthash => $arg{opthash},
      verbose => $verbose_config,
  );

  # The "verbose" option gets compared a lot and needs to be defined
  $arg{opthash}->{verbose} ||= 0;

  # The "hostname" option is set specially if it's not defined
  if (not defined $arg{opthash}->{hostname}) {
    use Sys::Hostname;
    $arg{opthash}->{hostname} = hostname;
  }

  # We can require some options to be set
  if (ref $arg{required_options} eq 'ARRAY') {
    for my $option (@{$arg{required_options}}) {
      if (not defined $arg{opthash}->{$option}) {
        croak "Required option '$option' not given on command line or specified in config file!\n";
      }
    }
  }

  return $arg{opthash};
}

sub prompt ($) {
  my ($prompt) = @_;
  if (not defined $term) {
    require Term::ReadLine;
    $term = new Term::ReadLine 'slack'
  }

  # disable underline
  $term->ornaments(0);
  $term->readline($prompt);
}


# Calls the callback on absolute pathnames of files in the source directory,
# and also on names of directories that don't exist in the destination
# directory (i.e. where $source/foo exists but $destination/foo does not).
sub find_files_to_install ($$$) {
  my ($source, $destination, $callback) = @_;
  return find ({
      wanted => sub {
        if (-l or not -d _) {
          # Copy all files, links, etc
          my $file = $File::Find::name;
          &$callback($file);
        } elsif (-d _) {
          # For directories, we only want to copy it if it doesn't
          # exist in the destination yet.
          my $dir = $File::Find::name;
          # We know the root directory will exist (we make it above),
          # so skip the base of the source
          (my $short_source = $source) =~ s#/$##;
          return if $dir eq $short_source;

          # Strip the $source from the path,
          # so we can build the destination dir from it.
          my $subdir = $dir;
          ($subdir =~ s#^$source##)
            or croak "sub failed: $source|$subdir";

          if (not -d "$destination/$subdir") {
            &$callback($dir);
          }
        }
      }
    },
    $source,
  );
}

# Runs rsync with the necessary redirection to its filehandles
sub wrap_rsync (@) {
  my @command = @_;
  my ($pid);

  if ($pid = fork) {
    # Parent
  } elsif (defined $pid) {
    # Child
    open(STDIN, "<", "/dev/null")
      or die "Could not redirect STDIN from /dev/null\n";
    # This redirection is necessary because rsync sends
    #   verbose output to STDOUT
    open(STDOUT, ">&STDERR")
      or die "Could not redirect STDOUT to STDERR\n";
    exec(@command);
    die "Could not exec '@command': $!\n";
  } else {
    die "Could not fork: $!\n";
  }

  my $kid = waitpid($pid, 0);
  if ($kid != $pid) {
    die "waitpid returned $kid\n";
  } elsif ($?) {
    Slack::check_system_exit(@command);
  }
}

# Runs rsync with the necessary redirection to its filehandles, but also
# returns an FH to stdin and a PID.
sub wrap_rsync_fh (@) {
  my @command = @_;
  my ($fh, $pid);

  if ($pid = open($fh, "|-")) {
    # Parent
  } elsif (defined $pid) {
    # Child
    # This redirection is necessary because rsync sends
    #   verbose output to STDOUT
    open(STDOUT, ">&STDERR")
      or die "Could not redirect STDOUT to STDERR\n";
    exec(@command);
    die "Could not exec '@command': $!\n";
  } else {
    die "Could not fork: $!\n";
  }
  return($fh, $pid);
}

1;
