The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package Test::CheckManifest;

# ABSTRACT: Check if your Manifest matches your distro

use strict;
use warnings;

use Cwd;
use Carp;
use File::Spec;
use File::Basename;
use Test::Builder;
use File::Find;

our $VERSION = '1.29';
our $VERBOSE = 1;

my $test      = Test::Builder->new();
my $test_bool = 1;
my $plan      = 0;
my $counter   = 0;

sub import {
    my $self   = shift;
    my $caller = caller;
    my %plan   = @_;

    for my $func ( qw( ok_manifest ) ) {
        no strict 'refs';
        *{$caller."::".$func} = \&$func;
    }

    $test->exported_to($caller);
    $test->plan(%plan);
    
    $plan = 1 if(exists $plan{tests});
}

sub ok_manifest{
    my ($hashref,$msg) = @_;
    
    $test->plan(tests => 1) unless $plan;
    
    my $is_hashref = 1;
    $is_hashref = 0 unless ref($hashref);
    
    unless ( $is_hashref ) {
        $msg = $hashref;
        $hashref = {};
    }

    my $tmp_path = dirname( File::Spec->rel2abs( $0 ) );

    if ( $hashref->{file} ) {
        $tmp_path = dirname $hashref->{file};
    }
    elsif ( $hashref->{dir} ) {
        $tmp_path = $hashref->{dir};
    }
    
    my $bool = 1;
    my $home = Cwd::realpath( $tmp_path );
    my $manifest;

    my $counter = 0;
    while ( 1 ) {
        my $manifest_path = File::Spec->catfile( $home . '/MANIFEST' );
        last if -f $manifest_path;

        my $tmp_home = Cwd::realpath( File::Spec->catdir( $home, '..' ) );

        last if !$tmp_home || $tmp_home eq $home || $counter++ == 20;
        $home = $tmp_home;
    }

    eval { $manifest = Cwd::realpath( $home . '/MANIFEST' ); 1; };
    if ( !$manifest ) {
        $test->BAILOUT( 'Cannot find a MANIFEST. Please check!' );
    }
    
    my $skip;
    my $skip_path = File::Spec->catfile( $home, 'MANIFEST.SKIP' );
    eval { $skip = Cwd::realpath( $skip_path ) if -f $skip_path; 1; };
    
    my @missing_files = ();
    my @files_plus    = ();
    my $arref         = ['/blib' , '/_build'];
    my $filter        = $is_hashref && 
                        $hashref->{filter} ? $hashref->{filter}  : [];
    my $comb          = $is_hashref && 
                        $hashref->{bool} && 
                        $hashref->{bool} =~ m/^and$/i ?
                               'and' :
                               'or'; 
                   
    push @$arref, @{$hashref->{exclude}} 
        if $is_hashref and exists $hashref->{exclude} and 
            ref($hashref->{exclude}) eq 'ARRAY';
    
    for(@$arref){
        croak 'path in excluded array must be "absolute"' unless m!^/!;
        my $path = $home . $_;
        next unless -e $path;
        $_ = Cwd::realpath($path);
    }
    
    @$arref = grep { defined }@$arref;
    
    unless( open my $fh, '<', $manifest ){
        $bool = 0;
        $msg  = "can't open $manifest";
    }
    else{
        { # extra block to use "last"
        
        my $files_in_skip = _read_skip( $skip, \$msg, \$bool );
        last unless $files_in_skip;

        my @files = _read_file( $fh );
        close $fh;
    
        chomp @files;
    
        {
            local $/ = "\r";
            chomp @files;
        }
    
        for my $tfile(@files){
            $tfile = (split(/\s{2,}/,$tfile,2))[0];
            next unless -e $home . '/' . $tfile;
            $tfile = Cwd::realpath($home . '/' . $tfile);
        }
    
        my (@dir_files,%files_hash,%excluded);
        @files_hash{@files} = ();
    
        find({no_chdir => 1,
            wanted   => sub{
                my $file         = $File::Find::name;
                my $is_excluded  = _is_excluded(
                    $file,
                    $arref,
                    $filter,
                    $comb,
                    $files_in_skip,
                    $home,
                );
                
                push(@dir_files,Cwd::realpath($file)) if -f $file and !$is_excluded;
                
                $excluded{$file} = 1 if -f $file and $is_excluded
            }
        },$home);

        #use Data::Dumper;
        #print STDERR ">>",++$counter,":",Dumper(\@files,\@dir_files);
        SFILE:
        for my $file(@dir_files){
            for my $check(@files){
                if($file eq $check){
                    delete $files_hash{$check};
                    next SFILE;
                }
            }
            push(@missing_files,$file);
            $bool = 0;
        }
    
        delete $files_hash{$_} for keys %excluded;
        @files_plus = sort keys %files_hash;
        $bool = 0 if scalar @files_plus > 0;
        
        } # close extra block
    }
    
    my $diag = 'The following files are not named in the MANIFEST file: '.
               join(', ',@missing_files);
    my $plus = 'The following files are not part of distro but named in the MANIFEST file: '.
               join(', ',@files_plus);
    
    $test->is_num($bool,$test_bool,$msg);
    $test->diag($diag) if scalar @missing_files >= 1 and $test_bool == 1 and $VERBOSE;
    $test->diag($plus) if scalar @files_plus    >= 1 and $test_bool == 1 and $VERBOSE;
}

sub _read_file {
    my ($fh) = @_;
    
    my @files;
    my $selftest = 0;

    while( my $fh_line = <$fh> ){
        chomp $fh_line;
        
        $selftest++ if $fh_line =~ m{# MANIFEST for Test-CheckManifest};

        next if $fh_line =~ m{ \A \s* \# }x;
        next if $selftest && $fh_line =~ m{# selftest};
        
        my ($file);
        
        if ( ($file) = $fh_line =~ /^'(\\[\\']|.+)+'\s*(.*)/) {
            $file =~ s/\\([\\'])/$1/g;
        }
        else {
            ($file) = $fh_line =~ /^(\S+)\s*(.*)/;
        }

        next unless $file;

        push @files, $file;
    }
    
    return @files;
}

sub _not_ok_manifest{
    $test_bool = 0;
    ok_manifest(@_);
    $test_bool = 1;
}

sub _is_excluded{
    my ($file,$dirref,$filter,$bool,$files_in_skip,$home) = @_;
    my @excluded_files = qw(
        pm_to_blib Makefile META.yml Build pod2htmd.tmp 
        pod2htmi.tmp Build.bat .cvsignore MYMETA.json MYMETA.yml
    );

    if ( $files_in_skip and 'ARRAY' eq ref $files_in_skip ) {
        (my $local_file = $file) =~ s{\Q$home\E/?}{};
        for my $rx ( @{$files_in_skip} ) {
            my $regex = qr/$rx/;
            return 1 if $local_file =~ $regex;
        }
    }
        
    my @matches = grep{ $file =~ /$_$/ }@excluded_files;
    
    if($bool eq 'or'){
        push @matches, $file if grep{ref($_) and ref($_) eq 'Regexp' and $file =~ /$_/}@$filter;
        push @matches, $file if grep{$file =~ /^\Q$_\E/}@$dirref;
    }
    else{
        if(grep{$file =~ /$_/ and ref($_) and ref($_) eq 'Regexp'}@$filter and
           grep{$file =~ /^\Q$_\E/ and not ref($_)}@$dirref){
            push @matches, $file;
        }
    }
    
    return scalar @matches;
}

sub _read_skip {
    my ($skip, $msg, $bool) = @_;

    return [] unless $skip and -e $skip;
    
    my @files;
    if( -e $skip and not open my $skip_fh, '<', $skip ) {
        $$bool = 0;
        $$msg  = "can't open $skip";
        return;
    }
    else {
        @files = _read_file( $skip_fh );
    }

    return \@files;
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Test::CheckManifest - Check if your Manifest matches your distro

=head1 VERSION

version 1.29

=head1 SYNOPSIS

  use Test::CheckManifest;
  ok_manifest();

=head2 EXPORT

There is only one method exported: C<ok_manifest>

=head1 METHODS

=head2 ok_manifest   [{exclude => $arref}][$msg]

checks whether the Manifest file matches the distro or not. To match a distro
the Manifest has to name all files that come along with the distribution.

To check the Manifest file, this module searches for a file named C<MANIFEST>.

To exclude some directories from this test, you can specify these dirs in the
hashref.

  ok_manifest({exclude => ['/var/test/']});

is ok if the files in C</path/to/your/dist/var/test/> are not named in the
C<MANIFEST> file. That means that the paths in the exclude array must be
"pseudo-absolute" (absolute to your distribution).

To use a "filter" you can use the key "filter"

  ok_manifest({filter => [qr/\.svn/]});

With that you can exclude all files with an '.svn' in the filename or in the
path from the test.

These files would be excluded (as examples):

=over 4

=item * /dist/var/.svn/test

=item * /dist/lib/test.svn

=back

You can also combine "filter" and "exclude" with 'and' or 'or' default is 'or':

  ok_manifest({exclude => ['/var/test'], 
               filter  => [qr/\.svn/], 
               bool    => 'and'});

These files have to be named in the C<MANIFEST>:

=over 4

=item * /var/foo/.svn/any.file

=item * /dist/t/file.svn

=item * /var/test/test.txt

=back

These files not:

=over 4

=item * /var/test/.svn/*

=item * /var/test/file.svn

=back

By default, C<ok_manifest> will look for the file C<MANIFEST> in the current working directory (which is how tests are traditionally run). If you wish to specify a different directory, you may pass the C<file> or C<dir> parameters, for example:

  ok_manifest({dir => '/path/to/my/dist/'});

=head1 EXCLUDING FILES

Beside C<filter> and C<exclude> there is another way to exclude files:
C<MANIFEST.SKIP>. This is a file with filenames that should be excluded:

  t/my_very_own.t
  file_to.skip

=head1 ACKNOWLEDGEMENT

Great thanks to Christopher H. Laco, who did a lot of testing stuff for me and
he reported some bugs to RT.

=head1 AUTHOR

Renee Baecker <reneeb@cpan.org>

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2015 by Renee Baecker.

This is free software, licensed under:

  The Artistic License 2.0 (GPL Compatible)

=cut