diff --git a/src/Composer.php b/src/Composer.php new file mode 100644 index 0000000..2bbcee1 --- /dev/null +++ b/src/Composer.php @@ -0,0 +1,72 @@ +data['require'] ?? []; + if (! is_array($require)) { + $require = []; + } + $constraint = $require['php'] ?? null; + + if (! is_string($constraint)) { + throw new UnexpectedValueException('The "require.php" field is not set or not a string.'); + } + + try { + $this->versionParser->parseConstraints($constraint); + } catch (\UnexpectedValueException $e) { + $message = sprintf( + 'The "require.php" field is not a valid version constraint: %s', + $e->getMessage(), + ); + throw new UnexpectedValueException($message, previous: $e); + } + + return $constraint; + } +} diff --git a/src/Console/Application.php b/src/Console/Application.php index d0f53d9..135f11c 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -19,6 +19,7 @@ public static function make(): ConsoleApplication ); $app->addCommands([ + new ComposerCommand, new ConstraintCommand, ]); diff --git a/src/Console/ComposerCommand.php b/src/Console/ComposerCommand.php new file mode 100644 index 0000000..d0085f1 --- /dev/null +++ b/src/Console/ComposerCommand.php @@ -0,0 +1,50 @@ +value, + #[ModeOption] + string $mode = Mode::MinorOnly->value, + ): int { + try { + $composer = Composer::fromFile($path); + + $constraint = $composer->requiredPhpConstraint(); + + /** @phpstan-ignore method.notFound,return.type */ + return $application->get('constraint') + ->__invoke($io, $constraint, $source, $mode); + } catch (ExceptionInterface $e) { + $this->printError( + $io, + $e->getMessage() + ); + + return Command::FAILURE; + } + } +} diff --git a/src/Console/ConstraintCommand.php b/src/Console/ConstraintCommand.php index d7892de..8f7e635 100644 --- a/src/Console/ConstraintCommand.php +++ b/src/Console/ConstraintCommand.php @@ -8,8 +8,9 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Style\SymfonyStyle; +use TypistTech\PhpMatrix\Exceptions\ExceptionInterface; +use TypistTech\PhpMatrix\Exceptions\RuntimeException; use TypistTech\PhpMatrix\Versions; -use UnexpectedValueException; #[AsCommand( name: 'constraint', @@ -34,43 +35,39 @@ public function __invoke( #[ModeOption] string $mode = Mode::MinorOnly->value, ): int { - $matrix = $this->matrixFactory->make( - Source::fromValue($source), - Mode::fromValue($mode), - ); - try { + $matrix = $this->matrixFactory->make( + Source::fromValue($source), + Mode::fromValue($mode), + ); + $versions = $matrix->satisfiedBy($constraint); - } catch (UnexpectedValueException $e) { - $this->printError( - $io, - $e->getMessage() + if ($versions === []) { + throw new RuntimeException( + sprintf('No PHP versions could satisfy the constraint "%s".', $constraint), + ); + } + + $result = json_encode( + (object) [ + 'constraint' => $constraint, + 'versions' => Versions::sort(...$versions), + 'lowest' => Versions::lowest(...$versions), + 'highest' => Versions::highest(...$versions), + ], + JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT ); - return Command::FAILURE; - } + $io->writeln($result); - if ($versions === []) { + return Command::SUCCESS; + } catch (ExceptionInterface $e) { $this->printError( $io, - sprintf('No PHP versions could satisfy the constraint "%s".', $constraint) + $e->getMessage() ); return Command::FAILURE; } - - $result = json_encode( - (object) [ - 'constraint' => $constraint, - 'versions' => Versions::sort(...$versions), - 'lowest' => Versions::lowest(...$versions), - 'highest' => Versions::highest(...$versions), - ], - JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT - ); - - $io->writeln($result); - - return Command::SUCCESS; } } diff --git a/src/Exceptions/ExceptionInterface.php b/src/Exceptions/ExceptionInterface.php new file mode 100644 index 0000000..72f7fd0 --- /dev/null +++ b/src/Exceptions/ExceptionInterface.php @@ -0,0 +1,7 @@ +releases->all(), - $constraint - ); + try { + return Semver::satisfiedBy( + $this->releases->all(), + $constraint + ); + } catch (UnexpectedValueException $e) { + throw new Exceptions\UnexpectedValueException( + $e->getMessage(), + previous: $e + ); + } + } } diff --git a/src/Releases/OfflineReleases.php b/src/Releases/OfflineReleases.php index c6b4683..9a66b19 100644 --- a/src/Releases/OfflineReleases.php +++ b/src/Releases/OfflineReleases.php @@ -4,7 +4,7 @@ namespace TypistTech\PhpMatrix\Releases; -use RuntimeException; +use TypistTech\PhpMatrix\Exceptions\RuntimeException; use TypistTech\PhpMatrix\ReleasesInterface; class OfflineReleases implements ReleasesInterface diff --git a/tests/Feature/Console/ConstraintCommandTest.php b/tests/Feature/Console/ConstraintCommandTest.php deleted file mode 100644 index 85f2cd3..0000000 --- a/tests/Feature/Console/ConstraintCommandTest.php +++ /dev/null @@ -1,67 +0,0 @@ - $matrix, - 'constraint' => $constraint, - 'expectedObject' => $expectedObject, - ] = $this->mockMatrix(); - - $matrixFactory = Mockery::mock(MatrixFactory::class); - $matrixFactory->expects() - ->make() - ->withAnyArgs() - ->andReturn($matrix) - ->getMock(); - - $command = new ConstraintCommand($matrixFactory); - - $tester = new CommandTester($command); - $tester->execute(['constraint' => $constraint]); - - $tester->assertCommandIsSuccessful(); - - $actualDisplay = $tester->getDisplay(); - $actualDisplayObject = json_decode($actualDisplay, false, 512, JSON_THROW_ON_ERROR); - - expect($actualDisplayObject)->toEqual($expectedObject); - }); - - it('uses the matrix factory', function (Source $expectedSource, Mode $expectedMode): void { - [ - 'matrix' => $matrix, - 'constraint' => $constraint, - ] = $this->mockMatrix(); - - $matrixFactory = Mockery::mock(MatrixFactory::class); - $matrixFactory->expects() - ->make($expectedSource, $expectedMode) - ->andReturn($matrix) - ->getMock(); - - $command = new ConstraintCommand($matrixFactory); - - $tester = new CommandTester($command); - $tester->execute( - ['constraint' => $constraint, '--source' => $expectedSource->value, '--mode' => $expectedMode->value], - ); - })->with( - Source::cases() - )->with( - Mode::cases() - ); -}); diff --git a/tests/Feature/TestCase.php b/tests/Feature/TestCase.php index 8e965e9..b8775c1 100644 --- a/tests/Feature/TestCase.php +++ b/tests/Feature/TestCase.php @@ -8,9 +8,7 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; -use Mockery; use Tests\TestCase as BaseTestCase; -use TypistTech\PhpMatrix\MatrixInterface; abstract class TestCase extends BaseTestCase { @@ -36,27 +34,4 @@ protected function mockHttpClient(): Http return new Http(['handler' => $handlerStack]); } - - public function mockMatrix(): array - { - $constraint = '^1.2.3'; - $expectedObject = (object) [ - 'constraint' => $constraint, - 'versions' => ['1.2.2', '1.2.4', '1.3.3', '1.4.4'], - 'lowest' => '1.2.2', - 'highest' => '1.4.4', - ]; - - $matrix = Mockery::mock(MatrixInterface::class); - - $matrix->expects() - ->satisfiedBy($constraint) - ->andReturn($expectedObject->versions); - - return [ - 'matrix' => $matrix, - 'constraint' => $constraint, - 'expectedObject' => $expectedObject, - ]; - } } diff --git a/tests/Unit/ComposerTest.php b/tests/Unit/ComposerTest.php new file mode 100644 index 0000000..828071e --- /dev/null +++ b/tests/Unit/ComposerTest.php @@ -0,0 +1,87 @@ +tempFile = tempnam(sys_get_temp_dir(), 'composer_'); + }); + + afterEach(function () { + unlink($this->tempFile); + }); + + describe('::fromFile()', static function (): void { + it('reads valid file', function () { + file_put_contents($this->tempFile, '{"require":{"php":"^8.1"}}'); + + $composer = Composer::fromFile($this->tempFile); + + expect($composer)->toBeInstanceOf(Composer::class); + }); + + it('throws on unreadable file', function () { + $path = '/nonexistent/path/composer.json'; + + Composer::fromFile($path); + })->throws(InvalidArgumentException::class); + + it('throws on invalid JSON', function () { + file_put_contents($this->tempFile, '{invalid json'); + + Composer::fromFile($this->tempFile); + })->throws(JsonException::class); + }); + + describe('::requiredPhpConstraint()', static function (): void { + it('reads valid file', function (string $content) { + file_put_contents($this->tempFile, $content); + + $composer = Composer::fromFile($this->tempFile); + $actual = $composer->requiredPhpConstraint(); + + expect($actual)->toBe('^1.0'); + })->with([ + '{"require":{"php":"^1.0"}}', + '{"require":{"php":"^1.0","some/package":"^1.0"}}', + ]); + + it('throws when no PHP constraint string is set', function (string $content) { + file_put_contents($this->tempFile, $content); + + $composer = Composer::fromFile($this->tempFile); + + $composer->requiredPhpConstraint(); + })->throws(UnexpectedValueException::class) + ->with([ + // No "require.php" is set. + '{"require":{"some/package":"^1.0"}}', + '{"require":{}}', + '{"require":123}', + '{"require-dev":{"php":"^1.0"}}', + '{"php":"^1.0"}', + '{}', + // "require.php" is not a string. + '{"require":{"php":null}}', + '{"require":{"php":{}}}', + '{"require":{"php":[]}}', + '{"require":{"php":123}}', + '{"require":{"php":12.3}}', + '{"require":{"php":true}}', + '{"require":{"php":false}}', + // "require.php" is not a valid constraint. + '{"require":{"php":"invalid constraint"}}', + '{"require":{"php":"~>1.0"}}', + '{"require":{"php":""}}', + ]); + }); +}); diff --git a/tests/Unit/MatrixTest.php b/tests/Unit/MatrixTest.php index 0da4fc2..aae6c50 100644 --- a/tests/Unit/MatrixTest.php +++ b/tests/Unit/MatrixTest.php @@ -5,6 +5,7 @@ namespace Tests\Feature\Releases; use Mockery; +use TypistTech\PhpMatrix\Exceptions\UnexpectedValueException; use TypistTech\PhpMatrix\Matrix; use TypistTech\PhpMatrix\MatrixInterface; use TypistTech\PhpMatrix\ReleasesInterface; @@ -68,5 +69,17 @@ expect($actual)->toBe($expected); })->with('satisfied_by'); + + it('throws on invalid constraint', function () { + $releases = Mockery::mock(ReleasesInterface::class); + + $releases->allows() + ->all() + ->andReturn(['1.0.0', '2.0.0']); + + $matrix = new Matrix($releases); + + $matrix->satisfiedBy('invalid constraint'); + })->throws(UnexpectedValueException::class); }); });