#!/usr/bin/perl

use 5.010001;
use strict;
use warnings;

use Fcntl qw(:DEFAULT :flock);
use Getopt::Long;
use Time::Local;

our $VERSION = '0.02'; # VERSION

my $Key;
my $Data_File      = "$ENV{HOME}/norepeat.dat";
my $Num            = 1;
my $Period         = "";
my $Ignore_Failure = 0;
my $Exit_Code;

my $Debug = $ENV{DEBUG};
my $FH;
my $Calc_SEOP_Times; # start and end of period times
my $Now;
my @Now;
my $Should_Run;

sub parse_cmdline {
    Getopt::Long::Configure("nopass_through");
    my $res = GetOptions(
        'key|k=s'        => \$Key,
        'data-file|f=s'  => \$Data_File,
        'num|n=i'        => \$Num,
        'period|p=s'     => \$Period,
        'ignore-failure' => \$Ignore_Failure,
        'help|h'         => sub {
            print <<USAGE;
Usage:
  norepeat [norepeat options] -- [command] [command options ...]
  norepeat --help
Options:
  --key=s, -k
  --data-file=s, -f
  --num=i, -n
  --period=s, -p
  --ignore-failure
For more details, see the manpage/documentation.
USAGE
            exit 0;
        },
    );
    exit 99 if !$res;

    $Key //= join(" ", @ARGV);
    $Key =~ s/[\t\r\n]/ /g;

    if ($Period) {
        if ($Period =~ /\A(\d+)\s*
                        (s|secs?|seconds?|mins?|minutes?|hours?|
                            d|days?|mons?|months?|y|years?)\z/x
                    ) {
            my ($n, $unit) = ($1, $2);
            my $mult = $unit =~ /\A(s|secs?|seconds?)\z/ ? 1 :
                $unit =~ /\A(mins?|minutes?)\z/ ? 60 :
                    $unit =~ /\A(h|hours?)\z/ ? 3600 :
                        $unit =~ /\A(d|days?)\z/ ? 24*3600 :
                            $unit =~ /\A(mons?|months?)\z/ ? 30.5*24*3600 :
                                $unit =~ /\A(y|years?)\z/ ? 365.25*24*3600 : 0;
            $Calc_SEOP_Times = sub {
                my ($t, $e) = @_;
                ($Now, $t + $n * $mult);
            };
        } elsif ($Period eq 'hourly') {
            $Calc_SEOP_Times = sub {
                my ($t, $e) = @_;
                (
                    timelocal( 0,  0, $e->[2], $e->[3], $e->[4], $e->[5]),
                    timelocal(59, 59, $e->[2], $e->[3], $e->[4], $e->[5]),
                );
            };
        } elsif ($Period eq 'daily') {
            $Calc_SEOP_Times = sub {
                my ($t, $e) = @_;
                (
                    timelocal( 0,  0,  0, $e->[3], $e->[4], $e->[5]),
                    timelocal(59, 59, 23, $e->[3], $e->[4], $e->[5]),
                );
            };
        } elsif ($Period eq 'monthly') {
            $Calc_SEOP_Times = sub {
                my ($t, $e) = @_;
                my ($newm, $newy);
                $newm = $e->[4]+1;
                $newy = $e->[5];
                if ($newm == 12) { $newm = 0; $newy++ }
                (
                    timelocal(0, 0, 0, 1, $e->[4], $e->[5]), # 1st of this mon
                    timelocal(0, 0, 0, 1, $newm, $newy)-1,   # 1st of next mon
                );
            };
        } elsif ($Period eq 'yearly') {
            $Calc_SEOP_Times = sub {
                my ($t, $e) = @_;
                (
                    timelocal(0, 0, 0, 1, 1, $e->[5]),     # 1st jan this year
                    timelocal(0, 0, 0, 1, 1, $e->[5]+1)-1, # 1st jan of next y
                );
            };
        } else {
            warn "norepeat: Invalid period '$Period'\n";
            exit 99;
        }
    }
}

sub read_data_file {
    sysopen($FH, $Data_File, O_RDWR | O_CREAT) or do {
        warn "norepeat: Can't open data file '$Data_File': $!\n"; exit 98;
    };
    flock($FH, LOCK_EX) or do {
        warn "norepeat: Can't lock data file '$Data_File': $!\n"; exit 98;
    };
    my $n = 0;
    while (<$FH>) {
        chomp;
        my %row = map { split/:/, $_, 2 } split /\t/, $_;

        next unless $Key eq $row{key};

        # parse time
        my $time;
        if (!$row{time}) {
            warn "norepeat: No time defined in data file line $., skipped";
            next;
        } elsif ($row{time} =~ /\A\d+\z/) {
            $time = $row{time};
        } elsif ($row{time} =~
                     /\A(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(Z?)\z/) {
            $time = $7 ? timegm($6, $5, $4, $3, $2-1, $1-1900) :
                timelocal($6, $5, $4, $3, $2-1, $1-1900);
        } else {
            warn "norepeat: Invalid time in data file line $. '$row{time}', ".
                "ignored";
            next;
        }
        my @time = localtime($time);

        # check whether this is in the same period
        my $eop_time;
        if (!$Period) {
            $n++;
        } else {
            my ($t1, $t2) = $Calc_SEOP_Times->($time, \@time);
            $n++ if $t1 <= $Now && $t2 >= $Now;
        }
    }
    say "DEBUG: Have recorded $n execution(s) over the period" if $Debug;
    $Should_Run = $n < $Num;
}

sub record_cmd {
    print $FH "time:$Now\tkey:$Key\n";
    close $FH or do {
        warn "norepeat: Can't write data file '$Data_File': $!\n"; exit 98;
    };
}

sub run_cmd {
    do { warn "No command specified\n"; exit 99 } unless @ARGV;
    system @ARGV;
    $Exit_Code = $? >> 8;
}

# MAIN

$Now = $Debug ? ($ENV{NOW} // time()) : time();
@Now = localtime($Now);

parse_cmdline();
read_data_file();
unless ($Should_Run) {
    say "DEBUG: Skipped repeated execution" if $Debug;
    exit 0;
}
run_cmd();
if ($Exit_Code && !$Ignore_Failure) {
    exit $Exit_Code;
}
record_cmd();
exit $Exit_Code;

1;
# ABSTRACT: Run commands, but not repeatedly
# PODNAME: norepeat

__END__

=pod

=encoding UTF-8

=head1 NAME

norepeat - Run commands, but not repeatedly

=head1 VERSION

version 0.02

=head1 SYNOPSIS

Usage:

 % norepeat [NOREPEAT OPTIONS] -- [PROGRAM] [PROGRAM OPTIONS ...]

Below are some examples.

This will run C<somecmd>:

 % norepeat -- somecmd --cmdopt

This won't run command again:

 % norepeat -- somecmd --cmdopt

This will still run command because option is different:

 % norepeat -- somecmd --anotheropt

This won't repeat C<somecmd> with any argument:

 % norepeat --key somecmd -- somecomd --cmdopt othervalue   ; # won't repeat
 % norepeat --key somecmd -- somecomd --anotheropt          ; # won't repeat

This will repeat after 24 hours:

 % norepeat --period 24h -- ...

This will repeat after change of day (equals to once daily):

 % norepeat --period daily -- ...
 % norepeat --daily -- ...

This allows twice daily:

 % norepeat -n 2 --daily -- ...

=head1 DESCRIPTION

C<norepeat> allows you to avoid repeat execution of the same command. You can
customize the key (which command are considered the same, the default is the
whole command), the repeat period, and some other stuffs.

It works simply by recording the keys and timestamps in a data file (defaults to
C<~/norepeat.dat>, can be customized) after successful execution of commands.
Commands might still repeat if C<norepeat> fails to record to data file (e.g.
disk is full, permission problem).

C<norepeat> is typically used in shell startup scripts. I also use it with
L<Dist::Zilla::Plugin::Run> to run stuffs but not repeatedly.

Keywords: repeat interval, not too frequently, not more often than, at most,
once daily, weekly, monthly, yearly, period, limit rate.

=head1 OPTIONS

=over

=item * --key=STR, -k

Set key for determining which commands are considered the same. The default is
the whole command. Example:

 % norepeat -- cmd --opt1
 % norepeat -- cmd        ; # not considered the same command
 % norepeat -- cmd --opt2 ; # not considered the same command, different options
 % norepeat -- cmd2       ; # not considered the same command, obviously

But with --key:

 % norepeat -k cmd -- cmd --opt1
 % norepeat -k cmd -- cmd        ; # considered the same command, same key
 % norepeat -k cmd -- cmd --opt2 ; # considered the same command, same key
 % norepeat -k cmd -- whatever   ; # even this is considered the same command

=item * --period=STR, -p

Set maximum period for repeat detection. The default is forever (never repeat if
the same command has ever been executed). Can either be number +
{sec,min,hour,day,month,year}, or {hourly,daily,monthly,yearly}.

When comparing, local times will be used.

=item * --ignore-failure

By default, if command exits with non-zero status, it is assumed to be a failure
and won't be recorded in the data file. Another invocation will be allowed to
repeat. This option will disregard exit status and will still log the data file.

=item * --data-file, -f

Set filename to record execution to. Defaults to C<~/norepeat.dat>.

=item * --num=INT, -n

Allow (n-1) repeating during the same period. Defaults to 1, which means to not
allow any repeats. 2 means allow repeat once (for a total of 2 executions).

=back

=head1 EXIT CODES

0 on success.

0 on skipping repeated execution.

98 on failure to record to data file.

99 on command-line options error.

Otherwise exit code from command is returned.

=head1 DATA FILE

Data file is a line-oriented text file, using labeled tab-separated value format
(L<http://ltsv.org/>). Each row contains these labels: C<time> (a timestamp
either in the format of UTC ISO8601C<YYYY-MM-DDTHH:MM:SSZ>, local ISO8601
C<YYYY-MM-DDTHH:MM:SS>, or Unix timestamp), C<key> (tabs and newlines will be
converted to spaces).

The rows are assumed to be sorted chronologically (increasing time).

=head1 TODO

More proper date calculation.

Weekly period and C<n weeks> interval.

=head1 SEE ALSO

Unix cron facility for periodic/scheduling of execution.

Related: modules to limit the number of program instances that can run at a
single time: L<Proc::Govern>, L<Sys::RunAlone>.

=head1 HOMEPAGE

Please visit the project's homepage at L<https://metacpan.org/release/App-norepeat>.

=head1 SOURCE

Source repository is at L<https://github.com/sharyanto/perl-App-norepeat>.

=head1 BUGS

Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=App-norepeat>

When submitting a bug or request, please include a test-file or a
patch to an existing test-file that illustrates the bug or desired
feature.

=head1 AUTHOR

Steven Haryanto <stevenharyanto@gmail.com>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2014 by Steven Haryanto.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
