#!/usr/bin/perl
# vim: set filetype=perl :
use strict;
use warnings;
use 5.010;
use English qw( -no_match_vars );
use Carp;
use autodie;
use Run::Parts;

main() unless caller(0);

sub main {
    use Pod::Usage;
    use Getopt::Long qw( :config pass_through no_auto_abbrev );

    my $help;
    my $pass_through_options;
    my $show_cmdline;
    my $force_install;
    my $config_dir = '/etc/aptitude-robot';
    GetOptions(
        'help'          => \$help,
        'show-cmdline'  => \$show_cmdline,
        'force-install' => \$force_install,
        'config-dir=s'  => \$config_dir,
    );
    pod2usage('-verbose' => 2, '-exit_status' => 0) if $help;

    my $aptitude_robot = Aptitude::Robot::Command->new(
        config_dir           => $config_dir,
        force_install        => $force_install,
        pass_through_options => \@ARGV,
    );
    die $aptitude_robot->error_msg() . "\n" if $aptitude_robot->error_msg();

    _run_triggers("$config_dir/triggers.pre", $show_cmdline);

    my @command = $aptitude_robot->command();
    if ($show_cmdline) {
        say "'" . join("' '", @command) . "'";
    }
    else {
        system(@command);
        if ($CHILD_ERROR == -1) {
            die "aptitude failed to execute: $OS_ERROR\n";
        }
        elsif ($CHILD_ERROR) {
            my $exit_code = ($CHILD_ERROR >> 8);
            printf STDERR "aptitude exited with value %d\n", $exit_code;
            exit $exit_code;
        }
    }

    _run_triggers("$config_dir/triggers.post", $show_cmdline);
}

sub _run_triggers {
    return unless @_;
    my ($triggers, $show_cmdline) = @_;

    if ( -d $triggers ) {
        my $rp = Run::Parts->new($triggers);
        if ($show_cmdline) {
            say $rp->test;
        } else {
            $rp->run;
        }
    }
    return;
}

BEGIN{
package Aptitude::Robot::Command;
use Moo;

has( 'config_dir' => (
        is       => 'ro',
        required => 1,
    )
);
has( 'pass_through_options' => (
        is       => 'ro',
        required => 0,
    )
);
has( 'force_install' => (
        is       => 'rw',
        default  => sub { return 0; },
    )
);
has( 'error_msg'  => (
        is       => 'ro',
        init_arg => undef,
        default  => sub {
            my $self = shift;
            if ( -d $self->config_dir() . '/pkglist.d/.' ) {
                return '';
            }
            else {
                return 'Error: ' . $self->config_dir . ' is not a aptitude-robot config directory';
            }
        },
    )
);

sub command {
    my $self = shift;
    return () if $self->error_msg();

    my %pkg_action = ();
    for my $line ($self->run_parts_lines('pkglist.d')) {
        my ($action, $pkg) = split(/\s+/, $line, 2);

        # ignore extra ':' and '=' entries on force-install
        next if
            $self->force_install()        and
            defined($pkg_action{$pkg})    and
            ($action eq ':' or $action eq '=');

        $pkg_action{$pkg} = $action;
    }
    my @cmd = ( 'aptitude' );
    push(@cmd, @{$self->pass_through_options()}) if $self->pass_through_options();
    push(@cmd, $self->options(), 'full-upgrade', '~U !~ahold' );
    for my $pkg (sort keys %pkg_action) {
        push(@cmd, $pkg . $pkg_action{$pkg});
    }
    return @cmd;
}

sub options {
    my $self = shift;
    return () if $self->error_msg();

    return
        _split_parameters($self->run_parts_lines('options.d'));
}

sub run_parts_lines {
    my ($self, $parts_dir) = @_;
    return () if $self->error_msg();

    my $full_path = $self->config_dir() . "/$parts_dir";
    return () unless -d $full_path;

    my $rp = Run::Parts->new($full_path);
    return _clean_comments_and_whitespace( $rp->concat );
}

sub _clean_comments_and_whitespace {
    my (@lines) = @_;

    for my $line (@lines) {
        $line =~ s{\# .* \Z}{}xms; # remove comments
        $line =~ s{\A \s+}{}xms; # remove leading space
        $line =~ s{\s+ \Z}{}xms; # remove trailing space
    }
    return grep { $_ ne '' } @lines; # non-empty lines only
}

sub _split_parameters {
    my (@lines) = @_;

    my @parameters = ();
    for my $line (@lines) {
        push(@parameters, split(qr(=|\s+), $line, 2));
    }
    return @parameters;
}

no Moo;

1;
}

__END__

=head1 NAME

aptitude-robot - automate package choice management

=head1 SYNOPSIS

aptitude-robot [options]

aptitude-robot --help

=head1 DESCRIPTION

aptitude-robot uses configuration files to install and remove Debian software
packages automatically.  This allows hands-off setup and maintenance of
workstations and servers.  Create package lists in a development environment
and copy the package lists over to the production machines.  aptitude-robot
will then make sure that the packages mentioned in the lists are installed or
removed as indicated.

=head1 OPTIONS

=over 4

=item B<--force-install>

give priority to install(+), remove(-), and purge(_) actions over keep(:) and hold(=)

=item B<--config-dir F</path/to/config/dir>>

specify an alternate configuration directory.  Defaults to F</etc/aptitude-robot>

=item B<--show-cmdline>

only show the command that would be executed

=item B<--help>

Prints this page.

=item I<other options>

any option not recognized by aptitude-robot itself is passed through to
aptitude.  The options B<-q> and B<-s> are likely to be used occasionally.  See
L<aptitude(8)> for details.

=back

=head1 CONFIGURATION

The configuration directory given by the C<--config-dir> (or
F</etc/aptitude-robot> by default) must contain a directory named F<pkglist.d>.
This directory may have several package list files that are chosen according to
the criteria of L<run-parts(8)>.  They are concatenated in the order given by
L<run-parts(8)>.

Each line shall contain an action and a package name separated by white space.
The actions are those used by the C<aptitude> command line interface (see
aptitude documentation).  Typically they are:

I<+  package  # install package>

I<+M package  # install package and mark it as automatically installed>

I<-  package  # remove package>

I<_  package  # purge package>

I<:  package  # keep package at current version>

I<=  package  # mark a package as "hold", i.e., prevent automatic updates>

If a package is mentioned several times the last entry will determine the
action.  If the C<--force-install> option is given the keep(:) and hold(=)
actions are given lower priority, i.e., the appear only when no other action
for the package is specified.

A C<#> character starts a comment extending to the end of line.  Empty lines or
extra white space at the beginning or end of line is ignored.

Optionally the configuration directory may contain a file name F<options>
containing extra options given to aptitude in the form of one option per line.

Optionally the configuration directory may contain two directories
F<triggers.pre> and F<triggers.post> with scripts that are executed by
L<run-parts(8)> before and after aptitude respectively.

=head1 SEE ALSO

L<aptitude(8)>, L<aptitude-robot-session(8)>

=head1 AUTHORS

Elmar S. Heeb <elmar@heebs.ch> and Axel Beckert <abe@debian.org>

=cut
