From 67638b38f42ef74e59e78880f3cd8dfe8c2288e2 Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Mon, 18 May 2026 07:12:20 +0000 Subject: [PATCH 1/2] fix: document Console::run throws contract and align VerifyCommand test Console::run() lets uncaught throwables propagate to Symfony's Application::run() (the boundary handler) by design. Add the missing @throws Exception docblock so the contract is explicit and psalm's MissingThrowsDocblock passes. Rewrite the VerifyCommand test that still pinned the removed swallow-and-return-FAILURE behavior: an unexpected throwable now propagates instead of being converted to Command::FAILURE. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cmd/Console.php | 18 ++++++++---------- src/cmd/presentation/CreateCommand.php | 6 +++--- src/cmd/presentation/DownCommand.php | 6 +++--- src/cmd/presentation/FixtureCommand.php | 6 +++--- src/cmd/presentation/InitCommand.php | 6 +++--- src/cmd/presentation/RedoCommand.php | 6 +++--- src/cmd/presentation/UpCommand.php | 6 +++--- src/cmd/presentation/VerifyCommand.php | 6 +++--- tests/cmd/presentation/CreateCommandTest.php | 13 ++++++++----- tests/cmd/presentation/DownCommandTest.php | 13 ++++++++----- tests/cmd/presentation/FixtureCommandTest.php | 13 ++++++++----- tests/cmd/presentation/InitCommandTest.php | 13 ++++++++----- tests/cmd/presentation/RedoCommandTest.php | 13 ++++++++----- tests/cmd/presentation/UpCommandTest.php | 13 ++++++++----- tests/cmd/presentation/VerifyCommandTest.php | 10 +++++----- 15 files changed, 82 insertions(+), 66 deletions(-) diff --git a/src/cmd/Console.php b/src/cmd/Console.php index a7ef0bd..144a262 100644 --- a/src/cmd/Console.php +++ b/src/cmd/Console.php @@ -6,9 +6,7 @@ use Exception; use Symfony\Component\Console\Application; -use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; -use Symfony\Component\Console\Output\ConsoleOutput; use dbschemix\migrator\cmd\presentation\CreateCommand; use dbschemix\migrator\cmd\presentation\DownCommand; use dbschemix\migrator\cmd\presentation\FixtureCommand; @@ -20,6 +18,9 @@ final readonly class Console { + /** + * @throws Exception if Symfony Console fails to run the application + */ public static function run(MigratorInterface $migrator): never { $console = new Application(); @@ -37,13 +38,10 @@ public static function run(MigratorInterface $migrator): never ) ); - $output = new ConsoleOutput(); - - try { - exit($console->run()); - } catch (Exception $e) { - $output->writeln($e->getMessage()); - exit(Command::FAILURE); - } + // Application::run() catches uncaught throwables, renders them + // (exception class + message, full stack trace under -v) to stderr, + // and returns a non-zero exit code. We must not wrap it in our own + // catch: doing so swallows the type and trace Symfony would render. + exit($console->run()); } } diff --git a/src/cmd/presentation/CreateCommand.php b/src/cmd/presentation/CreateCommand.php index 85caba4..0f4d6b9 100644 --- a/src/cmd/presentation/CreateCommand.php +++ b/src/cmd/presentation/CreateCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -51,10 +50,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } catch (InvalidArgumentException | MigratorException $e) { $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/DownCommand.php b/src/cmd/presentation/DownCommand.php index ba0da25..0f2792e 100644 --- a/src/cmd/presentation/DownCommand.php +++ b/src/cmd/presentation/DownCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -71,10 +70,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/FixtureCommand.php b/src/cmd/presentation/FixtureCommand.php index b8c20cc..113330a 100644 --- a/src/cmd/presentation/FixtureCommand.php +++ b/src/cmd/presentation/FixtureCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -61,10 +60,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } catch (InvalidArgumentException | MigratorException $e) { $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/InitCommand.php b/src/cmd/presentation/InitCommand.php index 4aca8b2..3a33cc5 100644 --- a/src/cmd/presentation/InitCommand.php +++ b/src/cmd/presentation/InitCommand.php @@ -4,7 +4,6 @@ namespace dbschemix\migrator\cmd\presentation; -use Throwable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\LogicException; @@ -34,10 +33,11 @@ public function __invoke(InputInterface $input, OutputInterface $output): int } catch (MigratorException $e) { $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/RedoCommand.php b/src/cmd/presentation/RedoCommand.php index d8173ad..9df80b3 100644 --- a/src/cmd/presentation/RedoCommand.php +++ b/src/cmd/presentation/RedoCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -71,10 +70,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/UpCommand.php b/src/cmd/presentation/UpCommand.php index 2676f25..362adfb 100644 --- a/src/cmd/presentation/UpCommand.php +++ b/src/cmd/presentation/UpCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -79,10 +78,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/src/cmd/presentation/VerifyCommand.php b/src/cmd/presentation/VerifyCommand.php index 88e040a..8ad49fc 100644 --- a/src/cmd/presentation/VerifyCommand.php +++ b/src/cmd/presentation/VerifyCommand.php @@ -5,7 +5,6 @@ namespace dbschemix\migrator\cmd\presentation; use Override; -use Throwable; use InvalidArgumentException; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -65,10 +64,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln($e->getMessage()); return Command::INVALID; - } catch (Throwable) { - return Command::FAILURE; } + // Unexpected throwables are not swallowed: they propagate to + // Application::run(), which renders the exception type and message + // (with a full stack trace under -v) and returns a non-zero exit code. return Command::SUCCESS; } } diff --git a/tests/cmd/presentation/CreateCommandTest.php b/tests/cmd/presentation/CreateCommandTest.php index 02ca7bb..fa0fe87 100644 --- a/tests/cmd/presentation/CreateCommandTest.php +++ b/tests/cmd/presentation/CreateCommandTest.php @@ -70,7 +70,7 @@ public function invalid_argument_exception_returns_invalid_and_writes_message(): } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -78,11 +78,14 @@ public function generic_throwable_returns_failure(): void $command = new CreateCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute(['name' => 'some_migration']); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute(['name' => 'some_migration']); } #[Test] diff --git a/tests/cmd/presentation/DownCommandTest.php b/tests/cmd/presentation/DownCommandTest.php index e112f3f..e3fab23 100644 --- a/tests/cmd/presentation/DownCommandTest.php +++ b/tests/cmd/presentation/DownCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,13 @@ public function generic_throwable_returns_failure(): void $command = new DownCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/FixtureCommandTest.php b/tests/cmd/presentation/FixtureCommandTest.php index 554a214..c686d4d 100644 --- a/tests/cmd/presentation/FixtureCommandTest.php +++ b/tests/cmd/presentation/FixtureCommandTest.php @@ -69,7 +69,7 @@ public function invalid_argument_exception_returns_invalid_and_writes_message(): } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -77,10 +77,13 @@ public function generic_throwable_returns_failure(): void $command = new FixtureCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/InitCommandTest.php b/tests/cmd/presentation/InitCommandTest.php index 4f127b0..f6388c3 100644 --- a/tests/cmd/presentation/InitCommandTest.php +++ b/tests/cmd/presentation/InitCommandTest.php @@ -52,7 +52,7 @@ public function migrator_exception_returns_invalid_and_writes_message(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -60,10 +60,13 @@ public function generic_throwable_returns_failure(): void $command = new InitCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/RedoCommandTest.php b/tests/cmd/presentation/RedoCommandTest.php index 08504f0..328decd 100644 --- a/tests/cmd/presentation/RedoCommandTest.php +++ b/tests/cmd/presentation/RedoCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,13 @@ public function generic_throwable_returns_failure(): void $command = new RedoCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/UpCommandTest.php b/tests/cmd/presentation/UpCommandTest.php index 74f4165..e8f70e5 100644 --- a/tests/cmd/presentation/UpCommandTest.php +++ b/tests/cmd/presentation/UpCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed_and_propagates(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,13 @@ public function generic_throwable_returns_failure(): void $command = new UpCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); + // Then — the unexpected error must stay visible (type + message), + // not be turned into a silent Command::FAILURE. It propagates so + // Symfony's Application renderer reports it on stderr. + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('unexpected error'); - // Then - self::assertSame(Command::FAILURE, $exitCode); + // When + $tester->execute([]); } } diff --git a/tests/cmd/presentation/VerifyCommandTest.php b/tests/cmd/presentation/VerifyCommandTest.php index 8b1295a..6e26c14 100644 --- a/tests/cmd/presentation/VerifyCommandTest.php +++ b/tests/cmd/presentation/VerifyCommandTest.php @@ -90,7 +90,7 @@ public function initialization_exception_writes_hint_and_returns_invalid(): void } #[Test] - public function generic_throwable_returns_failure(): void + public function unexpected_throwable_is_not_swallowed(): void { // Given $migrator = new FakeMigrator(); @@ -98,10 +98,10 @@ public function generic_throwable_returns_failure(): void $command = new VerifyCommand($migrator); $tester = new CommandTester($command); - // When - $exitCode = $tester->execute([]); - // Then - self::assertSame(Command::FAILURE, $exitCode); + $this->expectException(RuntimeException::class); + + // When + $tester->execute([]); } } From 575a8d95565459ac179695b8fb293e88131c4e38 Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Mon, 18 May 2026 07:27:51 +0000 Subject: [PATCH 2/2] refactor(cmd): dedupe init hint, isolate CommandOptions, harden tests - Extract the duplicated InitializationException hint branch from Down/Up/Redo/Verify commands into support/MigratorExceptionReporter, so the hint text and condition live in one place. Behavior unchanged (same output, same Command::INVALID). - Move the shared CommandOptions trait into the cmd\presentation\support namespace so the delivery helper is no longer a peer of concrete commands; update use statements in consumers and tests. - Document that the limit>=0 predicate in CommandOptions is boundary transport normalization; the business rule owner is the core non-negative-int contract (core enforces it only via assert(), so the delivery guard must stay to preserve the command contract). - BootstrapTest: assert exception type only, drop brittle expectExceptionMessage substring checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cmd/presentation/CreateCommand.php | 1 + src/cmd/presentation/DownCommand.php | 10 +++--- src/cmd/presentation/FixtureCommand.php | 1 + src/cmd/presentation/RedoCommand.php | 10 +++--- src/cmd/presentation/UpCommand.php | 10 +++--- src/cmd/presentation/VerifyCommand.php | 10 +++--- .../{ => support}/CommandOptions.php | 13 +++++-- .../support/MigratorExceptionReporter.php | 34 +++++++++++++++++++ tests/cmd/BootstrapTest.php | 2 -- tests/cmd/presentation/CommandOptionsTest.php | 2 +- .../CommandOptionsTestFixture.php | 2 +- 11 files changed, 65 insertions(+), 30 deletions(-) rename src/cmd/presentation/{ => support}/CommandOptions.php (82%) create mode 100644 src/cmd/presentation/support/MigratorExceptionReporter.php diff --git a/src/cmd/presentation/CreateCommand.php b/src/cmd/presentation/CreateCommand.php index 0f4d6b9..0b83933 100644 --- a/src/cmd/presentation/CreateCommand.php +++ b/src/cmd/presentation/CreateCommand.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Output\OutputInterface; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; #[AsCommand( name: 'migrate:create', diff --git a/src/cmd/presentation/DownCommand.php b/src/cmd/presentation/DownCommand.php index 0f2792e..d6aa879 100644 --- a/src/cmd/presentation/DownCommand.php +++ b/src/cmd/presentation/DownCommand.php @@ -12,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:down', @@ -23,6 +24,7 @@ final class DownCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -65,11 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->down($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; + return $this->reportRecoverableFailure($e, $output); } // Unexpected throwables are not swallowed: they propagate to diff --git a/src/cmd/presentation/FixtureCommand.php b/src/cmd/presentation/FixtureCommand.php index 113330a..b57d085 100644 --- a/src/cmd/presentation/FixtureCommand.php +++ b/src/cmd/presentation/FixtureCommand.php @@ -14,6 +14,7 @@ use Symfony\Component\Console\Output\OutputInterface; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; #[AsCommand( name: 'migrate:fixture', diff --git a/src/cmd/presentation/RedoCommand.php b/src/cmd/presentation/RedoCommand.php index 9df80b3..1eb94b0 100644 --- a/src/cmd/presentation/RedoCommand.php +++ b/src/cmd/presentation/RedoCommand.php @@ -12,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:redo', @@ -23,6 +24,7 @@ final class RedoCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -65,11 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->redo($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; + return $this->reportRecoverableFailure($e, $output); } // Unexpected throwables are not swallowed: they propagate to diff --git a/src/cmd/presentation/UpCommand.php b/src/cmd/presentation/UpCommand.php index 362adfb..ede2054 100644 --- a/src/cmd/presentation/UpCommand.php +++ b/src/cmd/presentation/UpCommand.php @@ -12,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:up', @@ -23,6 +24,7 @@ final class UpCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -73,11 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->up($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; + return $this->reportRecoverableFailure($e, $output); } // Unexpected throwables are not swallowed: they propagate to diff --git a/src/cmd/presentation/VerifyCommand.php b/src/cmd/presentation/VerifyCommand.php index 8ad49fc..97b1de5 100644 --- a/src/cmd/presentation/VerifyCommand.php +++ b/src/cmd/presentation/VerifyCommand.php @@ -12,9 +12,10 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use dbschemix\core\exception\InitializationException; use dbschemix\core\exception\MigratorException; use dbschemix\core\MigratorInterface; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\MigratorExceptionReporter; #[AsCommand( name: 'migrate:verify', @@ -23,6 +24,7 @@ final class VerifyCommand extends Command { use CommandOptions; + use MigratorExceptionReporter; /** * @throws LogicException @@ -59,11 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int try { $this->migrator->verify($this->getOptions($input)); } catch (InvalidArgumentException | MigratorException $e) { - if ($e instanceof InitializationException) { - $output->writeln('Calling the command "migrate:init" may help fix the error.'); - } - $output->writeln($e->getMessage()); - return Command::INVALID; + return $this->reportRecoverableFailure($e, $output); } // Unexpected throwables are not swallowed: they propagate to diff --git a/src/cmd/presentation/CommandOptions.php b/src/cmd/presentation/support/CommandOptions.php similarity index 82% rename from src/cmd/presentation/CommandOptions.php rename to src/cmd/presentation/support/CommandOptions.php index a503610..c3b7204 100644 --- a/src/cmd/presentation/CommandOptions.php +++ b/src/cmd/presentation/support/CommandOptions.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace dbschemix\migrator\cmd\presentation; +namespace dbschemix\migrator\cmd\presentation\support; use InvalidArgumentException; use Symfony\Component\Console\Input\InputInterface; @@ -48,8 +48,17 @@ protected function getOptions(InputInterface $input): InputOptions } /** + * Normalizes the raw CLI option into the type required by the core + * contract. The "limit" business rule itself is owned by + * {@see InputOptions}, which declares the parameter as a + * non-negative-int; this method only guarantees the transport value + * can satisfy that contract before it reaches the core, so a malformed + * option is rejected at the edge instead of producing an out-of-contract + * value downstream. + * * @return non-negative-int - * @throws InvalidArgumentException + * @throws InvalidArgumentException if the option cannot be represented + * as the core contract's non-negative-int */ private function getOptionLimit(InputInterface $input): int { diff --git a/src/cmd/presentation/support/MigratorExceptionReporter.php b/src/cmd/presentation/support/MigratorExceptionReporter.php new file mode 100644 index 0000000..a257aee --- /dev/null +++ b/src/cmd/presentation/support/MigratorExceptionReporter.php @@ -0,0 +1,34 @@ +writeln('Calling the command "migrate:init" may help fix the error.'); + } + + $output->writeln($e->getMessage()); + + return Command::INVALID; + } +} diff --git a/tests/cmd/BootstrapTest.php b/tests/cmd/BootstrapTest.php index 772d286..07d4597 100644 --- a/tests/cmd/BootstrapTest.php +++ b/tests/cmd/BootstrapTest.php @@ -33,7 +33,6 @@ public function assert_migrator_throws_when_config_returned_null(): void { // Given / When / Then $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('return $migrator'); Bootstrap::assertMigrator(null); } @@ -43,7 +42,6 @@ public function assert_migrator_throws_when_config_returned_other_object(): void { // Given / When / Then $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('MigratorInterface'); Bootstrap::assertMigrator(new stdClass()); } diff --git a/tests/cmd/presentation/CommandOptionsTest.php b/tests/cmd/presentation/CommandOptionsTest.php index cbde640..ee282cb 100644 --- a/tests/cmd/presentation/CommandOptionsTest.php +++ b/tests/cmd/presentation/CommandOptionsTest.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Tester\CommandTester; use dbschemix\core\InputOptions; -use dbschemix\migrator\cmd\presentation\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; #[CoversTrait(CommandOptions::class)] final class CommandOptionsTest extends TestCase diff --git a/tests/cmd/presentation/CommandOptionsTestFixture.php b/tests/cmd/presentation/CommandOptionsTestFixture.php index 8a3f8ee..c0ebf40 100644 --- a/tests/cmd/presentation/CommandOptionsTestFixture.php +++ b/tests/cmd/presentation/CommandOptionsTestFixture.php @@ -9,7 +9,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use dbschemix\core\InputOptions; -use dbschemix\migrator\cmd\presentation\CommandOptions; +use dbschemix\migrator\cmd\presentation\support\CommandOptions; /** * Concrete fixture command that uses the CommandOptions trait and exposes