Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 60 additions & 1 deletion lib/Overload/FileCheck.pm
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ my @STAT_HELPERS = qw{ stat_as_directory stat_as_file stat_as_symlink

our @EXPORT_OK = (
qw{
mock_all_from_stat
mock_all_from_stat mock_virtual_filesystem
mock_all_file_checks mock_file_check mock_file_check_guard mock_stat
unmock_file_check unmock_all_file_checks unmock_stat
get_basetime
},
@CHECK_STATUS,
@STAT_T_IX,
Expand Down Expand Up @@ -292,6 +293,21 @@ sub mock_all_from_stat {
return 1;
}

sub mock_virtual_filesystem {
my (%fs) = @_;

mock_all_from_stat(sub {
my ( $op, $file ) = @_;

return $fs{$file} if defined $file && exists $fs{$file};
return FALLBACK_TO_REAL_OP;
});

# All ops (including stat/lstat) are now mocked — the guard
# will unmock everything when it goes out of scope.
return Overload::FileCheck::Guard->new( keys %MAP_FC_OP );
}

sub _check_from_stat {
my ( $check, $f_or_fh, $sub_for_stat ) = @_;

Expand Down Expand Up @@ -1087,6 +1103,49 @@ of mocking all other -X checks.

read L</" Mocking all file checks from a single 'stat' function"> for sample usage.

=head2 mock_virtual_filesystem( %PATH_TO_STAT )

Convenience wrapper around C<mock_all_from_stat> for the common pattern of
defining a set of mocked files as a hash of path to stat result. Returns a
guard object that unmocks everything when it goes out of scope.

use Overload::FileCheck qw(mock_virtual_filesystem stat_as_file
stat_as_directory);

{
my $guard = mock_virtual_filesystem(
'/app/config.yml' => stat_as_file( size => 256 ),
'/app/data/' => stat_as_directory( perms => 0755 ),
'/app/missing' => [], # file not found
);

ok -e '/app/config.yml';
ok -f '/app/config.yml';
ok -d '/app/data/';
ok !-e '/app/missing';
ok -e $^X; # real files fall through
}
# all mocks automatically cleaned up here

Paths not present in the hash fall back to the real filesystem
(C<FALLBACK_TO_REAL_OP>). Call C<< $guard->cancel >> to keep the mocks
active beyond the guard's scope.

=head2 get_basetime()

Returns Perl's C<$^T> (script start time) as seen by the XS layer.
Useful when constructing timestamps for C<-M>, C<-C>, or C<-A> mocks:

use Overload::FileCheck qw(get_basetime stat_as_file mock_all_from_stat);

my $one_day_ago = get_basetime() - 86400;
mock_all_from_stat(sub {
return stat_as_file( mtime => $one_day_ago ) if $_[1] eq '/old';
return FALLBACK_TO_REAL_OP;
});

# -M '/old' is approximately 1.0

=head2 stat_as_directory( %OPTS )

Create a stat array ref for a directory.
Expand Down
111 changes: 111 additions & 0 deletions t/mock-virtual-filesystem.t
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#!/usr/bin/perl -w

use strict;
use warnings;

use Test2::Bundle::Extended;
use Test2::Tools::Explain;
use Test2::Plugin::NoWarnings;

use Overload::FileCheck qw(
mock_virtual_filesystem
get_basetime
stat_as_file stat_as_directory stat_as_symlink
CHECK_IS_TRUE CHECK_IS_FALSE FALLBACK_TO_REAL_OP
ST_SIZE ST_MTIME ST_MODE
);

# --- get_basetime is exportable and matches $^T ---
is get_basetime(), $^T, "get_basetime() returns script start time";

# --- Basic virtual filesystem with guard ---
{
my $guard = mock_virtual_filesystem(
'/mock/file.txt' => stat_as_file( size => 42 ),
'/mock/dir' => stat_as_directory( perms => 0755 ),
'/mock/link' => stat_as_symlink(),
'/mock/gone' => [], # file not found
);

ok $guard, "mock_virtual_filesystem returns a guard";

# Existence
ok -e '/mock/file.txt', "-e mocked file";
ok -e '/mock/dir', "-e mocked directory";
ok !-e '/mock/gone', "-e returns false for missing file";

# File types
ok -f '/mock/file.txt', "-f mocked file";
ok !-d '/mock/file.txt', "-d false for mocked file";
ok -d '/mock/dir', "-d mocked directory";
ok !-f '/mock/dir', "-f false for mocked directory";

# Size
is -s '/mock/file.txt', 42, "-s returns mocked size";

# Stacked ops with _
ok -e '/mock/file.txt' && -f _, "-e && -f _ works";

# Fallback to real filesystem
ok -e $^X, "real perl binary still accessible via fallback";
}

# Guard out of scope — mocks should be unmocked now
{
# After guard destruction, file checks use real filesystem again.
# A path that never existed shouldn't be mockable anymore.
ok !-e '/mock/file.txt', "mocks cleaned up after guard goes out of scope";
}

# --- Nested guards ---
{
my $guard1 = mock_virtual_filesystem(
'/vfs/a' => stat_as_file( size => 10 ),
);
ok -e '/vfs/a', "first guard active";

# Note: mock_virtual_filesystem calls mock_all_from_stat which will
# fail if already mocked. This tests that the guard cleanup works.
}

# After first guard destroyed, we can create a new one
{
my $guard2 = mock_virtual_filesystem(
'/vfs/b' => stat_as_file( size => 20 ),
);
ok -e '/vfs/b', "second guard active";
ok !-e '/vfs/a', "first guard's files not present";
}

# --- Guard cancel ---
{
my $guard = mock_virtual_filesystem(
'/cancel/test' => stat_as_file(),
);
ok -e '/cancel/test', "mock active before cancel";
$guard->cancel();
}
# After cancel, mocks persist (guard didn't clean up)
ok -e '/cancel/test', "mock persists after cancelled guard";
# Manual cleanup
Overload::FileCheck::unmock_all_file_checks();
Overload::FileCheck::unmock_stat();
ok !-e '/cancel/test', "manual cleanup works after cancel";

# --- stat() returns mocked data ---
{
my $now = time();
my $guard = mock_virtual_filesystem(
'/stat/test' => stat_as_file( size => 999, mtime => $now ),
);

my @st = stat('/stat/test');
ok scalar(@st), "stat() returns data for mocked file";
is $st[ST_SIZE], 999, "stat size matches";
is $st[ST_MTIME], $now, "stat mtime matches";

my @empty = stat('/stat/missing');
ok !scalar(@empty), "stat() returns empty for unmocked nonexistent file";
}

done_testing;
Loading