#!perl

use 5.010001;
use strict;
use warnings;

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

our $VERSION = '0.04'; # 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,
        'hourly'         => sub { $Period = 'hourly' },
        'daily'          => sub { $Period = 'daily' },
        'weekly'         => sub { $Period = 'weekly' },
        'monthly'        => sub { $Period = 'monthly' },
        'yearly'         => sub { $Period = 'yearly' },
        '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?|w|weeks?|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(w|weeks?)\z/       ? 7*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 'weekly') {
            $Calc_SEOP_Times = sub {
                my ($t, $e) = @_;
                # week starts on sunday (wday=0), so ends on the next sunday
                my $wday = $e->[6];
                my $t2 = $t + (7-$wday)*24*3600;
                my $e2 = [localtime $t2];
                (
                    timelocal( 0,  0,  0, $e->[3] , $e->[4] , $e->[5] ),
                    timelocal( 0,  0,  0, $e2->[3], $e2->[4], $e2->[5])-1,
                );
            };
        } 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);
            say "DEBUG: period = [".localtime($t1)." to [".localtime($t2)."]"
                if $Debug;
            $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;
    say "DEBUG: Executing command: ".join(" ", @ARGV) if $Debug;
    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

This document describes version 0.04 of norepeat (from Perl distribution App-norepeat), released on 2014-10-07.

=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}" to express elapsed period after the last
execution, or "{hourly,daily,monthly,yearly}" to express no repetition before
the named period (hour,day,month,year) changes.

For example, if period is "2 hour" then subsequent invocation won't repeat
commands until 2 hours have elapsed. In other words, command won't repeat until
the next 2 hours. Note that a month is defined as 30.5 days and a year is
defined as 365.25 days.

If period is "monthly", command won't repeat execution until the month changes
(e.g. from June to July). If you execute the first command on June 3rd, command
won't repeat until July 1st. The same thing would happen if you first executed
the command on June 30th.

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).

=item * --hourly

Shortcut for C<--period hourly>.

=item * --daily

Shortcut for C<--period daily>.

=item * --weekly

Shortcut for C<--period weekly>.

=item * --monthly

Shortcut for C<--period monthly>.

=item * --yearly

Shortcut for C<--period yearly>.

=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 ENVIRONMENT

=head2 DEBUG => bool

If set to true will show debugging messages.

=head2 NOW => int

Set arbitrary time as the current time. Useful for debugging, and will only take
effect under C<DEBUG>.

=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

=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/perlancar/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

perlancar <perlancar@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2014 by perlancar@cpan.org.

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
