Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.78% covered (warning)
79.78%
71 / 89
62.50% covered (warning)
62.50%
5 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PhpFileConfig
79.78% covered (warning)
79.78%
71 / 89
62.50% covered (warning)
62.50%
5 / 8
58.30
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
 __invoke
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 set
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 remove
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 loadConfig
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 mutate
85.71% covered (warning)
85.71%
18 / 21
0.00% covered (danger)
0.00%
0 / 1
10.29
 dump
67.86% covered (warning)
67.86%
19 / 28
0.00% covered (danger)
0.00%
0 / 1
22.47
 resolveTargetFile
45.45% covered (danger)
45.45%
5 / 11
0.00% covered (danger)
0.00%
0 / 1
9.06
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/config.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/config
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\Config;
20
21use Brick\VarExporter\VarExporter;
22use FastForward\Config\Exception\InvalidArgumentException;
23
24/**
25 * Class PhpFileConfig.
26 *
27 * Provides a configuration source backed by a single PHP file returning an array.
28 * This class MUST load the configuration from the specified PHP file.
29 * It SHALL lazily initialize and retrieve configuration data upon invocation.
30 * If configured as persistent, mutations made via `set` and `remove` SHALL be persisted back to the file.
31 */
32 class PhpFileConfig implements ConfigInterface
33{
34    use LazyLoadConfigTrait;
35
36    /**
37     * Constructs a PhpFileConfig instance.
38     *
39     * @param string $file the path to the PHP configuration file
40     * @param bool $persistent whether changes should be saved back to the file
41     * @param array<string, mixed>|ConfigInterface|null $defaultConfig optional default configuration when the file does not exist
42     */
43    public function __construct(
44        private  string $file,
45        private  bool $persistent = false,
46        private  array|ConfigInterface|null $defaultConfig = null,
47    ) {
48    }
49
50    /**
51     * Invokes the configuration and returns the configuration data.
52     *
53     * If the file exists, it MUST return a valid array.
54     * If the file does not exist but a default configuration is provided,
55     * the default configuration SHALL be used.
56     *
57     * @return ConfigInterface a ConfigInterface implementation containing the configuration data
58     *
59     * @throws InvalidArgumentException if the file is unreadable or does not return an array
60     */
61    public function __invoke(): ConfigInterface
62    {
63        return $this->loadConfig();
64    }
65
66    /**
67     * Sets configuration data.
68     *
69     * This method MUST update the configuration data in the file if the persistent flag is set to true.
70     *
71     * @param array<string, mixed>|ConfigInterface|string $key the configuration key or an array of key-value pairs to set
72     * @param mixed $value the value to set for the specified key
73     *
74     * @return void
75     *
76     * @throws InvalidArgumentException if the key is invalid or file cannot be written
77     */
78    public function set(array|ConfigInterface|string $key, mixed $value = null): void
79    {
80        if (! $this->persistent) {
81            $this->getConfig()->set($key, $value);
82
83            return;
84        }
85
86        $this->mutate(static function (ConfigInterface $config) use ($key, $value): void {
87            $config->set($key, $value);
88        });
89    }
90
91    /**
92     * Removes a configuration key and its associated value.
93     *
94     * This method MUST update the configuration data in the file if the persistent flag is set to true.
95     *
96     * @param string $key the configuration key to remove
97     *
98     * @return void
99     *
100     * @throws InvalidArgumentException if the file cannot be written
101     */
102    public function remove(string $key): void
103    {
104        if (! $this->persistent) {
105            $this->getConfig()->remove($key);
106
107            return;
108        }
109
110        $this->mutate(static function (ConfigInterface $config) use ($key): void {
111            $config->remove($key);
112        });
113    }
114
115    /**
116     * Loads the configuration from the file or default configuration.
117     *
118     * @return ConfigInterface
119     *
120     * @throws InvalidArgumentException if the file is unreadable or does not return an array
121     */
122    private function loadConfig(): ConfigInterface
123    {
124        if (! file_exists($this->file)) {
125            if ($this->defaultConfig !== null) {
126                $data = $this->defaultConfig instanceof ConfigInterface
127                    ? $this->defaultConfig->toArray()
128                    : $this->defaultConfig;
129
130                return new ArrayConfig($data);
131            }
132
133            throw InvalidArgumentException::forUnreadableFile($this->file);
134        }
135
136        if (! is_file($this->file) || ! is_readable($this->file)) {
137            throw InvalidArgumentException::forUnreadableFile($this->file);
138        }
139
140        $data = (static function (string $file): mixed {
141            return require $file;
142        })($this->file);
143
144        if (! \is_array($data)) {
145            throw InvalidArgumentException::forInvalidConfigFile($this->file);
146        }
147
148        return new ArrayConfig($data);
149    }
150
151    /**
152     * Executes a persistent mutation under an exclusive process lock.
153     *
154     * @param callable(ConfigInterface): void $operation
155     *
156     * @return void
157     *
158     * @throws InvalidArgumentException if the lock or file cannot be written
159     */
160    private function mutate(callable $operation): void
161    {
162        $targetFile = $this->resolveTargetFile();
163        $dir        = \dirname($targetFile);
164
165        if (! is_dir($dir)) {
166            if (! @mkdir($dir, 0777, true) && ! is_dir($dir)) {
167                throw InvalidArgumentException::forUnwritableFile($this->file);
168            }
169        } elseif (! is_writable($dir)) {
170            throw InvalidArgumentException::forUnwritableFile($this->file);
171        }
172
173        if (file_exists($targetFile) && (! is_file($targetFile) || ! is_writable($targetFile))) {
174            throw InvalidArgumentException::forUnwritableFile($this->file);
175        }
176
177        $lockPath = $dir . '/.' . \basename($targetFile) . '.lock';
178        $lockFp   = @fopen($lockPath, 'c+');
179
180        if (false === $lockFp) {
181            throw InvalidArgumentException::forUnwritableFile($this->file);
182        }
183
184        try {
185            if (! @flock($lockFp, \LOCK_EX)) {
186                throw InvalidArgumentException::forUnwritableFile($this->file);
187            }
188
189            try {
190                $config = $this->loadConfig();
191                $operation($config);
192                $this->dump($config->toArray(), $targetFile);
193                $this->config = $config;
194            } finally {
195                @flock($lockFp, \LOCK_UN);
196            }
197        } finally {
198            @fclose($lockFp);
199        }
200    }
201
202    /**
203     * Dumps the configuration array to the PHP file.
204     *
205     * @param array<array-key, mixed> $data the configuration data to write
206     * @param string|null $targetFile optional target file override (e.g. resolved symlink target)
207     *
208     * @return void
209     *
210     * @throws InvalidArgumentException if the file or directory is not writable, or cannot be exported
211     */
212    private function dump(array $data, ?string $targetFile = null): void
213    {
214        $targetFile ??= $this->resolveTargetFile();
215        $dir = \dirname($targetFile);
216
217        if (! is_dir($dir)) {
218            if (! @mkdir($dir, 0777, true) && ! is_dir($dir)) {
219                throw InvalidArgumentException::forUnwritableFile($this->file);
220            }
221        } elseif (! is_writable($dir)) {
222            throw InvalidArgumentException::forUnwritableFile($this->file);
223        }
224
225        if (file_exists($targetFile) && (! is_file($targetFile) || ! is_writable($targetFile))) {
226            throw InvalidArgumentException::forUnwritableFile($this->file);
227        }
228
229        try {
230            $exported = VarExporter::export($data);
231        } catch (\Throwable $e) {
232            throw new InvalidArgumentException($e->getMessage(), (int) $e->getCode(), $e);
233        }
234
235        $content = "<?php\n\ndeclare(strict_types=1);\n\nreturn {$exported};\n";
236
237        $tmpFile = tempnam($dir, 'cfg_');
238        if (false === $tmpFile) {
239            throw InvalidArgumentException::forUnwritableFile($this->file);
240        }
241
242        $bytesWritten = @file_put_contents($tmpFile, $content, \LOCK_EX);
243        if ($bytesWritten !== \strlen($content)) {
244            @unlink($tmpFile);
245            throw InvalidArgumentException::forUnwritableFile($this->file);
246        }
247
248        $existingPerms = file_exists($targetFile) ? @fileperms($targetFile) : false;
249        $mode          = false !== $existingPerms ? ($existingPerms & 0777) : (0666 & ~umask());
250        @chmod($tmpFile, $mode);
251
252        if (! @rename($tmpFile, $targetFile)) {
253            @unlink($tmpFile);
254            throw InvalidArgumentException::forUnwritableFile($this->file);
255        }
256
257        if (\function_exists('opcache_invalidate')) {
258            @opcache_invalidate($targetFile, true);
259        }
260    }
261
262    /**
263     * Resolves the target file path, resolving symlinks to their canonical target.
264     *
265     * @return string
266     */
267    private function resolveTargetFile(): string
268    {
269        if (! is_link($this->file)) {
270            return $this->file;
271        }
272
273        $resolved = realpath($this->file);
274        if (false !== $resolved) {
275            return $resolved;
276        }
277
278        $target = readlink($this->file);
279        if (false === $target) {
280            return $this->file;
281        }
282
283        if (! str_starts_with($target, '/')) {
284            return \dirname($this->file) . '/' . $target;
285        }
286
287        return $target;
288    }
289}