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;
27use function Safe\preg_match;
28use function Safe\json_decode;
29
30/**
31 * Resolves DevTools freshness through Composer metadata without coupling callers to Composer commands.
32 */
33  class ComposerVersionChecker implements VersionCheckerInterface
34{
35    private const string PACKAGE = 'fast-forward/dev-tools';
36
37    private const string VERSION_UNKNOWN = '0.0.0';
38
39    private const int TIMEOUT_SECONDS = 5;
40
41    /**
42     * @param ProcessBuilderInterface $processBuilder the process builder used to query Composer metadata
43     */
44    public function __construct(
45        private ProcessBuilderInterface $processBuilder,
46    ) {}
47
48    /**
49     * Returns version information when it can be resolved without blocking command execution.
50     */
51    public function check(): ?VersionCheckResult
52    {
53        if (DevToolsPathResolver::isRepositoryCheckout()) {
54            return null;
55        }
56
57        $currentVersion = $this->getCurrentVersion();
58
59        if (self::VERSION_UNKNOWN === $currentVersion) {
60            return null;
61        }
62
63        $latestVersion = $this->resolveLatestStableVersion();
64
65        if (null === $latestVersion) {
66            return null;
67        }
68
69        return new VersionCheckResult($currentVersion, $latestVersion);
70    }
71
72    /**
73     * Returns the installed DevTools version without running external Composer commands.
74     *
75     * This method MUST return the package version when composer metadata is
76     * available.
77     * It MUST return `VERSION_UNKNOWN` when metadata is unavailable or on
78     * resolution errors.
79     */
80    public function getCurrentVersion(): string
81    {
82        if (! InstalledVersions::isInstalled(self::PACKAGE)) {
83            return self::VERSION_UNKNOWN;
84        }
85
86        try {
87            return InstalledVersions::getPrettyVersion(self::PACKAGE)
88                ?? InstalledVersions::getVersion(self::PACKAGE)
89                ?? self::VERSION_UNKNOWN;
90        } catch (Throwable) {
91            return self::VERSION_UNKNOWN;
92        }
93    }
94
95    /**
96     * Resolves the latest stable DevTools version available to Composer.
97     */
98    private function resolveLatestStableVersion(): ?string
99    {
100        $process = $this->processBuilder
101            ->withArgument(self::PACKAGE)
102            ->withArgument('--available')
103            ->withArgument('--format=json')
104            ->withArgument('--no-interaction')
105            ->build('composer show');
106
107        $process->setTimeout(self::TIMEOUT_SECONDS);
108
109        $process->run();
110
111        if (! $process->isSuccessful()) {
112            return null;
113        }
114
115        try {
116            $payload = json_decode($process->getOutput(), true);
117        } catch (JsonException) {
118            return null;
119        }
120
121        if (! \is_array($payload)) {
122            return null;
123        }
124
125        $versions = $payload['versions'] ?? null;
126
127        if (! \is_array($versions)) {
128            return null;
129        }
130
131        foreach ($versions as $version) {
132            if (! \is_string($version)) {
133                continue;
134            }
135
136            if (1 === preg_match('/^v?\d+\.\d+\.\d+$/', $version)) {
137                return $version;
138            }
139        }
140
141        return null;
142    }
143}