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

eliashaeussler / version-bumper / 30636491704

31 Jul 2026 01:56PM UTC coverage: 88.594% (-0.5%) from 89.113%
30636491704

push

github

web-flow
Merge pull request #160 from eliashaeussler/fix/merge

[BUGFIX] Perform deep-merge of objects when applying config presets

18 of 24 new or added lines in 2 files covered. (75.0%)

2 existing lines in 1 file now uncovered.

1235 of 1394 relevant lines covered (88.59%)

5.27 hits per line

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

97.53
/src/Config/ConfigReader.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\Config;
25

26
use CuyZ\Valinor;
27
use EliasHaeussler\VersionBumper\Exception;
28
use EliasHaeussler\VersionBumper\Version;
29
use ReflectionObject;
30
use SplFileObject;
31
use Symfony\Component\Filesystem;
32
use Symfony\Component\Yaml;
33

34
use function array_merge;
35
use function dirname;
36
use function is_a;
37
use function is_array;
38
use function is_callable;
39
use function is_object;
40

41
/**
42
 * ConfigReader.
43
 *
44
 * @author Elias Häußler <elias@haeussler.dev>
45
 * @license GPL-3.0-or-later
46
 */
47
final readonly class ConfigReader
48
{
49
    private Filesystem\Filesystem $filesystem;
50
    private Valinor\Mapper\TreeMapper $mapper;
51

52
    public function __construct()
18✔
53
    {
54
        $this->filesystem = new Filesystem\Filesystem();
18✔
55
        $this->mapper = $this->createMapper();
18✔
56
    }
57

58
    /**
59
     * @throws Exception\ConfigFileIsInvalid
60
     * @throws Exception\ConfigFileIsNotSupported
61
     * @throws Exception\FileDoesNotExist
62
     * @throws Valinor\Mapper\MappingError
63
     */
64
    public function readFromFile(string $file): VersionBumperConfig
16✔
65
    {
66
        if (!$this->filesystem->exists($file)) {
16✔
67
            throw new Exception\FileDoesNotExist($file);
1✔
68
        }
69

70
        $extension = Filesystem\Path::getExtension($file, true);
15✔
71

72
        if ('php' === $extension) {
15✔
73
            $config = $this->parsePhpFile($file);
3✔
74
        } else {
75
            $source = match ($extension) {
12✔
76
                'json' => Valinor\Mapper\Source\Source::file(new SplFileObject($file)),
9✔
77
                'yaml', 'yml' => Valinor\Mapper\Source\Source::array($this->parseYamlFile($file)),
2✔
78
                default => throw new Exception\ConfigFileIsNotSupported($file),
1✔
79
            };
12✔
80
            $config = $this->mapper->map(VersionBumperConfig::class, $source);
10✔
81
        }
82

83
        if (null === $config->rootPath()) {
11✔
84
            $config->setRootPath(dirname($file));
1✔
85
        } elseif (!Filesystem\Path::isAbsolute($config->rootPath())) {
10✔
86
            $config->setRootPath(
10✔
87
                Filesystem\Path::makeAbsolute($config->rootPath(), dirname($file)),
10✔
88
            );
10✔
89
        }
90

91
        foreach ($config->presets() as $preset) {
11✔
92
            // Merge user config on top of preset defaults so that explicit
93
            // user values (e.g. releaseOptions, rootPath) win over preset values.
94
            $config = $this->mergeObjects($preset->getConfig($config), $config);
6✔
95
        }
96

97
        return $config;
11✔
98
    }
99

100
    public function detectFile(string $rootPath): ?string
2✔
101
    {
102
        $filenames = [
2✔
103
            'version-bumper.php',
2✔
104
            'version-bumper.json',
2✔
105
            'version-bumper.yaml',
2✔
106
            'version-bumper.yml',
2✔
107
        ];
2✔
108

109
        foreach ($filenames as $filename) {
2✔
110
            $path = Filesystem\Path::join($rootPath, $filename);
2✔
111

112
            if ($this->filesystem->exists($path)) {
2✔
113
                return $path;
1✔
114
            }
115
        }
116

117
        return null;
1✔
118
    }
119

120
    /**
121
     * @throws Exception\ConfigFileIsInvalid
122
     */
123
    private function parsePhpFile(string $file): VersionBumperConfig
3✔
124
    {
125
        $returnValue = require $file;
3✔
126

127
        if ($returnValue instanceof VersionBumperConfig) {
3✔
128
            return $returnValue;
1✔
129
        }
130

131
        if (!is_callable($returnValue)) {
2✔
132
            throw new Exception\ConfigFileIsInvalid($file);
1✔
133
        }
134

135
        $config = $returnValue();
1✔
136

137
        if (!$config instanceof VersionBumperConfig) {
1✔
138
            throw new Exception\ConfigFileIsInvalid($file);
×
139
        }
140

141
        return $config;
1✔
142
    }
143

144
    /**
145
     * @return array<array-key, mixed>
146
     *
147
     * @throws Exception\ConfigFileIsInvalid
148
     */
149
    private function parseYamlFile(string $file): array
2✔
150
    {
151
        $yaml = Yaml\Yaml::parseFile($file);
2✔
152

153
        if (!is_array($yaml)) {
2✔
154
            throw new Exception\ConfigFileIsInvalid($file);
1✔
155
        }
156

157
        return $yaml;
1✔
158
    }
159

160
    /**
161
     * @template T of object
162
     *
163
     * @param T $a
164
     * @param T $b
165
     *
166
     * @return T
167
     */
168
    private function mergeObjects(object $a, object $b): object
6✔
169
    {
170
        if (!is_a($b, $a::class)) {
6✔
NEW
171
            throw new Exception\ObjectsAreIncompatible($a, $b);
×
172
        }
173

174
        $reflection = new ReflectionObject($b);
6✔
175
        $parameters = $reflection->getConstructor()?->getParameters() ?? [];
6✔
176
        $properties = [];
6✔
177

178
        foreach ($parameters as $parameter) {
6✔
179
            $property = $reflection->getProperty($parameter->getName());
6✔
180
            $aValue = $property->getValue($a);
6✔
181
            $bValue = $property->getValue($b);
6✔
182

183
            /* @phpstan-ignore notEqual.notAllowed (Loose comparison is intended as we may compare objects) */
184
            if ($parameter->getDefaultValue() != $bValue) {
6✔
185
                $aValue = match (true) {
6✔
186
                    is_array($aValue) && is_array($bValue) => array_merge($aValue, $bValue),
6✔
187
                    is_object($aValue) && is_object($bValue) => $this->mergeObjects($aValue, $bValue),
6✔
188
                    default => $bValue,
6✔
189
                };
6✔
190
            }
191

192
            $properties[] = $aValue;
6✔
193
        }
194

195
        /* @phpstan-ignore return.type (Revisit once https://github.com/phpstan/phpstan/issues/15032 is solved) */
196
        return $reflection->newInstance(...$properties);
6✔
197
    }
198

199
    private function createMapper(): Valinor\Mapper\TreeMapper
18✔
200
    {
201
        $actionFactory = new Version\Action\ActionFactory();
18✔
202
        $presetFactory = new Preset\PresetFactory();
18✔
203

204
        return (new Valinor\MapperBuilder())
18✔
205
            ->registerConstructor(
18✔
206
                $actionFactory->get(...),
18✔
207
                $presetFactory->get(...),
18✔
208
                static fn (string $name): Preset\Preset => $presetFactory->get($name),
18✔
209
            )
18✔
210
            ->allowPermissiveTypes()
18✔
211
            ->mapper()
18✔
212
        ;
18✔
213
    }
214
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc