From f48f2b675b83bf7a65c95795ac9a6ed74b3bb12e Mon Sep 17 00:00:00 2001 From: Hafiz Muhammad Moaz Date: Mon, 7 Sep 2026 14:47:02 +0500 Subject: [PATCH] feat: command-decided exit codes via Console::run return (fixes #50) --- src/Command.php | 33 ++++++++++++++++++++++++ src/Commands/Help.php | 1 + src/Console.php | 50 ++++++++++++++++++++++++++++-------- tests/ConsoleTest.php | 42 ++++++++++++++++++++++++++++++ tests/QuitterCommandMock.php | 23 +++++++++++++++++ tests/ValidationTest.php | 3 +++ 6 files changed, 142 insertions(+), 10 deletions(-) create mode 100644 tests/QuitterCommandMock.php diff --git a/src/Command.php b/src/Command.php index 582bd36..f94fcfe 100644 --- a/src/Command.php +++ b/src/Command.php @@ -70,6 +70,10 @@ abstract class Command * Tells if command is active. */ protected bool $active = true; + /** + * The exit code the command wants the process to use. + */ + protected int $exitCode = 0; /** * Command constructor. @@ -449,6 +453,35 @@ protected function definitionLabel(int | string $key, array $definition) : strin return (string) $key; } + /** + * Get the exit code the command wants the process to use. + * + * Returns 0 unless the command called setExitCode() during run(). + * + * @return int + */ + #[Pure] + public function getExitCode() : int + { + return $this->exitCode; + } + + /** + * Set the exit code the process should use after this command runs. + * + * Lets a command fail, or succeed with a specific code, without writing + * to STDERR or terminating the process itself. + * + * @param int $code The exit code, 0 means success + * + * @return static + */ + public function setExitCode(int $code) : static + { + $this->exitCode = $code; + return $this; + } + /** * Tells if the command is active. * diff --git a/src/Commands/Help.php b/src/Commands/Help.php index efff803..06ffac5 100644 --- a/src/Commands/Help.php +++ b/src/Commands/Help.php @@ -44,6 +44,7 @@ protected function showCommand(string $commandName) : void { $command = $this->console->getCommand($commandName); if ($command === null) { + $this->setExitCode(1); CLI::error( $this->console->getLanguage()->render('cli', 'commandNotFound', [$commandName]), \defined('TESTING') ? null : 1 diff --git a/src/Console.php b/src/Console.php index 8323604..c480630 100644 --- a/src/Console.php +++ b/src/Console.php @@ -60,6 +60,11 @@ class Console * applied, restored when the dispatch finishes. */ protected bool $previousAnsi = true; + /** + * The exit code reported by the last dispatched command, or 1 when the + * command was not found or failed validation. + */ + protected int $exitCode = 0; /** * Console constructor. @@ -321,12 +326,18 @@ public function getCommandName() : string } /** - * Run the Console. + * Run the Console and return the resulting exit code. + * + * The code comes from the dispatched Command via setExitCode(), or is 1 + * when the command was not found or failed validation. Entry points can + * forward it to the process with exit($console->run()). + * + * @return int The process exit code, 0 means success */ - public function run() : void + public function run() : int { try { - $this->dispatch(); + return $this->dispatch(); } finally { CLI::setQuiet($this->previousQuiet); CLI::setAnsi($this->previousAnsi); @@ -334,30 +345,47 @@ public function run() : void } /** - * Dispatch the current command. + * Get the exit code reported by the last dispatched command. + * + * @return int + */ + #[Pure] + public function getExitCode() : int + { + return $this->exitCode; + } + + /** + * Dispatch the current command and return its exit code. + * + * @return int The process exit code, 0 means success */ - protected function dispatch() : void + protected function dispatch() : int { + $this->exitCode = 0; if ($this->command === '') { $this->command = 'index'; } if ($this->isHelpRequested()) { $help = $this->getCommand('help') ?? new Help($this); $help->run(); - return; + $this->exitCode = $help->getExitCode(); + return $this->exitCode; } $command = $this->getCommand($this->command); if ($command === null) { $this->commandNotFound($this->command); - return; + return $this->exitCode; } $errors = $command->validate($this->arguments, $this->options); if ($errors !== []) { $this->validationFailed($errors); - return; + return $this->exitCode; } $command->applyDefaults($this); $command->run(); + $this->exitCode = $command->getExitCode(); + return $this->exitCode; } /** @@ -367,6 +395,7 @@ protected function dispatch() : void */ protected function validationFailed(array $errors) : void { + $this->exitCode = 1; $message = \implode(\PHP_EOL, $errors); CLI::error( CLI::style($message, ForegroundColor::brightRed), @@ -405,6 +434,7 @@ protected function isHelpRequested() : bool */ protected function commandNotFound(string $command) : void { + $this->exitCode = 1; $message = $this->getLanguage()->render('cli', 'commandNotFound', [$command]); $suggestion = $this->suggestCommand($command); if ($suggestion !== null && $suggestion !== $command) { @@ -454,12 +484,12 @@ protected function suggestCommand(string $command) : ?string return $best; } - public function exec(string $command) : void + public function exec(string $command) : int { $argumentValues = static::commandToArgs($command); \array_unshift($argumentValues, 'removed'); $this->prepare($argumentValues); - $this->run(); + return $this->run(); } protected function reset() : void diff --git a/tests/ConsoleTest.php b/tests/ConsoleTest.php index 15c06db..a3b3041 100644 --- a/tests/ConsoleTest.php +++ b/tests/ConsoleTest.php @@ -555,4 +555,46 @@ public function testHelpFallsBackToLegacyOptionsMap() : void self::assertStringContainsString('Options', $contents); self::assertStringContainsString('foo bar', $contents); } + + public function testSuccessfulCommandExitsWithZero() : void + { + $this->console->addCommand(new QuitterCommandMock($this->console)); + $this->console->prepare(['file.php', 'quitter', '0']); + self::assertSame(0, $this->console->run()); + self::assertSame(0, $this->console->getExitCode()); + } + + public function testCommandCanDecideTheExitCode() : void + { + $this->console->addCommand(new QuitterCommandMock($this->console)); + $this->console->prepare(['file.php', 'quitter', '2']); + self::assertSame(2, $this->console->run()); + self::assertSame(2, $this->console->getExitCode()); + } + + public function testUnknownCommandYieldsExitCodeOne() : void + { + $this->console->prepare(['file.php', 'nope-nope-nope']); + self::assertSame(1, $this->console->run()); + self::assertSame(1, $this->console->getExitCode()); + } + + public function testValidationFailureYieldsExitCodeOne() : void + { + $command = new CommandMock($this->console); + $command->setArgumentDefinitions([ + 0 => ['type' => 'int', 'required' => true], + ]); + $this->console->addCommand($command); + $this->console->prepare(['file.php', 'test', 'not-an-int']); + self::assertSame(1, $this->console->run()); + self::assertSame(1, $this->console->getExitCode()); + } + + public function testHelpForUnknownCommandYieldsExitCodeOne() : void + { + $this->console->prepare(['file.php', 'help', 'nope-nope-nope']); + self::assertSame(1, $this->console->run()); + self::assertSame(1, $this->console->getExitCode()); + } } diff --git a/tests/QuitterCommandMock.php b/tests/QuitterCommandMock.php new file mode 100644 index 0000000..8f756c4 --- /dev/null +++ b/tests/QuitterCommandMock.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace Tests\CLI; + +use Framework\CLI\Command; + +class QuitterCommandMock extends Command +{ + protected string $name = 'quitter'; + + public function run() : void + { + $code = $this->getConsole()->getArgument(0); + $this->setExitCode($code === null ? 0 : (int) $code); + } +} diff --git a/tests/ValidationTest.php b/tests/ValidationTest.php index 3b73729..f978369 100644 --- a/tests/ValidationTest.php +++ b/tests/ValidationTest.php @@ -274,6 +274,9 @@ public function testMissingNameFallsBackToTheKey() : void public function testApplyDefaultsDirectlyFillsTheConsole() : void { + // Reset the parsed command line so PHPUnit argv values cannot leak in + // and fill the argument slots before the defaults are applied. + $this->console->prepare(['phpunit']); $command = new ValidatedCommandMock($this->console); $command->setArgumentDefinitions([ 0 => ['default' => 'fallback'],