diff --git a/README-es.md b/README-es.md index f9963562..1c0396df 100644 --- a/README-es.md +++ b/README-es.md @@ -169,7 +169,7 @@ También puedes descargar la versión más reciente desde [GitHub releases](http |--------|-------------|-------------------| | `--path` | Directorio donde guardar los binarios | Directorio actual | | `--arch` | Arquitectura de destino (amd64, arm64) | Arquitectura del sistema | -| `--os` | Sistema operativo de destino (linux, darwin, windows) | SO actual | +| `--os` | Sistema operativo de destino (linux, alpine, darwin, windows, android) | SO actual | | `--stability` | Estabilidad del release (stable, beta) | stable | | `--config` | Ruta al archivo de configuración | ./dload.xml | | `--force`, `-f` | Forzar descarga aunque el binario ya exista | false | diff --git a/README-ru.md b/README-ru.md index 38d78516..4d4db625 100644 --- a/README-ru.md +++ b/README-ru.md @@ -170,7 +170,7 @@ composer require internal/dload -W |-------|----------|--------------| | `--path` | Папка для хранения бинарников | Текущая папка | | `--arch` | Целевая архитектура (amd64, arm64) | Архитектура системы | -| `--os` | Целевая ОС (linux, darwin, windows) | Текущая ОС | +| `--os` | Целевая ОС (linux, alpine, darwin, windows, android) | Текущая ОС | | `--stability` | Стабильность релиза (stable, beta) | stable | | `--config` | Путь к конфигурационному файлу | ./dload.xml | | `--force`, `-f` | Принудительная загрузка даже если бинарник уже есть | false | diff --git a/README-zh.md b/README-zh.md index 2316910f..8d5db4b9 100644 --- a/README-zh.md +++ b/README-zh.md @@ -169,7 +169,7 @@ composer require internal/dload -W |--------|-------------|---------| | `--path` | 二进制文件存储目录 | 当前目录 | | `--arch` | 目标架构 (amd64, arm64) | 系统架构 | -| `--os` | 目标操作系统 (linux, darwin, windows) | 当前操作系统 | +| `--os` | 目标操作系统 (linux, alpine, darwin, windows, android) | 当前操作系统 | | `--stability` | 发布稳定性 (stable, beta) | stable | | `--config` | 配置文件路径 | ./dload.xml | | `--force`, `-f` | 即使二进制文件已存在也强制下载 | false | diff --git a/README.md b/README.md index cc98273c..5ac825bf 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,7 @@ Alternatively, you can download the latest release from [GitHub releases](https: |--------|-------------|---------| | `--path` | Directory to store binaries | Current directory | | `--arch` | Target architecture (amd64, arm64) | System architecture | -| `--os` | Target OS (linux, darwin, windows) | Current OS | +| `--os` | Target OS (linux, alpine, darwin, windows, android) | Current OS | | `--stability` | Release stability (stable, beta) | stable | | `--config` | Path to configuration file | ./dload.xml | | `--force`, `-f` | Force download even if binary exists | false | diff --git a/resources/software.json b/resources/software.json index 0a58283e..f6ef13aa 100644 --- a/resources/software.json +++ b/resources/software.json @@ -287,7 +287,7 @@ "type": "github", "uri": "oven-sh/bun", "tag-prefix": "bun-", - "asset-pattern": "/^bun-[a-z]+-(?:x64|aarch64)\\.zip$/" + "asset-pattern": "/^bun-.*/" } ], "binary": { diff --git a/skills/dload-fetch-tool/references/registry-entry.md b/skills/dload-fetch-tool/references/registry-entry.md index d8eac32c..2659a959 100644 --- a/skills/dload-fetch-tool/references/registry-entry.md +++ b/skills/dload-fetch-tool/references/registry-entry.md @@ -22,12 +22,15 @@ Filters the release's asset list. dload then runs OS/arch detection on every mat - match all OS/arch variants of the tool; - leave out sibling tools, checksums, signatures, source archives. +Build variants stay in the pattern too. Among the assets for the host, dload prefers the host libc (`musl` builds on Alpine and Android, the others elsewhere), then the name with the fewest extra tokens — so `bun-linux-x64.zip` wins over `-baseline`, `-profile` and `-debug` twins — then archives. Android builds are dropped on Linux whenever the entry has a `binary` (without one, every other platform is only ranked lower). On ARM macOS and Windows, x86-64 builds are a fallback: Rosetta 2 and the Windows emulation run them. + Tokens the OS/arch matchers recognise (case-insensitive, bounded by `_` or a word boundary): | Kind | Tokens | |---|---| -| OS | `windows`, `linux`, `darwin`, `macos`, `alpine`, `bsd`, `freebsd`, `win32`, `win64` | +| OS | `windows`, `linux`, `darwin`, `macos`, `alpine`, `bsd`, `freebsd`, `win32`, `win64`, `android` (wins over `linux`) | | Arch | `amd64`, `arm64`, `aarch64`, `x86_64`, `x64`, `win64` | +| Libc | `musl`, `musleabi*`, `alpine`; `gnu`, `gnueabi*`, `glibc`; none means glibc | | Situation | Pattern | |---|---| diff --git a/skills/dload-fetch-tool/references/troubleshooting.md b/skills/dload-fetch-tool/references/troubleshooting.md index 6cd7ca44..3d8cc34e 100644 --- a/skills/dload-fetch-tool/references/troubleshooting.md +++ b/skills/dload-fetch-tool/references/troubleshooting.md @@ -34,7 +34,7 @@ curl -s "https://api.github.com/repos///releases/tags/" \ After filtering, dload picks the host's variant with: -- OS: `/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i` +- OS: `/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i`; an `android` token makes it Android, a bare `musl` makes it Linux - Arch: `/(?:\b|_)(amd64|arm64|aarch64|x86_64|x64|win64)(?:\b|_)/i` A candidate that matches neither is discarded. Common offenders: @@ -44,6 +44,8 @@ A candidate that matches neither is discarded. Common offenders: Compare with the host: `php -r "echo PHP_OS_FAMILY, ' / ', php_uname('m'), PHP_EOL;"`. +Several candidates left: dload tries them best first — host libc, then the fewest extra name tokens, then archives, then the release order. Run with `-vvv` to see the rank of every asset (`Asset ... ranked: os=0 arch=0 libc=0 extras=1 archive=0`); a wrong pick usually means the wanted build carries an extra token the others lack. + ## Stage 4 — binary extraction The chosen asset is unpacked into `temp-dir`, then `binary.pattern` selects the executable. diff --git a/src/Bootstrap.php b/src/Bootstrap.php index 65ab415d..7b1852ff 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -10,6 +10,7 @@ use Internal\DLoad\Module\Binary\Internal\BinaryProviderImpl; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\Internal\Injection\ConfigInflector; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Cache as CacheConfig; @@ -115,6 +116,7 @@ public function withConfig( // Register bindings $this->container->bind(Architecture::class); $this->container->bind(OperatingSystem::class); + $this->container->bind(Libc::class); $this->container->bind(Stability::class); $this->container->bind( RepositoryProvider::class, diff --git a/src/Command/Get.php b/src/Command/Get.php index 150be58d..81730046 100644 --- a/src/Command/Get.php +++ b/src/Command/Get.php @@ -7,6 +7,7 @@ use Internal\Container\Container; use Internal\DLoad\DLoad; use Internal\DLoad\Module\Common\Architecture; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; @@ -117,7 +118,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $output->writeln('Architecture: ' . $container->get(Architecture::class)->name); - $output->writeln(' Op. system: ' . $container->get(OperatingSystem::class)->name); + $output->writeln( + ' Op. system: ' . $container->get(OperatingSystem::class)->name + . self::describeLibc($container->get(Libc::class)), + ); $output->writeln(' Stability: ' . $container->get(Stability::class)->name); $actions === [] and throw new \RuntimeException('No software to download.'); @@ -259,6 +263,14 @@ private static function applyFlags(InputInterface $input, Container $container): )); } + /** + * Names the libc next to the OS where builds differ by it. + */ + private static function describeLibc(Libc $libc): string + { + return $libc === Libc::System ? '' : " ({$libc->value})"; + } + /** * Prints the reason of every failed download and returns a failure exit code. * diff --git a/src/Module/Common/Libc.php b/src/Module/Common/Libc.php new file mode 100644 index 00000000..99e81b2a --- /dev/null +++ b/src/Module/Common/Libc.php @@ -0,0 +1,109 @@ +get(Libc::class); + * + * // Or read it from a build name + * $libc = Libc::tryFromBuildName('tool-x86_64-unknown-linux-musl.tar.gz'); + * ``` + * + * @internal + */ +enum Libc: string implements Factoriable +{ + /** + * glibc: most Linux distributions. + */ + case Gnu = 'gnu'; + + /** + * musl: Alpine and a few other Linux distributions. + */ + case Musl = 'musl'; + + /** + * Android's own libc: it runs no glibc build, while musl builds are usually static. + */ + case Bionic = 'bionic'; + + /** + * The one libc the OS ships, like on Windows, macOS and BSD: builds do not differ by it. + */ + case System = 'system'; + + public static function create(Build $config, OperatingSystem $os): self + { + return match (true) { + \in_array(\strtolower((string) $config->os), ['alpine', 'unknown-musl'], true) => self::Musl, + $os === OperatingSystem::Android => self::Bionic, + $os !== OperatingSystem::Linux => self::System, + default => self::fromGlobals(), + }; + } + + public static function fromGlobals(): self + { + return self::detect(\PHP_OS_FAMILY, '/'); + } + + /** + * @param string $osFamily OS family in terms of {@see PHP_OS_FAMILY}. + * @param non-empty-string $root Root of the file system to look for the musl loader in. + */ + public static function detect(string $osFamily, string $root): self + { + if ($osFamily !== 'Linux') { + return self::System; + } + + // The musl dynamic loader exists on musl systems only, and checking it runs nothing + $loaders = \glob(\rtrim($root, '/') . '/lib/ld-musl-*.so.1'); + + // `glob()` reports an error as `false`, which must not count as a found loader + return \is_array($loaders) && $loaders !== [] ? self::Musl : self::Gnu; + } + + /** + * Reads the libc a build is linked against from its name. + * + * @return self::Gnu|self::Musl|null Null when the name tells nothing. + */ + public static function tryFromBuildName(string $name): ?self + { + if (\preg_match('/(?:\b|_)(musl(?:eabi(?:hf)?)?|alpine|gnu(?:eabi(?:hf)?)?|glibc)(?:\b|_)/i', $name, $matches) !== 1) { + return null; + } + + $token = \strtolower($matches[1]); + return \str_starts_with($token, 'musl') || $token === 'alpine' ? self::Musl : self::Gnu; + } + + /** + * Whether a host with this libc prefers the build. + * + * A build that names no libc counts as a glibc one. + * + * @param self|null $build Libc the build names. + */ + public function prefers(?self $build): bool + { + return match ($this) { + self::Gnu => $build !== self::Musl, + self::Musl, self::Bionic => $build === self::Musl, + self::System => true, + }; + } +} diff --git a/src/Module/Common/OperatingSystem.php b/src/Module/Common/OperatingSystem.php index 22f4b4b4..c7f1e5bc 100644 --- a/src/Module/Common/OperatingSystem.php +++ b/src/Module/Common/OperatingSystem.php @@ -28,20 +28,35 @@ enum OperatingSystem: string implements Factoriable case BSD = 'freebsd'; case Linux = 'linux'; case Windows = 'windows'; - case Alpine = 'unknown-musl'; + case Android = 'android'; private const ERROR_UNKNOWN_OS = 'Current OS `%s` may not be supported'; public static function create(Build $config): static { - return self::tryFrom((string) $config->os) ?? self::fromGlobals(); + return self::tryFrom((string) $config->os) + ?? self::tryFromString((string) $config->os) + ?? self::fromGlobals(); } public static function fromGlobals(): self { - return self::tryFromString(\PHP_OS_FAMILY) ?? throw new \OutOfRangeException( - \sprintf(self::ERROR_UNKNOWN_OS, \PHP_OS_FAMILY), + // The variable is set by the Android runtime and Termux + return self::fromHost(\PHP_OS_FAMILY, \getenv('ANDROID_ROOT') !== false); + } + + /** + * @param string $osFamily OS family in terms of {@see PHP_OS_FAMILY}. + * @param bool $androidRuntime Whether the Android runtime is present. + */ + public static function fromHost(string $osFamily, bool $androidRuntime): self + { + $os = self::tryFromString($osFamily) ?? throw new \OutOfRangeException( + \sprintf(self::ERROR_UNKNOWN_OS, $osFamily), ); + + // PHP reports Android as Linux + return $os === self::Linux && $androidRuntime ? self::Android : $os; } public static function tryFromString(string $name): ?self @@ -50,22 +65,30 @@ public static function tryFromString(string $name): ?self 'windows', 'win32', 'win64' => self::Windows, 'bsd', 'freebsd' => self::BSD, 'darwin', 'macos' => self::Darwin, - 'linux' => \str_contains(\PHP_OS, 'alpine') - ? self::Alpine - : self::Linux, + 'android' => self::Android, + // The libc is a separate trait, see {@see Libc} + 'linux', 'alpine', 'unknown-musl' => self::Linux, default => null, }; } public static function tryFromBuildName(string $name): ?self { - return \preg_match( + // Android builds are also named after Linux, like `aarch64-linux-android` + if (\preg_match('/(?:\b|_)android(?:\b|_)/i', $name) === 1) { + return self::Android; + } + + if (\preg_match( '/(?:\b|_)(windows|linux|darwin|macos|alpine|bsd|freebsd|win32|win64)(?:\b|_)/i', $name, $matches, - ) === 1 - ? self::tryFromString(\strtolower($matches[1])) - : null; + ) === 1) { + return self::tryFromString(\strtolower($matches[1])); + } + + // Only Linux builds name the libc alone, like `unknown-musl` + return Libc::tryFromBuildName($name) === Libc::Musl ? self::Linux : null; } public function getBinaryExtension(): string diff --git a/src/Module/Common/Pipeline/Interceptor.php b/src/Module/Common/Pipeline/Interceptor.php new file mode 100644 index 00000000..da5789a7 --- /dev/null +++ b/src/Module/Common/Pipeline/Interceptor.php @@ -0,0 +1,17 @@ +with( + * static fn(Input $input): Output => new Output($input), + * 'process', + * ); + * $output = $pipeline($input); + * ``` + * + * @template-covariant TClass of TInterceptor + * @template TInput + * @template-covariant TOutput of mixed + * + * @psalm-immutable + * @internal + * + * @psalm-suppress PropertyNotSetInConstructor $method and $last are set later via {@see self::with()}. + */ +final class Pipeline +{ + /** @var non-empty-string */ + private string $method; + + /** @var callable(TInput): TOutput */ + private mixed $last; + + /** @var int<0, max> Current interceptor key */ + private int $current = 0; + + /** + * @param list $interceptors + */ + private function __construct( + private readonly array $interceptors, + ) {} + + /** + * Create a pipeline with given interceptors. + * + * @template-covariant TInt of TInterceptor + * @template TIn + * @template-covariant TOut + * @param TInterceptor ...$interceptors Instantiated interceptors, in the order they run. + * @return self + * + * @note Make sure that interceptors implement the same interface. + * @psalm-suppress InvalidTemplateParam, UndefinedDocblockClass, InvalidReturnType, InvalidReturnStatement + */ + public static function prepare(TInterceptor ...$interceptors): self + { + return new self(\array_values($interceptors)); + } + + /** + * @param non-empty-string $method Method name of the all interceptors. + * + * @return callable(object): TOutput + * @psalm-suppress InvalidReturnType, InvalidReturnStatement, MixedPropertyTypeCoercion + */ + public function with(callable $last, string $method): callable + { + $new = clone $this; + + $new->last = $last; + $new->method = $method; + + return $new; + } + + /** + * Must be used after {@see self::with()} method. + * + * @param TInput $input Input value for the first interceptor. + * + * @return TOutput + * @psalm-suppress ImpureFunctionCall, MixedReturnStatement + */ + public function __invoke(object $input): mixed + { + $interceptor = $this->interceptors[$this->current] ?? null; + + if ($interceptor === null) { + return ($this->last)($input); + } + + $next = $this->next(); + + return $interceptor->{$this->method}($input, $next); + } + + private function next(): self + { + $new = clone $this; + ++$new->current; + + return $new; + } +} diff --git a/src/Module/Downloader/Downloader.php b/src/Module/Downloader/Downloader.php index 2e712641..203cad75 100644 --- a/src/Module/Downloader/Downloader.php +++ b/src/Module/Downloader/Downloader.php @@ -18,13 +18,14 @@ use Internal\DLoad\Module\Downloader\Exception\NotFound; use Internal\DLoad\Module\Downloader\Exception\ReleaseGone; use Internal\DLoad\Module\Downloader\Internal\Diagnostics\DownloadDiagnostics; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\Rule\ArchitectureRule; use Internal\DLoad\Module\Downloader\Internal\DownloadContext; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Downloader\Task\DownloadTask; use Internal\DLoad\Module\Registry\RepositoryId; use Internal\DLoad\Module\Registry\VersionRegistry; use Internal\DLoad\Module\Repository\AssetInterface; -use Internal\DLoad\Module\Repository\Collection\AssetsCollection; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; use Internal\DLoad\Module\Repository\Exception\AssetNotFoundException; use Internal\DLoad\Module\Repository\Exception\RateLimitException; @@ -71,6 +72,7 @@ public function __construct( private readonly Stability $stability, private readonly ArchiveFactory $archiveService, private readonly VersionRegistry $registry, + private readonly AssetSelector $assetSelector, ) {} /** @@ -269,8 +271,8 @@ private function retryRepository(DownloadContext $context): ReleaseInterface /** * Processes a release to find suitable assets. * - * If software has binary configuration, filters assets using all criteria at once. - * If no binary configuration exists, applies filters gradually to find the best matching asset. + * If software has binary configuration, only assets for the host OS and architecture are tried. + * Otherwise assets for another platform are tried after them. * * @param DownloadContext $context Download context information * @return \Closure(): AssetInterface Closure that returns the selected asset @@ -286,147 +288,58 @@ private function processRelease(DownloadContext $context): \Closure $context->releaseAttempt->registerAssets($names); - return match (true) { - // Phar assets usually don't depend on OS or architecture, so we can use gradual filtering - $context->actionConfig->type === Type::Phar => $this->findAssetWithGradualFiltering($context), - // Use strict filtering when binary configuration exists - $context->software->binary !== null => $this->findAssetWithStrictFiltering($context), - // Use gradual filtering when no binary configuration exists - default => $this->findAssetWithGradualFiltering($context), - }; - }; - } - - /** - * Finds an asset using strict filtering with all criteria applied at once. - * - * @param DownloadContext $context Download context information - * @return AssetInterface Selected asset - * @throws NotFound If no suitable asset is found - */ - private function findAssetWithStrictFiltering(DownloadContext $context): AssetInterface - { - // Apply all filters at once: OS, architecture, and name pattern - $assetsCollection = $context->release->getAssets() - ->whereOperatingSystem($this->operatingSystem) - ->whereArchitecture($this->architecture) - ->whereNameMatches($context->repoConfig->assetPattern); - - /** @var AssetInterface[] $allAssets */ - $allAssets = $this->addFormatFilter($assetsCollection, $context->actionConfig)->toArray(); - $this->logger->debug('%d matching assets found.', \count($allAssets)); - - $allAssets === [] and throw new NotFound( - \sprintf( - 'no asset matches OS `%s`, architecture `%s`, name pattern `%s`%s', - $this->operatingSystem->value, - $this->architecture->value, - $context->repoConfig->assetPattern, - $this->describeFormatFilter($context->actionConfig), - ), - ); - - // Sort assets by priority and try to process them - $sortedAssets = $this->sortAssetsByPriority($allAssets, $this->archiveService->getSupportedExtensions()); - - return $this->tryProcessAssets($sortedAssets, $context); - } - - /** - * Finds an asset using gradual filtering, trying different combinations of criteria. - * - * @param DownloadContext $context Download context information - * @return AssetInterface Selected asset - * @throws NotFound If no suitable asset is found - */ - private function findAssetWithGradualFiltering(DownloadContext $context): AssetInterface - { - $assetsCollection = $context->release->getAssets() - ->whereNameMatches($context->repoConfig->assetPattern); - - $assetsCollection = $this->addFormatFilter($assetsCollection, $context->actionConfig); - $supportedExtensions = $this->archiveService->getSupportedExtensions(); - - // If we got here, no assets were found with any filter combination - \count($assetsCollection) === 0 and throw new NotFound( - \sprintf( - 'no asset matches name pattern `%s`%s', + // Phar assets usually don't depend on OS or architecture; without a binary configuration + // there is nothing to verify the choice, so assets for another platform stay as a fallback. + $strict = $context->actionConfig->type !== Type::Phar && $context->software->binary !== null; + $selection = $this->assetSelector->select( + $context->release->getAssets(), $context->repoConfig->assetPattern, - $this->describeFormatFilter($context->actionConfig), - ), - ); - - // Try #1: Filter by both OS and architecture (most specific) - $filteredAssets = $assetsCollection - ->whereOperatingSystem($this->operatingSystem) - ->whereArchitecture($this->architecture) - ->toArray(); - - if ($filteredAssets !== []) { - $this->logger->debug( - 'Found %d assets matching OS %s and architecture %s.', - \count($filteredAssets), - $this->operatingSystem->value, - $this->architecture->value, + $context->actionConfig->type, + $strict, ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - try { - return $this->tryProcessAssets($sortedAssets, $context); - } catch (NotFound $e) { - $this->logger->debug('Failed to process assets with OS and architecture filtering: %s', $e->getMessage()); - // Continue to next filter strategy - } - } - - // Try #2: Filter by OS only - $filteredAssets = $assetsCollection - ->whereOperatingSystem($this->operatingSystem) - ->toArray(); - - if ($filteredAssets !== []) { - $this->logger->debug( - 'Found %d assets matching OS %s (any architecture).', - \count($filteredAssets), - $this->operatingSystem->value, + $this->logger->debug('%d matching assets found.', \count($selection->candidates)); + + $selection->isEmpty() and throw new NotFound( + $strict + ? \sprintf( + 'no asset matches OS `%s`, architecture `%s`, name pattern `%s`%s', + $this->operatingSystem->value, + $this->architecture->value, + $context->repoConfig->assetPattern, + $this->describeFormatFilter($context->actionConfig), + ) + : \sprintf( + 'no asset matches name pattern `%s`%s', + $context->repoConfig->assetPattern, + $this->describeFormatFilter($context->actionConfig), + ), ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - try { - return $this->tryProcessAssets($sortedAssets, $context); - } catch (NotFound $e) { - $this->logger->debug('Failed to process assets with OS-only filtering: %s', $e->getMessage()); - // Continue to next filter strategy - } - } - - // Try #3: Filter by architecture only - $filteredAssets = $assetsCollection - ->whereArchitecture($this->architecture) - ->toArray(); - if ($filteredAssets !== []) { - $this->logger->debug( - 'Found %d assets matching architecture %s (any OS).', - \count($filteredAssets), - $this->architecture->value, - ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - try { - return $this->tryProcessAssets($sortedAssets, $context); - } catch (NotFound $e) { - $this->logger->debug('Failed to process assets with architecture-only filtering: %s', $e->getMessage()); - // Continue to next filter strategy + foreach ($selection->sorted() as $candidate) { + $this->logger->debug( + 'Asset `%s` ranked: %s.', + $candidate->asset->getName(), + \implode(' ', \array_map( + static fn(string $key, int $rank): string => "{$key}={$rank}", + \array_keys($candidate->ranks), + $candidate->ranks, + )), + ); } - } - // Try #4: Use name pattern only (least specific) - $filteredAssets = $assetsCollection->toArray(); + $asset = $this->tryProcessAssets($selection->assets(), $context); + foreach ($selection->candidates as $candidate) { + // The host runs this build only through emulation, which may be missing (Rosetta 2 is optional) + $candidate->asset === $asset && ($candidate->ranks[ArchitectureRule::KEY] ?? null) === ArchitectureRule::EMULATED and $this->logger->warning( + 'No `%s` build of `%s` found, `%s` needs an x86-64 emulator to run.', + $this->architecture->value, + $context->software->getId(), + $asset->getName(), + ); + } - $this->logger->debug( - 'Found %d assets matching name pattern (any OS, any architecture).', - \count($filteredAssets), - ); - $sortedAssets = $this->sortAssetsByPriority($filteredAssets, $supportedExtensions); - return $this->tryProcessAssets($sortedAssets, $context); + return $asset; + }; } /** @@ -500,36 +413,6 @@ private function describeFormatFilter(DownloadConfig $actionOptions): string }; } - /** - * Sorts assets by priority with supported archives first, then other files. - * - * @param AssetInterface[] $assets List of assets to sort - * @param list $supportedExtensions List of supported archive extensions - * @return AssetInterface[] Sorted list of assets - */ - private function sortAssetsByPriority(array $assets, array $supportedExtensions): array - { - $archiveAssets = []; - $otherAssets = []; - - foreach ($assets as $asset) { - $assetName = \strtolower($asset->getName()); - $isArchive = false; - - foreach ($supportedExtensions as $extension) { - if (\str_ends_with($assetName, '.' . $extension)) { - $archiveAssets[] = $asset; - $isArchive = true; - break; - } - } - - $isArchive or $otherAssets[] = $asset; - } - - return [...$archiveAssets, ...$otherAssets]; - } - /** * Downloads the selected asset to a temporary file. * @@ -590,20 +473,4 @@ private function getTempDirectory(): Path return $temp; } - - /** - * Adds format filter to the assets collection if specified in action options. - * - * @param AssetsCollection $collection Collection of assets to filter - * @param DownloadConfig $actionOptions Download action options - * @return AssetsCollection Filtered collection - */ - private function addFormatFilter(AssetsCollection $collection, DownloadConfig $actionOptions): AssetsCollection - { - return match ($actionOptions->type) { - Type::Phar => $collection->whereFileExtensions(['phar']), - Type::Archive => $collection->whereFileExtensions($this->archiveService->getSupportedExtensions()), - default => $collection, - }; - } } diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetName.php b/src/Module/Downloader/Internal/AssetSelection/AssetName.php new file mode 100644 index 00000000..0ca6cd3d --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/AssetName.php @@ -0,0 +1,62 @@ + $extras Name tokens that are not a platform, a version or an extension, + * like `profile` or `debug`. The tool name is one of them too. + */ + private function __construct( + public readonly ?Libc $libc, + public readonly array $extras, + ) {} + + public static function fromString(string $name): self + { + return new self( + libc: Libc::tryFromBuildName($name), + extras: self::extras($name), + ); + } + + /** + * @return list + */ + private static function extras(string $name): array + { + $name = \strtolower($name); + do { + $name = (string) \preg_replace(self::EXTENSION, '', $name, count: $count); + } while ($count > 0); + + /** @var list|false $tokens */ + $tokens = \preg_split('/[-_.\s]+/', (string) \preg_replace(self::PLATFORM, '', $name), flags: \PREG_SPLIT_NO_EMPTY); + + return \array_values(\array_filter( + $tokens === false ? [] : $tokens, + static fn(string $token): bool => \preg_match('/^v?\d+$/', $token) !== 1, + )); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetRule.php b/src/Module/Downloader/Internal/AssetSelection/AssetRule.php new file mode 100644 index 00000000..58eeadcd --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/AssetRule.php @@ -0,0 +1,26 @@ + + * + * @internal + * @psalm-internal Internal\DLoad\Module\Downloader + */ +interface AssetRule extends Interceptor +{ + /** + * @param callable(Selection): Selection $next + */ + public function select(Selection $selection, callable $next): Selection; +} diff --git a/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php new file mode 100644 index 00000000..26bcd209 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/AssetSelector.php @@ -0,0 +1,74 @@ +select($release->getAssets(), '/^tool-.*$/', Type::Archive, strict: true); + * $selection->isEmpty() or $best = $selection->assets()[0]; + * ``` + * + * @internal + * @psalm-internal Internal\DLoad\Module\Downloader + */ +final class AssetSelector +{ + /** @var callable(Selection): Selection */ + private $pipeline; + + public function __construct( + OperatingSystem $operatingSystem, + Architecture $architecture, + ContainerInterface $container, + ArchiveFactory $archiveFactory, + ) { + /** + * Rules run in this order, which is also the priority of their ranks. + * + * @see AssetRule::select() + * @var callable(Selection): Selection $pipeline + */ + $pipeline = Pipeline::prepare( + new NamePatternRule(), + new FormatRule($archiveFactory), + new CompanionRule(), + new OperatingSystemRule($operatingSystem), + new ArchitectureRule($architecture, $operatingSystem), + new LibcRule($container), + new ExtrasRule(), + new ArchiveRule($archiveFactory), + )->with(static fn(Selection $selection): Selection => $selection, 'select'); + $this->pipeline = $pipeline; + } + + /** + * @param iterable $assets + * @param non-empty-string $assetPattern Pattern the asset names must match. + * @param Type|null $type Download action type restricting the asset format. + * @param bool $strict Whether assets for another OS or architecture are removed rather than ranked lower. + */ + public function select(iterable $assets, string $assetPattern, ?Type $type, bool $strict): Selection + { + return ($this->pipeline)(Selection::create($assets, $assetPattern, $type, $strict)); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Candidate.php b/src/Module/Downloader/Internal/AssetSelection/Candidate.php new file mode 100644 index 00000000..3a13c7d9 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Candidate.php @@ -0,0 +1,50 @@ + $position Position of the asset in the release, the last tie-breaker. + * @param array $ranks One key per ranking rule in the pipeline order; lower is better. + */ + public function __construct( + public readonly AssetInterface $asset, + public readonly AssetName $name, + public readonly int $position, + public readonly array $ranks = [], + ) {} + + /** + * @param list $extensions File extensions without the leading dot. + */ + public function hasExtension(array $extensions): bool + { + $name = \strtolower($this->asset->getName()); + foreach ($extensions as $extension) { + if (\str_ends_with($name, '.' . $extension)) { + return true; + } + } + + return false; + } + + /** + * @param non-empty-string $key Name of the rank, unique within the selection. + */ + public function withRank(string $key, int $rank): self + { + return new self($this->asset, $this->name, $this->position, [...$this->ranks, $key => $rank]); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php new file mode 100644 index 00000000..42b44c92 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRule.php @@ -0,0 +1,60 @@ +strict and $selection = $selection->remove( + fn(Candidate $candidate): bool => $this->rank($candidate->asset->getArchitecture()) === null, + ); + + return $next($selection->rank( + self::KEY, + fn(Candidate $candidate): int => $this->rank($candidate->asset->getArchitecture()) ?? 2, + )); + } + + /** + * @return int<0, 1>|null Null when the host cannot run the asset. + */ + private function rank(?Architecture $arch): ?int + { + return match (true) { + $arch === $this->architecture => 0, + // Rosetta 2 on macOS and the built-in emulation of Windows run x86-64 builds on ARM + $arch === Architecture::X86_64 + && $this->architecture === Architecture::ARM_64 + && \in_array($this->operatingSystem, [OperatingSystem::Darwin, OperatingSystem::Windows], true) => self::EMULATED, + default => null, + }; + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php new file mode 100644 index 00000000..f8a4d730 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRule.php @@ -0,0 +1,30 @@ +archiveFactory->getSupportedExtensions(); + + return $next($selection->prefer('archive', static fn(Candidate $candidate): bool => $candidate->hasExtension($extensions))); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php new file mode 100644 index 00000000..bea1a911 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/CompanionRule.php @@ -0,0 +1,30 @@ +remove( + static fn(Candidate $candidate): bool => \preg_match(self::PATTERN, $candidate->asset->getName()) === 1, + )); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php new file mode 100644 index 00000000..fe9e7cad --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/ExtrasRule.php @@ -0,0 +1,27 @@ +rank('extras', static fn(Candidate $candidate): int => \count($candidate->name->extras))); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php new file mode 100644 index 00000000..ef9a5878 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/FormatRule.php @@ -0,0 +1,39 @@ +type) { + Type::Phar => ['phar'], + Type::Archive => $this->archiveFactory->getSupportedExtensions(), + default => null, + }; + + $extensions === null or $selection = $selection->remove( + static fn(Candidate $candidate): bool => !$candidate->hasExtension($extensions), + ); + + return $next($selection); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php new file mode 100644 index 00000000..9e6ea6d9 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/LibcRule.php @@ -0,0 +1,45 @@ +candidates as $candidate) { + $libcs[($candidate->name->libc ?? Libc::Gnu)->value] = true; + } + + // The host is probed only when there is a choice between libcs + if (\count($libcs) < 2) { + return $next($selection->rank('libc', static fn(): int => 0)); + } + + /** @var Libc $host */ + $host = $this->container->get(Libc::class); + return $next($selection->prefer('libc', static fn(Candidate $candidate): bool => $host->prefers($candidate->name->libc))); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php new file mode 100644 index 00000000..8ef01bb3 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRule.php @@ -0,0 +1,30 @@ +assetPattern; + + return $next($selection->remove( + static fn(Candidate $candidate): bool => @\preg_match( + $pattern, + $candidate->asset->getName(), + ) !== 1, + )); + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php new file mode 100644 index 00000000..6e36c8b3 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRule.php @@ -0,0 +1,50 @@ +strict and $selection = $selection->remove( + fn(Candidate $candidate): bool => $this->rank($candidate->asset->getOperatingSystem()) === null, + ); + + return $next($selection->rank( + 'os', + fn(Candidate $candidate): int => $this->rank($candidate->asset->getOperatingSystem()) ?? 2, + )); + } + + /** + * @return int<0, 1>|null Null when the host cannot run the asset. + */ + private function rank(?OperatingSystem $os): ?int + { + return match (true) { + $os === $this->operatingSystem => 0, + // Static Linux binaries run on Android, while Android builds need its runtime + $os === OperatingSystem::Linux && $this->operatingSystem === OperatingSystem::Android => 1, + default => null, + }; + } +} diff --git a/src/Module/Downloader/Internal/AssetSelection/Selection.php b/src/Module/Downloader/Internal/AssetSelection/Selection.php new file mode 100644 index 00000000..6bc105a3 --- /dev/null +++ b/src/Module/Downloader/Internal/AssetSelection/Selection.php @@ -0,0 +1,129 @@ + $candidates + * @param non-empty-string $assetPattern Pattern the asset names must match. + * @param Type|null $type Download action type restricting the asset format. + * @param bool $strict Whether assets for another OS or architecture are removed rather than ranked lower. + */ + private function __construct( + public readonly array $candidates, + public readonly string $assetPattern, + public readonly ?Type $type, + public readonly bool $strict, + ) {} + + /** + * @param iterable $assets + * @param non-empty-string $assetPattern + */ + public static function create(iterable $assets, string $assetPattern, ?Type $type, bool $strict): self + { + $candidates = []; + foreach ($assets as $asset) { + $candidates[] = new Candidate($asset, AssetName::fromString($asset->getName()), \count($candidates)); + } + + return new self($candidates, $assetPattern, $type, $strict); + } + + /** + * Removes the candidates matching the predicate. + * + * The predicate looks at one candidate only: a decision that depends on the other candidates + * is a rank, see {@see self::rank()}. + * + * @param \Closure(Candidate): bool $predicate + */ + public function remove(\Closure $predicate): self + { + return $this->withCandidates(\array_values(\array_filter( + $this->candidates, + static fn(Candidate $candidate): bool => !$predicate($candidate), + ))); + } + + /** + * Gives every candidate a rank; lower is better. + * + * @param non-empty-string $key Name of the rank, unique within the selection. + * @param \Closure(Candidate): int $rank + */ + public function rank(string $key, \Closure $rank): self + { + return $this->withCandidates(\array_map( + static fn(Candidate $candidate): Candidate => $candidate->withRank($key, $rank($candidate)), + $this->candidates, + )); + } + + /** + * Moves the candidates matching the predicate ahead of the others. + * + * @param non-empty-string $key Name of the rank, unique within the selection. + * @param \Closure(Candidate): bool $predicate + */ + public function prefer(string $key, \Closure $predicate): self + { + return $this->rank($key, static fn(Candidate $candidate): int => $predicate($candidate) ? 0 : 1); + } + + public function isEmpty(): bool + { + return $this->candidates === []; + } + + /** + * Candidates from the best to the worst. + * + * @return list + */ + public function sorted(): array + { + $candidates = $this->candidates; + \usort( + $candidates, + static fn(Candidate $a, Candidate $b): int => [...\array_values($a->ranks), $a->position] + <=> [...\array_values($b->ranks), $b->position], + ); + + return $candidates; + } + + /** + * Assets from the best to the worst. + * + * @return list + */ + public function assets(): array + { + return \array_map(static fn(Candidate $candidate): AssetInterface => $candidate->asset, $this->sorted()); + } + + /** + * @param list $candidates + */ + private function withCandidates(array $candidates): self + { + return new self($candidates, $this->assetPattern, $this->type, $this->strict); + } +} diff --git a/src/Service/Logger.php b/src/Service/Logger.php index c5946289..f5c39ffe 100644 --- a/src/Service/Logger.php +++ b/src/Service/Logger.php @@ -81,6 +81,17 @@ public function debug(string $message, string|int|float|bool ...$values): void $this->echo("\033[34m" . \sprintf($message, ...self::values($values)) . "\033[0m\n"); } + /** + * Outputs a warning message in yellow. + * + * @param string $message Message format string + * @param string|int|float|bool ...$values Format values + */ + public function warning(string $message, string|int|float|bool ...$values): void + { + $this->echo("\033[33m" . \sprintf($message, ...self::values($values)) . "\033[0m\n", false); + } + /** * Outputs an error message in red. * diff --git a/tests/Unit/Module/Common/LibcTest.php b/tests/Unit/Module/Common/LibcTest.php new file mode 100644 index 00000000..417a298c --- /dev/null +++ b/tests/Unit/Module/Common/LibcTest.php @@ -0,0 +1,109 @@ + ['Linux', true, Libc::Musl]; + yield 'Linux without it' => ['Linux', false, Libc::Gnu]; + yield 'not Linux' => ['Darwin', true, Libc::System]; + } + + public static function providePreferences(): iterable + { + yield 'glibc host, glibc build' => [Libc::Gnu, Libc::Gnu, true]; + yield 'glibc host, plain build' => [Libc::Gnu, null, true]; + yield 'glibc host, musl build' => [Libc::Gnu, Libc::Musl, false]; + yield 'musl host, musl build' => [Libc::Musl, Libc::Musl, true]; + yield 'musl host, plain build' => [Libc::Musl, null, false]; + yield 'Android, musl build' => [Libc::Bionic, Libc::Musl, true]; + yield 'Android, glibc build' => [Libc::Bionic, Libc::Gnu, false]; + yield 'system libc, musl build' => [Libc::System, Libc::Musl, true]; + yield 'system libc, glibc build' => [Libc::System, Libc::Gnu, true]; + } + + #[DataProvider('provideBuildNames')] + #[Test] + public function tryFromBuildName(string $name, ?Libc $expected): void + { + Assert::same(Libc::tryFromBuildName($name), $expected); + } + + #[DataProvider('providePreferences')] + #[Test] + public function prefers(Libc $host, ?Libc $build, bool $expected): void + { + Assert::same($host->prefers($build), $expected); + } + + #[Test] + public function aMuslOsOptionSelectsMusl(): void + { + $build = new Build(); + $build->os = 'alpine'; + + Assert::same(Libc::create($build, OperatingSystem::Linux), Libc::Musl); + } + + #[Test] + public function androidHasItsOwnLibc(): void + { + Assert::same(Libc::create(new Build(), OperatingSystem::Android), Libc::Bionic); + } + + #[Test] + public function aHostOtherThanLinuxIsNotProbed(): void + { + Assert::same(Libc::create(new Build(), OperatingSystem::Darwin), Libc::System); + } + + #[DataProvider('provideHosts')] + #[Test] + public function muslIsDetectedByItsLoaderOnLinux(string $osFamily, bool $loader, Libc $expected): void + { + $root = \sys_get_temp_dir() . '/dload-libc-' . \bin2hex(\random_bytes(6)); + \mkdir($root . '/lib', recursive: true); + $loader and \touch($root . '/lib/ld-musl-x86_64.so.1'); + + try { + Assert::same(Libc::detect($osFamily, $root), $expected); + } finally { + $loader and \unlink($root . '/lib/ld-musl-x86_64.so.1'); + \rmdir($root . '/lib'); + \rmdir($root); + } + } + + #[Test] + public function theCurrentHostIsProbedAtTheFileSystemRoot(): void + { + Assert::same(Libc::fromGlobals(), Libc::detect(\PHP_OS_FAMILY, '/')); + } +} diff --git a/tests/Unit/Module/Common/OperatingSystemTest.php b/tests/Unit/Module/Common/OperatingSystemTest.php index 40b44368..3ff34d92 100644 --- a/tests/Unit/Module/Common/OperatingSystemTest.php +++ b/tests/Unit/Module/Common/OperatingSystemTest.php @@ -7,6 +7,7 @@ use Internal\DLoad\Module\Common\OperatingSystem; use Testo\Assert; use Testo\Data\DataProvider; +use Testo\Expect; use Testo\Test; class OperatingSystemTest @@ -17,10 +18,20 @@ public static function provideBuildNames(): iterable yield ['temporal_cli_0.13.2_windows_amd64.tar.gz', OperatingSystem::Windows]; yield ['roadrunner-2024.1.5-linux-amd64.deb', OperatingSystem::Linux]; yield ['roadrunner-2024.1.5-linux-amd64.tar.gz', OperatingSystem::Linux]; - yield ['roadrunner-2024.1.5-unknown-musl-amd64.tar.gz', null]; + yield ['roadrunner-2024.1.5-unknown-musl-amd64.tar.gz', OperatingSystem::Linux]; + yield ['tool-alpine-amd64.tar.gz', OperatingSystem::Linux]; yield ['protoc-27.3-win64.zip', OperatingSystem::Windows]; yield ['protoc-27.3-win32.zip', OperatingSystem::Windows]; yield ['temporal-test-server_1.33.0_macOS_arm64.tar.gz', OperatingSystem::Darwin]; + yield ['bun-linux-x64-android-baseline.zip', OperatingSystem::Android]; + yield ['tool-aarch64-linux-android.tar.gz', OperatingSystem::Android]; + } + + public static function provideHosts(): iterable + { + yield 'Linux with the Android runtime' => ['Linux', true, OperatingSystem::Android]; + yield 'Linux without it' => ['Linux', false, OperatingSystem::Linux]; + yield 'not Linux' => ['Darwin', true, OperatingSystem::Darwin]; } #[DataProvider('provideBuildNames')] @@ -29,4 +40,25 @@ public function tryFromBuildName(string $name, ?OperatingSystem $expected): void { Assert::same(OperatingSystem::tryFromBuildName($name), $expected); } + + #[DataProvider('provideHosts')] + #[Test] + public function androidIsDetectedByItsRuntimeOnLinux(string $osFamily, bool $androidRuntime, OperatingSystem $expected): void + { + Assert::same(OperatingSystem::fromHost($osFamily, $androidRuntime), $expected); + } + + #[Test] + public function androidCanBeRequestedByName(): void + { + Assert::same(OperatingSystem::tryFromString('android'), OperatingSystem::Android); + } + + #[Test] + public function anUnknownHostFamilyIsRejected(): void + { + Expect::exception(\OutOfRangeException::class); + + OperatingSystem::fromHost('Solaris', false); + } } diff --git a/tests/Unit/Module/Common/Pipeline/PipelineTest.php b/tests/Unit/Module/Common/Pipeline/PipelineTest.php new file mode 100644 index 00000000..3e079d4d --- /dev/null +++ b/tests/Unit/Module/Common/Pipeline/PipelineTest.php @@ -0,0 +1,71 @@ +with(self::last(...), 'handle'); + + $first = $pipeline(new \ArrayObject()); + $second = $pipeline(new \ArrayObject()); + + Assert::same($first->getArrayCopy(), $second->getArrayCopy()); + } + + /** + * @return list + */ + private static function run(TraceInterceptor|HaltInterceptor ...$interceptors): array + { + $trace = new \ArrayObject(); + Pipeline::prepare(...$interceptors)->with(self::last(...), 'handle')($trace); + + return $trace->getArrayCopy(); + } + + /** + * @param \ArrayObject $trace + * @return \ArrayObject + */ + private static function last(\ArrayObject $trace): \ArrayObject + { + $trace[] = 'last'; + + return $trace; + } +} diff --git a/tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php b/tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php new file mode 100644 index 00000000..2a55d359 --- /dev/null +++ b/tests/Unit/Module/Common/Pipeline/Stub/HaltInterceptor.php @@ -0,0 +1,26 @@ +, \ArrayObject> + */ +final class HaltInterceptor implements Interceptor +{ + /** + * @param \ArrayObject $trace + * @return \ArrayObject + */ + public function handle(\ArrayObject $trace, callable $next): \ArrayObject + { + $trace[] = 'halt'; + + return $trace; + } +} diff --git a/tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php b/tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php new file mode 100644 index 00000000..d681465f --- /dev/null +++ b/tests/Unit/Module/Common/Pipeline/Stub/TraceInterceptor.php @@ -0,0 +1,33 @@ +, \ArrayObject> + */ +final class TraceInterceptor implements Interceptor +{ + public function __construct( + public readonly string $label, + ) {} + + /** + * @param \ArrayObject $trace + * @param callable(\ArrayObject): \ArrayObject $next + * @return \ArrayObject + */ + public function handle(\ArrayObject $trace, callable $next): \ArrayObject + { + $trace[] = "{$this->label}:in"; + $result = $next($trace); + $trace[] = "{$this->label}:out"; + + return $result; + } +} diff --git a/tests/Unit/Module/Downloader/AssetSelectionTest.php b/tests/Unit/Module/Downloader/AssetSelectionTest.php index c6a62721..6414b395 100644 --- a/tests/Unit/Module/Downloader/AssetSelectionTest.php +++ b/tests/Unit/Module/Downloader/AssetSelectionTest.php @@ -7,12 +7,14 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\FileSystem\FS; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; use Internal\DLoad\Module\Config\Schema\Downloader as DownloaderConfig; use Internal\DLoad\Module\Config\Schema\Embed\Software; use Internal\DLoad\Module\Downloader\Downloader; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; use Internal\DLoad\Module\Repository\RepositoryProvider; @@ -24,9 +26,10 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\ReleaseStub; use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; +use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; +use Symfony\Component\Console\Output\BufferedOutput; use Testo\Assert; use Testo\Codecov\Covers; -use Testo\Core\Exception\SkipTest; use Testo\Data\DataProvider; use Testo\Lifecycle\AfterTest; use Testo\Lifecycle\BeforeTest; @@ -84,6 +87,46 @@ final class AssetSelectionTest 'SHASUMS256.txt.asc', ]; + /** + * Assets of the Mago 1.51.2 release: Rust target triples with glibc and musl builds. + */ + private const MAGO_ASSETS = [ + 'mago-1.51.2-aarch64-apple-darwin.tar.gz', + 'mago-1.51.2-aarch64-unknown-linux-gnu.tar.gz', + 'mago-1.51.2-aarch64-unknown-linux-musl.tar.gz', + 'mago-1.51.2-arm-unknown-linux-gnueabi.tar.gz', + 'mago-1.51.2-arm-unknown-linux-gnueabihf.tar.gz', + 'mago-1.51.2-arm-unknown-linux-musleabi.tar.gz', + 'mago-1.51.2-arm-unknown-linux-musleabihf.tar.gz', + 'mago-1.51.2-armv7-unknown-linux-gnueabihf.tar.gz', + 'mago-1.51.2-armv7-unknown-linux-musleabihf.tar.gz', + 'mago-1.51.2-wasm.tar.gz', + 'mago-1.51.2-x86_64-apple-darwin.tar.gz', + 'mago-1.51.2-x86_64-pc-windows-gnu.tar.gz', + 'mago-1.51.2-x86_64-pc-windows-msvc.zip', + 'mago-1.51.2-x86_64-unknown-freebsd.tar.gz', + 'mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz', + 'mago-1.51.2-x86_64-unknown-linux-musl.tar.gz', + 'source-code.tar.gz', + 'source-code.zip', + ]; + + /** + * Assets of the TigerBeetle 0.17.9 release: every build has a `-debug` twin listed first. + */ + private const TIGERBEETLE_ASSETS = [ + 'tigerbeetle-aarch64-linux-debug.zip', + 'tigerbeetle-aarch64-linux.zip', + 'tigerbeetle-universal-macos-debug.zip', + 'tigerbeetle-universal-macos.zip', + 'tigerbeetle-x86_64-linux-debug.zip', + 'tigerbeetle-x86_64-linux.zip', + 'tigerbeetle-x86_64-windows-debug.zip', + 'tigerbeetle-x86_64-windows.zip', + 'vortex-driver-zig-aarch64-linux.zip', + 'vortex-driver-zig-x86_64-linux.zip', + ]; + private string $tempDir; public static function provideBunHosts(): \Generator @@ -98,6 +141,27 @@ public static function provideBunHosts(): \Generator yield 'FreeBSD arm64' => [OperatingSystem::BSD, Architecture::ARM_64, 'bun-freebsd-aarch64.zip']; } + public static function provideBunMuslHosts(): \Generator + { + yield 'Linux x64' => [Architecture::X86_64, 'bun-linux-x64-musl.zip']; + yield 'Linux arm64' => [Architecture::ARM_64, 'bun-linux-aarch64-musl.zip']; + } + + public static function provideTigerBeetleHosts(): \Generator + { + yield 'Linux x64' => [OperatingSystem::Linux, Architecture::X86_64, 'tigerbeetle-x86_64-linux.zip']; + yield 'Linux arm64' => [OperatingSystem::Linux, Architecture::ARM_64, 'tigerbeetle-aarch64-linux.zip']; + yield 'Windows x64' => [OperatingSystem::Windows, Architecture::X86_64, 'tigerbeetle-x86_64-windows.zip']; + } + + public static function provideMagoHosts(): \Generator + { + yield 'Linux x64 glibc' => [Architecture::X86_64, Libc::Gnu, 'mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz']; + yield 'Linux x64 musl' => [Architecture::X86_64, Libc::Musl, 'mago-1.51.2-x86_64-unknown-linux-musl.tar.gz']; + yield 'Linux arm64 glibc' => [Architecture::ARM_64, Libc::Gnu, 'mago-1.51.2-aarch64-unknown-linux-gnu.tar.gz']; + yield 'Linux arm64 musl' => [Architecture::ARM_64, Libc::Musl, 'mago-1.51.2-aarch64-unknown-linux-musl.tar.gz']; + } + #[DataProvider('provideBunHosts')] #[Test] public function bunRegistryEntrySelectsThePlainBuild( @@ -110,30 +174,77 @@ public function bunRegistryEntrySelectsThePlainBuild( Assert::same($result->file->getFilename(), $expected); } - /** - * The target of variant ranking: the plain build wins without a pattern that spells out - * the asset shape of one tool. - */ - #[DataProvider('provideBunHosts')] + #[DataProvider('provideBunMuslHosts')] + #[Test] + public function bunMuslBuildIsSelectedOnAMuslHost(Architecture $arch, string $expected): void + { + $result = $this->download( + self::registryEntry('bun'), + 'oven-sh/bun', + 'bun-v1.4.2', + self::BUN_ASSETS, + OperatingSystem::Linux, + $arch, + Libc::Musl, + ); + + Assert::same($result->file->getFilename(), $expected); + } + + #[DataProvider('provideTigerBeetleHosts')] #[Test] - public function bunPlainBuildIsSelectedWithABroadAssetPattern( + public function tigerBeetleReleaseBuildIsSelectedOverTheDebugOne( OperatingSystem $os, Architecture $arch, string $expected, ): void { - $software = Software::fromArray([ - 'name' => 'Bun', - 'alias' => 'bun', - 'repositories' => [['type' => 'github', 'uri' => 'oven-sh/bun', 'asset-pattern' => '/^bun-.*/']], - 'binary' => ['name' => 'bun'], - ]); - - $result = $this->downloadBun($software, $os, $arch); - - $result->file->getFilename() === $expected or throw new SkipTest(\sprintf( - 'Picks `%s`: asset variants are not ranked yet, see https://github.com/php-internal/dload/issues/134', - $result->file->getFilename(), - )); + $result = $this->download( + self::registryEntry('tigerbeetle'), + 'tigerbeetle/tigerbeetle', + '0.17.9', + self::TIGERBEETLE_ASSETS, + $os, + $arch, + Libc::Gnu, + ); + + Assert::same($result->file->getFilename(), $expected); + } + + #[Test] + public function anEmulatedBuildIsInstalledWithAWarning(): void + { + $output = new BufferedOutput(); + + $result = $this->download( + self::registryEntry('tigerbeetle'), + 'tigerbeetle/tigerbeetle', + '0.17.9', + ['tigerbeetle-x86_64-windows.zip', 'tigerbeetle-aarch64-linux.zip'], + OperatingSystem::Windows, + Architecture::ARM_64, + Libc::Gnu, + new Logger($output), + ); + + Assert::same($result->file->getFilename(), 'tigerbeetle-x86_64-windows.zip'); + Assert::string($output->fetch())->contains('needs an x86-64 emulator'); + } + + #[DataProvider('provideMagoHosts')] + #[Test] + public function magoBuildForTheHostLibcIsSelected(Architecture $arch, Libc $libc, string $expected): void + { + $result = $this->download( + self::registryEntry('mago'), + 'carthage-software/mago', + '1.51.2', + self::MAGO_ASSETS, + OperatingSystem::Linux, + $arch, + $libc, + ); + Assert::same($result->file->getFilename(), $expected); } @@ -173,26 +284,46 @@ private static function registryEntry(string $alias): Software private function downloadBun(Software $software, OperatingSystem $os, Architecture $arch): DownloadResult { - $repository = new RepositoryStub('oven-sh/bun'); - $release = new ReleaseStub($repository, 'Bun v1.4.2', Version::fromVersionString('v1.4.2'), tag: 'bun-v1.4.2'); + return $this->download($software, 'oven-sh/bun', 'bun-v1.4.2', self::BUN_ASSETS, $os, $arch, Libc::Gnu); + } + + /** + * Runs the downloader against one release with the given assets. + * + * @param non-empty-string $repositoryName + * @param non-empty-string $tag + * @param list $assets Asset names in the order the API lists them. + */ + private function download( + Software $software, + string $repositoryName, + string $tag, + array $assets, + OperatingSystem $os, + Architecture $arch, + Libc $libc, + Logger $logger = new Logger(), + ): DownloadResult { + $repository = new RepositoryStub($repositoryName); + $release = new ReleaseStub($repository, $tag, Version::fromVersionString(\preg_replace('/^[a-z]+-/', '', $tag)), tag: $tag); $release->setAssets(\array_map( static fn(string $name): AssetStub => new AssetStub( $release, $name, - 'https://github.com/oven-sh/bun/releases/download/bun-v1.4.2/' . $name, + "https://github.com/{$repositoryName}/releases/download/{$tag}/{$name}", OperatingSystem::tryFromBuildName($name), Architecture::tryFromBuildName($name), ), - self::BUN_ASSETS, + $assets, )); - $repository = new RepositoryStub('oven-sh/bun', ReleasesCollection::create([$release])); + $repository = new RepositoryStub($repositoryName, ReleasesCollection::create([$release])); $config = new DownloaderConfig(); $config->tmpDir = $this->tempDir; $downloader = new Downloader( config: $config, - logger: new Logger(), + logger: $logger, repositoryProvider: (new RepositoryProvider())->addRepositoryFactory( new SequenceRepositoryFactoryStub([$repository]), ), @@ -201,8 +332,9 @@ private function downloadBun(Software $software, OperatingSystem $os, Architectu stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: new RecordingRegistry(), + assetSelector: new AssetSelector($os, $arch, new LibcContainer($libc), new ArchiveFactory()), ); - $task = $downloader->download($software, DownloadConfig::fromSoftwareId('bun'), static fn(): null => null); + $task = $downloader->download($software, DownloadConfig::fromSoftwareId($software->getId()), static fn(): null => null); /** @var DownloadResult */ return await(($task->handler)()); diff --git a/tests/Unit/Module/Downloader/DownloaderTest.php b/tests/Unit/Module/Downloader/DownloaderTest.php index 8c6fa654..45b01680 100644 --- a/tests/Unit/Module/Downloader/DownloaderTest.php +++ b/tests/Unit/Module/Downloader/DownloaderTest.php @@ -7,6 +7,7 @@ use Internal\DLoad\Module\Archive\ArchiveFactory; use Internal\DLoad\Module\Common\Architecture; use Internal\DLoad\Module\Common\FileSystem\FS; +use Internal\DLoad\Module\Common\Libc; use Internal\DLoad\Module\Common\OperatingSystem; use Internal\DLoad\Module\Common\Stability; use Internal\DLoad\Module\Config\Schema\Action\Download as DownloadConfig; @@ -14,6 +15,7 @@ use Internal\DLoad\Module\Config\Schema\Downloader as DownloaderConfig; use Internal\DLoad\Module\Config\Schema\Embed\Software; use Internal\DLoad\Module\Downloader\Downloader; +use Internal\DLoad\Module\Downloader\Internal\AssetSelection\AssetSelector; use Internal\DLoad\Module\Downloader\Exception\DownloadFailed; use Internal\DLoad\Module\Downloader\Task\DownloadResult; use Internal\DLoad\Module\Repository\Collection\ReleasesCollection; @@ -31,6 +33,7 @@ use Internal\DLoad\Tests\Unit\Module\Repository\Stub\ReleaseStub; use Internal\DLoad\Tests\Unit\Module\Repository\Stub\RepositoryStub; use Internal\Path; +use Internal\DLoad\Tests\Unit\Module\Downloader\Internal\AssetSelection\Stub\LibcContainer; use Testo\Assert; use Testo\Codecov\Covers; use Testo\Data\DataProvider; @@ -426,6 +429,7 @@ private function download(array|SequenceRepositoryFactoryStub $repositories, str stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, + assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, new LibcContainer(Libc::Gnu), new ArchiveFactory()), ); $software = Software::fromArray([ @@ -474,6 +478,7 @@ private function makeDownloader(SequenceRepositoryFactoryStub $factory, Download stability: Stability::Stable, archiveService: new ArchiveFactory(), registry: $this->registry, + assetSelector: new AssetSelector(OperatingSystem::Linux, Architecture::X86_64, new LibcContainer(Libc::Gnu), new ArchiveFactory()), ); } diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php new file mode 100644 index 00000000..19e067fd --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetNameTest.php @@ -0,0 +1,46 @@ + ['bun-linux-x64.zip', ['bun']]; + yield 'variant' => ['bun-linux-x64-baseline-profile.zip', ['bun', 'baseline', 'profile']]; + yield 'debug build' => ['tigerbeetle-x86_64-linux-debug.zip', ['tigerbeetle', 'debug']]; + yield 'target triple' => ['mago-1.51.2-x86_64-unknown-linux-gnu.tar.gz', ['mago']]; + yield 'underscores' => ['temporal_cli_1.1.0_linux_amd64.tar.gz', ['temporal', 'cli']]; + yield 'version with v' => ['tool-v1.4.2-darwin-arm64.tar.gz', ['tool']]; + yield 'checksum' => ['tool-linux-amd64.tar.gz.sha256sum', ['tool']]; + yield 'unknown extension' => ['deno-x86_64-unknown-linux-gnu.from-2.9.6.bsdiff', ['deno', 'from', 'bsdiff']]; + yield 'no platform' => ['tool.phar', ['tool']]; + } + + /** + * @param list $expected + */ + #[DataProvider('provideExtras')] + #[Test] + public function extrasAreTheTokensLeftAfterThePlatformVersionAndExtension(string $name, array $expected): void + { + Assert::same(AssetName::fromString($name)->extras, $expected); + } + + #[Test] + public function libcIsReadFromTheName(): void + { + Assert::same(AssetName::fromString('bun-linux-x64-musl.zip')->libc, Libc::Musl); + Assert::null(AssetName::fromString('bun-linux-x64.zip')->libc); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php new file mode 100644 index 00000000..364fbf95 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/AssetSelectorTest.php @@ -0,0 +1,233 @@ + $assets + * @param non-empty-string $pattern + * @return list + */ + private static function select( + array $assets, + string $pattern = '/.*/', + ?Type $type = null, + bool $strict = true, + OperatingSystem $os = OperatingSystem::Linux, + Architecture $arch = Architecture::X86_64, + Libc $libc = Libc::Gnu, + ): array { + $selection = (new AssetSelector($os, $arch, new LibcContainer($libc), new ArchiveFactory())) + ->select(NamedAssets::create(...$assets), $pattern, $type, $strict); + + return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $selection->assets()); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php new file mode 100644 index 00000000..5b4dc155 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchitectureRuleTest.php @@ -0,0 +1,75 @@ + [OperatingSystem::Darwin]; + yield 'Windows' => [OperatingSystem::Windows]; + } + + #[Test] + public function aStrictSelectionKeepsOnlyTheHostArchitecture(): void + { + $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64, OperatingSystem::Linux), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'arch'), ['tool-aarch64.zip' => 0]); + } + + #[Test] + public function aGradualSelectionRanksOtherArchitecturesLast(): void + { + $selection = RuleRunner::run( + new ArchitectureRule(Architecture::ARM_64, OperatingSystem::Linux), + self::ASSETS, + strict: false, + ); + + Assert::same(RuleRunner::ranks($selection, 'arch'), [ + 'tool-x86_64.zip' => 2, + 'tool-aarch64.zip' => 0, + 'tool.zip' => 2, + ]); + } + + #[DataProvider('provideEmulatingHosts')] + #[Test] + public function armHostsThatEmulateX86KeepItsBuildsAsAFallback(OperatingSystem $os): void + { + $selection = RuleRunner::run(new ArchitectureRule(Architecture::ARM_64, $os), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'arch'), [ + 'tool-x86_64.zip' => 1, + 'tool-aarch64.zip' => 0, + ]); + } + + #[DataProvider('provideEmulatingHosts')] + #[Test] + public function x86HostsDoNotRunArmBuilds(OperatingSystem $os): void + { + $selection = RuleRunner::run(new ArchitectureRule(Architecture::X86_64, $os), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'arch'), ['tool-x86_64.zip' => 0]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php new file mode 100644 index 00000000..f94fe6fb --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/ArchiveRuleTest.php @@ -0,0 +1,34 @@ + 1, + 'tool-linux-amd64.deb' => 1, + 'tool-linux-amd64.tar.gz' => 0, + 'tool-linux-amd64.zip' => 0, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php new file mode 100644 index 00000000..958a4382 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/CompanionRuleTest.php @@ -0,0 +1,41 @@ + 3, + 'bun-linux-x64-baseline.zip' => 2, + 'bun-linux-x64.zip' => 1, + ]); + } + + #[Test] + public function aChecksumCountsAsItsAsset(): void + { + $selection = RuleRunner::run(new ExtrasRule(), ['tool-linux-amd64.tar.gz', 'tool-linux-amd64.tar.gz.sha256']); + + Assert::same(RuleRunner::ranks($selection, 'extras'), [ + 'tool-linux-amd64.tar.gz' => 1, + 'tool-linux-amd64.tar.gz.sha256' => 1, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php new file mode 100644 index 00000000..0da5e141 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/FormatRuleTest.php @@ -0,0 +1,63 @@ + 0, + 'tool-x86_64-unknown-linux-musl.tar.gz' => 1, + 'tool-linux-amd64.tar.gz' => 0, + ]); + } + + #[Test] + public function aMuslHostRanksMuslBuildsFirst(): void + { + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Musl)), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-x86_64-unknown-linux-gnu.tar.gz' => 1, + 'tool-x86_64-unknown-linux-musl.tar.gz' => 0, + 'tool-linux-amd64.tar.gz' => 1, + ]); + } + + #[Test] + public function androidRanksMuslBuildsFirst(): void + { + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Bionic)), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-x86_64-unknown-linux-gnu.tar.gz' => 1, + 'tool-x86_64-unknown-linux-musl.tar.gz' => 0, + 'tool-linux-amd64.tar.gz' => 1, + ]); + } + + #[Test] + public function aSystemLibcRanksNothing(): void + { + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::System)), [ + 'tool-x86_64-pc-windows-gnu.tar.gz', + 'tool-x86_64-pc-windows-msvc.zip', + ]); + + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-x86_64-pc-windows-gnu.tar.gz' => 0, + 'tool-x86_64-pc-windows-msvc.zip' => 0, + ]); + } + + #[Test] + public function nothingIsRemoved(): void + { + $selection = RuleRunner::run(new LibcRule(new LibcContainer(Libc::Gnu)), self::ASSETS); + + Assert::same(RuleRunner::names($selection), self::ASSETS); + } + + #[Test] + public function theHostIsNotProbedWithoutAChoiceOfLibc(): void + { + $container = new LibcContainer(Libc::Gnu); + + $selection = RuleRunner::run(new LibcRule($container), ['tool-linux-amd64.tar.gz', 'tool-x86_64-unknown-linux-gnu.tar.gz']); + + Assert::same($container->requests, 0); + Assert::same(RuleRunner::ranks($selection, 'libc'), [ + 'tool-linux-amd64.tar.gz' => 0, + 'tool-x86_64-unknown-linux-gnu.tar.gz' => 0, + ]); + } + + #[Test] + public function theHostIsProbedWhenTheLibcDecides(): void + { + $container = new LibcContainer(Libc::Musl); + + $selection = RuleRunner::run(new LibcRule($container), self::ASSETS); + + Assert::same($container->requests, 1); + Assert::same(RuleRunner::ranks($selection, 'libc')['tool-x86_64-unknown-linux-musl.tar.gz'], 0); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php new file mode 100644 index 00000000..c7ef60c5 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/NamePatternRuleTest.php @@ -0,0 +1,43 @@ +isEmpty()); + } + + #[Test] + public function nothingIsRanked(): void + { + $selection = RuleRunner::run(new NamePatternRule(), ['tool-linux-amd64.zip']); + + Assert::same($selection->candidates[0]->ranks, []); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php new file mode 100644 index 00000000..5ea45f1f --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Rule/OperatingSystemRuleTest.php @@ -0,0 +1,55 @@ + 0]); + } + + #[Test] + public function aGradualSelectionRanksOtherOsesLast(): void + { + $selection = RuleRunner::run(new OperatingSystemRule(OperatingSystem::Linux), self::ASSETS, strict: false); + + Assert::same(RuleRunner::ranks($selection, 'os'), [ + 'tool-linux-amd64.zip' => 0, + 'tool-linux-amd64-android.zip' => 2, + 'tool-darwin-amd64.zip' => 2, + 'tool-amd64.zip' => 2, + ]); + } + + #[Test] + public function androidKeepsLinuxBuildsAsAFallback(): void + { + $selection = RuleRunner::run(new OperatingSystemRule(OperatingSystem::Android), self::ASSETS); + + Assert::same(RuleRunner::ranks($selection, 'os'), [ + 'tool-linux-amd64.zip' => 1, + 'tool-linux-amd64-android.zip' => 0, + ]); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php new file mode 100644 index 00000000..76dd730e --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/SelectionTest.php @@ -0,0 +1,95 @@ +remove(static fn(Candidate $candidate): bool => $candidate->asset->getName() === 'b'); + + Assert::same(self::names($selection), ['a', 'c']); + } + + #[Test] + public function anEarlierRankOutweighsALaterOne(): void + { + $selection = self::selection('a-slow', 'b-fast', 'c-fast') + ->prefer('first', static fn(Candidate $candidate): bool => $candidate->asset->getName() !== 'b-fast') + ->prefer('second', static fn(Candidate $candidate): bool => \str_ends_with($candidate->asset->getName(), 'fast')); + + Assert::same(self::names($selection), ['c-fast', 'a-slow', 'b-fast']); + } + + #[Test] + public function aLaterRankOnlyOrdersCandidatesEqualByTheEarlierOnes(): void + { + $selection = self::selection('aaa', 'b', 'cc') + ->rank('length', static fn(Candidate $candidate): int => \strlen($candidate->asset->getName())); + + Assert::same(self::names($selection), ['b', 'cc', 'aaa']); + } + + #[Test] + public function equallyRankedCandidatesKeepTheReleaseOrder(): void + { + $selection = self::selection('b', 'a', 'c')->rank('same', static fn(): int => 1); + + Assert::same(self::names($selection), ['b', 'a', 'c']); + } + + #[Test] + public function ranksAreKeptUnderTheirNames(): void + { + $selection = self::selection('a') + ->rank('os', static fn(): int => 2) + ->prefer('libc', static fn(): bool => true); + + Assert::same($selection->sorted()[0]->ranks, ['os' => 2, 'libc' => 0]); + } + + #[Test] + public function aSelectionWithoutCandidatesIsEmpty(): void + { + $selection = self::selection('a')->remove(static fn(): bool => true); + + Assert::true($selection->isEmpty()); + Assert::same($selection->assets(), []); + } + + /** + * @param non-empty-string ...$names + */ + private static function selection(string ...$names): Selection + { + return Selection::create(NamedAssets::create(...$names), '/.*/', null, strict: true); + } + + /** + * @return list + */ + private static function names(Selection $selection): array + { + return \array_map(static fn(AssetInterface $asset): string => $asset->getName(), $selection->assets()); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php new file mode 100644 index 00000000..f79f7da4 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/LibcContainer.php @@ -0,0 +1,34 @@ + */ + public int $requests = 0; + + public function __construct( + private readonly Libc $libc, + ) {} + + public function get(string $id): Libc + { + $id === Libc::class or throw new \LogicException("Unexpected request for `$id`."); + ++$this->requests; + + return $this->libc; + } + + public function has(string $id): bool + { + return $id === Libc::class; + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php new file mode 100644 index 00000000..afad5895 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/NamedAssets.php @@ -0,0 +1,39 @@ + + */ + public static function create(string ...$names): array + { + $release = new ReleaseStub(new RepositoryStub('owner/repo'), 'v1.0.0', Version::fromVersionString('v1.0.0')); + + return \array_values(\array_map( + static fn(string $name): AssetInterface => new AssetStub( + $release, + $name, + 'https://example.com/' . $name, + OperatingSystem::tryFromBuildName($name), + Architecture::tryFromBuildName($name), + ), + $names, + )); + } +} diff --git a/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php new file mode 100644 index 00000000..9035efb1 --- /dev/null +++ b/tests/Unit/Module/Downloader/Internal/AssetSelection/Stub/RuleRunner.php @@ -0,0 +1,66 @@ + $assets Asset names in release order. + * @param non-empty-string $pattern + */ + public static function run( + AssetRule $rule, + array $assets, + bool $strict = true, + ?Type $type = null, + string $pattern = '/.*/', + ): Selection { + $calls = 0; + $result = $rule->select( + Selection::create(NamedAssets::create(...$assets), $pattern, $type, $strict), + static function (Selection $selection) use (&$calls): Selection { + ++$calls; + return $selection; + }, + ); + + $calls === 1 or throw new \LogicException(\sprintf('The rule called `$next` %d times.', $calls)); + return $result; + } + + /** + * Names of the candidates left, in release order. + * + * @return list + */ + public static function names(Selection $selection): array + { + return \array_map(static fn(Candidate $candidate): string => $candidate->asset->getName(), $selection->candidates); + } + + /** + * The rank the rule gave each candidate under the key, by asset name. + * + * @param non-empty-string $key + * @return array + */ + public static function ranks(Selection $selection, string $key): array + { + $ranks = []; + foreach ($selection->candidates as $candidate) { + $ranks[$candidate->asset->getName()] = $candidate->ranks[$key] ?? null; + } + + return $ranks; + } +}