• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

eliashaeussler / version-bumper / 26179712348

20 May 2026 05:44PM UTC coverage: 78.161% (-10.5%) from 88.618%
26179712348

Pull #144

github

eliashaeussler
[FEATURE] Introduce `next-version` command
Pull Request #144: [FEATURE] Introduce `next-version` command

7 of 176 new or added lines in 5 files covered. (3.98%)

12 existing lines in 2 files now uncovered.

1156 of 1479 relevant lines covered (78.16%)

4.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/src/Command/NextVersionCommand.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the Composer package "eliashaeussler/version-bumper".
7
 *
8
 * Copyright (C) 2024-2026 Elias Häußler <elias@haeussler.dev>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 3 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace EliasHaeussler\VersionBumper\Command;
25

26
use Composer\Command;
27
use Composer\Composer;
28
use CuyZ\Valinor;
29
use EliasHaeussler\VersionBumper\Config;
30
use EliasHaeussler\VersionBumper\Enum;
31
use EliasHaeussler\VersionBumper\Error;
32
use EliasHaeussler\VersionBumper\Exception;
33
use EliasHaeussler\VersionBumper\Helper;
34
use EliasHaeussler\VersionBumper\Result;
35
use EliasHaeussler\VersionBumper\Version;
36
use GitElephant\Command\Caller;
37
use GitElephant\Repository;
38
use Symfony\Component\Console;
39
use Symfony\Component\Filesystem;
40

41
use function dirname;
42
use function getcwd;
43
use function implode;
44
use function is_string;
45
use function method_exists;
46
use function sprintf;
47
use function trim;
48

49
/**
50
 * NextVersionCommand.
51
 *
52
 * @author Elias Häußler <elias@haeussler.dev>
53
 * @license GPL-3.0-or-later
54
 */
55
final class NextVersionCommand extends Command\BaseCommand
56
{
57
    private readonly Version\VersionBumper $bumper;
58
    private readonly Config\ConfigReader $configReader;
59
    private readonly Version\VersionRangeDetector $versionRangeDetector;
60
    private readonly Error\DeprecationHandler $deprecationHandler;
61
    private Console\Style\SymfonyStyle $io;
62

NEW
63
    public function __construct(
×
64
        ?Composer $composer = null,
65
        private readonly ?Caller\CallerInterface $caller = null,
66
    ) {
NEW
67
        if (null !== $composer) {
×
NEW
68
            $this->setComposer($composer);
×
69
        }
70

NEW
71
        parent::__construct('next-version');
×
72

NEW
73
        $this->bumper = new Version\VersionBumper();
×
NEW
74
        $this->configReader = new Config\ConfigReader();
×
NEW
75
        $this->versionRangeDetector = new Version\VersionRangeDetector($caller);
×
NEW
76
        $this->deprecationHandler = Error\DeprecationHandler::new();
×
77
    }
78

NEW
79
    protected function configure(): void
×
80
    {
NEW
81
        $this->setAliases(['nv', 'next']);
×
NEW
82
        $this->setDescription('Calculate next package version');
×
83

NEW
84
        $this->addArgument(
×
NEW
85
            'range',
×
NEW
86
            Console\Input\InputArgument::OPTIONAL,
×
NEW
87
            sprintf(
×
NEW
88
                'Version range (one of "%s") for the next package version',
×
NEW
89
                implode('", "', Enum\VersionRange::all()),
×
NEW
90
            ),
×
NEW
91
        );
×
92

NEW
93
        $this->addOption(
×
NEW
94
            'config',
×
NEW
95
            'c',
×
NEW
96
            Console\Input\InputOption::VALUE_REQUIRED,
×
NEW
97
            'Path to configuration file (JSON, YAML or PHP)',
×
NEW
98
            $this->readConfigFileFromRootPackage(),
×
NEW
99
        );
×
100
    }
101

NEW
102
    protected function initialize(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): void
×
103
    {
NEW
104
        $this->io = new Console\Style\SymfonyStyle($input, $output);
×
105
    }
106

NEW
107
    protected function execute(Console\Input\InputInterface $input, Console\Output\OutputInterface $output): int
×
108
    {
NEW
109
        $rootPath = (string) getcwd();
×
NEW
110
        $rangeOrVersion = $input->getArgument('range');
×
NEW
111
        $configFile = $input->getOption('config') ?? $this->configReader->detectFile($rootPath);
×
112

NEW
113
        if (null === $configFile) {
×
NEW
114
            $this->io->error('Please provide a config file path using the --config option.');
×
115

NEW
116
            return self::INVALID;
×
117
        }
118

NEW
119
        if (Filesystem\Path::isRelative($configFile)) {
×
NEW
120
            $configFile = Filesystem\Path::makeAbsolute($configFile, $rootPath);
×
121
        } else {
NEW
122
            $rootPath = dirname($configFile);
×
123
        }
124

125
        // Register custom error handler to collect deprecations from config presets
NEW
126
        $this->deprecationHandler->enable();
×
127

128
        try {
NEW
129
            $config = $this->configReader->readFromFile($configFile);
×
130

131
            // Override root path from config file
NEW
132
            if (null !== $config->rootPath()) {
×
NEW
133
                $rootPath = $config->rootPath();
×
134
            }
135

136
            // Auto-detect version range from indicators
NEW
137
            $versionRange = $this->resolveVersionRange($config, $rangeOrVersion, $rootPath);
×
138

NEW
139
            if (null === $versionRange) {
×
NEW
140
                return self::FAILURE;
×
141
            }
142

NEW
143
            $results = $this->bumper->bump($config->filesToModify(), $rootPath, $versionRange, true);
×
NEW
144
            $version = $this->resolveVersionFromResultsOrRange($results, $versionRange, $rootPath);
×
145

NEW
146
            if (null === $version) {
×
NEW
147
                $this->io->error('Unable to calculate next package version.');
×
148

NEW
149
                return self::FAILURE;
×
150
            }
151

NEW
152
            $this->io->writeln($version->full());
×
NEW
153
        } catch (Valinor\Mapper\MappingError $error) {
×
NEW
154
            $this->decorateMappingError($error, $configFile);
×
155

NEW
156
            return self::FAILURE;
×
NEW
157
        } catch (Exception\Exception $exception) {
×
NEW
158
            $this->io->error($exception->getMessage());
×
159

NEW
160
            return self::FAILURE;
×
161
        } finally {
NEW
162
            $this->deprecationHandler->disable();
×
NEW
163
            $this->deprecationHandler->decorate($this->io);
×
164
        }
165

NEW
166
        return self::SUCCESS;
×
167
    }
168

NEW
169
    private function resolveVersionRange(
×
170
        Config\VersionBumperConfig $config,
171
        ?string $rangeOrVersion,
172
        string $rootPath,
173
    ): ?Enum\VersionRange {
NEW
174
        if (null !== $rangeOrVersion) {
×
NEW
175
            $versionRange = Enum\VersionRange::tryFromInput($rangeOrVersion);
×
NEW
176
        } elseif ([] !== $config->versionRangeIndicators()) {
×
NEW
177
            $versionRange = $this->versionRangeDetector->detect($rootPath, $config->versionRangeIndicators());
×
178
        } else {
NEW
179
            $this->io->error(
×
NEW
180
                sprintf(
×
NEW
181
                    'Please provide a valid version range, must be one of "%s".',
×
NEW
182
                    implode('", "', Enum\VersionRange::all()),
×
NEW
183
                ),
×
NEW
184
            );
×
NEW
185
            $this->io->block(
×
NEW
186
                'You can also enable auto-detection by adding version range indicators to your configuration file.',
×
NEW
187
                null,
×
NEW
188
                'fg=cyan',
×
NEW
189
                '💡 ',
×
NEW
190
            );
×
191

NEW
192
            return null;
×
193
        }
194

195
        // Exit early if version range detection fails
NEW
196
        if (null === $versionRange) {
×
NEW
197
            $this->io->error('Unable to auto-detect version range. Please provide a version range instead.');
×
198

NEW
199
            return null;
×
200
        }
201

NEW
202
        return $versionRange;
×
203
    }
204

205
    /**
206
     * @param list<Result\VersionBumpResult> $results
207
     *
208
     * @throws Exception\AmbiguousVersionsDetected
209
     * @throws Exception\CannotFetchLatestGitTag
210
     * @throws Exception\VersionIsNotSupported
211
     */
NEW
212
    private function resolveVersionFromResultsOrRange(
×
213
        array $results,
214
        ?Enum\VersionRange $versionRange,
215
        string $rootPath,
216
    ): ?Version\Version {
NEW
217
        $version = Helper\VersionHelper::extractVersionFromResults($results);
×
218

NEW
219
        if (null !== $version || null === $versionRange) {
×
NEW
220
            return $version;
×
221
        }
222

NEW
223
        $repository = new Repository($rootPath);
×
224

NEW
225
        if (null !== $this->caller) {
×
NEW
226
            $repository->setCaller($this->caller);
×
227
        }
228

NEW
229
        return Helper\VersionHelper::detectVersionFromVersionRange($versionRange, $repository);
×
230
    }
231

NEW
232
    private function decorateMappingError(Valinor\Mapper\MappingError $error, string $configFile): void
×
233
    {
NEW
234
        $errorMessages = [];
×
NEW
235
        $errors = $error->messages()->errors();
×
236

NEW
237
        $this->io->error(
×
NEW
238
            sprintf('The config file "%s" is invalid.', $configFile),
×
NEW
239
        );
×
240

NEW
241
        foreach ($errors as $propertyError) {
×
NEW
242
            $errorMessages[] = sprintf('%s: %s', $propertyError->path(), $propertyError->toString());
×
243
        }
244

NEW
245
        $this->io->listing($errorMessages);
×
246
    }
247

NEW
248
    private function readConfigFileFromRootPackage(): ?string
×
249
    {
NEW
250
        $composer = $this->getComposerInstance();
×
251

NEW
252
        if (null === $composer) {
×
NEW
253
            return null;
×
254
        }
255

NEW
256
        $extra = $composer->getPackage()->getExtra();
×
257
        /* @phpstan-ignore offsetAccess.nonOffsetAccessible */
NEW
258
        $configFile = $extra['version-bumper']['config-file'] ?? null;
×
259

NEW
260
        if (is_string($configFile) && '' !== trim($configFile)) {
×
NEW
261
            return $configFile;
×
262
        }
263

NEW
264
        return null;
×
265
    }
266

NEW
267
    private function getComposerInstance(): ?Composer
×
268
    {
269
        // Composer >= 2.3
NEW
270
        if (method_exists($this, 'tryComposer')) {
×
NEW
271
            return $this->tryComposer();
×
272
        }
273

274
        // Composer < 2.3
NEW
275
        return $this->getComposer(false);
×
276
    }
277
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc