diff --git a/infection.json.dist b/infection.json.dist index 13c0f673e..e93213c3b 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -16,7 +16,17 @@ "global-ignoreSourceCodeByRegex": [ "Assert::.*" ], + "Coalesce": { + ignore: [ + "KevinGH\\Box\\Console\\Command\\Info\\RequirementsCommand::execute" + ] + }, "IdenticalEqual": false, + "MethodCallRemoval": { + ignore: [ + "KevinGH\\Box\\Console\\Command\\Info\\RequirementsCommand::execute" + ] + }, "NotIdenticalNotEqual": false, "PublicVisibility": false } diff --git a/src/Console/Application.php b/src/Console/Application.php index 42f38d465..15d95120a 100644 --- a/src/Console/Application.php +++ b/src/Console/Application.php @@ -23,6 +23,7 @@ use KevinGH\Box\Console\Command\ExtractCommand; use KevinGH\Box\Console\Command\GenerateDockerFileCommand; use KevinGH\Box\Console\Command\Info\InfoSignatureCommand as InfoSignature; +use KevinGH\Box\Console\Command\Info\RequirementsCommand as InfoRequirements; use KevinGH\Box\Console\Command\InfoCommand; use KevinGH\Box\Console\Command\NamespaceCommand; use KevinGH\Box\Console\Command\ProcessCommand; @@ -105,6 +106,9 @@ public function getCommands(): array new InfoCommand(), new InfoCommand('info:general'), new InfoSignature(), + new InfoRequirements( + new AppRequirementsFactory(), + ), new CheckSignature(), new ProcessCommand(), new ExtractCommand(), diff --git a/src/Console/Command/Info/RequirementsCommand.php b/src/Console/Command/Info/RequirementsCommand.php new file mode 100644 index 000000000..bfbc6a883 --- /dev/null +++ b/src/Console/Command/Info/RequirementsCommand.php @@ -0,0 +1,335 @@ + + * ThΓ©o Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace KevinGH\Box\Console\Command\Info; + +use Fidry\Console\Command\Command; +use Fidry\Console\Command\Configuration as ConsoleConfiguration; +use Fidry\Console\ExitCode; +use Fidry\Console\IO; +use KevinGH\Box\Composer\Artifact\ComposerJson; +use KevinGH\Box\Composer\Artifact\ComposerLock; +use KevinGH\Box\Configuration\Configuration; +use KevinGH\Box\Console\Command\ChangeWorkingDirOption; +use KevinGH\Box\Console\Command\ConfigOption; +use KevinGH\Box\RequirementChecker\AppRequirementsFactory; +use KevinGH\Box\RequirementChecker\Requirement; +use KevinGH\Box\RequirementChecker\Requirements as RequirementsCollection; +use KevinGH\Box\RequirementChecker\RequirementType; +use stdClass; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Input\InputOption; +use function array_map; +use function count; +use function is_array; +use function iter\filter; +use function iter\toArray; + +final readonly class RequirementsCommand implements Command +{ + private const NO_CONFIG_OPTION = 'no-config'; + + public function __construct( + private AppRequirementsFactory $factory, + ) { + } + + public function getConfiguration(): ConsoleConfiguration + { + return new ConsoleConfiguration( + 'info:requirements', + 'Lists the application requirements found', + 'The %command.name% command will list the PHP versions and extensions required to run the built PHAR.', + options: [ + new InputOption( + self::NO_CONFIG_OPTION, + null, + InputOption::VALUE_NONE, + 'Ignore the config file even when one is specified with the `--config` option.', + ), + ConfigOption::getOptionInput(), + ChangeWorkingDirOption::getOptionInput(), + ], + ); + } + + public function execute(IO $io): int + { + ChangeWorkingDirOption::changeWorkingDirectory($io); + + $config = $io->getTypedOption(self::NO_CONFIG_OPTION)->asBoolean() + ? Configuration::create(null, new stdClass()) + : ConfigOption::getConfig($io, true); + + $composerJson = $config->getComposerJson(); + $composerLock = $config->getComposerLock(); + + if (null === $composerJson) { + $io->error('Could not find a composer.json file.'); + + return ExitCode::FAILURE; + } + + if (null === $composerLock) { + $io->error('Could not find a composer.lock file.'); + + return ExitCode::FAILURE; + } + + [ + $phpRequirements, + $requiredExtensions, + $conflictingExtensions, + ] = $this->getAllRequirements( + $composerJson, + $composerLock, + $config, + ); + + $optimizedExtensionRequirements = $this->getOptimizedExtensionRequirements( + $composerJson, + $composerLock, + $config, + ); + + self::renderRequiredPHPVersionsSection($phpRequirements, $io); + $io->newLine(); + + self::renderExtensionsSection( + $requiredExtensions, + $io, + ); + $io->newLine(); + + self::renderOptimizedRequiredExtensionsSection($optimizedExtensionRequirements, $io); + $io->newLine(); + + self::renderConflictingExtensionsSection($conflictingExtensions, $io); + + return ExitCode::SUCCESS; + } + + /** + * @return array{Requirement[], Requirement[], Requirement[]} + */ + private function getAllRequirements( + ComposerJson $composerJson, + ComposerLock $composerLock, + Configuration $config, + ): array { + $requirements = $this->factory->createUnfiltered( + $composerJson, + $composerLock, + $config->getCompressionAlgorithm(), + ); + + return self::filterRequirements($requirements); + } + + /** + * @return Requirement[] + */ + private function getOptimizedExtensionRequirements( + ComposerJson $composerJson, + ComposerLock $composerLock, + Configuration $config, + ): array { + $optimizedRequirements = $this->factory->create( + $composerJson, + $composerLock, + $config->getCompressionAlgorithm(), + ); + + $isExtension = static fn (Requirement $requirement) => RequirementType::EXTENSION === $requirement->type; + + return toArray( + filter($isExtension, $optimizedRequirements), + ); + } + + /** + * @return array{Requirement[], Requirement[], Requirement[], Requirement[]} + */ + private static function filterRequirements(RequirementsCollection $requirements): array + { + $phpRequirements = []; + $requiredExtensions = []; + $conflictingExtensions = []; + + foreach ($requirements as $requirement) { + /** @var Requirement $requirement */ + switch ($requirement->type) { + case RequirementType::PHP: + $phpRequirements[] = $requirement; + break; + + case RequirementType::EXTENSION: + case RequirementType::PROVIDED_EXTENSION: + $requiredExtensions[] = $requirement; + break; + + case RequirementType::EXTENSION_CONFLICT: + $conflictingExtensions[] = $requirement; + break; + } + } + + return [ + $phpRequirements, + $requiredExtensions, + $conflictingExtensions, + ]; + } + + /** + * @param Requirement[] $requirements + */ + private static function renderRequiredPHPVersionsSection( + array $requirements, + IO $io, + ): void { + if (0 === count($requirements)) { + $io->writeln('No PHP constraint found.'); + + return; + } + + $io->writeln('The following PHP constraints were found:'); + + self::renderTable( + $io, + ['Constraints', 'Source'], + array_map( + static fn (Requirement $requirement) => [ + $requirement->condition, + $requirement->source ?? 'root', + ], + $requirements, + ), + ); + } + + /** + * @param Requirement[] $required + */ + private static function renderExtensionsSection( + array $required, + IO $io, + ): void { + if (0 === count($required)) { + $io->writeln('No extension constraint found.'); + + return; + } + + $io->writeln('The following extensions constraints were found:'); + + self::renderTable( + $io, + ['Type', 'Extension', 'Source'], + array_map( + static fn (Requirement $requirement) => [ + match ($requirement->type) { + RequirementType::EXTENSION => 'required', + RequirementType::PROVIDED_EXTENSION => 'provided', + }, + $requirement->condition, + $requirement->source ?? 'root', + ], + $required, + ), + ); + } + + /** + * @param Requirement[] $required + */ + private static function renderOptimizedRequiredExtensionsSection( + array $required, + IO $io, + ): void { + $io->writeln('The required and provided extensions constraints (see above) are resolved to compute the final required extensions.'); + + if (0 === count($required)) { + $io->writeln('The application does not have any extension constraint.'); + + return; + } + + $io->writeln('The application requires the following extension constraints:'); + + self::renderTable( + $io, + ['Extension', 'Source'], + array_map( + static fn (Requirement $requirement) => [ + $requirement->condition, + $requirement->source ?? 'root', + ], + $required, + ), + ); + } + + /** + * @param Requirement[] $conflicting + */ + private static function renderConflictingExtensionsSection( + array $conflicting, + IO $io, + ): void { + if (0 === count($conflicting)) { + $io->writeln('No conflicting extension found.'); + + return; + } + + $io->writeln('Conflicting extensions:'); + + self::renderTable( + $io, + ['Extension', 'Source'], + array_map( + static fn (Requirement $requirement) => [ + $requirement->condition, + $requirement->source ?? 'root', + ], + $conflicting, + ), + ); + } + + private static function renderTable( + IO $io, + array $headers, + array|TableSeparator ...$rowsList, + ): void { + /** @var Table $table */ + $table = $io->createTable(); + $table->setStyle('box'); + + $table->setHeaders($headers); + + foreach ($rowsList as $rowsOrTableSeparator) { + if (is_array($rowsOrTableSeparator)) { + $table->addRows($rowsOrTableSeparator); + } else { + $table->addRow($rowsOrTableSeparator); + } + } + + $table->render(); + } +} diff --git a/src/RequirementChecker/AppRequirementsFactory.php b/src/RequirementChecker/AppRequirementsFactory.php index 5aede6d6e..f1f123a07 100644 --- a/src/RequirementChecker/AppRequirementsFactory.php +++ b/src/RequirementChecker/AppRequirementsFactory.php @@ -22,9 +22,10 @@ /** * Collect the list of requirements for running the application. * + * @final * @private */ -final class AppRequirementsFactory +class AppRequirementsFactory { private const SELF_PACKAGE = null; diff --git a/tests/Console/ApplicationTest.php b/tests/Console/ApplicationTest.php index 0aefedcd6..2a0a3354f 100644 --- a/tests/Console/ApplicationTest.php +++ b/tests/Console/ApplicationTest.php @@ -135,6 +135,7 @@ public function test_get_helper_menu(): void composer:vendor-dir Shows the Composer vendor-dir configured info info:general πŸ” Displays information about the PHAR extension or file + info:requirements Lists the application requirements found info:signature Displays the hash of the signature EOF; diff --git a/tests/Console/Command/Info/RequirementsCommandTest.php b/tests/Console/Command/Info/RequirementsCommandTest.php new file mode 100644 index 000000000..62653ef4d --- /dev/null +++ b/tests/Console/Command/Info/RequirementsCommandTest.php @@ -0,0 +1,143 @@ + + * ThΓ©o Fidry + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Console\Command\Info; + +use Fidry\Console\Test\CommandTester; +use KevinGH\Box\Console\Command\Info\RequirementsCommand; +use KevinGH\Box\RequirementChecker\AppRequirementsFactory; +use KevinGH\Box\RequirementChecker\Requirement; +use KevinGH\Box\RequirementChecker\Requirements; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +/** + * @internal + */ +#[CoversClass(RequirementsCommand::class)] +class RequirementsCommandTest extends TestCase +{ + use ProphecyTrait; + + private AppRequirementsFactory|ObjectProphecy $factoryProphecy; + private CommandTester $commandTester; + + protected function setUp(): void + { + $this->factoryProphecy = $this->prophesize(AppRequirementsFactory::class); + + $this->commandTester = CommandTester::fromConsoleCommand( + new RequirementsCommand($this->factoryProphecy->reveal()), + ); + } + + #[DataProvider('requirementsProvider')] + public function test_it_provides_info_about_the_app_requirements( + Requirements $allRequirements, + Requirements $optimizedRequirements, + string $expected, + ): void { + $this->factoryProphecy + ->createUnfiltered(Argument::cetera()) + ->willReturn($allRequirements); + + $this->factoryProphecy + ->create(Argument::cetera()) + ->willReturn($optimizedRequirements); + + $this->commandTester->execute(['--no-config' => null]); + + $this->commandTester->assertCommandIsSuccessful(); + $display = $this->commandTester->getNormalizedDisplay(); + + self::assertSame($expected, $display); + } + + public static function requirementsProvider(): iterable + { + yield 'empty' => [ + new Requirements([]), + new Requirements([]), + <<<'OUTPUT' + No PHP constraint found. + + No extension constraint found. + + The required and provided extensions constraints (see above) are resolved to compute the final required extensions. + The application does not have any extension constraint. + + No conflicting extension found. + + OUTPUT, + ]; + + yield 'a real case' => [ + new Requirements([ + Requirement::forPHP('>=7.2', null), + Requirement::forRequiredExtension('http', 'package1'), + Requirement::forRequiredExtension('http', 'package2'), + Requirement::forProvidedExtension('http', null), + Requirement::forRequiredExtension('openssl', 'package1'), + Requirement::forProvidedExtension('zip', null), + Requirement::forConflictingExtension('openssl', 'package3'), + Requirement::forConflictingExtension('phar', 'package1'), + ]), + new Requirements([ + Requirement::forRequiredExtension('openssl', 'package1'), + Requirement::forConflictingExtension('openssl', 'package3'), + Requirement::forConflictingExtension('phar', 'package1'), + ]), + <<<'OUTPUT' + The following PHP constraints were found: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Constraints β”‚ Source β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ >=7.2 β”‚ root β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + The following extensions constraints were found: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Type β”‚ Extension β”‚ Source β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ required β”‚ http β”‚ package1 β”‚ + β”‚ required β”‚ http β”‚ package2 β”‚ + β”‚ provided β”‚ http β”‚ root β”‚ + β”‚ required β”‚ openssl β”‚ package1 β”‚ + β”‚ provided β”‚ zip β”‚ root β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + The required and provided extensions constraints (see above) are resolved to compute the final required extensions. + The application requires the following extension constraints: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Extension β”‚ Source β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ openssl β”‚ package1 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + Conflicting extensions: + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Extension β”‚ Source β”‚ + β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ + β”‚ openssl β”‚ package3 β”‚ + β”‚ phar β”‚ package1 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + + OUTPUT, + ]; + } +}