Passed
Push — main ( c19e8c...628b24 )
by Sebastian
03:36
created

Factory   A

Complexity

Total Complexity 38

Size/Duplication

Total Lines 316
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 75
c 2
b 0
f 0
dl 0
loc 316
ccs 89
cts 89
cp 1
rs 9.36
wmc 38

17 Methods

Rating   Name   Duplication   Size   Complexity  
A createConfig() 0 7 2
A combineArgumentsAndSettingFile() 0 8 2
A setupConfig() 0 5 2
A includeConfig() 0 7 2
A loadConfigFromFile() 0 21 4
A configureHook() 0 8 2
A usesPathResolver() 0 3 2
A appendIncludedConfigurations() 0 11 3
A validatePhpPath() 0 20 5
A mergeHookConfigFromIncludes() 0 13 3
A readMaxIncludeLevel() 0 5 3
A extractConditions() 0 3 1
A extractSettings() 0 3 1
A extractOptions() 0 3 1
A loadIncludedConfigs() 0 10 2
A create() 0 4 1
A copyActionsFromTo() 0 4 2
1
<?php
2
3
/**
4
 * This file is part of CaptainHook
5
 *
6
 * (c) Sebastian Feldmann <[email protected]>
7
 *
8
 * For the full copyright and license information, please view the LICENSE
9
 * file that was distributed with this source code.
10
 */
11
12
namespace CaptainHook\App\Config;
13
14
use CaptainHook\App\CH;
15
use CaptainHook\App\Config;
16
use CaptainHook\App\Hook\Util as HookUtil;
17
use CaptainHook\App\Storage\File\Json;
18
use RuntimeException;
19
20
/**
21
 * Class Factory
22
 *
23
 * @package CaptainHook
24
 * @author  Sebastian Feldmann <[email protected]>
25
 * @link    https://github.com/captainhookphp/captainhook
26
 * @since   Class available since Release 0.9.0
27
 * @internal
28
 */
29
final class Factory
30
{
31
    /**
32
     * Maximal level in including config files
33
     *
34
     * @var int
35
     */
36
    private int $maxIncludeLevel = 1;
37
38
    /**
39
     * Current level of inclusion
40
     *
41
     * @var int
42
     */
43
    private int $includeLevel = 0;
44
45
    /**
46
     * Create a CaptainHook configuration
47
     *
48
     * @param  string               $path     Path to the configuration file
49
     * @param  array<string, mixed> $settings Settings passed as options on the command line
50
     * @return \CaptainHook\App\Config
51
     * @throws \Exception
52
     */
53 63
    public function createConfig(string $path = '', array $settings = []): Config
54
    {
55 63
        $path     = $path ?: getcwd() . DIRECTORY_SEPARATOR . CH::CONFIG;
56 63
        $file     = new Json($path);
57 63
        $settings = $this->combineArgumentsAndSettingFile($file, $settings);
58
59 63
        return $this->setupConfig($file, $settings);
60
    }
61
62
    /**
63
     * Read settings from a local 'config' file
64
     *
65
     * If you prefer a different verbosity or use a different run mode locally then your teammates do.
66
     * You can create a 'captainhook.config.json' in the same directory as your captainhook
67
     * configuration file and use it to overwrite the 'config' settings of that configuration file.
68
     * Exclude the 'captainhook.config.json' from version control, and you don't have to edit the
69
     * version controlled configuration for your local specifics anymore.
70
     *
71
     * Settings provided as arguments still overrule config file settings:
72
     *
73
     * ARGUMENTS > SETTINGS_FILE > CONFIGURATION
74
     *
75
     * @param  \CaptainHook\App\Storage\File\Json $file
76
     * @param  array<string, mixed>               $settings
77
     * @return array<string, mixed>
78
     */
79 63
    private function combineArgumentsAndSettingFile(Json $file, array $settings): array
80
    {
81 63
        $settingsFile = new Json(dirname($file->getPath()) . '/captainhook.config.json');
82 63
        if ($settingsFile->exists()) {
83 1
            $fileSettings = $settingsFile->readAssoc();
84 1
            $settings     = array_merge($fileSettings, $settings);
85
        }
86 63
        return $settings;
87
    }
88
89
    /**
90
     * Includes an external captainhook configuration
91
     *
92
     * @param  string $path
93
     * @return \CaptainHook\App\Config
94
     * @throws \Exception
95
     */
96 7
    private function includeConfig(string $path): Config
97
    {
98 7
        $file = new Json($path);
99 7
        if (!$file->exists()) {
100 1
            throw new RuntimeException('Config to include not found: ' . $path);
101
        }
102 6
        return $this->setupConfig($file);
103
    }
104
105
    /**
106
     * Return a configuration with data loaded from json file if it exists
107
     *
108
     * @param  \CaptainHook\App\Storage\File\Json $file
109
     * @param  array<string, mixed>               $settings
110
     * @return \CaptainHook\App\Config
111
     * @throws \Exception
112
     */
113 63
    private function setupConfig(Json $file, array $settings = []): Config
114
    {
115 63
        return $file->exists()
116 56
            ? $this->loadConfigFromFile($file, $settings)
117 61
            : new Config($file->getPath(), false, $settings);
118
    }
119
120
    /**
121
     * Loads a given file into given the configuration
122
     *
123
     * @param  \CaptainHook\App\Storage\File\Json $file
124
     * @param  array<string, mixed>               $settings
125
     * @return \CaptainHook\App\Config
126
     * @throws \Exception
127
     */
128 56
    private function loadConfigFromFile(Json $file, array $settings): Config
129
    {
130 56
        $json = $file->readAssoc();
131 56
        Util::validateJsonConfiguration($json);
132
133 56
        $settings = Util::mergeSettings($this->extractSettings($json), $settings);
134 56
        $config   = new Config($file->getPath(), true, $settings);
135 56
        if (!empty($settings)) {
136 44
            $json['config'] = $settings;
137
        }
138
139 56
        $this->appendIncludedConfigurations($config, $json);
140
141 55
        foreach (HookUtil::getValidHooks() as $hook => $class) {
142 55
            if (isset($json[$hook])) {
143 55
                $this->configureHook($config->getHookConfig($hook), $json[$hook]);
144
            }
145
        }
146
147 55
        $this->validatePhpPath($config);
148 54
        return $config;
149
    }
150
151
    /**
152
     * Return the `config` section of some json
153
     *
154
     * @param  array<string, mixed> $json
155
     * @return array<string, mixed>
156
     */
157 56
    private function extractSettings(array $json): array
158
    {
159 56
        return Util::extractListFromJson($json, 'config');
160
    }
161
162
    /**
163
     * Returns the `conditions` section of an actionJson
164
     *
165
     * @param array<string, mixed> $json
166
     * @return array<string, mixed>
167
     */
168 47
    private function extractConditions(mixed $json): array
169
    {
170 47
        return Util::extractListFromJson($json, 'conditions');
171
    }
172
173
    /**
174
     * Returns the `options` section af some json
175
     *
176
     * @param array<string, mixed> $json
177
     * @return array<string, string>
178
     */
179 47
    private function extractOptions(mixed $json): array
180
    {
181 47
        return Util::extractListFromJson($json, 'options');
182
    }
183
184
    /**
185
     * Set up a hook configuration by json data
186
     *
187
     * @param  \CaptainHook\App\Config\Hook $config
188
     * @param  array<string, mixed>         $json
189
     * @return void
190
     * @throws \Exception
191
     */
192 55
    private function configureHook(Config\Hook $config, array $json): void
193
    {
194 55
        $config->setEnabled($json['enabled']);
195 55
        foreach ($json['actions'] as $actionJson) {
196 47
            $options    = $this->extractOptions($actionJson);
197 47
            $conditions = $this->extractConditions($actionJson);
198 47
            $settings   = $this->extractSettings($actionJson);
199 47
            $config->addAction(new Config\Action($actionJson['action'], $options, $conditions, $settings));
200
        }
201
    }
202
203
    /**
204
     * Makes sure the configured PHP executable exists
205
     *
206
     * @param  \CaptainHook\App\Config $config
207
     * @return void
208
     */
209 55
    private function validatePhpPath(Config $config): void
210
    {
211 55
        if (empty($config->getPhpPath())) {
212 53
            return;
213
        }
214 2
        $pathToCheck = [$config->getPhpPath()];
215 2
        $parts       = explode(' ', $config->getPhpPath());
216
        // if there are spaces in the php-path and they are not escaped
217
        // it looks like an executable is used to find the PHP binary
218
        // so at least check if the executable exists
219 2
        if ($this->usesPathResolver($parts)) {
220 1
            $pathToCheck[] = $parts[0];
221
        }
222
223 2
        foreach ($pathToCheck as $path) {
224 2
            if (file_exists($path)) {
225 1
                return;
226
            }
227
        }
228 1
        throw new RuntimeException('The configured php-path is wrong: ' . $config->getPhpPath());
229
    }
230
231
    /**
232
     * Is a binary used to resolve the php path
233
     *
234
     * @param array<int, string> $parts
235
     * @return bool
236
     */
237 2
    private function usesPathResolver(array $parts): bool
238
    {
239 2
        return count($parts) > 1 && !str_ends_with($parts[0], '\\');
240
    }
241
242
    /**
243
     * Append all included configuration to the current configuration
244
     *
245
     * @param  \CaptainHook\App\Config $config
246
     * @param  array<string, mixed>    $json
247
     * @throws \Exception
248
     */
249 56
    private function appendIncludedConfigurations(Config $config, array $json): void
250
    {
251 56
        $this->readMaxIncludeLevel($json);
252
253 56
        if ($this->includeLevel < $this->maxIncludeLevel) {
254 56
            $this->includeLevel++;
255 56
            $includes = $this->loadIncludedConfigs($json, $config->getPath());
256 55
            foreach (HookUtil::getValidHooks() as $hook => $class) {
257 55
                $this->mergeHookConfigFromIncludes($config->getHookConfig($hook), $includes);
258
            }
259 55
            $this->includeLevel--;
260
        }
261
    }
262
263
    /**
264
     * Check config section for 'includes-level' setting
265
     *
266
     * @param array<string, mixed> $json
267
     */
268 56
    private function readMaxIncludeLevel(array $json): void
269
    {
270
        // read the include-level setting only for the actual configuration
271 56
        if ($this->includeLevel === 0 && isset($json['config'][Config::SETTING_INCLUDES_LEVEL])) {
272 2
            $this->maxIncludeLevel = (int) $json['config'][Config::SETTING_INCLUDES_LEVEL];
273
        }
274
    }
275
276
    /**
277
     * Merge a given hook config with the corresponding hook configs from a list of included configurations
278
     *
279
     * @param  \CaptainHook\App\Config\Hook $hook
280
     * @param  \CaptainHook\App\Config[]    $includes
281
     * @return void
282
     */
283 55
    private function mergeHookConfigFromIncludes(Hook $hook, array $includes): void
284
    {
285 55
        foreach ($includes as $includedConfig) {
286 6
            $includedHook = $includedConfig->getHookConfig($hook->getName());
287 6
            if ($includedHook->isEnabled()) {
288 6
                $hook->setEnabled(true);
289
                // This `setEnable` is solely to overwrite the main configuration in the special case that the hook
290
                // is not configured at all. In this case the empty config is disabled by default, and adding an
291
                // empty hook config just to enable the included actions feels a bit dull.
292
                // Since the main hook is processed last (if one is configured) the enabled flag will be overwritten
293
                // once again by the main config value. This is to make sure that if somebody disables a hook in its
294
                // main configuration no actions will get executed, even if we have enabled hooks in any include file.
295 6
                $this->copyActionsFromTo($includedHook, $hook);
296
            }
297
        }
298
    }
299
300
    /**
301
     * Return list of included configurations to add them to the main configuration afterwards
302
     *
303
     * @param  array<string, mixed> $json
304
     * @param  string               $path
305
     * @return \CaptainHook\App\Config[]
306
     * @throws \Exception
307
     */
308 56
    protected function loadIncludedConfigs(array $json, string $path): array
309
    {
310 56
        $includes  = [];
311 56
        $directory = dirname($path);
312 56
        $files     = Util::extractListFromJson(Util::extractListFromJson($json, 'config'), Config::SETTING_INCLUDES);
313
314 56
        foreach ($files as $file) {
315 7
            $includes[] = $this->includeConfig($directory . DIRECTORY_SEPARATOR . $file);
316
        }
317 55
        return $includes;
318
    }
319
320
    /**
321
     * Copy action from a given configuration to the second given configuration
322
     *
323
     * @param \CaptainHook\App\Config\Hook $sourceConfig
324
     * @param \CaptainHook\App\Config\Hook $targetConfig
325
     */
326 6
    private function copyActionsFromTo(Hook $sourceConfig, Hook $targetConfig): void
327
    {
328 6
        foreach ($sourceConfig->getActions() as $action) {
329 6
            $targetConfig->addAction($action);
330
        }
331
    }
332
333
    /**
334
     * Config factory method
335
     *
336
     * @param  string               $path
337
     * @param  array<string, mixed> $settings
338
     * @return \CaptainHook\App\Config
339
     * @throws \Exception
340
     */
341 63
    public static function create(string $path = '', array $settings = []): Config
342
    {
343 63
        $factory = new static();
344 63
        return $factory->createConfig($path, $settings);
345
    }
346
}
347