From a3c93b40e698e6ee2ff599ad885ea5b3f6507a13 Mon Sep 17 00:00:00 2001 From: Toddr Bot Date: Sun, 22 Mar 2026 02:24:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Test::MockFileSys=20=E2=80=94=20v?= =?UTF-8?q?irtual=20filesystem=20container=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the core MockFileSys module (Phases 1 & 2 from the plan): - Singleton container managing a tree of Test::MockFile mocks - mkdirs() for mkdir -p style directory tree setup - file(), dir(), symlink() with parent-dir checks and dedup - write_file(), overwrite(), path(), unmock(), clear() convenience API - Strict-mode integration via dynamic rule - Automatic cleanup on scope exit (deepest-first destruction) - 60 tests covering all methods, edge cases, and error paths Also adds _push_strict_rule / _remove_strict_rule internal API to MockFile.pm so MockFileSys can manage strict rules without accessing the lexical @STRICT_RULES array directly. Refs: #115 Co-Authored-By: Claude Opus 4.6 --- lib/Test/MockFile.pm | 20 ++ lib/Test/MockFileSys.pm | 611 ++++++++++++++++++++++++++++++++++++++++ t/mockfilesys.t | 373 ++++++++++++++++++++++++ 3 files changed, 1004 insertions(+) create mode 100644 lib/Test/MockFileSys.pm create mode 100644 t/mockfilesys.t diff --git a/lib/Test/MockFile.pm b/lib/Test/MockFile.pm index e9c0380..effed6b 100644 --- a/lib/Test/MockFile.pm +++ b/lib/Test/MockFile.pm @@ -462,6 +462,26 @@ sub clear_strict_rules { return; } +# Internal API for Test::MockFileSys — not part of the public interface. +# Pushes a pre-built rule hashref onto @STRICT_RULES and returns it. +sub _push_strict_rule { + my ( $class, $rule ) = @_; + + ref $rule eq 'HASH' + or croak("_push_strict_rule requires a hashref"); + + push @STRICT_RULES, $rule; + return $rule; +} + +# Internal API for Test::MockFileSys — removes a specific rule ref from @STRICT_RULES. +sub _remove_strict_rule { + my ( $class, $rule ) = @_; + + @STRICT_RULES = grep { $_ != $rule } @STRICT_RULES if $rule; + return; +} + =head2 add_strict_rule_for_filename( $file_rule, $action ) Args: ($file_rule, $action) diff --git a/lib/Test/MockFileSys.pm b/lib/Test/MockFileSys.pm new file mode 100644 index 0000000..d036d75 --- /dev/null +++ b/lib/Test/MockFileSys.pm @@ -0,0 +1,611 @@ +# Copyright (c) 2018, cPanel, LLC. +# All rights reserved. +# http://cpanel.net +# +# This is free software; you can redistribute it and/or modify it under the +# same terms as Perl itself. See L. + +package Test::MockFileSys; + +use strict; +use warnings; + +use Carp qw(carp confess croak); +use Scalar::Util (); +use File::Basename (); + +# We need Test::MockFile loaded for its internals. +# Import nothing — we just need the class and its package variables. +use Test::MockFile (); + +our $VERSION = '0.001'; + +# Singleton tracking — only one MockFileSys alive at a time. +my $_active_instance; + +=head1 NAME + +Test::MockFileSys - Virtual filesystem container for Test::MockFile + +=head1 SYNOPSIS + + use Test::MockFile; # strict mode by default + use Test::MockFileSys; + + { + my $fs = Test::MockFileSys->new; + + # Set up directory structure (like mkdir -p) + $fs->mkdirs( '/usr/local/bin', '/etc', '/tmp' ); + + # Create files (parent dir must exist) + $fs->file( '/etc/hosts', "127.0.0.1 localhost\n" ); + $fs->file( '/tmp/data.txt', 'hello world' ); + + # Create symlinks + $fs->symlink( '/etc/hosts', '/etc/hosts.bak' ); + + # Now use normal Perl I/O — all intercepted by Test::MockFile + open my $fh, '<', '/etc/hosts' or die $!; + my $content = <$fh>; + close $fh; + + # Modify mid-test + $fs->overwrite( '/tmp/data.txt', 'updated' ); + + # Inspect internals + my $mock = $fs->path('/etc/hosts'); # Test::MockFile object + + # Remove a single mock + $fs->unmock('/tmp/data.txt'); + + # Reset to empty filesystem + $fs->clear; + } + # All mocks gone after scope exit + +=head1 DESCRIPTION + +Test::MockFileSys provides a higher-level API over L for +tests that need to set up an entire mock filesystem tree. Instead of +manually managing individual mock objects and worrying about scope, users +create a single MockFileSys instance that owns all mocks, auto-creates +implied parent directories via C, and integrates with strict mode +so that only managed paths are accessible. + +When the MockFileSys object goes out of scope, all its mocks are cleaned +up automatically. + +=head1 METHODS + +=head2 new + + my $fs = Test::MockFileSys->new; + +Creates a new MockFileSys container. Only one instance may be alive at a +time — creating a second while the first exists will croak. + +The constructor mocks C as an existing empty directory and registers a +strict-mode rule that allows access to any path present in +C<%Test::MockFile::files_being_mocked>. + +=cut + +sub new { + my ($class) = @_; + + if ($_active_instance) { + croak("A Test::MockFileSys instance is already active — only one is allowed at a time"); + } + + my $self = bless { + _mocks => {}, # path => Test::MockFile object (strong refs) + _auto_parents => {}, # path => 1 for dirs created by _ensure_parents / mkdirs + _strict_rule => undef, + _root_mock => undef, + }, $class; + + # Mock '/' as an existing empty directory. + # We go through Test::MockFile->dir directly, then mark as existing. + # dir() alone creates a non-existent dir mock; has_content makes it "real". + $self->{_root_mock} = Test::MockFile->dir( '/' ); + $self->{_root_mock}{'has_content'} = 1; + $self->{_mocks}{'/'} = $self->{_root_mock}; + + # Register a strict-mode rule: allow any path that's in %files_being_mocked. + # This acts as a safety net so paths managed by this MockFileSys pass strict checks. + my $rule = { + 'command_rule' => qr/.*/, + 'file_rule' => qr/.*/, + 'action' => sub { + my ($ctx) = @_; + return exists $Test::MockFile::files_being_mocked{ $ctx->{'filename'} } ? 1 : undef; + }, + }; + Test::MockFile->_push_strict_rule($rule); + $self->{_strict_rule} = $rule; + + $_active_instance = $self; + Scalar::Util::weaken($_active_instance); + + return $self; +} + +=head2 file + + $fs->file( '/path/to/file' ); # non-existent file mock + $fs->file( '/path/to/file', 'contents' ); # file with contents + $fs->file( '/path/to/file', 'contents', \%stats ); # file with contents and stats + +Creates a mock file at the given path. The parent directory must already +be a mocked existing directory (use C to set up the tree first), +or this method will croak. + +If the path is already mocked within this MockFileSys, returns the +existing mock object (deduplication). + +=cut + +sub file { + my ( $self, $file, $contents, @stats ) = @_; + + defined $file && length $file + or croak("file() requires a path"); + + my $path = Test::MockFile::_abs_path_to_file($file); + + $path eq '/' + and croak("Cannot mock '/' as a file — root must be a directory"); + + # Deduplication: return existing mock if present + if ( my $existing = $self->{_mocks}{$path} ) { + return $existing; + } + + # Check for conflict with standalone mocks outside this MockFileSys + if ( $Test::MockFile::files_being_mocked{$path} ) { + croak("Path $path is already mocked outside this MockFileSys"); + } + + # Parent directory must exist and be a directory + $self->_check_parent_exists($path); + + my $mock = Test::MockFile->file( $path, $contents, @stats ); + $self->{_mocks}{$path} = $mock; + + return $mock; +} + +=head2 dir + + $fs->dir( '/path/to/dir' ); + $fs->dir( '/path/to/dir', \%opts ); + +Creates a mock directory at the given path. The parent directory must +already be a mocked existing directory, or this method will croak. + +If the path is already mocked within this MockFileSys, returns the +existing mock object. + +Note: the root directory C is always created by the constructor. +Calling C<< $fs->dir('/') >> returns the existing root mock. + +=cut + +sub dir { + my ( $self, $dirname, @opts ) = @_; + + defined $dirname && length $dirname + or croak("dir() requires a path"); + + my $path = Test::MockFile::_abs_path_to_file($dirname); + + # Cleanup trailing slashes (same as MockFile.pm) + $path =~ s{[/\\]$}{}xmsg if $path ne '/'; + + # Deduplication: return existing mock if present + if ( my $existing = $self->{_mocks}{$path} ) { + return $existing; + } + + # Check for conflict with standalone mocks + if ( $Test::MockFile::files_being_mocked{$path} ) { + croak("Path $path is already mocked outside this MockFileSys"); + } + + # Parent must exist (except for root, which is created in constructor) + if ( $path ne '/' ) { + $self->_check_parent_exists($path); + } + + my $mock = Test::MockFile->dir( $path, @opts ); + $mock->{'has_content'} = 1; # mark as existing directory + $self->{_mocks}{$path} = $mock; + + return $mock; +} + +=head2 symlink + + $fs->symlink( $target, '/path/to/link' ); + +Creates a mock symlink at C<$path> pointing to C<$target>. The parent +directory of the link path must be a mocked existing directory. + +If the path is already mocked within this MockFileSys, returns the +existing mock object. + +=cut + +sub symlink { + my ( $self, $readlink, $file ) = @_; + + defined $file && length $file + or croak("symlink() requires a link path"); + + my $path = Test::MockFile::_abs_path_to_file($file); + + # Deduplication + if ( my $existing = $self->{_mocks}{$path} ) { + return $existing; + } + + # Check for conflict + if ( $Test::MockFile::files_being_mocked{$path} ) { + croak("Path $path is already mocked outside this MockFileSys"); + } + + $self->_check_parent_exists($path); + + my $mock = Test::MockFile->symlink( $readlink, $path ); + $self->{_mocks}{$path} = $mock; + + return $mock; +} + +=head2 mkdirs + + $fs->mkdirs( '/a/b/c', '/usr/local/bin', '/etc' ); + +Creates directory trees (like C). For each path, creates +directory mocks for all intermediate components that don't already +exist. All created directories have C 1>. + +Croaks if an intermediate path is already mocked as a non-directory +(e.g., a file at C blocks C). + +=cut + +sub mkdirs { + my ( $self, @paths ) = @_; + + @paths or croak("mkdirs() requires at least one path"); + + for my $raw_path (@paths) { + my $path = Test::MockFile::_abs_path_to_file($raw_path); + $self->_mkdirs_single($path); + } + + return; +} + +=head2 write_file + + $fs->write_file( '/path/to/file', 'contents' ); + $fs->write_file( '/path/to/file', 'contents', \%stats ); + +Like C but requires content (croaks if content is undef). + +=cut + +sub write_file { + my ( $self, $file, $contents, @stats ) = @_; + + defined $contents + or croak("write_file() requires content — use file() for non-existent files"); + + return $self->file( $file, $contents, @stats ); +} + +=head2 overwrite + + $fs->overwrite( '/path/to/file', 'new contents' ); # setter + my $contents = $fs->overwrite( '/path/to/file' ); # getter + +Updates the contents of an existing mock file. Croaks if the path is not +mocked within this MockFileSys. + +With no second argument, returns current contents (getter mode). + +=cut + +sub overwrite { + my ( $self, $file, @rest ) = @_; + + defined $file && length $file + or croak("overwrite() requires a path"); + + my $path = Test::MockFile::_abs_path_to_file($file); + my $mock = $self->{_mocks}{$path} + or croak("Cannot overwrite '$path' — not mocked in this MockFileSys"); + + # Getter mode + return $mock->contents() unless @rest; + + # Setter mode + my $new_contents = $rest[0]; + $mock->contents($new_contents); + + # Update mtime/ctime + my $now = time; + $mock->{'mtime'} = $now; + $mock->{'ctime'} = $now; + + return $mock; +} + +=head2 mkdir + + $fs->mkdir( '/path/to/dir' ); + $fs->mkdir( '/path/to/dir', 0755 ); + +Convenience alias for C. If a numeric mode is provided, it is +applied to the directory's permissions. + +=cut + +sub mkdir { + my ( $self, $dirname, $mode ) = @_; + + my $mock = $self->dir($dirname); + + if ( defined $mode ) { + # Apply mode like Test::MockFile does + my $perms = Test::MockFile::S_IFPERMS() & int($mode); + $mock->{'mode'} = ( $perms & ~umask ) | Test::MockFile::S_IFDIR(); + } + + return $mock; +} + +=head2 path + + my $mock_obj = $fs->path('/path/to/file'); + +Returns the underlying L object for the given path, or +C if the path is not mocked within this MockFileSys. + +=cut + +sub path { + my ( $self, $file ) = @_; + + defined $file && length $file + or return undef; + + my $path = Test::MockFile::_abs_path_to_file($file); + return $self->{_mocks}{$path}; +} + +=head2 unmock + + $fs->unmock('/path/to/file'); + +Removes a single path from the MockFileSys container. The underlying +L object goes out of scope and is destroyed. + +Croaks if the path has mocked children still present in this +MockFileSys. + +=cut + +sub unmock { + my ( $self, $file ) = @_; + + defined $file && length $file + or croak("unmock() requires a path"); + + my $path = Test::MockFile::_abs_path_to_file($file); + + $path eq '/' + and croak("Cannot unmock '/' — use clear() to reset the filesystem"); + + exists $self->{_mocks}{$path} + or croak("Cannot unmock '$path' — not mocked in this MockFileSys"); + + # Check for children + my @children = grep { $_ ne $path && m{^\Q$path/\E} } keys %{ $self->{_mocks} }; + if (@children) { + my $list = join ', ', sort @children; + croak("Cannot unmock '$path' — still has mocked children: $list"); + } + + # Remove from our tracking. The strong ref drop triggers MockFile DESTROY. + delete $self->{_mocks}{$path}; + delete $self->{_auto_parents}{$path}; + + return; +} + +=head2 clear + + $fs->clear; + +Destroys all mocks and resets the virtual filesystem to an empty tree +(just the root C mock remains). Useful for multi-scenario tests. + +=cut + +sub clear { + my ($self) = @_; + + # Destroy all mocks in reverse-depth order (deepest first), skipping root. + my @paths = sort { length($b) <=> length($a) || $b cmp $a } + grep { $_ ne '/' } + keys %{ $self->{_mocks} }; + + for my $path (@paths) { + delete $self->{_mocks}{$path}; + } + + $self->{_auto_parents} = {}; + + # Root mock should still be alive. If it got destroyed somehow, recreate it. + if ( !$Test::MockFile::files_being_mocked{'/'} ) { + $self->{_root_mock} = Test::MockFile->dir('/'); + $self->{_root_mock}{'has_content'} = 1; + $self->{_mocks}{'/'} = $self->{_root_mock}; + } + + return; +} + +# ---- Internal methods ---- + +# Verify that the parent directory of $path is a mocked existing directory +# within this MockFileSys. Croaks on failure. +sub _check_parent_exists { + my ( $self, $path ) = @_; + + my $parent = _parent_dir($path); + + my $parent_mock = $self->{_mocks}{$parent}; + unless ($parent_mock) { + croak("Parent directory '$parent' does not exist in this MockFileSys — use mkdirs() to create it first"); + } + + # Parent must be an existing directory + unless ( $parent_mock->is_dir ) { + croak("Parent path '$parent' is not a directory"); + } + + return 1; +} + +# Create a full directory tree for a single path (mkdir -p semantics). +# Creates all intermediate components that don't already exist. +sub _mkdirs_single { + my ( $self, $target_path ) = @_; + + # Split into components and build up incrementally + my @parts = split m{/}, $target_path; + shift @parts; # remove empty string before leading / + + my $current = ''; + for my $part (@parts) { + $current .= "/$part"; + + # Already mocked in this MockFileSys? Verify it's a directory. + if ( my $existing = $self->{_mocks}{$current} ) { + if ( $existing->is_dir ) { + next; + } + else { + croak("Cannot mkdirs through '$current' — it is already mocked as a non-directory"); + } + } + + # Already mocked outside? Check it's a dir. + if ( my $existing = $Test::MockFile::files_being_mocked{$current} ) { + if ( $existing->is_dir ) { + # Take ownership — store in our tracking + $self->{_mocks}{$current} = $existing; + $self->{_auto_parents}{$current} = 1; + next; + } + else { + croak("Cannot mkdirs through '$current' — it is already mocked as a non-directory"); + } + } + + # Create the directory mock and mark as existing + my $mock = Test::MockFile->dir($current); + $mock->{'has_content'} = 1; + $self->{_mocks}{$current} = $mock; + $self->{_auto_parents}{$current} = 1; + } + + return; +} + +# Return the parent directory of an absolute path. +# _parent_dir('/a/b/c') => '/a/b' +# _parent_dir('/a') => '/' +sub _parent_dir { + my ($path) = @_; + + return '/' if $path eq '/'; + + ( my $parent = $path ) =~ s{/[^/]+$}{}; + return length($parent) ? $parent : '/'; +} + +sub DESTROY { + my ($self) = @_; + ref $self or return; + + # 1. Remove strict rule from @STRICT_RULES + if ( my $rule = $self->{_strict_rule} ) { + Test::MockFile->_remove_strict_rule($rule); + $self->{_strict_rule} = undef; + } + + # 2. Delete all explicitly-managed mocks deepest-first (skipping root) + my @paths = sort { length($b) <=> length($a) || $b cmp $a } + grep { $_ ne '/' } + keys %{ $self->{_mocks} }; + + for my $path (@paths) { + delete $self->{_mocks}{$path}; + } + + # 3. Destroy root mock last + delete $self->{_mocks}{'/'}; + $self->{_root_mock} = undef; + + # 4. Clear singleton + $_active_instance = undef; +} + +1; + +__END__ + +=head1 STRICT MODE INTEGRATION + +When L is loaded in strict mode (the default), MockFileSys +registers a single dynamic strict rule that allows access to any path +present in C<%Test::MockFile::files_being_mocked>. This means: + +=over 4 + +=item * Paths created via C, C, C, or C +are accessible. + +=item * Paths not managed by this MockFileSys (and not mocked elsewhere) +will trigger a strict-mode violation, as expected. + +=back + +=head1 SINGLETON ENFORCEMENT + +Only one MockFileSys instance may be alive at a time. This prevents +conflicts between multiple filesystem containers. If you need to reset +the filesystem mid-test, use C instead of creating a new instance. + +=head1 PARENT DIRECTORY SEMANTICS + +Operations that create files, directories, or symlinks require the parent +directory to already exist as a mocked directory. This matches real +filesystem behavior. Use C to set up directory trees before +creating files: + + my $fs = Test::MockFileSys->new; + $fs->mkdirs('/usr/local/bin'); + $fs->file('/usr/local/bin/perl', '#!/usr/bin/perl'); + +=head1 SEE ALSO + +L + +=cut diff --git a/t/mockfilesys.t b/t/mockfilesys.t new file mode 100644 index 0000000..fdc2efb --- /dev/null +++ b/t/mockfilesys.t @@ -0,0 +1,373 @@ +#!/usr/bin/perl -w + +use strict; +use warnings; + +use Test2::Bundle::Extended; +use Test2::Tools::Explain; +use Test2::Plugin::NoWarnings; + +use Test::MockFile qw< nostrict >; +use Test::MockFileSys; + +note "-------------- MockFileSys: constructor and root --------------"; +{ + my $fs = Test::MockFileSys->new; + + ok( $fs, 'MockFileSys constructor returns object' ); + isa_ok( $fs, 'Test::MockFileSys' ); + + # Root is mocked as existing directory + ok( -d '/', 'root / is a directory' ); + ok( -e '/', 'root / exists' ); + + # path() on root returns a Test::MockFile object + my $root = $fs->path('/'); + ok( $root, 'path("/") returns a mock object' ); + isa_ok( $root, 'Test::MockFile' ); +} + +note "-------------- MockFileSys: singleton enforcement --------------"; +{ + my $fs = Test::MockFileSys->new; + + like( + dies { Test::MockFileSys->new }, + qr/already active/, + 'Second MockFileSys while first is alive croaks' + ); +} + +note "-------------- MockFileSys: singleton released after scope exit --------------"; +{ + { + my $fs = Test::MockFileSys->new; + ok( $fs, 'first instance alive' ); + } + # First instance destroyed — should be able to create a new one + my $fs2 = Test::MockFileSys->new; + ok( $fs2, 'second instance created after first went out of scope' ); +} + +note "-------------- MockFileSys: mkdirs creates tree --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs( '/a/b/c', '/usr/local/bin' ); + + ok( -d '/a', '/a created' ); + ok( -d '/a/b', '/a/b created' ); + ok( -d '/a/b/c', '/a/b/c created' ); + ok( -d '/usr', '/usr created' ); + ok( -d '/usr/local', '/usr/local created' ); + ok( -d '/usr/local/bin', '/usr/local/bin created' ); +} + +note "-------------- MockFileSys: mkdirs deduplication --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/a/b'); + $fs->mkdirs('/a/c'); # /a already exists, should not croak + + ok( -d '/a/b', '/a/b from first mkdirs' ); + ok( -d '/a/c', '/a/c from second mkdirs' ); +} + +note "-------------- MockFileSys: file requires parent dir --------------"; +{ + my $fs = Test::MockFileSys->new; + + like( + dies { $fs->file( '/no/parent/file.txt', 'data' ) }, + qr/does not exist/, + 'file() croaks when parent dir missing' + ); +} + +note "-------------- MockFileSys: file creation and I/O --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/etc'); + my $mock = $fs->file( '/etc/hosts', "127.0.0.1 localhost\n" ); + + ok( $mock, 'file() returns mock object' ); + isa_ok( $mock, 'Test::MockFile' ); + + ok( -f '/etc/hosts', '-f on mocked file' ); + ok( -e '/etc/hosts', '-e on mocked file' ); + + # Read via Perl I/O + ok( open( my $fh, '<', '/etc/hosts' ), 'open mocked file for reading' ); + my $content = do { local $/; <$fh> }; + close $fh; + is( $content, "127.0.0.1 localhost\n", 'file content matches' ); +} + +note "-------------- MockFileSys: file deduplication --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/tmp'); + my $f1 = $fs->file('/tmp/foo', 'hello'); + my $f2 = $fs->file('/tmp/foo'); + + ok( $f1 == $f2, 'file() returns same object for same path (deduplication)' ); +} + +note "-------------- MockFileSys: non-existent file mock --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/tmp'); + $fs->file('/tmp/ghost'); # no contents = non-existent + + ok( !-e '/tmp/ghost', 'non-existent file mock: -e returns false' ); +} + +note "-------------- MockFileSys: dir creation --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/a'); + my $d = $fs->dir('/a/subdir'); + + ok( $d, 'dir() returns mock object' ); + ok( -d '/a/subdir', '-d on mocked directory' ); +} + +note "-------------- MockFileSys: dir deduplication --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/etc'); + my $d1 = $fs->dir('/etc'); + my $d2 = $fs->dir('/etc'); + + ok( $d1 == $d2, 'dir() returns same object for existing dir' ); +} + +note "-------------- MockFileSys: symlink creation --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/etc'); + $fs->file( '/etc/hosts', '127.0.0.1 localhost' ); + my $link = $fs->symlink( '/etc/hosts', '/etc/hosts.bak' ); + + ok( $link, 'symlink() returns mock object' ); + ok( -l '/etc/hosts.bak', '-l on mocked symlink' ); + is( readlink('/etc/hosts.bak'), '/etc/hosts', 'readlink returns target' ); +} + +note "-------------- MockFileSys: root cannot be a file --------------"; +{ + my $fs = Test::MockFileSys->new; + + like( + dies { $fs->file('/') }, + qr/root must be a directory/, + 'file("/") croaks' + ); +} + +note "-------------- MockFileSys: write_file requires content --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/tmp'); + + like( + dies { $fs->write_file( '/tmp/foo', undef ) }, + qr/requires content/, + 'write_file with undef contents croaks' + ); + + my $mock = $fs->write_file( '/tmp/bar', 'data' ); + ok( -f '/tmp/bar', 'write_file creates file' ); +} + +note "-------------- MockFileSys: overwrite getter/setter --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/tmp'); + $fs->file( '/tmp/data', 'original' ); + + # Getter + is( $fs->overwrite('/tmp/data'), 'original', 'overwrite getter' ); + + # Setter + $fs->overwrite( '/tmp/data', 'modified' ); + + ok( open( my $fh, '<', '/tmp/data' ), 'open after overwrite' ); + my $content = do { local $/; <$fh> }; + close $fh; + is( $content, 'modified', 'overwrite setter changes content' ); +} + +note "-------------- MockFileSys: overwrite on unmocked path croaks --------------"; +{ + my $fs = Test::MockFileSys->new; + + like( + dies { $fs->overwrite('/nonexistent') }, + qr/not mocked/, + 'overwrite on unmocked path croaks' + ); +} + +note "-------------- MockFileSys: mkdir convenience method --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/usr'); + $fs->mkdir( '/usr/bin', 0755 ); + + ok( -d '/usr/bin', 'mkdir creates directory' ); +} + +note "-------------- MockFileSys: path returns mock or undef --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/etc'); + $fs->file( '/etc/passwd', 'root:x:0:0' ); + + my $mock = $fs->path('/etc/passwd'); + ok( $mock, 'path() returns mock for existing path' ); + isa_ok( $mock, 'Test::MockFile' ); + + is( $fs->path('/nowhere'), undef, 'path() returns undef for unknown path' ); +} + +note "-------------- MockFileSys: unmock removes single path --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/tmp'); + $fs->file( '/tmp/remove-me', 'data' ); + + ok( -f '/tmp/remove-me', 'file exists before unmock' ); + + $fs->unmock('/tmp/remove-me'); + + ok( !$fs->path('/tmp/remove-me'), 'path() returns undef after unmock' ); +} + +note "-------------- MockFileSys: unmock root croaks --------------"; +{ + my $fs = Test::MockFileSys->new; + + like( + dies { $fs->unmock('/') }, + qr/Cannot unmock '\/'/, + 'unmock root croaks' + ); +} + +note "-------------- MockFileSys: unmock dir with children croaks --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/a/b'); + $fs->file( '/a/b/c', 'data' ); + + like( + dies { $fs->unmock('/a/b') }, + qr/still has mocked children/, + 'unmock dir with children croaks' + ); +} + +note "-------------- MockFileSys: unmock unmocked path croaks --------------"; +{ + my $fs = Test::MockFileSys->new; + + like( + dies { $fs->unmock('/nonexistent') }, + qr/not mocked/, + 'unmock unknown path croaks' + ); +} + +note "-------------- MockFileSys: clear resets to empty tree --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs( '/a/b', '/usr' ); + $fs->file( '/a/b/c', 'data' ); + + $fs->clear; + + ok( -d '/', 'root still exists after clear' ); + ok( !$fs->path('/a'), '/a gone after clear' ); + ok( !$fs->path('/a/b'), '/a/b gone after clear' ); + ok( !$fs->path('/usr'), '/usr gone after clear' ); +} + +note "-------------- MockFileSys: clear then reuse --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/old'); + $fs->file( '/old/data', 'old' ); + + $fs->clear; + + $fs->mkdirs('/new'); + $fs->file( '/new/data', 'fresh' ); + + ok( -f '/new/data', 'new file after clear+reuse' ); + ok( open( my $fh, '<', '/new/data' ), 'read new file after clear' ); + my $content = do { local $/; <$fh> }; + close $fh; + is( $content, 'fresh', 'content is correct after clear+reuse' ); +} + +note "-------------- MockFileSys: scope cleanup frees all mocks --------------"; +{ + { + my $fs = Test::MockFileSys->new; + $fs->mkdirs('/scoped'); + $fs->file( '/scoped/test', 'data' ); + } + # After scope exit, /scoped should not be in %files_being_mocked + ok( !$Test::MockFile::files_being_mocked{'/scoped'}, '/scoped cleared after scope exit' ); + ok( !$Test::MockFile::files_being_mocked{'/scoped/test'}, '/scoped/test cleared after scope exit' ); + ok( !$Test::MockFile::files_being_mocked{'/'}, '/ cleared after scope exit' ); +} + +note "-------------- MockFileSys: conflict with standalone mock --------------"; +{ + my $standalone = Test::MockFile->file('/standalone', 'data'); + + my $fs = Test::MockFileSys->new; + $fs->mkdirs('/standalone-dir'); + + # /standalone was mocked outside — should croak + like( + dies { $fs->file('/standalone') }, + qr/already mocked outside/, + 'file() croaks on conflict with standalone mock' + ); + + undef $standalone; +} + +note "-------------- MockFileSys: mkdirs through non-directory croaks --------------"; +{ + my $fs = Test::MockFileSys->new; + + $fs->mkdirs('/a'); + $fs->file( '/a/notadir', 'I am a file' ); + + like( + dies { $fs->mkdirs('/a/notadir/sub') }, + qr/non-directory/, + 'mkdirs through a file croaks' + ); +} + +done_testing;