diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..4f6e451
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+ tests/Unit
+
+
+
diff --git a/tests/Unit/VersionBumpTest.php b/tests/Unit/VersionBumpTest.php
new file mode 100644
index 0000000..f6dd408
--- /dev/null
+++ b/tests/Unit/VersionBumpTest.php
@@ -0,0 +1,164 @@
+
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace MokoStandards\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests for cli/version_bump.php
+ */
+class VersionBumpTest extends TestCase
+{
+ private string $tmpDir;
+ private string $script;
+
+ protected function setUp(): void
+ {
+ $this->tmpDir = sys_get_temp_dir() . '/moko-test-' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+ $this->script = dirname(__DIR__, 2) . '/cli/version_bump.php';
+ }
+
+ protected function tearDown(): void
+ {
+ $this->rmdir($this->tmpDir);
+ }
+
+ public function testPatchBump(): void
+ {
+ $this->writeReadme('01.02.03');
+ $output = $this->execute();
+ $this->assertStringContainsString('01.02.04', $output);
+ $this->assertReadmeVersion('01.02.04');
+ }
+
+ public function testPatchBumpRollover(): void
+ {
+ $this->writeReadme('01.02.99');
+ $this->execute();
+ $this->assertReadmeVersion('01.03.00');
+ }
+
+ public function testMinorBump(): void
+ {
+ $this->writeReadme('01.02.03');
+ $this->execute(['--minor']);
+ $this->assertReadmeVersion('01.03.00');
+ }
+
+ public function testMajorBump(): void
+ {
+ $this->writeReadme('01.02.03');
+ $this->execute(['--major']);
+ $this->assertReadmeVersion('02.00.00');
+ }
+
+ public function testBumpsFromHtmlComment(): void
+ {
+ file_put_contents(
+ "{$this->tmpDir}/README.md",
+ "\nSome content\n"
+ );
+
+ $this->execute();
+ $content = file_get_contents("{$this->tmpDir}/README.md");
+ $this->assertStringContainsString('03.05.02', $content);
+ $this->assertStringContainsString('Some content', $content);
+ }
+
+ public function testBumpsWhenXmlHasSuffix(): void
+ {
+ $this->writeReadme('01.00.00');
+ mkdir("{$this->tmpDir}/src", 0755, true);
+ file_put_contents(
+ "{$this->tmpDir}/src/test.xml",
+ ''
+ . '01.00.00-dev'
+ );
+
+ $output = $this->execute();
+ $this->assertStringContainsString('01.00.01', $output);
+ }
+
+ public function testFailsWithNoVersion(): void
+ {
+ file_put_contents(
+ "{$this->tmpDir}/README.md",
+ "# No version\n"
+ );
+
+ $code = 0;
+ $this->execute([], $code);
+ $this->assertSame(1, $code);
+ }
+
+ private function writeReadme(string $version): void
+ {
+ file_put_contents(
+ "{$this->tmpDir}/README.md",
+ "\n"
+ );
+ }
+
+ private function assertReadmeVersion(string $expected): void
+ {
+ $content = file_get_contents("{$this->tmpDir}/README.md");
+ $this->assertMatchesRegularExpression(
+ '/VERSION:\s*' . preg_quote($expected, '/') . '/',
+ $content
+ );
+ }
+
+ /**
+ * @param string[] $extraArgs
+ */
+ private function execute(
+ array $extraArgs = [],
+ int &$exitCode = 0
+ ): string {
+ $cmd = ['php', $this->script, '--path', $this->tmpDir];
+ $cmd = array_merge($cmd, $extraArgs);
+
+ $descriptors = [
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ $proc = proc_open($cmd, $descriptors, $pipes);
+ $stdout = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ $exitCode = proc_close($proc);
+
+ return $stdout ?: '';
+ }
+
+ private function rmdir(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $iter = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator(
+ $dir,
+ \FilesystemIterator::SKIP_DOTS
+ ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($iter as $file) {
+ $file->isDir()
+ ? rmdir($file->getPathname())
+ : unlink($file->getPathname());
+ }
+
+ rmdir($dir);
+ }
+}
diff --git a/tests/Unit/VersionReadTest.php b/tests/Unit/VersionReadTest.php
new file mode 100644
index 0000000..9715c29
--- /dev/null
+++ b/tests/Unit/VersionReadTest.php
@@ -0,0 +1,133 @@
+
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+declare(strict_types=1);
+
+namespace MokoStandards\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Tests for cli/version_read.php
+ */
+class VersionReadTest extends TestCase
+{
+ private string $tmpDir;
+ private string $script;
+
+ protected function setUp(): void
+ {
+ $this->tmpDir = sys_get_temp_dir() . '/moko-test-' . uniqid();
+ mkdir($this->tmpDir, 0755, true);
+ $this->script = dirname(__DIR__, 2) . '/cli/version_read.php';
+ }
+
+ protected function tearDown(): void
+ {
+ $this->rmdir($this->tmpDir);
+ }
+
+ public function testReadsVersionFromReadme(): void
+ {
+ file_put_contents(
+ "{$this->tmpDir}/README.md",
+ "# Test\n\n"
+ );
+
+ $this->assertSame('02.03.04', trim($this->runScript()));
+ }
+
+ public function testReadsVersionFromXmlManifest(): void
+ {
+ mkdir("{$this->tmpDir}/src", 0755, true);
+ file_put_contents(
+ "{$this->tmpDir}/src/test.xml",
+ ''
+ . '05.01.00'
+ );
+
+ $this->assertSame('05.01.00', trim($this->runScript()));
+ }
+
+ public function testStripsStabilitySuffixFromXml(): void
+ {
+ mkdir("{$this->tmpDir}/src", 0755, true);
+ file_put_contents(
+ "{$this->tmpDir}/src/test.xml",
+ ''
+ . '01.00.00-dev'
+ );
+
+ $this->assertSame('01.00.00', trim($this->runScript()));
+ }
+
+ public function testReturnsHigherOfReadmeAndManifest(): void
+ {
+ file_put_contents(
+ "{$this->tmpDir}/README.md",
+ "\n"
+ );
+ mkdir("{$this->tmpDir}/src", 0755, true);
+ file_put_contents(
+ "{$this->tmpDir}/src/test.xml",
+ ''
+ . '01.03.00'
+ );
+
+ $this->assertSame('01.03.00', trim($this->runScript()));
+ }
+
+ public function testExitsNonZeroWhenNoVersion(): void
+ {
+ file_put_contents(
+ "{$this->tmpDir}/README.md",
+ "# No version here\n"
+ );
+
+ $code = 0;
+ $this->runScript($code);
+ $this->assertSame(1, $code);
+ }
+
+ private function runScript(int &$exitCode = 0): string
+ {
+ $proc = proc_open(
+ ['php', $this->script, '--path', $this->tmpDir],
+ [1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
+ $pipes
+ );
+
+ $stdout = stream_get_contents($pipes[1]);
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ $exitCode = proc_close($proc);
+
+ return $stdout ?: '';
+ }
+
+ private function rmdir(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+
+ $iter = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator(
+ $dir,
+ \FilesystemIterator::SKIP_DOTS
+ ),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($iter as $file) {
+ $file->isDir()
+ ? rmdir($file->getPathname())
+ : unlink($file->getPathname());
+ }
+
+ rmdir($dir);
+ }
+}