package Java::Build::Tasks;
use strict; use warnings;

=head1 NAME

Java::Build::Tasks - collects common Java build tasks in one place: jar, jarsigner, etc.

=head1 SYNOPSIS

    use Java::Build::Tasks;

    set_logger($log_object);

    copy_file('source', 'dest');
    copy_file('list', 'of', 'sources', 'dest');
    copy_file('-r', 'list', 'of', 'sources', 'with', 'flags', 'dest');
    copy_file([ 'list', 'of', 'sources' ], 'dest');
    copy_file([ '-r', 'list', 'of', 'sources', 'with', 'flags' ], 'dest');

    my $file_list = build_file_list(
        BASE_DIR         => 'where/to/start',
        EXCLUDE_PATTERNS => [ qr/leave/, qr/these/, qr/out/ ],
        INCLUDE_PATTERNS => [ qr/.*include/, qr/these.*/    ],
        EXCLUDE_DEFAULTS => 1,
        STRIP_BASE_DIR   => 1,
        QUOTE_DOLLARS    => 1,
    );

    jar(
        JAR_FILE  => 'some/full/path/ending/in/a.jar',
        FILE_LIST => $file_list;
        MANIFEST  => 'location/of/manifest/to/put/in/jar',
        BASE_DIR  => 'path/to/change/to/before/building/jar',
        APPEND    => 1,
    );

    signjar(
        JAR_FILE         => 'what/to/sign',
        KEYSTORE         => 'path/to/your/keystore',
        ALIAS            => $your_alias,
        STOREPASS        => $your_keystore_pass,
    );

    my $config_hash = read_prop_file($prop_file_name);

    update_prop_file(
        NAME      => '/optional/path/and/file.properties',
        NEW_PROPS => \%values_to_add_or_update,
    );

    filter_file(
        INPUT   => 't/file1',
        OUTPUT  => 't/file2',
        FILTERS => [
            sub { my $string = shift; $string =~ s/Happy/Joyous/g; $_;}
        ],
    );

    my $dirties = what_needs_compiling(
        SOURCE_FILE_LIST         => $list,
        SOURCE_DIR               => 'path/to/your/source/files',
        DEST_DIR                 => 'path/to/your/compiled/files',
        SOURCE_TO_COMPILIED_NAME => sub { ... },
    );

    my $classpath = make_jar_classpath(
        DIRS             => [ '/path/to/some/set/of/jars',
                              '/path/to/some/other/jars' ],
        INCLUDE_PATTERNS => [ qr/.jar$/, qr/.ZIP$/ ], # optional
    );

    purge_dirs($base_dir, qw(sub directories to remove));

=head1 DESCRIPTION

There are currently six tasks in this file.  Three are like Ant tasks:
update_prop_file (Ant calls this propertyfile), jar and signjar.  Two
build lists of files: build_file_list and what_needs_compiling.  One
reads a config file (or properties file) into a hash which it returns.

Call all of them with their hashes as shown above.  The $file_list which
jar expects, can be formed using build_file_list (as shown) or by simply
supplying a list of file names, as in:

    FILE_SET => [ "file1", "file2" ],

=head2 EXPORT

build_file_list
copy_file
filter_file
jar
make_jar_classpath
read_prop_file
set_logger
signjar
update_prop_file
what_needs_compiling

=head1 DEPENDENCIES

    Carp
    Exporter
    Cwd
    File::Find
    File::Temp

=head1 FUNCTIONS

Each function is described in more detail below.

=cut

use Carp;
use File::Find;
use Cwd;
use File::Temp qw( tempfile );
use Exporter;
our @ISA     = qw( Exporter );
our @EXPORT  = qw(
    filter_file build_file_list what_needs_compiling
    jar signjar read_prop_file update_prop_file copy_file
    make_jar_classpath purge_dirs
);
our $logger;
our $VERSION = "0.03";

sub _my_croak {
    my $message = shift;

    $logger->log($message, 100) if $logger;
    croak "$message\n";
}

sub _my_carp {
    my $message = shift;

    $logger->log($message, 60) if $logger;
    carp "$message\n";
}

sub _my_log {
    my $message  = shift;
    my $severity = shift;
    $logger->log($message, $severity) if $logger;
}

=head1 copy_file

This function performs a copy.  The only advantages of using it are to
gain error checking, file name quoting (to guard spaces), and optional
logging.  The last argument must be the destination.  The other argument(s)
can be either

    a flattened list which starts with optional flags for the cp command
    or
    an array reference which starts with optional flags

See sample calls in the SYNOPSIS for examples.

All space in the arguments are quoted.  Those in the source(s) with backslash,
those in the destintation with single quotes.

=cut

sub copy_file {
    my $dest         = pop;
    my @sources;

    if (ref($_[0]) =~ /ARRAY/) { @sources = @{$_[0]}; }
    else                       { @sources = @_;       }

    @sources = map { s/ /\ /g; $_ } @sources;

    my $cp_out = `cp @sources '$dest' 2>&1`;
    if ($?) {
        _my_croak("couldn't cp @sources to $dest $cp_out $?");
    }
}

=head1 purge_dirs

Pass in the parent directory and a list of its children to vaporize.

=cut

sub purge_dirs {
    _my_log("purging...", 40);
    my $base = shift;

    foreach my $doomed_subdir (@_) {
        my $command = "rm -rf $base/$doomed_subdir";
        _my_log("$command", 20);
        my $purge_out = `$command 2>&1`;
        if ($?) {
            _my_log(
                "couldn't purge $base/$doomed_subdir $purge_out $?",
                HertzLog->WARNING
            );
        }
    }
}

=head1 set_logger

If you want logging of the tasks, pass a logging object to this method.
That object must implement a log method, which will receive two parameters
(in addition to the invocant): message and level.  Levels go from 0 (debug)
through 100 (fatal).  Typically, the tasks croak immediately after
sending fatal log messages.  Most message are informational an have level 40.
Only 0, 20, 40, 60, 80, and 100 are used.

Note that this is a class method, so only one logger can be used at any
given time.

To turn off logging, call the method again with undef.

=cut

sub set_logger {
    $logger = shift;
}

=head1 read_prop_file

Opens the given file, reads it, and returns a hash of the var=value pairs.
The config file may have blank lines or comments which start with a #
at the left margin.  For example:

    # This is a config file....

    base_dir=/some/path

=cut

sub read_prop_file {
    my $file = shift;
    my %config;

    open CONFIG, "$file" or return;

    _my_log("reading prop file: $file", 0);

    while (<CONFIG>) {
        next if (/^#/);
        next if (/^\s*$/);
        chomp;
        my ($var, $value) = split /\s*=\s*/;
        $config{$var}     = $value;
    }

    close CONFIG;

    return \%config;
}

=head1 update_prop_file

The method reads a properties file, updates its keys with new supplied
values (adding keys when necessary) and writes the result back to the
disk.

At present, it does not preserve comments or the order of the keys.

There are two parameters, both are required:

=over 4

=item NAME

The name of the properties file, it will be created if it does not exist.
This name should probably be an absolute path, though it could be relative
to the directory from which the script was launched.

=item NEW_PROPS

A hash reference with the keys you want to change or add and their new values.

=back

=cut

sub update_prop_file {
    my %args             = @_;
    my $name             = delete $args{NAME}
        or _my_croak "You didn't supply a NAME to update_prop_file";
    my $new_props        = delete $args{NEW_PROPS}
        or _my_croak "You didn't supply a NEW_PROPS to update_prop_file";

    _my_croak "Bad argument to update_prop_file: ", keys %args if (keys %args);

    my $old_props        = read_prop_file($name) || {};
    my %output_props     = ( %$old_props, %$new_props );

    open PROP_FILE, ">$name" or _my_croak "Couldn't write to $name $!\n";

    _my_log("writing updated prop file: $name", 0);

    foreach my $key (keys %output_props) {
        print PROP_FILE "$key=$output_props{$key}\n";
    }

    close PROP_FILE;
}

=head1 filter_file

The method reads a file one line at a time, filtering it according
to your rules before writing it out.

There are three parameters, two are required: INPUT and FILTERS.
OUTPUT is optional, if it is omitted, the result will overwrite the
INPUT.

Note Well: This function is line oriented.  It won't work on multi-line
patterns.  Suggestions for a more general approach are welcome.

=over 4

=item INPUT

The name of a file to filter.

=item FILTERS

A list of filtering functions.  The function receives one line at a time.
It should modify the received line (change $_[0]) if needed.  It is fine
to leave the parameter unchanged.

=item OUTPUT

The name of the filtered file.  If this is omitted, or is the same
as INPUT, the result will overwrite the original.  (A temporarly file
is used so that data is not lost.)

=back

=cut

sub filter_file {
    my %args    = @_;
    my $input   = delete $args{INPUT}
        or _my_croak "You didn't supply a INPUT to filter_file";
    my $filters = delete $args{FILTERS}
        or _my_croak "You didn't supply a FILTERS to filter_file";
    my $output  = delete $args{OUTPUT};

    my ($OUTPUT, $tmp_out) = tempfile();

    _my_croak "Bad argument to filter_file: ", keys %args if (keys %args);

    open INPUT, $input or _my_croak "Couldn't read $input $!\n";

    _my_log("filtering file: $input", 40);

    while (my $line = <INPUT>) {
        foreach my $filter (@$filters) {
            &$filter($line);
        }
        print $OUTPUT $line;
    }

    close $OUTPUT;
    close INPUT;

    if ($output and $output ne $input) {
        `mv $tmp_out $output`;
    }
    else {
        `mv $tmp_out $input`;
    }
}

=head1 build_file_list

This returns a list of files (in an array reference).

There are several arguments to build_file_list, only BASE_DIR is required.

Note that this function does not include directories in its lists.  If
this is a problem, someone should add an INCLUDE_DIRS parameter which
callers can give a true value to receive directories.  For now, directories
are omitted.

=over 4

=item BASE_DIR

A path where all the files in the set live.  If you need more than one
BASE_DIR, you can call the method again, then combine the resulting lists
as in:

    my $combined_list = [ @$list1, @$list2 ];

where $list1 and $list2 are lists you built with this method or by hand.

=item EXCLUDE_PATTERNS

This is a list of regular expressions which a file must not match.  If it
matches one of these, it is left out of the list.

=item EXCLUDE_DEFAULTS => 1,

If this argument is true, a standard set of patterns will be added to the
EXCLUDE_PATTERNS.  The default excluded patterns are the same ones Ant uses:

    Files ending with ~
    Files which begin and end with #
    Files which start with .#
    Files which begin and end with %
    Files with CVS as a path element
    Files called .cvsignore
    Files with SCCS as a path element
    Files called vssver.scc

At this time, there is no way to change this list without a code change
in Java::Build::Tasks.

=item INCLUDE_PATTERNS

This is a list of regular expressions to include in the archive.  Any file
not excluded by matching something in EXCLUDE_PATTERNS (or the default
excluded patterns) will be included, if it matches any of these patterns.

=item STRIP_BASE_DIR

If this argument is true, the BASE_DIR will be removed from each file name
before it goes into the result list.  This makes all file names relative
to BASE_DIR.

Defaults to false.

=item DOTTIFY_NAMES

If this argument is true, all slashes in the file name will be replaced
with dots.  I'm not sure this is useful, but it was easy to include.

Defaults to false.

=item QUOTE_DOLLARS

If this argument is true, all dollar signs in the file name will be replaced
with \$.  This helps, since functions like jar pass the names to the shell
which doesn't treat the dollars literally.

Defaults to false.

=back

=cut

my @default_excludes = (
    qr/~$/,
    qr/^#.*#$/,
    qr/\.#/,
    qr/^%.*%$/,
    qr(/CVS$|/CVS/|^CVS/|^CVS$),
    qr/\.cvsignore/,
    qr(/SCCS$|/SCCS/|^SCCS/|^SCCS$),
    qr/vssver\.scc/,
);

sub build_file_list {
    my %args             = @_;
    my $base_dir         = delete $args{BASE_DIR}
        or _my_croak "You didn't supply a BASE_DIR to build_file_list";
    my $exclude_patterns = delete $args{EXCLUDE_PATTERNS};
    my $include_patterns = delete $args{INCLUDE_PATTERNS};
    my $exclude_defaults = delete $args{EXCLUDE_DEFAULTS};
    my $strip_base_dir   = delete $args{STRIP_BASE_DIR} || 0;
    my $dottify_names    = delete $args{DOTTIFY_NAMES}  || 0;
    my $quote_dollars    = delete $args{QUOTE_DOLLARS}  || 0;

    _my_croak "Bad argument to build_file_list: ", keys %args if (keys %args);
    _my_croak "$base_dir is not a directory" unless (-d $base_dir);

    if ($strip_base_dir and $base_dir =~ m!/$!) {
        _my_carp("You asked for STRIP_BASE_DIR, your BASE_DIR ends in /");
        _my_carp("I removed the trailing slash.");
        $base_dir =~ s!/$!!;
    }

    my @result_list;

    my $excluded_by_default = _gen_filter(\@default_excludes);
    my $excluded_by_pattern = _gen_filter($exclude_patterns);
    my $included_by_pattern = _gen_filter($include_patterns);

    my $wanted_function = sub {
        my $file = $File::Find::name;
        return if ($strip_base_dir and $file eq $base_dir);
        $file    =~ s!$base_dir/!! if ($strip_base_dir);

        return if ($exclude_defaults and &$excluded_by_default($file));
        return if &$excluded_by_pattern($file);
        if ($include_patterns) {
            return unless (&$included_by_pattern($file));
        }
        $file    =~ s!/!.!go        if ($dottify_names);
        $file    =~ s!\$!\$!g       if ($quote_dollars);
#        push @result_list, $file;
        push @result_list, $file unless (-d $File::Find::name);
    };

    find({ wanted => $wanted_function, no_chdir => 1 }, $base_dir);

    return \@result_list;
}

# _gen_filter returns a sub.  The sub is a noop when the first argument
# to _gen_filter is undefined.  This makes it safe to use the _gen_filter
# sub even if you don't validate the rules list in the calling code.
# If _gen_filter's first argument is defined, it must be a reference to an
# array of rules.  The generated function takes a file name.  It returns 1
# if the name matches any rule or 0 otherwise.
sub _gen_filter {
    my $rules = shift;

    return sub {} unless ($rules);

    return sub {
        my $file = shift;

        foreach my $rule (@$rules) {
            return 1 if ($file =~ $rule);
        }
        return 0;
    };
}

=head1 make_jar_classpath

This function makes a valid class path of all the jars in the supplied
list of directories.  It has two parameters.  DIRS is the list of directories
in which to look for jars, it is required.  INCLUDE_PATTERNS works
like it does in build_file_list.  Give an anonymous array of regexes.
By default this list is just [ qr/.jar$/ ].  You might need to set it
to [ qr/.jar$/, qr/.ZIP$/ ].

=cut

sub make_jar_classpath{
    my %args = @_;
    my $dirs = delete $args{DIRS}
        or _my_croak "You didn't supply a DIRS to make_jar_classpath";
    my $include_patterns = delete $args{INCLUDE_PATTERNS} || [ qr/jar$/ ];

    _my_croak "Bad argument to make_jar_classpath: ", keys %args
        if (keys %args);

    my @path_pieces;
    foreach my $dir (@$dirs) {
        my $jars = build_file_list(
            BASE_DIR         => $dir,
            INCLUDE_PATTERNS => $include_patterns,
        );
        my $piece = join ":", @$jars;
        push @path_pieces, $piece;
    }
    return join ":", @path_pieces;
}

=head1 what_needs_compiling

This method takes a list of source files and returns a new list which
includes source files only if they are newer than their compiled forms
(or if their compiled form is absent).

There are four paramters to this method, only SOURCE_FILE_LIST is required.

=over 4

=item SOURCE_FILE_LIST

An array reference storing paths to source code files.  If SOURCE_DIR is
omitted, paths must be absolute or they will be relative the starting
directory of the script.

=item SOURCE_DIR

A path to add to each name in the SOURCE_FILE_LIST (i.e. a parent directory
for all of the source files).

=item DEST_DIR

A path to use in place of SOURCE_DIR for compiled files (i.e. a parent
directory for all the compiled files).

=item SOURCE_TO_COMPILIED_NAME

A code reference.  The function must take a source file name and return
its compiled name.  A good choice might be:

    sub {
        my $file = shift;
        $file    =~ s/\.java/.class/;
        return $file;
    }

In fact, this is the default.  What a lucky coincidence.  If your source
and compiled files live under different base directories, it may be
convenient to leave out SOURCE_DIR and DEST_DIR, using a method to
perform the conversion from one to the other.

=back

If you have suggestions for the interface to this method, send them in.
It still doesn't feel right to me.

=cut

sub what_needs_compiling {
    my %defaults = (SOURCE_TO_COMPILIED_NAME => \&_source_to_compilied_name);
    my %args     = (%defaults, @_);

    my $source_file_list         = delete $args{SOURCE_FILE_LIST}
        or _my_croak
            "You didn't supply a SOURCE_FILE_LIST to what_needs_compiling";
    my $source_dir               = delete $args{SOURCE_DIR};
    my $dest_dir                 = delete $args{DEST_DIR};
    my $source_to_compilied_name = delete $args{SOURCE_TO_COMPILIED_NAME};

    _my_croak "Bad argument to what_needs_compiling: ", keys %args
        if (keys %args);

    $source_dir .= "/" if defined $source_dir;
    $dest_dir   .= "/" if defined $dest_dir  ;

    my @result_list;
    foreach my $source_file (@$source_file_list) {
        my $compiled_file = &$source_to_compilied_name($source_file);
        no warnings;  # dest_dir and source_dir could be undef
        if ( not -f "$dest_dir$compiled_file"
                    or
             -M "$dest_dir$compiled_file" > -M "$source_dir$source_file"
        ) {
            push @result_list, $source_file;
        }
    }
    return \@result_list;
}

sub _source_to_compilied_name {
    my $file = shift;
    $file    =~ s/\.java/.class/;
    return $file;
}

=head1 jar

jar uses the Sun supplied jar tool to package a list of files.  You may want
to construct the list by calling build_file_list above, but you may
produce it in any way you like.  The names in the list should be relative
to the BASE_DIR of the jar.

There are five arguments to jar, JAR_FILE, BASE_DIR and FILE_SET are required.
MANIFEST and APPEND are optional.

=over 4

=item JAR_FILE

The name of the destination file.

=item FILE_LIST

A list of files to put in the jar.

=item BASE_DIR

A directory to move to before issuing the jar command.  This might be
the same BASE_DIR you used to build the file list.

=item MANIFEST

The name of the manifest file to put in the jar.  If you omit this, jar
will do its default thing.  See the docs for jar to know what that default
thing is, and what to put in your MANIFEST if you choose to supply one.

=item APPEND

If this is true, files will be added to an existing jar.  Bad things will
happen if the file does not exist.

Defaults to false.

=back

=cut

sub jar {
    my %args = @_;

    my $jar_file  = delete $args{JAR_FILE}
        or _my_croak "You didn't supply a JAR_FILE to jar";
    my $file_list = delete $args{FILE_LIST}
        or _my_croak "You didn't supply a FILE_LIST to jar";
    my $manifest  = delete $args{MANIFEST};
    my $base_dir  = delete $args{BASE_DIR};
    my $append    = delete $args{APPEND};

    _my_croak "Bad argument to jar: ", keys %args if (keys %args);

    _my_croak "Nothing to jar" unless (@$file_list > 0);

    my $operation = "c";
    $operation    = "u" if $append;

    my @quoted_list = map { "'$_'" } @$file_list;

    local $" = " ";  # in case the caller has messed with it
    my $current_dir = cwd();
    chdir $base_dir if $base_dir;
    my $jar_out;
    _my_log("jar includes: @quoted_list", 0);
    if ($manifest) {
        _my_log("jarring $operation: $jar_file with $manifest", 40);

        $jar_out =
            `jar ${operation}fm '$jar_file' '$manifest' @quoted_list 2>&1`;
    }
    else {
        _my_log("jarring $operation: $jar_file", 40);

        $jar_out = `jar ${operation}f '$jar_file' @quoted_list 2>&1`;
    }
    chdir $current_dir if $base_dir;
    _my_croak($jar_out) if ($?);
    _my_log("jar out: $jar_out", 20) if ($jar_out);
}

=head1 ear

The only difference between a jar and a WebSphere ear is the application.xml
which must be in META-INF/application.xml.  This is a convenience routine
for making these.

There are four parameters to ear.  All are required.

=over 4

=item EAR_FILE

The name of the output file.

=item FILE_LIST

The regular files to put in the ear.

=item BASE_DIR

The parent directory of the regular files.

=item XML_BASE_DIR

The parent directory of META-INF/application.xml.

=back

=cut

sub ear {
    my %args         = @_;
    my $ear_file     = delete $args{EAR_FILE}
        or _my_croak "You didn't supply an ear file name to ear";
    my $file_list    = delete $args{FILE_LIST}
        or _my_croak "You didn't supply a FILE_LIST to ear";
    my $base_dir     = delete $args{BASE_DIR}
        or _my_croak "You didn't supply a BASE_DIR to ear";
    my $xml_base_dir = delete $args{XML_BASE_DIR}
        or _my_croak "You didn't supply a XML_BASE_DIR to ear";

    _my_croak "Bad argument to ear: ", keys %args if (keys %args);

    jar(
        JAR_FILE  => $ear_file,
        FILE_LIST => $file_list,
        BASE_DIR  => $base_dir,
    );

    jar(
        JAR_FILE  => $ear_file,
        FILE_LIST => [ "META-INF/application.xml" ],
        BASE_DIR  => $xml_base_dir,
        APPEND    => 1,
    );
}

=head1 signjar

There are four named attributes to signjar.  JAR_FILE and ALIAS are
required KEYSTORE and STOREPASS are not (but if you use KEYSTORE,
you must use STOREPASS or unexpected bad things may happen).

=over 4

=item JAR_FILE

The name of the jar file to be signed.

=item ALIAS

The alias under which the JAR_FILE will be signed.

=item KEYSTORE

The location of your key store file.  The default is to leave out this
parameter when calling jarsigner.  See its documentation for where the
default location is.

=item STOREPASS

The password which locks your keystore.

=back

=cut

sub signjar {
    my %args = @_;

    my $jar_file  = delete $args{JAR_FILE}
        or _my_croak "You didn't supply a JAR_FILE to signjar";
    my $alias     = delete $args{ALIAS}
        or _my_croak "You didn't supply an ALIAS to signjar";
    my $storepass = delete $args{STOREPASS};
    my $keystore  = delete $args{KEYSTORE};

    _my_croak "Bad argument to signjar: ", keys %args if (keys %args);

    my $com_out;
    if ($keystore) {
        _my_log("signing jar: $jar_file",     40);
        _my_log(
            "sign parms: alias = $alias; "
          . " -keystore '$keystore' -storepass $storepass",
            0
        );

        my $command = "jarsigner -keystore '$keystore' -storepass $storepass "
                    . "'$jar_file' $alias";
        $com_out = `$command 2>&1`;
    }
    else {
        _my_log("signing jar: $jar_file alias: $alias", 40);
        $com_out = `jarsigner '$jar_file' $alias 2>&1`;
    }
    _my_croak($com_out) if ($?);
    _my_log("jarsigner out: $com_out", 20) if ($com_out);
}

# Edit history
# 0.01 Initial release
# 0.02 Changed jar and jarsigner so they only log command output when there
#      is output from the command.  This reduces log clutter.
# 0.02 Changed jar and jar signer so they die when their commands fail.
# 0.02 Changed jar so that it goes back to the proper directory before
#      calling _my_croak.  Not doing that caused problems for people who
#      trap the fatal error.
# 0.03 Make read_prop_file use a bare return if it can't read the file.
#      This prevents annoying logging in update_prop_file.
# 0.03 Made it fatal to supply a BASE_DIR parameter to build_file_list which
#      is not an existing directory name.
# 0.03 Added an optional INCLUDE_PATTERNS parameter to make_jar_classpath.

1;
