#!/usr/bin/perl

# Copyright © 1998 Richard Braakman
# Copyright © 2008 Frank Lichtenheld
# Copyright © 2008, 2009 Russ Allbery
# Copyright © 2014 Niels Thykier
# Copyright © 2020 Felix Lechner
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, you can find it on the World Wide
# Web at http://www.gnu.org/copyleft/gpl.html, or write to the Free
# Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston,
# MA 02110-1301, USA.

# The harness for Lintian's test suite.  For detailed information on
# the test suite layout and naming conventions, see t/tests/README.
# For more information about running tests, see
# doc/tutorial/Lintian/Tutorial/TestSuite.pod
#

use v5.20;
use warnings;
use utf8;

use Const::Fast;
use Cwd qw(realpath);
use File::Basename qw(dirname);

# neither Path::This nor lib::relative are in Debian
use constant THISFILE => realpath __FILE__;
use constant THISDIR => dirname realpath __FILE__;

# use Lintian modules that belong to this program
use lib THISDIR . '/../lib';

use Capture::Tiny qw(capture_merged);
use Cwd qw(getcwd);
use File::Copy;
use File::Find::Rule;
use File::Path qw(make_path);
use File::Spec::Functions qw(abs2rel rel2abs splitpath splitdir);
use File::stat;
use Getopt::Long;
use IPC::Run3;
use List::Compare;
use List::SomeUtils qw(any uniq);
use List::Util qw(max);
use IO::Interactive qw(is_interactive);
use IO::Prompt::Tiny qw(prompt);
use MCE::Loop;
use Path::Tiny;
use Syntax::Keyword::Try;
use TAP::Formatter::Console;
use TAP::Formatter::File;
use TAP::Harness;
use TAP::Parser::Aggregator;
use Term::ANSIColor;
use Time::Duration;
use Time::Moment;
use Time::Piece;
use Unicode::UTF8 qw(encode_utf8 decode_utf8);

use Lintian::IPC::Run3 qw(safe_qx);

use Test::Lintian::Build qw(build_subject);
use Test::Lintian::ConfigFile qw(read_config);
use Test::Lintian::Filter
  qw(find_selected_scripts find_selected_lintian_testpaths);
use Test::Lintian::Helper
  qw(rfc822date cache_dpkg_architecture_values get_latest_policy get_recommended_debhelper_version);
use Test::Lintian::Hooks qw(sed_hook sort_lines calibrate);
use Test::Lintian::Prepare qw(filleval prepare);
use Test::Lintian::Run qw(logged_runner);
use Test::ScriptAge qw(perl_modification_epoch our_modification_epoch);

const my $EMPTY => q{};
const my $SPACE => q{ };
const my $INDENT => $SPACE x 4;
const my $NEWLINE => qq{\n};
const my $SLASH => q{/};
const my $COMMA => q{,};
const my $COLON => q{:};
const my $ARROW => q{>>>};
const my $YES => q{yes};
const my $NO => q{no};

const my $WIDELY_READABLE => oct(22);

# display output immediately
STDOUT->autoflush;

# something changes the default handler, see Bug#974575
$SIG{WINCH} = 'DEFAULT';

# see https://stackoverflow.com/a/60761593
$SIG{CHLD} ||= 'DEFAULT';
$SIG{HUP} ||= 'DEFAULT';

my $processing_start = Time::Moment->from_string(gmtime->datetime . 'Z');

# whitelist the environment we permit to avoid things that mess up
# tests, like CFLAGS, DH_OPTIONS, DH_COMPAT, DEB_HOST_ARCH
my %PRESERVE_ENV = map { $_ => 1 } qw(
  LINTIAN_TEST_INSTALLED
  PATH
  TMPDIR
);

my @disallowed = grep { !exists $PRESERVE_ENV{$_} } keys %ENV;

delete $ENV{$_} for @disallowed;

if (($ENV{LINTIAN_TEST_INSTALLED} // 'no') eq 'yes') {

    $ENV{LINTIAN_UNDER_TEST} = realpath('/usr/bin/lintian')
      // die encode_utf8('Lintian is not installed');

} else {
    $ENV{LINTIAN_UNDER_TEST} = realpath(THISDIR . '/../bin/lintian');
}

$ENV{LINTIAN_BASE}
  = realpath(dirname(dirname($ENV{LINTIAN_UNDER_TEST})))
  // die encode_utf8('Cannot resolve LINTIAN_BASE');

# options
my $coverage;
my $debug;
my $dump_logs = 1;
my $force_rebuild;
my $numjobs;
my $keep_going;
my $onlyrun;
my $outpath;
my $unattended;
my $verbose = 0;

Getopt::Long::Configure('bundling');
unless (
    Getopt::Long::GetOptions(
        'B|force-rebuild'  => \$force_rebuild,
        'c|coverage:s'     => \$coverage,
        'd|debug+'         => \$debug,
        'j|jobs:i'         => \$numjobs,
        'k|keep-going'     => \$keep_going,
        'L|dump-logs!'     => \$dump_logs,
        'o|onlyrun:s'      => \$onlyrun,
        'u|unattended'     => \$unattended,
        'v|verbose'        => \$verbose,
        'w|work-dir:s'     => \$outpath,
        'h|help'           => sub {usage(); exit;},
    )
) {
    usage();
    die;
}

# check number of arguments
die encode_utf8('Please use -h for usage information.')
  if @ARGV > 1;

# get arguments
my ($testset) = @ARGV;

# default test set
$testset ||= 't';

# check test set directory
die encode_utf8("Cannot find testset directory $testset")
  unless -d $testset;

# make sure testset is an absolute path
$testset = rel2abs($testset);

# calculate a default test work directory if none given
$outpath ||= dirname($testset) . '/debian/test-out';

# create test work directory unless it exists
make_path($outpath)
  unless -e $outpath;

# make sure test work path is a directory
die encode_utf8("Test work directory $outpath is not a directory")
  unless -d $outpath;

# make sure outpath is absolute
$outpath = rel2abs($outpath);

my $ACTIVE_JOBS = 0;

# get lintian modification date
my @lintianparts
  = ('checks', 'commands', 'data','bin', 'profiles', 'vendors', 'lib/Lintian');
my @lintianfiles
  = map { File::Find::Rule->file->in("$ENV{'LINTIAN_BASE'}/$_") }@lintianparts;
push(@lintianfiles, Cwd::realpath($ENV{'LINTIAN_UNDER_TEST'}));
$ENV{'LINTIAN_EPOCH'}
  = max(map { -e $_ ? stat($_)->mtime : time } @lintianfiles);
say encode_utf8('Lintian modified on '. rfc822date($ENV{'LINTIAN_EPOCH'}));

my $lintian_error;
my $bytes = capture_merged {
    my @command = ($ENV{'LINTIAN_UNDER_TEST'}, '--version');
    system(@command) == 0
      or $lintian_error = "system @command failed: $?";
};
my $string = decode_utf8($bytes);
die encode_utf8($string . $lintian_error)
  if length $lintian_error;

chomp $string;
my ($version) = $string =~ qr/^\S+\s+v(.+)$/;
die encode_utf8('Cannot get Lintian version') unless length $version;
say encode_utf8("Version under test is $version.");

say encode_utf8($EMPTY);

# set environment for coverage
if (defined $coverage) {
    # Only collect coverage for stuff that D::NYTProf and
    # Test::Pod::Coverage cannot do for us.  This makes cover use less
    # RAM in the other end.
    my @criteria = qw(statement branch condition path subroutine);
    my $args= '-MDevel::Cover=-silent,1,+ignore,^(.*/)?t/scripts/.+';
    $args .= ',+ignore,/usr/bin/.*,+ignore,(.*/)?Dpkg';
    $args .= ',-coverage,' . join(',-coverage,', @criteria);
    $args .= $COMMA . $coverage if $coverage ne $EMPTY;
    $ENV{'LINTIAN_COVERAGE'} = $args;

    $ENV{'HARNESS_PERL_SWITCHES'} //= $EMPTY;
    $ENV{'HARNESS_PERL_SWITCHES'} .= $SPACE . $args;
}

# Devel::Cover + one cover_db + multiple processes is a recipe
# for corruptions.  Force $numjobs to 1 if we are running under
# coverage.
$numjobs = 1 if exists $ENV{'LINTIAN_COVERAGE'};

# tie verbosity to debug
$verbose = 1 + $debug if $debug;

# can be 0 without value ("-j") or undef if option was not specified at all
$numjobs ||= default_parallel();
say encode_utf8("Running up to $numjobs tests concurrently")
  if $numjobs > 1 && $verbose >= 2;

$ENV{'DUMP_LOGS'} = $dump_logs//$NO ? $YES : $NO;

# Disable translation support in dpkg as it is a considerable
# unnecessary overhead.
$ENV{'DPKG_NLS'} = 0;

my $helperpath = "$testset/../private";
if (-d $helperpath) {
    my $helpers = rel2abs($helperpath)
      // die encode_utf8("Cannot resolve $helperpath: $!");
    $ENV{'PATH'} = "$helpers:$ENV{'PATH'}";
}

# get architecture
cache_dpkg_architecture_values();
say encode_utf8("Host architecture is $ENV{'DEB_HOST_ARCH'}.");

# get latest policy version and date
($ENV{'POLICY_VERSION'}, $ENV{'POLICY_EPOCH'}) = get_latest_policy();
say encode_utf8("Latest policy version is $ENV{'POLICY_VERSION'} from "
      . rfc822date($ENV{'POLICY_EPOCH'}));

# get current debhelper compat level; do not name DH_COMPAT; causes conflict
$ENV{'DEFAULT_DEBHELPER_COMPAT'} = get_recommended_debhelper_version();
say encode_utf8(
"Using compat level $ENV{'DEFAULT_DEBHELPER_COMPAT'} as a default for packages built with debhelper."
);

# get harness date, including templates, skeletons and whitelists
my @harnessparts
  = ('bin', 't/defaults', 't/templates', 't/skeletons', 't/whitelists');
my @harnessfiles
  = map { File::Find::Rule->file->in("$ENV{'LINTIAN_BASE'}/$_") }@harnessparts;
my $harness_files_epoch
  = max(map { -e $_ ? stat($_)->mtime : time } @harnessfiles);
$ENV{'HARNESS_EPOCH'}
  = max(our_modification_epoch, perl_modification_epoch, $harness_files_epoch);
say encode_utf8('Harness modified on '. rfc822date($ENV{'HARNESS_EPOCH'}));

say encode_utf8($EMPTY);

# print environment
my @vars = sort keys %ENV;
say encode_utf8('Environment:') if @vars;
for my $var (@vars) { say encode_utf8($INDENT . "$var=$ENV{$var}") }

say encode_utf8($EMPTY);

my $status = 0;

my $formatter = TAP::Formatter::File->new({
    errors => 1,
    jobs => $numjobs,
});
$formatter = TAP::Formatter::Console->new({
        errors => 1,
        jobs => $numjobs,
        color => 1,
    }) if is_interactive;

my $harness = TAP::Harness->new({
    formatter => $formatter,
    jobs => $numjobs,
    lib => ["$ENV{'LINTIAN_BASE'}/lib"],
});

my $aggregator = TAP::Parser::Aggregator->new;
$aggregator->start;

my @runscripts;
my $allscripts_path = "$testset/scripts";

# add selected scripts
push(@runscripts, find_selected_scripts($allscripts_path, $onlyrun));

# always add internal harness tests
my @requiredscripts;
@requiredscripts
  = sort File::Find::Rule->file()->name('*.t')->in("$allscripts_path/harness")
  unless length $onlyrun;
push(@runscripts, @requiredscripts);

# remove any duplicates
@runscripts = uniq @runscripts;

# make all paths relative
@runscripts = map { abs2rel($_) } @runscripts;

say encode_utf8('Running selected and required Perl test scripts.');
say encode_utf8($EMPTY);

# run scripts through harness
$harness->aggregate_tests($aggregator, sort @runscripts);

if (@runscripts && !$aggregator->all_passed && !$keep_going) {
    $aggregator->stop;
    $formatter->summary($aggregator);
    exit 1;
}

say encode_utf8($EMPTY);

my @testpaths = find_selected_lintian_testpaths($testset, $onlyrun);

my $recipe_root = "$testset/recipes";

# find test paths
my @recipes = map { path($_)->relative($recipe_root)->stringify }@testpaths;

# prepare output directories
say encode_utf8(
    'Preparing the sources for '. scalar @recipes. ' test packages.')
  if @recipes;

# for filled templates
my $source_root = "$outpath/package-sources";

# for built test packages
my $build_root = "$outpath/packages";

# find build specifications
my @all_recipes = map { path($_)->parent->stringify }
  sort File::Find::Rule->relative->name('build-spec')->in($recipe_root);

my @source_paths
  = map { path($_)->absolute($source_root)->stringify } @all_recipes;
my @build_paths
  = map { path($_)->absolute($build_root)->stringify } @all_recipes;

# remove obsolete package sources
my @found_sources = map { path($_)->parent->absolute->stringify; }
  File::Find::Rule->file->name('fill-values')->in($source_root);
my $sourcelc = List::Compare->new(\@found_sources, \@source_paths);
my @obsolete_sources = $sourcelc->get_Lonly;
path($_)->remove_tree for @obsolete_sources;

# remove obsolete built packages
my @found_builds = map { path($_)->parent->absolute->stringify; }
  File::Find::Rule->file->name('source-files.sha1sums')->in($build_root);
my $packagelc= List::Compare->new(\@found_builds, \@build_paths);
my @obsolete_builds = $packagelc->get_Lonly;
path($_)->remove_tree for @obsolete_builds;

# remove empty directories
for my $folder (@obsolete_sources, @obsolete_builds) {
    my $candidate = path($folder)->parent;
    while ($candidate->exists && !$candidate->children) {
        rmdir $candidate->stringify;
        $candidate = $candidate->parent;
    }
}

$ENV{PERL_PATH_TINY_NO_FLOCK} =1;

$SIG{INT} = sub { MCE::Loop->finish; die encode_utf8("Caught a sigint $!") };
my $mce_loop = MCE::Loop->init(
    max_workers => $numjobs,
    chunk_size => 1,
    flush_stdout => 1,
    flush_stderr => 1,
);

my %failedprep = mce_loop {
    my ($mce, $chunk_ref, $chunk_id) = @_;

    prepare_build($mce, $_);
}
@recipes;

if (%failedprep) {
    say encode_utf8($EMPTY);
    say encode_utf8('Failed preparation tasks:');
    for my $recipe (sort keys %failedprep) {
        say encode_utf8($EMPTY);
        say encode_utf8($ARROW
              . $SPACE
              . path("$recipe_root/$recipe")->relative->stringify
              . $COLON);
        print encode_utf8($failedprep{$recipe});
    }

    MCE::Loop->finish;
    exit 1;

} else {
    say encode_utf8('Package sources are ready.');
}

say encode_utf8($EMPTY);

my %failedbuilds = mce_loop {
    my ($mce, $chunk_ref, $chunk_id) = @_;

    build_package($mce, $_, $chunk_id, scalar @recipes);
}
@recipes;

$SIG{INT} = 'DEFAULT';
MCE::Loop->finish;

if (%failedbuilds) {
    say encode_utf8($EMPTY);
    say encode_utf8('Failed build tasks:');
    for my $recipe (sort keys %failedbuilds) {
        say encode_utf8($EMPTY);
        say encode_utf8($ARROW
              . $SPACE
              . path("$recipe_root/$recipe")->relative->stringify
              . $COLON);
        print encode_utf8($failedbuilds{$recipe});
    }

    exit 1;
} else {
    say encode_utf8('All test packages are up to date.');
}

say encode_utf8($EMPTY);

my $build_end = Time::Moment->from_string(gmtime->datetime . 'Z');
my $build_duration = duration($processing_start->delta_seconds($build_end));
say encode_utf8("Building the test packages took $build_duration.");

say encode_utf8($EMPTY);

# for built test packages
my $buildroot = "$outpath/packages";

# for built test packages
my $evalroot = "$outpath/eval";

$SIG{INT} = sub { MCE::Loop->finish; die encode_utf8("Caught a sigint $!") };

mce_loop {
    my ($mce, $chunk_ref, $chunk_id) = @_;

    prepare_test($mce, $_);
}
sort @testpaths;

MCE::Loop->finish;

$SIG{INT} = 'DEFAULT';

# remap paths from testset to outpath to get work directories
my @workpaths
  = map { rel2abs(abs2rel($_, "$testset/recipes"), "$outpath/eval") }
  @testpaths;

# if ($platforms ne 'any') {
#     my @wildcards = split(/$SPACE/, $platforms);
#     my @matches= map {
#         decode_utf8(qx{dpkg-architecture -a $ENV{'DEB_HOST_ARCH'} -i $_; echo -n \$?})
#     } @wildcards;
#     unless (any { $_ == 0 } @matches) {
#         say encode_utf8('Architecture mismatch');
#         return;
#     }
# }

# make all paths relative to current directory
@workpaths = map { path($_)->relative } @workpaths;

# add the scripts in generated tests to be run
my @workscripts;
for my $path (@workpaths) {

    my @runners = File::Find::Rule->file->name('*.t')->in($path);

    die encode_utf8("No runner in $path")
      unless scalar @runners;
    die encode_utf8("More than one runner in $path")
      if scalar @runners > 1;

    push(@workscripts, @runners);
}

# run scripts through harness
$harness->aggregate_tests($aggregator, sort @workscripts);

$aggregator->stop;
$formatter->summary($aggregator);

say encode_utf8($EMPTY);

my $test_end = Time::Moment->from_string(gmtime->datetime . 'Z');
my $test_duration = duration($processing_start->delta_seconds($test_end));
say encode_utf8("The test suite ran for $test_duration.");

$status = 1
  unless $aggregator->all_passed;

if (is_interactive && !$unattended) {
    my @failed = $aggregator->failed;
    say encode_utf8(
        'Offering to re-calibrate the hints expected in tests that failed.')
      if @failed;

    my $accept_all;

    for my $scriptpath (@failed) {
        my $workpath = dirname($scriptpath);

        my $descpath = "$workpath/desc";
        my $testcase = read_config($descpath);

        my $relative = abs2rel($workpath, $evalroot);
        my $testpath = abs2rel(rel2abs($relative, "$testset/recipes"));

        say encode_utf8($EMPTY);
        say encode_utf8(
            'Failed test: ' . colored($testpath, 'bold white on_blue'));

        my $match_strategy = $testcase->unfolded_value('Match-Strategy');

        if ($match_strategy eq 'hints') {

            my $diffpath = "$workpath/hintdiff";
            next
              unless -r $diffpath;

            my $diff = path($diffpath)->slurp_utf8;
            print encode_utf8($diff);

        } elsif ($match_strategy eq 'literal') {

            my $actualpath = "$workpath/literal.actual.parsed";
            next
              unless -r $actualpath;
            my @command
              = ('diff', '-uN', "$testpath/eval/literal", $actualpath);
            say encode_utf8(join($SPACE, @command));
            system(@command);

        } else {
            say encode_utf8(
"Do not know how to fix tests using matching strategy $match_strategy."
            );
            next;
        }

        unless ($accept_all) {

            my $decision_bytes = prompt(
                encode_utf8(
'>>>  Fix test (y), accept all (a), do not fix (n), quit (q/default)?'
                ));
            my $decision = decode_utf8($decision_bytes);

            last
              if $decision eq 'q' || $decision eq $EMPTY;

            next
              unless $decision eq 'y' || $decision eq 'a';

            $accept_all = 1
              if $decision eq 'a';
        }

        if ($match_strategy eq 'hints') {

            # create hints if needed; helps when writing new tests
            my $hintspath = "$testpath/eval/hints";
            path($hintspath)->touch
              unless -e $hintspath;

            my $diffpath = "$workpath/hintdiff";
            next
              unless -r $diffpath;

            my @adjustargs = ($diffpath, $hintspath);
            unshift(@adjustargs, '-i')
              unless $accept_all;

            die encode_utf8("Cannot run hintadjust for $testpath")
              if system('hintadjust', @adjustargs);

            # also copy the new hints to workpath; no need to rebuild
            die encode_utf8("Cannot copy updated hints to $workpath")
              if system('cp', $hintspath, "$workpath/hints");

        } elsif ($match_strategy eq 'literal') {

            my $actualpath = "$workpath/literal.actual.parsed";
            next
              unless -r $actualpath;

            die encode_utf8(
                "Cannot copy to accept literal output for $testpath")
              if system('cp', $actualpath, "$testpath/eval/literal");

        }
    }

    say encode_utf8($NEWLINE . 'Accepted all remaining hint changes.')
      if $accept_all;

} else {
    my @crashed = $aggregator->parse_errors;

    say encode_utf8('Showing full logs for tests with parse errors.')
      if @crashed;

    for my $absolutepath (@crashed) {

        my $scriptpath = abs2rel($absolutepath);
        my $workpath = dirname($scriptpath);
        my $logpath = "$workpath/log";

        next
          unless -e $logpath;

        say encode_utf8($EMPTY);
        say encode_utf8("Log for test $scriptpath:");

        my $log = path($logpath)->slurp_utf8;
        print encode_utf8($log);
    }
}

# give a hint if not enough tests were run
unless (scalar @runscripts - scalar @requiredscripts + scalar @workscripts
    || $onlyrun eq 'minimal:') {
    quick_hint($onlyrun);
    exit 1;
}

say encode_utf8($EMPTY);

exit $status;

# program is done

sub prepare_build {
    my ($mce, $recipe) = @_;

    # label process
    $0 = "Lintian prepare test: $recipe";

    # destination
    my $source_path = "$source_root/$recipe";

    my $error;

    # capture output
    my $log_bytes =capture_merged {

        try {

            # remove destination
            path($source_path)->remove_tree
              if -e $source_path;

            # prepare
            prepare("$recipe_root/$recipe/build-spec",
                $source_path, $testset, $force_rebuild);

        } catch {
            # catch any error
            $error = $@;
        }
    };

    my $log = decode_utf8($log_bytes);

    # save log;
    my $logfile = "$source_path.log";
    path($logfile)->spew_utf8($log) if $log;

    $mce->gather($recipe, $error)
      if length $error;

    return;
}

sub build_package {
    my ($mce, $recipe, $position, $total) = @_;

    # set a predictable locale
    $ENV{'LC_ALL'} = 'C';

    # many tests create files via debian/rules
    umask $WIDELY_READABLE;

    # get destination
    my $source_path = "$source_root/$recipe";
    my $build_path = "$build_root/$recipe";

    my $savedir = getcwd;
    chdir $source_path
      or die encode_utf8("Cannot change to directory $source_path");

    my $sha1sums_bytes;
    run3('find . -type f -print0 | sort -z | xargs -0 sha1sum',
        \undef, \$sha1sums_bytes);

    chdir $savedir
      or die encode_utf8("Cannot change to directory $savedir");

    my $sha1sums = decode_utf8($sha1sums_bytes);

    my $checksum_path = "$build_path/source-files.sha1sums";
    if (-r $checksum_path) {
        my $previous = path($checksum_path)->slurp_utf8;

        # only rebuild if needed
        # also need to look for build subject
        return
          if $sha1sums eq $previous;
    }

    $0 = "Lintian build test: $recipe [$position/$total]";
    say encode_utf8('Building in '
          . path($build_path)->relative->stringify
          . " [$position/$total]");

    path($build_path)->remove_tree
      if -e $build_path;
    path($build_path)->mkpath;

    # read dynamic file names
    my $runfiles = "$source_path/files";
    my $files = read_config($runfiles);

    my $error;

    my $log_bytes = capture_merged {

        try {
            # call runner
            build_subject($source_path, $build_path);

        } catch {
            # catch any error
            $error = $@;
        }
    };

    my $log = decode_utf8($log_bytes);

    # delete old runner log
    my $betterlogpath= $build_path . $SLASH . $files->unfolded_value('Log');
    if (-e $betterlogpath) {
        unlink $betterlogpath
          or die encode_utf8("Cannot unlink $betterlogpath");
    }

    # move the early log for directory preparation to position of runner log
    my $earlylogpath = "$source_path.log";
    move($earlylogpath, $betterlogpath) if -e $earlylogpath;

    # append runner log to population log
    path($betterlogpath)->append_utf8($log) if length $log;

    # add error if there was one
    path($betterlogpath)->append_utf8($error) if length $error;

    path($checksum_path)->spew_utf8($sha1sums)
      unless length $error;

    $mce->gather(path($build_path)->relative->stringify, $error . $log)
      if length $error;

    return;
}

sub prepare_test {
    my ($mce, $specpath) = @_;

    # label process
    $0 = "Lintian prepare test: $specpath";

    # calculate destination
    my $relative = path($specpath)->relative("$testset/recipes");
    my $buildpath = $relative->absolute($buildroot)->stringify;
    my $evalpath = $relative->absolute($evalroot)->relative->stringify;

    my $error;

    # capture output
    my $log_bytes = capture_merged {

        try {

            # remove destination
            path($evalpath)->remove_tree
              if -e $evalpath;

            path($evalpath)->mkpath;

            # prepare
            filleval("$specpath/eval", $evalpath, $testset);

            my $traversal = Cwd::realpath("$buildpath/subject");

            if (length $traversal) {
                die encode_utf8("Cannot link to subject in $buildpath")
                  if system("cd $evalpath; ln -s $traversal subject");
            }

        }catch {
            # catch any error
            $error = $@;
        }
    };

    my $log = decode_utf8($log_bytes);

    # save log;
    my $logfile = "$evalpath/log";
    path($logfile)->spew_utf8($log) if $log;

    # print something if there was an error
    die encode_utf8(
        ($log // $EMPTY) . "Preparation failed for $specpath: $error")
      if $error;

    return $specpath;
}

=item default_parallel

=cut

# Return the default number of parallelization to be used
sub default_parallel {
    # check cpuinfo for the number of cores...
    my $cpus = decode_utf8(safe_qx('nproc'));
    if ($cpus =~ m/^\d+$/) {
        # Running up to twice the number of cores usually gets the most out
        # of the CPUs and disks but it might be too aggressive to be the
        # default for -j. Only use <cores>+1 then.
        return $cpus + 1;
    }

    # No decent number of jobs? Just use 2 as a default
    return 2;
}

sub usage {
    my $message =<<"END";
Usage: $0 [options] [-j [<jobs>]] <testset-directory>

    --onlyrun   Select only some tests for a quick check
    --coverage  Run Lintian under Devel::Cover (Warning: painfully slow)
    -d          Display additional debugging information
    --dump-logs Print build log to STDOUT, if a build fails.
    -j [<jobs>] Run up to <jobs> jobs in parallel.
                If -j is passed without specifying <jobs>, the number
                of jobs started is <nproc>+1.
    -k          Do not stop after one failed test
    -v          Be more verbose
    --help, -h  Print this help and exit

    The option --onlyrun  causes runtests to only run tests that match
    the particular selection.  This parameter can be a list of selectors:
    what:<which>[,<what:...>]

      * test:<testname>
        - Run the named test. Please note that testnames may not be
          unique, so it may run more than one test.
      * script:(<script-name> || <dir-in-scripts-suite>)
        - Run the named code quality script or all in the named directory.
          E.g. "01-critic" will run all tests in "t/scripts/01-critic/".
      * check:<check-name>
        - Run all tests related to the given check.
      * suite:<suite>
        - Run all tests in the named suite.
      * tag:<tag-name>
        - Run any test that lists <tag-name> in "Test-For" or
          "Test-Against".

Test artifacts are cached in --work-dir [default: debian/test-out] and
will generally be reused to save time. To recreate the test packages,
run 'private/build-test-packages'.
END

    print encode_utf8($message);

    return;
}

sub quick_hint {
    my ($selection) = @_;

    my $message =<<"END";

No tests were selected by your filter:

    $selection

To select your tests, please use an appropriate argument with a
selector like:

    'suite:', 'test:', 'check:', 'tag:', or 'script:'

You can also use 'minimal:', which runs only the tests that cannot
be turned off, such as the internal tests for the harness.
END

    print encode_utf8($message);

    return;
}

# Local Variables:
# indent-tabs-mode: nil
# cperl-indent-level: 4
# End:
# vim: syntax=perl sw=4 sts=4 sr et
