Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
46.34% covered (danger)
46.34%
19 / 41
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
ComposerVersionChecker
46.34% covered (danger)
46.34%
19 / 41
25.00% covered (danger)
25.00%
1 / 4
55.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 check
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
20
 getCurrentVersion
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 resolveLatestStableVersion
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
9.00
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\SelfUpdate;
21
22use Composer\InstalledVersions;
23use FastForward\DevTools\Path\DevToolsPathResolver;
24use FastForward\DevTools\Process\ProcessBuilderInterface;
25use JsonException;
26use Throwable;
27
28use function Safe\preg_match;
29use function Safe\json_decode;
30
31/**
32 * Resolves DevTools freshness through Composer metadata without coupling callers to Composer commands.
33 */
34  class ComposerVersionChecker implements VersionCheckerInterface
35{
36    private const string PACKAGE = 'fast-forward/dev-tools';
37
38    private const string VERSION_UNKNOWN = '0.0.0';
39
40    private const int TIMEOUT_SECONDS = 5;
41
42    /**
43     * @param ProcessBuilderInterface $processBuilder the process builder used to query Composer metadata
44     */
45    public function __construct(
46        private ProcessBuilderInterface $processBuilder,
47    ) {}
48
49    /**
50     * Returns version information when it can be resolved without blocking command execution.
51     */
52    public function check(): ?VersionCheckResult
53    {
54        if (DevToolsPathResolver::isRepositoryCheckout()) {
55            return null;
56        }
57
58        $currentVersion = $this->getCurrentVersion();
59
60        if (self::VERSION_UNKNOWN === $currentVersion) {
61            return null;
62        }
63
64        $latestVersion = $this->resolveLatestStableVersion();
65
66        if (null === $latestVersion) {
67            return null;
68        }
69
70        return new VersionCheckResult($currentVersion, $latestVersion);
71    }
72
73    /**
74     * Returns the installed DevTools version without running external Composer commands.
75     *
76     * This method MUST return the package version when composer metadata is
77     * available.
78     * It MUST return `VERSION_UNKNOWN` when metadata is unavailable or on
79     * resolution errors.
80     */
81    public function getCurrentVersion(): string
82    {
83        if (! InstalledVersions::isInstalled(self::PACKAGE)) {
84            return self::VERSION_UNKNOWN;
85        }
86
87        try {
88            return InstalledVersions::getPrettyVersion(self::PACKAGE)
89                ?? InstalledVersions::getVersion(self::PACKAGE)
90                ?? self::VERSION_UNKNOWN;
91        } catch (Throwable) {
92            return self::VERSION_UNKNOWN;
93        }
94    }
95
96    /**
97     * Resolves the latest stable DevTools version available to Composer.
98     */
99    private function resolveLatestStableVersion(): ?string
100    {
101        $process = $this->processBuilder
102            ->withArgument(self::PACKAGE)
103            ->withArgument('--available')
104            ->withArgument('--format=json')
105            ->withArgument('--no-interaction')
106            ->build('composer show');
107
108        $process->setTimeout(self::TIMEOUT_SECONDS);
109
110        $process->run();
111
112        if (! $process->isSuccessful()) {
113            return null;
114        }
115
116        try {
117            $payload = json_decode($process->getOutput(), true);
118        } catch (JsonException) {
119            return null;
120        }
121
122        if (! \is_array($payload)) {
123            return null;
124        }
125
126        $versions = $payload['versions'] ?? null;
127
128        if (! \is_array($versions)) {
129            return null;
130        }
131
132        foreach ($versions as $version) {
133            if (! \is_string($version)) {
134                continue;
135            }
136
137            if (1 === preg_match('/^v?\d+\.\d+\.\d+$/', $version)) {
138                return $version;
139            }
140        }
141
142        return null;
143    }
144}