From f1657850be9e866cae1599a0724cf38c1fd5f2b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C5=8Dan?= Date: Wed, 6 May 2026 06:33:59 -0600 Subject: [PATCH] feat: add mock_virtual_filesystem() for declarative path-to-stat mocking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the most common mock_all_from_stat usage pattern — a hash lookup from file paths to stat results — into a single function that returns a scope guard for automatic cleanup. Also exports get_basetime() (previously XS-only, not importable) for users who need to construct timestamps relative to $^T for -M/-C/-A mocks. Co-Authored-By: Claude Opus 4.6 --- lib/Overload/FileCheck.pm | 61 +++++++++++++++++++- t/mock-virtual-filesystem.t | 111 ++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 t/mock-virtual-filesystem.t diff --git a/lib/Overload/FileCheck.pm b/lib/Overload/FileCheck.pm index 5034976..630781f 100644 --- a/lib/Overload/FileCheck.pm +++ b/lib/Overload/FileCheck.pm @@ -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, @@ -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 ) = @_; @@ -1087,6 +1103,49 @@ of mocking all other -X checks. read L for sample usage. +=head2 mock_virtual_filesystem( %PATH_TO_STAT ) + +Convenience wrapper around C 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). 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. diff --git a/t/mock-virtual-filesystem.t b/t/mock-virtual-filesystem.t new file mode 100644 index 0000000..58e631f --- /dev/null +++ b/t/mock-virtual-filesystem.t @@ -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;