From 005e018ae95075fa8e55244ce9a4de6dcbdf54e6 Mon Sep 17 00:00:00 2001 From: Enzo Innocenzi Date: Wed, 21 Jan 2026 16:15:27 +0100 Subject: [PATCH] feat(support): add `Filesystem\copy_directory` and `Filesystem\copy` --- packages/support/src/Filesystem/functions.php | 44 ++++++ .../tests/Filesystem/UnixFunctionsTest.php | 144 +++++++++++++++++- 2 files changed, 182 insertions(+), 6 deletions(-) diff --git a/packages/support/src/Filesystem/functions.php b/packages/support/src/Filesystem/functions.php index de31d864d..f2ca7d980 100644 --- a/packages/support/src/Filesystem/functions.php +++ b/packages/support/src/Filesystem/functions.php @@ -71,6 +71,50 @@ function copy_file(string $source, string $destination, bool $overwrite = false) } } +/** + * Recursively opies a directory from `$source` to `$destination`. + */ +function copy_directory(string $source, string $destination, bool $overwrite = false): void +{ + if (! namespace\exists($source)) { + throw Exceptions\PathWasNotFound::forDirectory($source); + } + + if (! namespace\is_directory($source)) { + throw new Exceptions\PathWasNotADirectory($source); + } + + if (! namespace\is_readable($source)) { + throw Exceptions\PathWasNotReadable::forDirectory($source); + } + + if (! $overwrite && namespace\is_directory($destination)) { + return; + } + + namespace\create_directory($destination); + + foreach (namespace\list_directory($source) as $node) { + namespace\copy( + source: $node, + destination: $destination . '/' . basename($node), + overwrite: $overwrite, + ); + } +} + +/** + * Copies a file or directory from `$source` to `$destination`. + */ +function copy(string $source, string $destination, bool $overwrite = false): void +{ + if (namespace\is_directory($source)) { + namespace\copy_directory($source, $destination, $overwrite); + } else { + namespace\copy_file($source, $destination, $overwrite); + } +} + /** * Writes the specified `$content` to the specified `$filename` after encoding it to JSON. */ diff --git a/packages/support/tests/Filesystem/UnixFunctionsTest.php b/packages/support/tests/Filesystem/UnixFunctionsTest.php index 7e74f0cf9..d30c3a8a7 100644 --- a/packages/support/tests/Filesystem/UnixFunctionsTest.php +++ b/packages/support/tests/Filesystem/UnixFunctionsTest.php @@ -40,6 +40,11 @@ protected function cleanup(): void return; } + // restore permissions for cleanup + if (Filesystem\exists($this->fixtures)) { + exec(sprintf('chmod -R 0755 %s 2>/dev/null', escapeshellarg($this->fixtures))); + } + Filesystem\delete_directory($this->fixtures); $this->assertFalse(is_dir($this->fixtures)); @@ -481,17 +486,38 @@ public function copy(): void } #[Test] - public function copy_directory(): void + public function copy_delegates_to_copy_file(): void { - $this->expectException(PathWasNotAFile::class); + $source = $this->fixtures . '/file.txt'; + $destination = $this->fixtures . '/file_copy.txt'; - $source = $this->fixtures . '/tmp'; - $destination = $this->fixtures . '/tmp2'; + file_put_contents($source, 'Hello'); + + Filesystem\copy($source, $destination); + + $this->assertTrue(is_file($destination)); + $this->assertEquals('Hello', file_get_contents($destination)); + } + + #[Test] + public function copy_delegates_to_copy_directory(): void + { + $source = $this->fixtures . '/source'; + $destination = $this->fixtures . '/destination'; mkdir($source); - file_put_contents($source . '/file.txt', ''); + file_put_contents($source . '/file.txt', 'Hello'); + mkdir($source . '/subdir'); + file_put_contents($source . '/subdir/nested.txt', 'World'); - Filesystem\copy_file($source, $destination); + Filesystem\copy($source, $destination); + + $this->assertTrue(is_dir($destination)); + $this->assertTrue(is_file($destination . '/file.txt')); + $this->assertEquals('Hello', file_get_contents($destination . '/file.txt')); + $this->assertTrue(is_dir($destination . '/subdir')); + $this->assertTrue(is_file($destination . '/subdir/nested.txt')); + $this->assertEquals('World', file_get_contents($destination . '/subdir/nested.txt')); } #[Test] @@ -533,6 +559,112 @@ public function copy_overwrite(): void $this->assertEquals('Hello', file_get_contents($destination)); } + #[Test] + public function copy_directory(): void + { + $source = $this->fixtures . '/source'; + $destination = $this->fixtures . '/destination'; + + mkdir($source); + file_put_contents($source . '/file.txt', 'Hello'); + mkdir($source . '/subdir'); + file_put_contents($source . '/subdir/nested.txt', 'World'); + + Filesystem\copy_directory($source, $destination); + + $this->assertTrue(is_dir($destination)); + $this->assertTrue(is_file($destination . '/file.txt')); + $this->assertEquals('Hello', file_get_contents($destination . '/file.txt')); + $this->assertTrue(is_dir($destination . '/subdir')); + $this->assertTrue(is_file($destination . '/subdir/nested.txt')); + $this->assertEquals('World', file_get_contents($destination . '/subdir/nested.txt')); + } + + #[Test] + public function copy_directory_non_existing(): void + { + $this->expectException(PathWasNotFound::class); + + $source = $this->fixtures . '/non-existing'; + $destination = $this->fixtures . '/destination'; + + Filesystem\copy_directory($source, $destination); + } + + #[Test] + public function copy_directory_file_as_source(): void + { + $this->expectException(PathWasNotADirectory::class); + + $source = $this->fixtures . '/file.txt'; + $destination = $this->fixtures . '/destination'; + + file_put_contents($source, ''); + + Filesystem\copy_directory($source, $destination); + } + + #[Test] + public function copy_directory_non_readable(): void + { + $this->expectException(PathWasNotReadable::class); + + $source = $this->fixtures . '/source'; + $destination = $this->fixtures . '/destination'; + + mkdir($source); + chmod($source, 0o000); + + Filesystem\copy_directory($source, $destination); + } + + #[Test] + public function copy_directory_no_overwrite(): void + { + $source = $this->fixtures . '/source'; + $destination = $this->fixtures . '/destination'; + + mkdir($source); + file_put_contents($source . '/file.txt', 'Hello'); + mkdir($destination); + file_put_contents($destination . '/existing.txt', 'World'); + + Filesystem\copy_directory($source, $destination, overwrite: false); + + $this->assertFalse(is_file($destination . '/file.txt')); + $this->assertTrue(is_file($destination . '/existing.txt')); + } + + #[Test] + public function copy_directory_overwrite(): void + { + $source = $this->fixtures . '/source'; + $destination = $this->fixtures . '/destination'; + + mkdir($source); + file_put_contents($source . '/file.txt', 'New'); + mkdir($destination); + file_put_contents($destination . '/file.txt', 'Old'); + + Filesystem\copy_directory($source, $destination, overwrite: true); + + $this->assertEquals('New', file_get_contents($destination . '/file.txt')); + } + + #[Test] + public function copy_file_throws_when_source_is_directory(): void + { + $this->expectException(PathWasNotAFile::class); + + $source = $this->fixtures . '/tmp'; + $destination = $this->fixtures . '/tmp2'; + + mkdir($source); + file_put_contents($source . '/file.txt', ''); + + Filesystem\copy_file($source, $destination); + } + #[Test] public function move(): void {