diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index f02d12eb..91fdd28e 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -6,12 +6,26 @@
src
+
src/preload.php
src/bootstrap.php
src/Psl/Internal
+
+
src/Psl/Exception
+
+
src/Psl/Str/constants.php
src/Psl/Math/constants.php
+ src/Psl/Filesystem/constants.php
+
+
+ src/Psl/Filesystem/get_group.php
+ src/Psl/Filesystem/change_group.php
+ src/Psl/Filesystem/Internal/change_group.php
+ src/Psl/Filesystem/get_owner.php
+ src/Psl/Filesystem/change_owner.php
+ src/Psl/Filesystem/Internal/change_owner.php
diff --git a/src/Psl/Filesystem/Exception/ExceptionInterface.php b/src/Psl/Filesystem/Exception/ExceptionInterface.php
new file mode 100644
index 00000000..62c9103d
--- /dev/null
+++ b/src/Psl/Filesystem/Exception/ExceptionInterface.php
@@ -0,0 +1,11 @@
+ file_put_contents($file, $content, $flags)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $written || null !== $error) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to write to file "%s": %s.',
+ $file,
+ $error ?? 'internal error',
+ ));
+ }
+
+ $length = Str\Byte\length($content);
+ if ($written !== $length) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to write the whole content to "%s" ( %g of %g bytes written ).',
+ $file,
+ $written,
+ $length,
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/append_file.php b/src/Psl/Filesystem/append_file.php
new file mode 100644
index 00000000..857bd490
--- /dev/null
+++ b/src/Psl/Filesystem/append_file.php
@@ -0,0 +1,21 @@
+ lchgrp($filename, $group);
+ } else {
+ $fun = static fn(): bool => chgrp($filename, $group);
+ }
+
+ [$success, $error] = Internal\box($fun);
+ if (!$success) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to change the group for file "%s": %s',
+ $filename,
+ $error ?? 'internal error.',
+ ));
+ }
+}
diff --git a/src/Psl/Filesystem/change_owner.php b/src/Psl/Filesystem/change_owner.php
new file mode 100644
index 00000000..ac305797
--- /dev/null
+++ b/src/Psl/Filesystem/change_owner.php
@@ -0,0 +1,37 @@
+ lchown($filename, $user);
+ } else {
+ $fun = static fn(): bool => chown($filename, $user);
+ }
+
+ [$success, $error] = Internal\box($fun);
+ if (!$success) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to change owner for file "%s": %s',
+ $filename,
+ $error ?? 'internal error.',
+ ));
+ }
+}
diff --git a/src/Psl/Filesystem/change_permissions.php b/src/Psl/Filesystem/change_permissions.php
new file mode 100644
index 00000000..ed7ae346
--- /dev/null
+++ b/src/Psl/Filesystem/change_permissions.php
@@ -0,0 +1,33 @@
+ chmod($filename, $permissions));
+ // @codeCoverageIgnoreStart
+ if (!$success) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to change permissions for file "%s": %s',
+ $filename,
+ $error ?? 'internal error.',
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/constants.php b/src/Psl/Filesystem/constants.php
new file mode 100644
index 00000000..70af81d4
--- /dev/null
+++ b/src/Psl/Filesystem/constants.php
@@ -0,0 +1,12 @@
+ fopen($source, 'rb'));
+ // @codeCoverageIgnoreStart
+ if (false === $source_stream) {
+ throw new Exception\RuntimeException('Failed to open $source file for reading');
+ }
+ // @codeCoverageIgnoreEnd
+
+ /**
+ * @psalm-suppress InvalidArgument - callable is not pure..
+ */
+ $destination_stream = Internal\suppress(static fn() => fopen($destination, 'wb'));
+ // @codeCoverageIgnoreStart
+ if (false === $destination_stream) {
+ throw new Exception\RuntimeException('Failed to open $destination file for writing.');
+ }
+ // @codeCoverageIgnoreEnd
+
+ $copied_bytes = stream_copy_to_stream($source_stream, $destination_stream);
+ fclose($source_stream);
+ fclose($destination_stream);
+
+ $total_bytes = file_size($source);
+
+ // preserve executable permission bits
+ change_permissions(
+ $destination,
+ get_permissions($destination) | (get_permissions($source) & 0111)
+ );
+
+ // @codeCoverageIgnoreStart
+ if ($copied_bytes !== $total_bytes) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to copy the whole content of "%s" to "%s" ( %g of %g bytes copied ).',
+ $source,
+ $destination,
+ $copied_bytes,
+ $total_bytes
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/create_directory.php b/src/Psl/Filesystem/create_directory.php
new file mode 100644
index 00000000..f9756a95
--- /dev/null
+++ b/src/Psl/Filesystem/create_directory.php
@@ -0,0 +1,32 @@
+ mkdir($directory, $permissions, true)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result && !is_directory($directory)) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to create directory "%s": %s.',
+ $directory,
+ $error_message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/create_file.php b/src/Psl/Filesystem/create_file.php
new file mode 100644
index 00000000..ba8f9a7f
--- /dev/null
+++ b/src/Psl/Filesystem/create_file.php
@@ -0,0 +1,51 @@
+ touch($filename);
+ } elseif (null === $access_time) {
+ $fun = static fn(): bool => touch($filename, $time);
+ } else {
+ $time = $time ?? $access_time;
+
+ $fun = static fn(): bool => touch($filename, $time, Math\maxva($access_time, $time));
+ }
+
+ /** @psalm-suppress MissingThrowsDocblock */
+ $directory = get_directory($filename);
+ if (!is_directory($directory)) {
+ create_directory($directory);
+ }
+
+ [$result, $error_message] = Internal\box($fun);
+ // @codeCoverageIgnoreStart
+ if (false === $result && !is_file($filename)) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to create file "%s": %s.',
+ $filename,
+ $error_message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/create_hard_link.php b/src/Psl/Filesystem/create_hard_link.php
new file mode 100644
index 00000000..e918ad2e
--- /dev/null
+++ b/src/Psl/Filesystem/create_hard_link.php
@@ -0,0 +1,55 @@
+ link($source, $destination));
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to create hard link "%s" from "%s": %s.',
+ $destination,
+ $source,
+ $error_message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/create_symbolic_link.php b/src/Psl/Filesystem/create_symbolic_link.php
new file mode 100644
index 00000000..4069ae07
--- /dev/null
+++ b/src/Psl/Filesystem/create_symbolic_link.php
@@ -0,0 +1,53 @@
+ symlink($source, $destination));
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to create symbolic link "%s" from "%s": %s.',
+ $destination,
+ $source,
+ $error_message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/create_temporary_file.php b/src/Psl/Filesystem/create_temporary_file.php
new file mode 100644
index 00000000..91220f2a
--- /dev/null
+++ b/src/Psl/Filesystem/create_temporary_file.php
@@ -0,0 +1,58 @@
+ is_symbolic_link($node)
+ );
+
+ Iter\apply($symbolic_nodes, static fn(string $node) => delete_file($node));
+ Iter\apply(
+ $nodes,
+ Fun\when(
+ static fn(string $node) => is_directory($node),
+ static fn(string $node) => delete_directory($node, true),
+ static fn(string $node) => delete_file($node),
+ )
+ );
+ }
+
+ [$result, $error_message] = Internal\box(static fn() => rmdir($directory));
+ // @codeCoverageIgnoreStart
+ if (false === $result && is_directory($directory)) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to delete directory "%s": %s.',
+ $directory,
+ $error_message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/delete_file.php b/src/Psl/Filesystem/delete_file.php
new file mode 100644
index 00000000..938799d9
--- /dev/null
+++ b/src/Psl/Filesystem/delete_file.php
@@ -0,0 +1,34 @@
+ unlink($filename));
+ // @codeCoverageIgnoreStart
+ if (false === $result && is_file($filename)) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to delete file "%s": %s.',
+ $filename,
+ $error_message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+}
diff --git a/src/Psl/Filesystem/exists.php b/src/Psl/Filesystem/exists.php
new file mode 100644
index 00000000..dab93a2d
--- /dev/null
+++ b/src/Psl/Filesystem/exists.php
@@ -0,0 +1,22 @@
+ filesize($filename));
+ if (false === $size) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Error reading the size of file "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $size;
+}
diff --git a/src/Psl/Filesystem/get_access_time.php b/src/Psl/Filesystem/get_access_time.php
new file mode 100644
index 00000000..49b710fe
--- /dev/null
+++ b/src/Psl/Filesystem/get_access_time.php
@@ -0,0 +1,40 @@
+ fileatime($filename)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve the access time of "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/get_basename.php b/src/Psl/Filesystem/get_basename.php
new file mode 100644
index 00000000..e526c81e
--- /dev/null
+++ b/src/Psl/Filesystem/get_basename.php
@@ -0,0 +1,30 @@
+ filectime($filename)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve the change time of "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/get_directory.php b/src/Psl/Filesystem/get_directory.php
new file mode 100644
index 00000000..52f165bb
--- /dev/null
+++ b/src/Psl/Filesystem/get_directory.php
@@ -0,0 +1,33 @@
+ 0, '$levels must be a positive integer, %d given.', $levels);
+
+ return dirname($path, $levels);
+}
diff --git a/src/Psl/Filesystem/get_extension.php b/src/Psl/Filesystem/get_extension.php
new file mode 100644
index 00000000..9d7bfb36
--- /dev/null
+++ b/src/Psl/Filesystem/get_extension.php
@@ -0,0 +1,19 @@
+ filegroup($filename)
+ );
+
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve group of file "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/get_inode.php b/src/Psl/Filesystem/get_inode.php
new file mode 100644
index 00000000..02773c2e
--- /dev/null
+++ b/src/Psl/Filesystem/get_inode.php
@@ -0,0 +1,40 @@
+ fileinode($filename)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve the inode of "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/get_modification_time.php b/src/Psl/Filesystem/get_modification_time.php
new file mode 100644
index 00000000..f86dab1f
--- /dev/null
+++ b/src/Psl/Filesystem/get_modification_time.php
@@ -0,0 +1,41 @@
+ filemtime($filename)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve the modification time of "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/get_owner.php b/src/Psl/Filesystem/get_owner.php
new file mode 100644
index 00000000..321eea22
--- /dev/null
+++ b/src/Psl/Filesystem/get_owner.php
@@ -0,0 +1,38 @@
+ fileowner($filename)
+ );
+
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve owner of file "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/get_permissions.php b/src/Psl/Filesystem/get_permissions.php
new file mode 100644
index 00000000..68244171
--- /dev/null
+++ b/src/Psl/Filesystem/get_permissions.php
@@ -0,0 +1,40 @@
+ fileperms($filename)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve permissions of file "%s": %s',
+ $filename,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/is_directory.php b/src/Psl/Filesystem/is_directory.php
new file mode 100644
index 00000000..fee1fa92
--- /dev/null
+++ b/src/Psl/Filesystem/is_directory.php
@@ -0,0 +1,25 @@
+
+ *
+ * @throws Psl\Exception\InvariantViolationException If the directory specified by
+ * $directory does not exist, or is not readable.
+ */
+function read_directory(string $directory): array
+{
+ Psl\invariant(exists($directory), '$directory does not exists.');
+ Psl\invariant(is_directory($directory), '$directory is not a directory.');
+ Psl\invariant(is_readable($directory), '$directory is not readable.');
+
+ /** @var list */
+ return Vec\values(new FilesystemIterator(
+ $directory,
+ FilesystemIterator::CURRENT_AS_PATHNAME | FilesystemIterator::SKIP_DOTS
+ ));
+}
diff --git a/src/Psl/Filesystem/read_file.php b/src/Psl/Filesystem/read_file.php
new file mode 100644
index 00000000..3e04c196
--- /dev/null
+++ b/src/Psl/Filesystem/read_file.php
@@ -0,0 +1,51 @@
+ file_get_contents($file, false, null, $offset)
+ );
+ } else {
+ [$content, $error] = Internal\box(
+ static fn() => file_get_contents($file, false, null, $offset, $length)
+ );
+ }
+
+ // @codeCoverageIgnoreStart
+ if (false === $content || null !== $error) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to read file "%s": %s.',
+ $file,
+ $error ?? 'internal error',
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $content;
+}
diff --git a/src/Psl/Filesystem/read_symbolic_link.php b/src/Psl/Filesystem/read_symbolic_link.php
new file mode 100644
index 00000000..da7e0588
--- /dev/null
+++ b/src/Psl/Filesystem/read_symbolic_link.php
@@ -0,0 +1,44 @@
+ readlink($symbolic_link)
+ );
+
+ // @codeCoverageIgnoreStart
+ if (false === $result) {
+ throw new Exception\RuntimeException(Str\format(
+ 'Failed to retrieve the target of symbolic link "%s": %s',
+ $symbolic_link,
+ $message ?? 'internal error'
+ ));
+ }
+ // @codeCoverageIgnoreEnd
+
+ return $result;
+}
diff --git a/src/Psl/Filesystem/write_file.php b/src/Psl/Filesystem/write_file.php
new file mode 100644
index 00000000..75bd5ace
--- /dev/null
+++ b/src/Psl/Filesystem/write_file.php
@@ -0,0 +1,21 @@
+cacheDirectory = Type\string()->assert(Filesystem\canonicalize(Str\join([
+ __DIR__, '..', '..', '.cache'
+ ], Filesystem\SEPARATOR)));
+
+ $this->directory = Str\join([$this->cacheDirectory, $this->function], Filesystem\SEPARATOR);
+ Filesystem\create_directory($this->directory);
+ $this->directoryPermissions = Filesystem\get_permissions($this->directory) & 0777;
+
+ static::assertTrue(Filesystem\exists($this->directory));
+ static::assertTrue(Filesystem\is_directory($this->directory));
+ }
+
+ protected function tearDown(): void
+ {
+ Filesystem\change_permissions($this->directory, $this->directoryPermissions);
+ Filesystem\delete_directory($this->directory, true);
+
+ static::assertFalse(Filesystem\is_directory($this->directory));
+ }
+
+ protected static function runOnlyOnLinux(): void
+ {
+ if ('Linux' !== PHP_OS_FAMILY) {
+ static::markTestSkipped('Test can only be executed on linux.');
+ }
+ }
+
+ protected static function runOnlyUsingRoot(): void
+ {
+ $user = Env\get_var('USER');
+ if (null === $user || 'root' === $user) {
+ return;
+ }
+
+ static::markTestSkipped('Test can only be executed by a superuser.');
+ }
+}
diff --git a/tests/Psl/Filesystem/CopyTest.php b/tests/Psl/Filesystem/CopyTest.php
new file mode 100644
index 00000000..4a3b6689
--- /dev/null
+++ b/tests/Psl/Filesystem/CopyTest.php
@@ -0,0 +1,58 @@
+directory, 'hello.txt'], Filesystem\SEPARATOR);
+ $markdown_file = Str\join([$this->directory, 'hello.md'], Filesystem\SEPARATOR);
+
+ Filesystem\write_file($text_file, 'Hello, World!');
+ Filesystem\copy($text_file, $markdown_file);
+
+ static::assertSame('Hello, World!', Filesystem\read_file($markdown_file));
+ }
+
+ public function testCopyOverwrite(): void
+ {
+ $text_file = Str\join([$this->directory, 'hello.txt'], Filesystem\SEPARATOR);
+ $markdown_file = Str\join([$this->directory, 'hello.md'], Filesystem\SEPARATOR);
+
+ Filesystem\write_file($text_file, 'Hello, World!');
+ Filesystem\write_file($markdown_file, '# Hello, World!');
+ Filesystem\copy($text_file, $markdown_file);
+
+ static::assertSame('Hello, World!', Filesystem\read_file($text_file));
+ static::assertSame('# Hello, World!', Filesystem\read_file($markdown_file));
+
+ Filesystem\copy($text_file, $markdown_file, true);
+
+ static::assertSame('Hello, World!', Filesystem\read_file($text_file));
+ static::assertSame('Hello, World!', Filesystem\read_file($markdown_file));
+ }
+
+ public function testCopyExecutableBits(): void
+ {
+ $shell_file = Str\join([$this->directory, 'hello.sh'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($shell_file);
+ Filesystem\change_permissions($shell_file, 0557);
+
+ static::assertTrue(Filesystem\is_executable($shell_file));
+
+ $shell_file_copy = Str\join([$this->directory, 'hey.sh'], Filesystem\SEPARATOR);
+
+ Filesystem\copy($shell_file, $shell_file_copy);
+
+ static::assertTrue(Filesystem\is_executable($shell_file_copy));
+ }
+}
diff --git a/tests/Psl/Filesystem/FileTest.php b/tests/Psl/Filesystem/FileTest.php
new file mode 100644
index 00000000..93909d07
--- /dev/null
+++ b/tests/Psl/Filesystem/FileTest.php
@@ -0,0 +1,179 @@
+directory);
+
+ static::assertTrue(Filesystem\is_file($file));
+ static::assertSame($this->directory, Filesystem\get_directory($file));
+ }
+
+ public function testTemporaryFileWithPrefix(): void
+ {
+ $file = Filesystem\create_temporary_file($this->directory, 'foo');
+
+ static::assertTrue(Filesystem\is_file($file));
+ static::assertSame($this->directory, Filesystem\get_directory($file));
+ static::assertStringContainsString('foo', Filesystem\get_filename($file));
+ }
+
+ public function testTemporaryFileIsCreateInTempDirectoryByDefault(): void
+ {
+ $file = Filesystem\create_temporary_file();
+
+ static::assertSame(Env\temp_dir(), Filesystem\get_directory($file));
+ }
+
+ public function testTemporaryFileThrowsForNonExistingDirectory(): void
+ {
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$directory is not a directory.');
+
+ Filesystem\create_temporary_file(__FILE__);
+ }
+
+ public function testTemporaryFileThrowsForPrefixWithSeparator(): void
+ {
+ $prefix = Str\join(['a', 'b'], Filesystem\SEPARATOR);
+
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage(
+ '$prefix should not contain a directory separator ( "' . Filesystem\SEPARATOR . '" ).'
+ );
+
+ Filesystem\create_temporary_file($this->directory, $prefix);
+ }
+
+ public function testCreateFileAndParentDirectory(): void
+ {
+ $directory = Str\join([$this->directory, 'foo'], Filesystem\SEPARATOR);
+ $file = Str\join([$directory, 'write.txt'], Filesystem\SEPARATOR);
+
+ static::assertFalse(Filesystem\is_directory($directory));
+
+ Filesystem\create_file($file);
+
+ static::assertTrue(Filesystem\is_directory($directory));
+ static::assertTrue(Filesystem\is_file($file));
+ }
+
+ public function testWriteFile(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+
+ static::assertFileDoesNotExist($file);
+
+ Filesystem\write_file($file, 'Hello!');
+
+ static::assertFileExists($file);
+
+ static::assertStringEqualsFile($file, 'Hello!');
+
+ Filesystem\write_file($file, 'Hello');
+
+ static::assertStringEqualsFile($file, 'Hello');
+
+ Filesystem\append_file($file, ', World!');
+
+ static::assertStringEqualsFile($file, 'Hello, World!');
+
+ Filesystem\delete_file($file);
+ }
+
+ public function testWriteFileThrowsForDirectories(): void
+ {
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$file is not a file.');
+
+ Filesystem\write_file($this->directory, 'hello');
+ }
+
+ public function testWriteFileThrowsForNonWritableFiles(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ Filesystem\create_file($file);
+ Filesystem\change_permissions($file, 0111);
+
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$file is not writeable.');
+
+ Filesystem\write_file($file, 'hello');
+ }
+
+ public function testReadFile(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ Filesystem\write_file($file, 'PHP Standard Library');
+ Filesystem\append_file($file, ' - a modern, consistent, centralized');
+ Filesystem\append_file($file, ' well-typed set of APIs for PHP programmers.');
+
+ $content = Filesystem\read_file($file, 0, 20);
+
+ static::assertSame('PHP Standard Library', $content);
+
+ $content = Filesystem\read_file($file, 84, 16);
+
+ static::assertSame('PHP programmers.', $content);
+ }
+
+ public function testFileModificationAndAccessTime(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+
+ $modification_time = time() - 3600;
+ $access_time = time() - 1800;
+
+ Filesystem\create_file($file, $modification_time, $access_time);
+
+ static::assertSame($modification_time, Filesystem\get_modification_time($file));
+ static::assertSame($access_time, Filesystem\get_access_time($file));
+ }
+
+ public function testFileAccessTime(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+
+ $access_time = time() - 1800;
+
+ Filesystem\create_file($file, null, $access_time);
+
+ static::assertSame($access_time, Filesystem\get_modification_time($file));
+ static::assertSame($access_time, Filesystem\get_access_time($file));
+ }
+
+ public function testFileModificationTime(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+
+ $modification_time = time() - 3600;
+
+ Filesystem\create_file($file, $modification_time);
+
+ static::assertSame($modification_time, Filesystem\get_modification_time($file));
+ static::assertSame($modification_time, Filesystem\get_access_time($file));
+ }
+
+ public function testFileChangeTime(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ static::assertEqualsWithDelta(time(), Filesystem\get_change_time($file), 1.0);
+ }
+}
diff --git a/tests/Psl/Filesystem/LinkTest.php b/tests/Psl/Filesystem/LinkTest.php
new file mode 100644
index 00000000..7179160c
--- /dev/null
+++ b/tests/Psl/Filesystem/LinkTest.php
@@ -0,0 +1,187 @@
+directory, 'write.txt'], Filesystem\SEPARATOR);
+ $symlink = Str\join([$this->directory, 'symlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ Filesystem\create_symbolic_link($file, $symlink);
+
+ static::assertTrue(Filesystem\exists($symlink));
+ static::assertTrue(Filesystem\is_symbolic_link($symlink));
+
+ static::assertSame($file, Filesystem\read_symbolic_link($symlink));
+ }
+
+ public function testSymbolicLinkAlreadyExists(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $symlink = Str\join([$this->directory, 'symlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ Filesystem\create_symbolic_link($file, $symlink);
+
+ static::assertTrue(Filesystem\exists($symlink));
+ static::assertTrue(Filesystem\is_symbolic_link($symlink));
+
+ static::assertSame($file, Filesystem\read_symbolic_link($symlink));
+
+ Filesystem\create_symbolic_link($file, $symlink);
+
+ static::assertTrue(Filesystem\exists($symlink));
+ static::assertTrue(Filesystem\is_symbolic_link($symlink));
+
+ static::assertSame($file, Filesystem\read_symbolic_link($symlink));
+ }
+
+ public function testSymbolicLinkOverwrite(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $symbolic_link = Str\join([$this->directory, 'symbolic_link.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+ Filesystem\create_file($symbolic_link);
+
+ static::assertFalse(Filesystem\is_symbolic_link($symbolic_link));
+
+ Filesystem\create_symbolic_link($file, $symbolic_link);
+
+ static::assertTrue(Filesystem\is_symbolic_link($symbolic_link));
+ static::assertSame($file, Filesystem\read_symbolic_link($symbolic_link));
+
+ $file = Str\join([$this->directory, 'foo', 'bar'], Filesystem\SEPARATOR);
+ $symbolic_link = Str\join([$this->directory, 'foo', 'baz'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+ Filesystem\create_directory($symbolic_link);
+
+ static::assertFalse(Filesystem\is_symbolic_link($symbolic_link));
+
+ Filesystem\create_symbolic_link($file, $symbolic_link);
+
+ static::assertTrue(Filesystem\is_symbolic_link($symbolic_link));
+ static::assertSame($file, Filesystem\read_symbolic_link($symbolic_link));
+ }
+
+ public function testSymbolicLinkCreatesDestinationsDirectory(): void
+ {
+ self::runOnlyOnLinux();
+
+ $directory = Str\join([$this->directory, 'foo'], Filesystem\SEPARATOR);
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $symbolic_link = Str\join([$directory, 'symbolic.txt'], Filesystem\SEPARATOR);
+
+ static::assertFalse(Filesystem\is_directory($directory));
+
+ Filesystem\create_file($file);
+ Filesystem\create_symbolic_link($file, $symbolic_link);
+
+ static::assertTrue(Filesystem\is_directory($directory));
+ }
+
+ public function testHardLink(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $hardlink = Str\join([$this->directory, 'hardlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertTrue(Filesystem\exists($hardlink));
+ static::assertFalse(Filesystem\is_symbolic_link($hardlink));
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+ }
+
+ public function testHardLinkAlreadyExists(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $hardlink = Str\join([$this->directory, 'hardlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertTrue(Filesystem\exists($hardlink));
+ static::assertFalse(Filesystem\is_symbolic_link($hardlink));
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+ }
+
+ public function testHardLinkCreatesDestinationDirectory(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $destination_directory = Str\join([$this->directory, 'foo'], Filesystem\SEPARATOR);
+ $hardlink = Str\join([$destination_directory, 'hardlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ static::assertFalse(Filesystem\is_directory($destination_directory));
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertTrue(Filesystem\is_directory($destination_directory));
+ static::assertTrue(Filesystem\exists($hardlink));
+ static::assertFalse(Filesystem\is_symbolic_link($hardlink));
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+ }
+
+ public function testHardLinkOverwrite(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $hardlink = Str\join([$this->directory, 'hardlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+ Filesystem\create_file($hardlink);
+
+ static::assertNotSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+
+ $file = Str\join([$this->directory, 'foo', 'bar'], Filesystem\SEPARATOR);
+ $hardlink = Str\join([$this->directory, 'foo', 'baz'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+ Filesystem\create_directory($hardlink);
+
+ static::assertNotSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+ }
+
+ public function testHardLinkCreatesDestinationsDirectory(): void
+ {
+ $file = Str\join([$this->directory, 'write.txt'], Filesystem\SEPARATOR);
+ $hardlink = Str\join([$this->directory, 'baz', 'hardlink.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($file);
+
+ Filesystem\create_hard_link($file, $hardlink);
+
+ static::assertSame(Filesystem\get_inode($file), Filesystem\get_inode($hardlink));
+ }
+}
diff --git a/tests/Psl/Filesystem/PathTest.php b/tests/Psl/Filesystem/PathTest.php
new file mode 100644
index 00000000..d47a31aa
--- /dev/null
+++ b/tests/Psl/Filesystem/PathTest.php
@@ -0,0 +1,68 @@
+expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$levels must be a positive integer, -3 given.');
+
+ Filesystem\get_directory('/home/azjezz/Projects/psl/src/Psl', -3);
+ }
+}
diff --git a/tests/Psl/Filesystem/PermissionsTest.php b/tests/Psl/Filesystem/PermissionsTest.php
new file mode 100644
index 00000000..8162cc0d
--- /dev/null
+++ b/tests/Psl/Filesystem/PermissionsTest.php
@@ -0,0 +1,56 @@
+directory, 'foo.txt'], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($filename);
+
+ $permissions = Filesystem\get_permissions($filename) & 0777;
+
+ try {
+ Filesystem\change_permissions($filename, 0444);
+
+ static::assertTrue(Filesystem\is_readable($filename));
+ static::assertFalse(Filesystem\is_writable($filename));
+ static::assertFalse(Filesystem\is_executable($filename));
+
+ Filesystem\change_permissions($filename, 0222);
+
+ static::assertTrue(Filesystem\is_writable($filename));
+ static::assertFalse(Filesystem\is_readable($filename));
+ static::assertFalse(Filesystem\is_executable($filename));
+
+ Filesystem\change_permissions($filename, 0111);
+
+ static::assertTrue(Filesystem\is_executable($filename));
+ static::assertFalse(Filesystem\is_writable($filename));
+ static::assertFalse(Filesystem\is_readable($filename));
+
+ Filesystem\change_permissions($filename, 0666);
+
+ static::assertTrue(Filesystem\is_writable($filename));
+ static::assertTrue(Filesystem\is_readable($filename));
+ static::assertFalse(Filesystem\is_executable($filename));
+
+ Filesystem\change_permissions($filename, 0777);
+
+ static::assertTrue(Filesystem\is_writable($filename));
+ static::assertTrue(Filesystem\is_readable($filename));
+ static::assertTrue(Filesystem\is_executable($filename));
+ } finally {
+ Filesystem\change_permissions($filename, $permissions);
+ }
+ }
+}
diff --git a/tests/Psl/Filesystem/ReadDirectoryTest.php b/tests/Psl/Filesystem/ReadDirectoryTest.php
new file mode 100644
index 00000000..f9842920
--- /dev/null
+++ b/tests/Psl/Filesystem/ReadDirectoryTest.php
@@ -0,0 +1,73 @@
+directory, 'hello.txt'
+ ], Filesystem\SEPARATOR));
+
+ Filesystem\create_directory(Str\join([
+ $this->directory, 'foo'
+ ], Filesystem\SEPARATOR));
+
+ $children = Filesystem\read_directory($this->directory);
+
+ static::assertCount(2, $children);
+ static::assertSame([
+ Str\join([$this->directory, 'foo'], Filesystem\SEPARATOR),
+ Str\join([$this->directory, 'hello.txt'], Filesystem\SEPARATOR),
+ ], Vec\sort($children));
+ }
+
+ public function testReadDirectoryThrowsIfDirectoryDoesNotExist(): void
+ {
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$directory does not exists.');
+
+ Filesystem\read_directory(Env\temp_dir() . '/foo-bar-baz');
+ }
+
+ public function testReadDirectoryThrowsIfNotDirectory(): void
+ {
+ $filename = Str\join([
+ $this->directory, 'hello.txt'
+ ], Filesystem\SEPARATOR);
+
+ Filesystem\create_file($filename);
+
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$directory is not a directory.');
+
+ Filesystem\read_directory($filename);
+ }
+
+ public function testReadDirectoryThrowsIfNotReadable(): void
+ {
+ Filesystem\change_permissions($this->directory, 0077);
+
+ $this->expectException(InvariantViolationException::class);
+ $this->expectExceptionMessage('$directory is not readable.');
+
+ try {
+ Filesystem\read_directory($this->directory);
+ } finally {
+ // restore $this->directory permissions, otherwise we won't
+ // be able to delete it.
+ Filesystem\change_permissions($this->directory, 0777);
+ }
+ }
+}