diff --git a/.gitattributes b/.gitattributes index 1f2ca4f..9b0f066 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,18 +2,16 @@ .github export-ignore .phive export-ignore -tests export-ignore .* export-ignore -box.json.dist export-ignore +*.dist export-ignore +*.yaml export-ignore +*.xml export-ignore composer.lock export-ignore composer-require-* export-ignore -docker-compose.yaml export-ignore Makefile export-ignore -phpunit.xml* export-ignore -psalm.* export-ignore -psalm-baseline.xml export-ignore infection.* export-ignore -codecov.* export-ignore +tests export-ignore +docs export-ignore resources/mock export-ignore *.http binary diff --git a/README.md b/README.md index 295473e..db6307e 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ composer require internal/dload -W ## Command Line Usage -DLoad offers two main commands: +DLoad offers three main commands: ### List Available Software @@ -54,6 +54,21 @@ This displays a list of all registered software packages with their IDs, names, DLoad comes with a pre-configured list of popular tools and software packages ready for download. You can contribute to this list by submitting issues or pull requests to the DLoad repository. +### Show Downloaded Software + +```bash +# View all downloaded software +./vendor/bin/dload show + +# Show detailed information about specific software +./vendor/bin/dload show rr + +# Show all available software, including those not downloaded +./vendor/bin/dload show --all +``` + +This command displays information about downloaded software. + ### Download Software ```bash diff --git a/bin/dload b/bin/dload index f6c9b5c..3dddb33 100644 --- a/bin/dload +++ b/bin/dload @@ -43,6 +43,7 @@ use Symfony\Component\Console\CommandLoader\FactoryCommandLoader; new FactoryCommandLoader([ Command\Get::getDefaultName() => static fn() => new Command\Get(), Command\ListSoftware::getDefaultName() => static fn() => new Command\ListSoftware(), + Command\Show::getDefaultName() => static fn() => new Command\Show(), ]), ); $application->setDefaultCommand(Command\Get::getDefaultName(), false); diff --git a/psalm-baseline.xml b/psalm-baseline.xml index cbb2d80..e37d0a9 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -37,20 +37,26 @@ ]]> - - - - - - - - homepage]]> + + + + + + + + + + + + + + @@ -101,6 +107,22 @@ + + + + + + + + + + + + + + + + diff --git a/psalm.xml b/psalm.xml index 48f9878..212781f 100644 --- a/psalm.xml +++ b/psalm.xml @@ -12,6 +12,11 @@ + + + + + diff --git a/resources/software.json b/resources/software.json index b0b6a56..37a7a50 100644 --- a/resources/software.json +++ b/resources/software.json @@ -4,7 +4,8 @@ "alias": "rr", "binary": { "name": "rr", - "pattern": "/^(roadrunner|rr)(?:\\.exe)?$/" + "pattern": "/^(roadrunner|rr)(?:\\.exe)?$/", + "version-command": "--version" }, "homepage": "https://roadrunner.dev", "description": "High-performance PHP application server, load-balancer and process manager written in Golang", @@ -29,7 +30,8 @@ } ], "binary": { - "name": "temporal" + "name": "temporal", + "version-command": "--version" } }, { @@ -45,7 +47,8 @@ } ], "binary": { - "name": "dolt" + "name": "dolt", + "version-command": "--version" } }, { @@ -75,7 +78,8 @@ } ], "binary": { - "name": "protoc" + "name": "protoc", + "version-command": "--version" } }, { @@ -91,11 +95,12 @@ } ], "binary": { - "name": "tigerbeetle" + "name": "tigerbeetle", + "version-command": "version" } }, { - "name": "CTX ", + "name": "CTX", "alias": "ctx", "description": "Context generator and MCP server", "homepage": "https://docs.ctxgithub.com/", @@ -108,7 +113,25 @@ ], "binary": { "name": "ctx", - "pattern": "/^ctx-.*$/" + "pattern": "/^ctx-.*$/", + "version-command": "--version" + } + }, + { + "name": "Trap", + "alias": "trap", + "description": "A minimized version of the Buggregator Server that does not require Docker and is intended solely for local use.\n", + "homepage": "https://buggregator.dev/", + "repositories": [ + { + "type": "github", + "uri": "buggregator/trap", + "asset-pattern": "/^trap-.*/" + } + ], + "binary": { + "name": "trap", + "version-command": "--version" } } ] diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 3ca60a9..66f356c 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -4,6 +4,8 @@ namespace Internal\DLoad; +use Internal\DLoad\Module\Binary\BinaryProvider; +use Internal\DLoad\Module\Binary\Internal\BinaryProviderImpl; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\Internal\Injection\ConfigLoader; use Internal\DLoad\Module\Common\Internal\ObjectContainer; @@ -100,6 +102,10 @@ public function withConfig( static fn(Container $container): RepositoryProvider => (new RepositoryProvider()) ->addRepositoryFactory($container->get(GithubRepositoryFactory::class)), ); + $this->container->bind( + BinaryProvider::class, + static fn(Container $c): BinaryProvider => $c->get(BinaryProviderImpl::class), + ); return $this; } diff --git a/src/Command/Base.php b/src/Command/Base.php index c6c7a54..564c4cc 100644 --- a/src/Command/Base.php +++ b/src/Command/Base.php @@ -91,7 +91,7 @@ protected function execute( * * @return non-empty-string|null Path to the configuration file */ - private function getConfigFile(InputInterface $input): ?string + protected function getConfigFile(InputInterface $input): ?string { /** @var string|null $config */ $config = $input->getOption('config'); diff --git a/src/Command/Show.php b/src/Command/Show.php new file mode 100644 index 0000000..af8e223 --- /dev/null +++ b/src/Command/Show.php @@ -0,0 +1,321 @@ +addArgument( + 'software', + InputArgument::OPTIONAL, + 'Software name to show detailed information about', + ); + $this->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'Show all available software, not just those configured or downloaded', + ); + } + + protected function execute( + InputInterface $input, + OutputInterface $output, + ): int { + // Always call parent execute first to initialize services + parent::execute($input, $output); + + // Get all software from collection + $collection = $this->container->get(SoftwareCollection::class); + $binaryProvider = $this->container->get(BinaryProvider::class); + $softwareName = (string) $input->getArgument('software'); + + // Get configuration if available + $configFile = $this->getConfigFile($input); + $actions = null; + + if ($configFile !== null) { + $actions = $this->container->get(Actions::class); + } + + if ($softwareName !== '') { + return $this->showSoftwareDetails($softwareName, $collection, $binaryProvider, $actions, $output); + } + + return $this->listAllSoftware($collection, $binaryProvider, $actions, $input, $output); + } + + private function listAllSoftware( + SoftwareCollection $collection, + BinaryProvider $binaryProvider, + ?Actions $actions, + InputInterface $input, + OutputInterface $output, + ): int { + $showAll = (bool) $input->getOption('all'); + $destinationPath = Path::create((string) \getcwd()); + + $configSoftwareIds = []; + if ($actions !== null) { + $configSoftwareIds = \array_map( + static fn($download) => $download->software, + $actions->downloads, + ); + } + + // Track downloaded software to avoid showing them twice + $downloadedSoftwareIds = []; + + // BLOCK 1: Software configured in project + if (!empty($configSoftwareIds)) { + $output->writeln('Configured software:'); + $foundConfigured = false; + + foreach ($collection as $software) { + if ($software->binary === null) { + continue; + } + + if (!\in_array($software->getId(), $configSoftwareIds, true)) { + continue; + } + + $binary = $binaryProvider->getBinary($destinationPath, $software->binary); + if ($binary === null) { + continue; + } + + $foundConfigured = true; + $downloadedSoftwareIds[] = $software->getId(); + + $output->writeln(\sprintf( + ' %s (%s) %s', + $software->getId(), + $binary->getVersion() ?? 'unknown', + $binary->getPath(), + )); + } + + if (!$foundConfigured) { + $output->writeln(' No configured software found'); + } + + $output->writeln(''); + } + + // BLOCK 2: Software downloaded but not configured + $output->writeln('Downloaded software (not configured):'); + $foundDownloaded = false; + + foreach ($collection as $software) { + if ($software->binary === null) { + continue; + } + + // Skip software already shown in configured block + if (\in_array($software->getId(), $downloadedSoftwareIds, true)) { + continue; + } + + // Skip software in project config + if (\in_array($software->getId(), $configSoftwareIds, true)) { + continue; + } + + $binary = $binaryProvider->getBinary($destinationPath, $software->binary); + if ($binary === null) { + continue; + } + + $foundDownloaded = true; + $downloadedSoftwareIds[] = $software->getId(); + + $output->writeln(\sprintf( + ' %s (%s) %s', + $software->getId(), + $binary->getVersion() ?? 'unknown', + $binary->getPath(), + )); + } + + if (!$foundDownloaded) { + $output->writeln(' No additional downloaded software found'); + } + + // BLOCK 3: Other available software (only shown with --all) + if ($showAll) { + $output->writeln(''); + $output->writeln('Other available software:'); + $foundOther = false; + + foreach ($collection as $software) { + if ($software->binary === null) { + continue; + } + + // Skip software already shown in downloaded blocks + if (\in_array($software->getId(), $downloadedSoftwareIds, true)) { + continue; + } + + $foundOther = true; + + $output->writeln(\sprintf( + ' %s %s', + $software->getId(), + $software->description ? '- ' . $software->description : '', + )); + } + + if (!$foundOther) { + $output->writeln(' No other software available'); + } + } else { + $output->writeln(''); + $output->writeln('Use --all flag to show all available software'); + } + + return Command::SUCCESS; + } + + /** + * @param non-empty-string $softwareName + */ + private function showSoftwareDetails( + string $softwareName, + SoftwareCollection $collection, + BinaryProvider $binaryProvider, + ?Actions $actions, + OutputInterface $output, + ): int { + $software = $collection->findSoftware($softwareName); + + if ($software === null) { + $output->writeln(\sprintf('Software "%s" not found in registry', $softwareName)); + return Command::FAILURE; + } + + if ($software->binary === null) { + $output->writeln(\sprintf('Software "%s" does not have a binary', $softwareName)); + return Command::FAILURE; + } + + $destinationPath = \getcwd(); + + + // Check if software is in project config + $inConfig = false; + $configConstraints = null; + $configExtractPath = null; + + if ($actions !== null) { + foreach ($actions->downloads as $download) { + if ($download->software === $software->getId()) { + $inConfig = true; + $configConstraints = $download->version; + $configExtractPath = $download->extractPath; + break; + } + } + } + + // Display detailed information + $output->writeln(\sprintf('Software: %s', $software->name)); + + $software->alias === null or $software->alias === $software->name or $output + ->writeln(\sprintf('Alias: %s', $software->alias)); + $software->description and $output + ->writeln(\sprintf('Description: %s', $software->description)); + $software->homepage === null or $output + ->writeln(\sprintf('Homepage: %s', $software->homepage)); + + // Show project config information + $output->writeln(''); + if ($actions !== null) { + if ($inConfig) { + $output->writeln('Project configuration: Included in project config'); + + if ($configConstraints !== null) { + $output->writeln(\sprintf( + ' Version constraint: %s', + $configConstraints, + )); + } + + if ($configExtractPath !== null) { + $output->writeln(\sprintf( + ' Extract path: %s', + $configExtractPath, + )); + } + } else { + $output->writeln('Project configuration: Not included in project config'); + } + } + + // Binary information + $binary = $binaryProvider->getBinary($destinationPath, $software->binary); + + $this->displayBinaryDetails($binary, $output); + + return Command::SUCCESS; + } + + private function displayBinaryDetails(?Binary $binary, OutputInterface $output): void + { + $output->writeln(''); + if ($binary === null) { + $output->writeln('Binary not exists'); + return; + } + + $binaryPath = $binary->getPath(); + + $output->writeln('Binary information:'); + $output->writeln(\sprintf(' Full path: %s', $binaryPath->absolute())); + $output->writeln(\sprintf(' Version: %s', $binary->getVersion() ?? 'unknown')); + $output->writeln(\sprintf(' Size: %s', $this->formatSize($binary->getSize()))); + + $mtime = $binary->getMTime(); + $mtime === null or $output->writeln(\sprintf( + ' Last modified: %s', + $mtime->format('Y-m-d H:i:s'), + )); + } + + private function formatSize(?int $bytes): string + { + if ($bytes === null) { + return 'unknown'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $i = 0; + while ($bytes >= 1024 && $i < \count($units) - 1) { + $bytes /= 1024; + $i++; + } + + return \sprintf('%.2f %s', $bytes, $units[$i]); + } +} diff --git a/src/DLoad.php b/src/DLoad.php index a536758..a682e56 100644 --- a/src/DLoad.php +++ b/src/DLoad.php @@ -5,13 +5,13 @@ namespace Internal\DLoad; use Internal\DLoad\Module\Archive\ArchiveFactory; +use Internal\DLoad\Module\Binary\BinaryProvider; use Internal\DLoad\Module\Common\Config\Action\Download as DownloadConfig; use Internal\DLoad\Module\Common\Config\Embed\File; use Internal\DLoad\Module\Common\Config\Embed\Software; use Internal\DLoad\Module\Common\Input\Destination; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Downloader\Downloader; -use Internal\DLoad\Module\Downloader\Internal\BinaryExistenceChecker; use Internal\DLoad\Module\Downloader\SoftwareCollection; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; @@ -49,7 +49,7 @@ public function __construct( private readonly ArchiveFactory $archiveFactory, private readonly Destination $configDestination, private readonly OutputInterface $output, - private readonly BinaryExistenceChecker $binaryChecker, + private readonly BinaryProvider $binaryProvider, private readonly OperatingSystem $os, ) {} @@ -57,7 +57,7 @@ public function __construct( * Adds a download task to the execution queue. * * Creates and schedules a task to download and extract a software package based on the provided action. - * Skips task creation if binary already exists and force flag is not set. + * Skips task creation if binary already exists with a satisfying version and force flag is not set. * * @param DownloadConfig $action Download configuration action * @param bool $force Whether to force download even if binary exists @@ -70,16 +70,32 @@ public function addTask(DownloadConfig $action, bool $force = false): void "Software `{$action->software}` not found in registry.", ); - // Check if binary already exists + // Check if binary already exists and satisfies version constraint $destinationPath = $this->getDestinationPath($action); - if (!$force && $software->binary !== null && $this->binaryChecker->exists($destinationPath, $software->binary)) { - $binaryPath = $this->binaryChecker->buildBinaryPath($destinationPath, $software->binary); - $this->logger->info( - "Binary {$binaryPath} already exists. Skipping download. Skipping download. Use --force to override.", - ); - - // Skip task creation entirely - return; + + if (!$force && $software->binary !== null) { + // Check different constraints + $binary = $this->binaryProvider->getBinary($destinationPath, $software->binary); + + // Check if binary exists and satisfies version constraint + if ($binary !== null && ($version = $binary->getVersion()) !== null) { + if ($action->version !== null && $binary->satisfiesVersion($action->version)) { + $this->logger->info( + 'Binary `%s` exists with version `%s`, satisfies constraint `%s`. Skipping download. Use `--force` to override.', + $binary->getName(), + (string) $binary->getVersion(), + $action->version, + ); + + // Skip task creation entirely + return; + } + + // Download a newer version only if version is specified + if ($version) { + // todo + } + } } $this->taskManager->addTask(function () use ($software, $action): void { diff --git a/src/Module/Binary/Binary.php b/src/Module/Binary/Binary.php new file mode 100644 index 0000000..c4b0579 --- /dev/null +++ b/src/Module/Binary/Binary.php @@ -0,0 +1,58 @@ +&1", $output, $returnCode); + + // If command failed, throw exception + if ($returnCode !== 0) { + throw new BinaryExecutionException( + \sprintf( + 'Failed to execute binary "%s" with command "%s". Exit code: %d. Output: %s', + $binaryPath, + $command, + $returnCode, + \implode("\n", $output), + ), + ); + } + + // Return combined output + return \implode("\n", $output); + } +} diff --git a/src/Module/Binary/Internal/BinaryInfo.php b/src/Module/Binary/Internal/BinaryInfo.php new file mode 100644 index 0000000..0c68a8f --- /dev/null +++ b/src/Module/Binary/Internal/BinaryInfo.php @@ -0,0 +1,108 @@ +name; + } + + public function getPath(): Path + { + return $this->path; + } + + public function exists(): bool + { + return $this->path->exists(); + } + + /** + * @return non-empty-string|null + */ + public function getVersion(): ?string + { + if ($this->version !== null) { + return $this->version === '' ? null : $this->version; + } + + if (!$this->exists() || $this->config->versionCommand === null) { + return null; + } + + try { + $output = $this->executor->execute($this->path, $this->config->versionCommand); + $this->version = (string) $this->versionResolver->resolveVersion($output); + return $this->version === '' ? null : $this->version; + } catch (\Throwable) { + $this->version = ''; + return null; + } + } + + public function satisfiesVersion(string $versionConstraint): ?bool + { + $version = $this->getVersion(); + return $version === null + ? null + : $this->versionComparator->satisfies($version, $versionConstraint); + } + + public function getSize(): ?int + { + if (!$this->exists()) { + return null; + } + + $size = \filesize((string) $this->path); + return $size === false ? null : $size; + } + + public function getMTime(): ?\DateTimeImmutable + { + if (!$this->exists()) { + return null; + } + + $mtime = \filemtime((string) $this->path); + if ($mtime === false) { + return null; + } + + try { + return new \DateTimeImmutable('@' . $mtime); + } catch (\Exception) { + return null; + } + } +} diff --git a/src/Module/Binary/Internal/BinaryProviderImpl.php b/src/Module/Binary/Internal/BinaryProviderImpl.php new file mode 100644 index 0000000..d924822 --- /dev/null +++ b/src/Module/Binary/Internal/BinaryProviderImpl.php @@ -0,0 +1,56 @@ +buildBinaryPath($destinationPath, $config); + + // Create binary instance + $binary = new BinaryInfo( + name: $config->name, + path: $binaryPath, + config: $config, + executor: $this->executor, + versionResolver: $this->versionResolver, + versionComparator: $this->versionComparator, + ); + + // Return binary only if it exists + return $binary->exists() ? $binary : null; + } + + /** + * Builds the path to a binary without checking if it exists. + * + * @param Path|non-empty-string $destinationPath Directory path + * @param BinaryConfig $config Binary configuration + * @internal + */ + private function buildBinaryPath(Path|string $destinationPath, BinaryConfig $config): Path + { + return Path::create($destinationPath) + ->join("{$config->name}{$this->operatingSystem->getBinaryExtension()}"); + } +} diff --git a/src/Module/Binary/Internal/VersionComparator.php b/src/Module/Binary/Internal/VersionComparator.php new file mode 100644 index 0000000..878f865 --- /dev/null +++ b/src/Module/Binary/Internal/VersionComparator.php @@ -0,0 +1,302 @@ +satisfiesCaretRange($version, \substr($constraint, 1)); + } + + if (\str_starts_with($constraint, '~')) { + return $this->satisfiesTildeRange($version, \substr($constraint, 1)); + } + + if (\str_contains($constraint, ' ')) { + return $this->satisfiesExplicitRange($version, $constraint); + } + + // Default to exact version comparison for simple constraints + return $this->compareVersions($version, $constraint) === 0; + } + + /** + * Compares two versions and returns comparison result. + * + * @param string $versionA First version + * @param string $versionB Second version + * @return int -1 if A < B, 0 if A = B, 1 if A > B + */ + public function compareVersions(string $versionA, string $versionB): int + { + // Normalize versions + $versionA = \ltrim($versionA, 'v'); + $versionB = \ltrim($versionB, 'v'); + + // Extract components + $componentsA = $this->extractVersionComponents($versionA); + $componentsB = $this->extractVersionComponents($versionB); + + // Compare major, minor, patch + for ($i = 0; $i < 3; $i++) { + $a = $componentsA[$i] ?? 0; + $b = $componentsB[$i] ?? 0; + + if ($a > $b) { + return 1; + } + + if ($a < $b) { + return -1; + } + } + + // If we reach here, major.minor.patch are equal + // Compare pre-release and build metadata + return $this->comparePreRelease($versionA, $versionB); + } + + /** + * Extracts version components from a version string. + * + * @param string $version Version string + * @return array Array of major, minor, patch components + */ + private function extractVersionComponents(string $version): array + { + // Remove build metadata and pre-release parts + $versionOnly = \preg_replace('/[-+].*$/', '', $version); + + // Split into components + $parts = \explode('.', $versionOnly); + + // Convert to integers and ensure we have 3 parts + return [ + (int) ($parts[0] ?? 0), + (int) ($parts[1] ?? 0), + (int) ($parts[2] ?? 0), + ]; + } + + /** + * Compares pre-release versions according to semver rules. + * + * @param string $versionA First version + * @param string $versionB Second version + * @return int -1 if A < B, 0 if A = B, 1 if A > B + */ + private function comparePreRelease(string $versionA, string $versionB): int + { + // Extract pre-release parts + \preg_match('/-([^+]+)/', $versionA, $preReleaseA); + \preg_match('/-([^+]+)/', $versionB, $preReleaseB); + + $hasPreA = isset($preReleaseA[1]); + $hasPreB = isset($preReleaseB[1]); + + // No pre-release > Has pre-release + if (!$hasPreA && $hasPreB) { + return 1; + } + + if ($hasPreA && !$hasPreB) { + return -1; + } + + if (!$hasPreA && !$hasPreB) { + return 0; + } + + // Both have pre-release identifiers, compare them + $identifiersA = \explode('.', $preReleaseA[1]); + $identifiersB = \explode('.', $preReleaseB[1]); + + $count = \min(\count($identifiersA), \count($identifiersB)); + + for ($i = 0; $i < $count; $i++) { + $a = $identifiersA[$i]; + $b = $identifiersB[$i]; + + // Numeric identifiers always have lower precedence than non-numeric + $aIsNum = \ctype_digit($a); + $bIsNum = \ctype_digit($b); + + if ($aIsNum && !$bIsNum) { + return -1; + } + + if (!$aIsNum && $bIsNum) { + return 1; + } + + // If both are numeric or both are non-numeric, compare normally + if ($aIsNum && $bIsNum) { + $aVal = (int) $a; + $bVal = (int) $b; + + if ($aVal > $bVal) { + return 1; + } + + if ($aVal < $bVal) { + return -1; + } + } else { + // Non-numeric comparison + $comparison = \strcmp($a, $b); + + if ($comparison !== 0) { + return $comparison > 0 ? 1 : -1; + } + } + } + + // If we've compared all common identifiers and they're equal, + // the one with more identifiers has lower precedence + return \count($identifiersA) <=> \count($identifiersB); + } + + /** + * Checks if a version satisfies a caret range constraint (^X.Y.Z). + * Allows changes that don't modify the most significant non-zero digit. + * + * @param string $version Version to check + * @param string $constraint Base version for the constraint (without ^) + * @return bool True if version satisfies constraint + */ + private function satisfiesCaretRange(string $version, string $constraint): bool + { + $components = $this->extractVersionComponents($constraint); + + // Find the most significant non-zero component + $significantIndex = 0; + foreach ($components as $i => $value) { + if ($value > 0) { + $significantIndex = $i; + break; + } + } + + // Create lower bound (same as constraint) + $lowerBound = $constraint; + + // Create upper bound by incrementing the significant digit and zeroing others + $upperComponents = $components; + $upperComponents[$significantIndex]++; + + for ($i = $significantIndex + 1; $i < \count($upperComponents); $i++) { + $upperComponents[$i] = 0; + } + + $upperBound = \implode('.', $upperComponents); + + // Check if version is in range [lowerBound, upperBound) + return $this->compareVersions($version, $lowerBound) >= 0 && + $this->compareVersions($version, $upperBound) < 0; + } + + /** + * Checks if a version satisfies a tilde range constraint (~X.Y.Z). + * Allows patch-level changes if minor version is specified, + * minor-level changes if only major version is specified. + * + * @param string $version Version to check + * @param string $constraint Base version for the constraint (without ~) + * @return bool True if version satisfies constraint + */ + private function satisfiesTildeRange(string $version, string $constraint): bool + { + $components = $this->extractVersionComponents($constraint); + $versionParts = \explode('.', \preg_replace('/[-+].*$/', '', $constraint)); + + // Determine the index to increment based on constraint specificity + $incrementIndex = \count($versionParts) > 1 ? 1 : 0; + + // Create lower bound (same as constraint) + $lowerBound = $constraint; + + // Create upper bound by incrementing the appropriate digit and zeroing others + $upperComponents = $components; + $upperComponents[$incrementIndex]++; + + for ($i = $incrementIndex + 1; $i < \count($upperComponents); $i++) { + $upperComponents[$i] = 0; + } + + $upperBound = \implode('.', $upperComponents); + + // Check if version is in range [lowerBound, upperBound) + return $this->compareVersions($version, $lowerBound) >= 0 && + $this->compareVersions($version, $upperBound) < 0; + } + + /** + * Checks if a version satisfies an explicit range constraint (e.g., ">1.0.0 <2.0.0"). + * + * @param string $version Version to check + * @param string $constraint Range constraint + * @return bool True if version satisfies constraint + */ + private function satisfiesExplicitRange(string $version, string $constraint): bool + { + $conditions = \explode(' ', \trim($constraint)); + + foreach ($conditions as $condition) { + if (empty($condition)) { + continue; + } + + // Extract the operator and version + \preg_match('/^([<>=!~^]+)(.*)$/', $condition, $matches); + + if (!isset($matches[1]) || !isset($matches[2])) { + continue; + } + + $operator = $matches[1]; + $conditionVersion = $matches[2]; + + $comparison = $this->compareVersions($version, $conditionVersion); + + // Check if condition is met + $satisfied = match ($operator) { + '>' => $comparison > 0, + '>=' => $comparison >= 0, + '<' => $comparison < 0, + '<=' => $comparison <= 0, + '=' => $comparison === 0, + '==' => $comparison === 0, + '!=' => $comparison !== 0, + '^' => $this->satisfiesCaretRange($version, $conditionVersion), + '~' => $this->satisfiesTildeRange($version, $conditionVersion), + default => false, + }; + + // If any condition is not satisfied, the range is not satisfied + if (!$satisfied) { + return false; + } + } + + // All conditions were satisfied + return true; + } +} diff --git a/src/Module/Binary/Internal/VersionResolver.php b/src/Module/Binary/Internal/VersionResolver.php new file mode 100644 index 0000000..d96a679 --- /dev/null +++ b/src/Module/Binary/Internal/VersionResolver.php @@ -0,0 +1,55 @@ +extractVersionWithFallbacks($output); + } + + /** + * Attempts to extract version using various fallback patterns. + * + * @param string $output Output from binary execution + * @return string|null Extracted version or null if no version found + */ + private function extractVersionWithFallbacks(string $output): ?string + { + // Fallback pattern for simple digits-only version (e.g., "2", "15") + if (\preg_match('/version:?\s*(\d+)/i', $output, $matches)) { + return $matches[1]; + } + + // Fallback pattern for partial semver (e.g., "2.0") + if (\preg_match('/version:?\s*(\d+\.\d+)/i', $output, $matches)) { + return $matches[1]; + } + + // No version could be extracted + return null; + } +} diff --git a/src/Module/Common/FileSystem/Path.php b/src/Module/Common/FileSystem/Path.php new file mode 100644 index 0000000..e79b851 --- /dev/null +++ b/src/Module/Common/FileSystem/Path.php @@ -0,0 +1,246 @@ +path; + + foreach ($paths as $path) { + if ($path instanceof self) { + $path->isAbsolute and throw new \LogicException('Joining an absolute path is not allowed.'); + $result .= self::DS . $path->path; + continue; + } + + if ($path === '') { + continue; + } + + $path = self::normalizePath($path); + self::_isAbsolute($path) and throw new \LogicException('Joining an absolute path is not allowed.'); + + $result .= self::DS . $path; + } + + // We return the raw string, not a normalized path, since it's already normalized + return self::create($result); + } + + /** + * Return the file name (the final path component) + */ + public function name(): string + { + $pos = \strrpos($this->path, self::DS); + return $pos === false + ? $this->path + : \substr($this->path, $pos + 1); + } + + /** + * Return the file stem (the file name without its extension) + */ + public function stem(): string + { + $name = $this->name(); + $pos = \strrpos($name, '.'); + return $pos === false || $pos === 0 ? $name : \substr($name, 0, $pos); + } + + /** + * Return the file suffix (extension) without the leading dot + */ + public function extension(): string + { + $name = $this->name(); + + return \pathinfo($name, PATHINFO_EXTENSION); + } + + /** + * Return whether this path is absolute + */ + public function isAbsolute(): bool + { + return $this->isAbsolute; + } + + /** + * Return whether this path is relative + */ + public function isRelative(): bool + { + return !$this->isAbsolute; + } + + /** + * Check if the path exists. + */ + public function exists(): bool + { + return \file_exists($this->path); + } + + /** + * Check if the path is a directory. + * True as the result doesn't mean that the directory exists. + */ + public function isDir(): bool + { + return match (true) { + $this->path === '.', + $this->path === '..', + $this->isAbsolute && \substr($this->path, -2) === self::DS . '.', + \is_dir($this->path) => true, + default => false, + }; + } + + /** + * Check if the path is a file. + * True as the result doesn't mean that the file exists. + */ + public function isFile(): bool + { + return match (true) { + $this->path === '.', + $this->path === '..', + $this->isAbsolute && \substr($this->path, -2) === self::DS . '.' => false, + \is_file($this->path) => true, + default => false, + }; + } + + /** + * Return a normalized absolute version of this path + */ + public function absolute(): self + { + if ($this->isAbsolute()) { + return $this; + } + + $cwd = \getcwd(); + $cwd === false and throw new \RuntimeException('Cannot get current working directory.'); + return self::create($cwd . self::DS . $this->path); + } + + public function __toString(): string + { + return $this->path; + } + + /** + * Check if a path is absolute + * + * @param non-empty-string $path A normalized path + */ + private static function _isAbsolute(string $path): bool + { + return \preg_match('~^[a-zA-Z]:~', $path) === 1 || \str_starts_with($path, self::DS); + } + + /** + * Normalize a path by converting directory separators and resolving special path segments + * + * @return non-empty-string + */ + private static function normalizePath(string $path): string + { + // Normalize directory separators + $path = \trim(\str_replace(['\\', '/'], self::DS, $path)); + + // Normalize multiple separators + $path = (string) \preg_replace( + '~' . \preg_quote(self::DS, '~') . '{2,}~', + self::DS, + $path, + ); + + // Empty path becomes current directory + if ($path === '') { + return '.'; + } + + + // Determine if the path is absolute + $isAbsolute = self::_isAbsolute($path); + + // Resolve special path segments + $parts = \explode(self::DS, $path); + /** @var non-empty-string|null $driverLetter */ + + if ($isAbsolute && \preg_match('~^([a-zA-Z]):~', $path, $matches) === 1) { + // Windows-style path with a drive letter + $driverLetter = $matches[1]; + \array_shift($parts); + } else { + $driverLetter = null; + } + + $result = []; + foreach ($parts as $part) { + $part = \trim($part, ' '); + if ($part === '.' || $part === '') { + continue; + } + + if ($part === '..') { + if ($result !== [] && $result[\array_key_last($result)] !== '..') { + \array_pop($result); + continue; + } + + $isAbsolute and throw new \LogicException("Cannot go up from root in `{$path}`"); + $result[] = '..'; + continue; + } + + $result[] = $part; + } + + // Reconstruct the path + $normalizedPath = $result === [] ? '.' : \implode(self::DS, $result); + + // Add an absolute path prefix if necessary + if ($isAbsolute) { + $normalizedPath = $driverLetter !== null + ? "$driverLetter:" . self::DS . $normalizedPath + : self::DS . $normalizedPath; + } + + return $normalizedPath; + } +} diff --git a/src/Service/Logger.php b/src/Service/Logger.php index 9c0add7..c594628 100644 --- a/src/Service/Logger.php +++ b/src/Service/Logger.php @@ -89,7 +89,7 @@ public function debug(string $message, string|int|float|bool ...$values): void */ public function error(string $message, string|int|float|bool ...$values): void { - $this->echo("\033[31m" . \sprintf($message, ...self::values($values)) . "\033[0m\n"); + $this->echo("\033[31m" . \sprintf($message, ...self::values($values)) . "\033[0m\n", false); } /** diff --git a/tests/Unit/Module/Common/FileSystem/PathTest.php b/tests/Unit/Module/Common/FileSystem/PathTest.php new file mode 100644 index 0000000..09f297b --- /dev/null +++ b/tests/Unit/Module/Common/FileSystem/PathTest.php @@ -0,0 +1,425 @@ + ['C:/Users/test', true]; + yield 'windows drive letter' => ['C:', true]; + yield 'windows relative path' => ['Users/test', false]; + yield 'windows implicit relative' => ['./test', false]; + yield 'unix absolute path' => ['/home/user', true]; + yield 'unix relative path' => ['home/user', false]; + yield 'unix implicit relative' => ['./test', false]; + yield 'dot path' => ['.', false]; + yield 'double dot path' => ['..', false]; + } + + public function testCreateReturnsPathInstance(): void + { + // Arrange & Act + $path = Path::create('test/path'); + + // Assert + self::assertInstanceOf(Path::class, $path); + } + + public function testCreateWithEmptyPathReturnsCurrentDirectory(): void + { + // Arrange & Act + $path = Path::create(''); + + // Assert + self::assertSame('.', (string) $path); + } + + public function testCreateNormalizesDirectorySeparators(): void + { + // Arrange & Act + $path = Path::create('test\\path/mixed/separators\\here'); + + // Assert + self::assertSame('test/path/mixed/separators/here', (string) $path); + } + + public function testCreateRemovesMultipleSeparators(): void + { + // Arrange & Act + $path = Path::create('test//path///extra//separators'); + + // Assert + self::assertSame('test/path/extra/separators', (string) $path); + } + + public function testCreateResolvesCurrentDirectorySegments(): void + { + // Arrange & Act + $path = Path::create('test/./path/./current'); + + // Assert + self::assertSame('test/path/current', (string) $path); + } + + public function testCreateResolvesParentDirectorySegments(): void + { + // Arrange & Act + $path = Path::create('test/parent/../path'); + + // Assert + self::assertSame('test/path', (string) $path); + } + + public function testCreateThrowsExceptionForInvalidParentNavigation(): void + { + // Arrange & Assert + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot go up from root'); + + // Act + Path::create('/test/../..'); + } + + public function testJoinPathComponents(): void + { + // Arrange + $path = Path::create('base/path'); + + // Act + $result = $path->join('additional', 'components'); + + // Assert + self::assertSame('base/path/additional/components', (string) $result); + } + + public function testJoinWithEmptyComponentsIgnoresThem(): void + { + // Arrange + $path = Path::create('base/path'); + + // Act + $result = $path->join('', 'component', ''); + + // Assert + self::assertSame('base/path/component', (string) $result); + } + + public function testJoinWithPathObjects(): void + { + // Arrange + $path = Path::create('base/path'); + $additionalPath = Path::create('additional/path'); + + // Assert (prepare for expected exception) + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Joining an absolute path is not allowed'); + + // Act + // Using an absolute Path object which should throw + $path->join($additionalPath->absolute()); + } + + public function testJoinWithRelativePathObjects(): void + { + // Arrange + $path = Path::create('base/path'); + $additionalPath = Path::create('additional/path'); + + // Act + $result = $path->join($additionalPath); + + // Assert + self::assertSame('base/path/additional/path', (string) $result); + } + + public function testJoinWithAbsolutePathString(): void + { + // Arrange + $path = Path::create('base/path'); + + // Assert (prepare for expected exception) + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Joining an absolute path is not allowed'); + + // Act + $path->join('/absolute/path'); + } + + public function testName(): void + { + // Arrange + $path = Path::create('some/path/file.txt'); + + // Act + $name = $path->name(); + + // Assert + self::assertSame('file.txt', $name); + } + + public function testNameWithNoDirectoryComponents(): void + { + // Arrange + $path = Path::create('file.txt'); + + // Act + $name = $path->name(); + + // Assert + self::assertSame('file.txt', $name); + } + + public function testStem(): void + { + // Arrange + $path = Path::create('some/path/file.txt'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('file', $stem); + } + + public function testStemWithNoExtension(): void + { + // Arrange + $path = Path::create('some/path/file'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('file', $stem); + } + + public function testStemWithMultipleDots(): void + { + // Arrange + $path = Path::create('some/path/file.config.json'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('file.config', $stem); + } + + public function testStemWithHiddenFile(): void + { + // Arrange + $path = Path::create('some/path/.hidden'); + + // Act + $stem = $path->stem(); + + // Assert + self::assertSame('.hidden', $stem); + } + + public function testExtension(): void + { + // Arrange + $path = Path::create('some/path/file.txt'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('txt', $extension); + } + + public function testExtensionWithMultipleDots(): void + { + // Arrange + $path = Path::create('some/path/file.config.json'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('json', $extension); + } + + public function testExtensionWithNoExtension(): void + { + // Arrange + $path = Path::create('some/path/file'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('', $extension); + } + + public function testExtensionWithHiddenFile(): void + { + // Arrange + $path = Path::create('some/path/.hidden'); + + // Act + $extension = $path->extension(); + + // Assert + self::assertSame('hidden', $extension); + } + + #[DataProvider('providePathsForAbsoluteDetection')] + public function testIsAbsolute(string $pathString, bool $expected): void + { + // Arrange + $path = Path::create($pathString); + + // Act + $isAbsolute = $path->isAbsolute(); + + // Assert + self::assertSame($expected, $isAbsolute, "Path '$pathString' should be " . ($expected ? 'absolute' : 'relative')); + } + + public function testIsRelative(): void + { + // Arrange + $absolutePath = DIRECTORY_SEPARATOR === '\\' + ? Path::create('C:/Users/test') + : Path::create('/home/user'); + + $relativePath = Path::create('relative/path'); + + // Act & Assert + self::assertFalse($absolutePath->isRelative()); + self::assertTrue($relativePath->isRelative()); + } + + /** + * This test uses real filesystem access to check if a path exists. + * It creates a temporary file and checks its existence. + */ + public function testExists(): void + { + // Arrange + $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_'); + self::assertIsString($tempFile, 'Failed to create temp file'); + + $path = Path::create($tempFile); + $nonExistingPath = Path::create('non/existing/path/file.txt'); + + // Act & Assert + try { + self::assertTrue($path->exists()); + self::assertFalse($nonExistingPath->exists()); + } finally { + // Clean up + @\unlink($tempFile); + } + } + + /** + * Note: This test might have limitations depending on the environment. + * It checks the expected behavior of isDir without requiring an actual directory to exist. + */ + public function testIsDir(): void + { + // Arrange + $currentDirPath = Path::create('.'); + $parentDirPath = Path::create('..'); + $filePath = Path::create('file.txt'); + + // Act & Assert + self::assertTrue($currentDirPath->isDir()); + self::assertTrue($parentDirPath->isDir()); + self::assertFalse($filePath->isDir()); + } + + /** + * Note: This test might have limitations depending on the environment. + * It checks the expected behavior of isFile without requiring an actual file to exist. + */ + public function testIsFile(): void + { + // Arrange + $currentDirPath = Path::create('.'); + $parentDirPath = Path::create('..'); + $filePath = Path::create('file.txt'); + + // Create a temporary file to test with + $tempFile = \tempnam(\sys_get_temp_dir(), 'path_test_'); + self::assertIsString($tempFile, 'Failed to create temp file'); + $realFilePath = Path::create($tempFile); + + // Act & Assert + try { + self::assertFalse($currentDirPath->isFile()); + self::assertFalse($parentDirPath->isFile()); + self::assertFalse($filePath->isFile()); // Doesn't exist yet + self::assertTrue($realFilePath->isFile(), "Temporary file should be a file `$realFilePath`"); + } finally { + // Clean up + @\unlink($tempFile); + } + } + + public function testAbsoluteForAlreadyAbsolutePath(): void + { + // Arrange + $absolutePath = DIRECTORY_SEPARATOR === '\\' + ? Path::create('C:/Users/test') + : Path::create('/home/user'); + + // Act + $result = $absolutePath->absolute(); + + // Assert + self::assertSame((string) $absolutePath, (string) $result); + } + + public function testAbsoluteForRelativePath(): void + { + // Arrange + $relativePath = Path::create('relative/path'); + + // Skip this test if we can't get cwd + $cwd = \getcwd(); + if ($cwd === false) { + self::markTestSkipped('Cannot get current working directory'); + } + + $expected = Path::create($cwd . DIRECTORY_SEPARATOR . 'relative/path'); + + // Act + $result = $relativePath->absolute(); + + // Assert + self::assertSame((string) $expected, (string) $result); + } + + public function testCreateWindowsTmpFile(): void + { + $path = Path::create('C:\Users\roxbl\AppData\Local\Temp\patB6E7.tmp'); + + self::assertSame('C:/Users/roxbl/AppData/Local/Temp/patB6E7.tmp', (string) $path); + } + + public function testToString(): void + { + // Arrange + $pathString = 'some/path/file.txt'; + $path = Path::create($pathString); + + // Act + $result = (string) $path; + + // Assert + self::assertSame('some/path/file.txt', $result); + } +}