Lines 82.05% 64 / 78
Functions and Methods 80.95% 17 / 21
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
ConfigSection 82.05% 64 / 78 80.95% 17 / 21 55.19 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 forRoot 100.00% 1 / 1 100.00% 1 / 1 1
 readSection 100.00% 1 / 1 100.00% 1 / 1 1
 findSection 100.00% 2 / 2 100.00% 1 / 1 2
 mapSubsections 100.00% 5 / 5 100.00% 1 / 1 3
 readInt 100.00% 2 / 2 100.00% 1 / 1 2
 readBool 100.00% 2 / 2 100.00% 1 / 1 2
 readString 100.00% 1 / 1 100.00% 1 / 1 1
 findString 100.00% 2 / 2 100.00% 1 / 1 3
 findInt 100.00% 2 / 2 100.00% 1 / 1 2
 readStringList 100.00% 1 / 1 100.00% 1 / 1 1
 readStringMap 100.00% 4 / 4 100.00% 1 / 1 3
 readMap 85.71% 6 / 7 0.00% 0 / 1 3.03
 readDuration 62.50% 10 / 16 0.00% 0 / 1 4.84
 findSwitchableValue 100.00% 2 / 2 100.00% 1 / 1 4
 readOptionalDuration 40.00% 4 / 10 0.00% 0 / 1 4.94
 readBackoff 100.00% 12 / 12 100.00% 1 / 1 2
 readParsedValue 100.00% 4 / 4 100.00% 1 / 1 2
 createKeyInvalidFailure 0.00% 0 / 1 0.00% 0 / 1 2
 createSchemaMismatchFailure 100.00% 1 / 1 100.00% 1 / 1 1
 buildKeyPath 100.00% 1 / 1 100.00% 1 / 1 2
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Config;
6
7use Closure;
8use LogicException;
9use SensitiveParameter;
10use Stewart\Contracts\Exception\StewartException;
11use Stewart\Contracts\Time\Duration;
12use Stewart\Runtime\Exception\ConfigurationError;
13use Stewart\Runtime\Exception\ConfigurationException;
14
15final readonly class ConfigSection
16{
17    /** @param array<array-key, mixed> $values */
18    private function __construct(
19        #[SensitiveParameter]
20        private array $values,
21        private string $path,
22    ) {}
23
24    /** @param array<array-key, mixed> $values */
25    public static function forRoot(#[SensitiveParameter] array $values): self
26    {
27        return new self($values, '');
28    }
29
30    public function readSection(string $key): self
31    {
32        return $this->findSection($key) ?? throw $this->createSchemaMismatchFailure($key);
33    }
34
35    public function findSection(string $key): ?self
36    {
37        $value = $this->values[$key] ?? null;
38
39        return \is_array($value) ? new self($value, $this->buildKeyPath($key)) : null;
40    }
41
42    /**
43     * @template T
44     * @param Closure(self, string): T $read
45     * @return list<T>
46     */
47    public function mapSubsections(Closure $read): array
48    {
49        $mapped = [];
50
51        foreach ($this->values as $key => $value) {
52            if (\is_array($value)) {
53                $mapped[] = $read(new self($value, $this->buildKeyPath((string) $key)), (string) $key);
54            }
55        }
56
57        return $mapped;
58    }
59
60    public function readInt(string $key): int
61    {
62        $value = $this->values[$key] ?? null;
63
64        return \is_int($value) ? $value : throw $this->createSchemaMismatchFailure($key);
65    }
66
67    public function readBool(string $key): bool
68    {
69        $value = $this->values[$key] ?? null;
70
71        return \is_bool($value) ? $value : throw $this->createSchemaMismatchFailure($key);
72    }
73
74    public function readString(string $key): string
75    {
76        return $this->findString($key) ?? throw $this->createSchemaMismatchFailure($key);
77    }
78
79    public function findString(string $key): ?string
80    {
81        $value = $this->values[$key] ?? null;
82
83        return \is_scalar($value) && !\is_bool($value) ? (string) $value : null;
84    }
85
86    public function findInt(string $key): ?int
87    {
88        $value = $this->values[$key] ?? null;
89
90        return \is_int($value) ? $value : null;
91    }
92
93    /**
94     * @return list<string>
95     * @throws ConfigurationException
96     */
97    public function readStringList(string $key): array
98    {
99        return array_values($this->readStringMap($key));
100    }
101
102    /**
103     * @return array<string, string>
104     * @throws ConfigurationException
105     */
106    public function readStringMap(string $key): array
107    {
108        $strings = [];
109
110        foreach ($this->readMap($key) as $name => $value) {
111            $strings[$name] = \is_string($value) ? $value : throw $this->createKeyInvalidFailure($key . '.' . $name, 'a string');
112        }
113
114        return $strings;
115    }
116
117    /** @return array<string, mixed> */
118    public function readMap(string $key): array
119    {
120        $value = $this->values[$key] ?? [];
121
122        if (!\is_array($value)) {
123            throw $this->createSchemaMismatchFailure($key);
124        }
125
126        $map = [];
127
128        foreach ($value as $name => $item) {
129            $map[(string) $name] = $item;
130        }
131
132        return $map;
133    }
134
135    /** @throws ConfigurationException */
136    public function readDuration(string $key, ?Duration $atLeast = null, ?Duration $atMost = null): Duration
137    {
138        $duration = $this->readParsedValue($key, Duration::parse(...));
139
140        if ($atLeast?->isLongerThan($duration) === true) {
141            throw ConfigurationException::createForReason(
142                ConfigurationError::DurationTooShort,
143                path: $this->buildKeyPath($key),
144                value: (string) $duration,
145                minimum: (string) $atLeast,
146            );
147        }
148
149        if ($atMost !== null && $duration->isLongerThan($atMost)) {
150            throw ConfigurationException::createForReason(
151                ConfigurationError::DurationTooLong,
152                path: $this->buildKeyPath($key),
153                value: (string) $duration,
154                maximum: (string) $atMost,
155            );
156        }
157
158        return $duration;
159    }
160
161    /**
162     * @template T
163     * @param Closure(string): T $parse
164     * @return T|null
165     * @throws ConfigurationException
166     */
167    public function findSwitchableValue(string $key, Closure $parse): mixed
168    {
169        $raw = $this->findString($key);
170
171        return $raw === null || $raw === '' || StewartConfigSchema::isOff($raw) ? null : $this->readParsedValue($key, $parse);
172    }
173
174    /** @throws ConfigurationException */
175    public function readOptionalDuration(string $key, ?Duration $atLeast = null): OptionalDuration
176    {
177        $optional = $this->readParsedValue($key, OptionalDuration::parse(...));
178        $duration = $optional->findDuration();
179
180        if ($duration !== null && $atLeast?->isLongerThan($duration) === true) {
181            throw ConfigurationException::createForReason(
182                ConfigurationError::DurationTooShort,
183                path: $this->buildKeyPath($key),
184                value: (string) $duration,
185                minimum: (string) $atLeast,
186            );
187        }
188
189        return $optional;
190    }
191
192    /** @throws ConfigurationException */
193    public function readBackoff(string $keyPrefix): BackoffPolicy
194    {
195        $oneMillisecond = Duration::milliseconds(1);
196        $initialDelay = $this->readDuration($keyPrefix . 'initial_delay', $oneMillisecond);
197        $maxDelay = $this->readDuration($keyPrefix . 'max_delay', $oneMillisecond);
198
199        if ($initialDelay->isLongerThan($maxDelay)) {
200            throw ConfigurationException::createForReason(
201                ConfigurationError::MaxDelayBelowInitialDelay,
202                path: $this->buildKeyPath($keyPrefix . 'max_delay'),
203                value: (string) $maxDelay,
204                initialDelayPath: $this->buildKeyPath($keyPrefix . 'initial_delay'),
205                initialDelay: (string) $initialDelay,
206            );
207        }
208
209        return new BackoffPolicy($initialDelay, $maxDelay);
210    }
211
212    /**
213     * @template T
214     * @param Closure(string): T $parse
215     * @return T
216     * @throws ConfigurationException
217     */
218    public function readParsedValue(string $key, Closure $parse): mixed
219    {
220        $raw = $this->readString($key);
221
222        try {
223            return $parse($raw);
224        } catch (StewartException $e) {
225            throw ConfigurationException::createForReason(ConfigurationError::KeyParseFailed, $e, path: $this->buildKeyPath($key));
226        }
227    }
228
229    private function createKeyInvalidFailure(string $key, string $expected): ConfigurationException
230    {
231        return ConfigurationException::createForReason(ConfigurationError::KeyInvalid, path: $this->buildKeyPath($key), expected: $expected);
232    }
233
234    // The schema already guarantees these types; a mismatch is a bug, not a user error.
235    private function createSchemaMismatchFailure(string $key): LogicException
236    {
237        return new LogicException(\sprintf('The config schema and its reader disagree on %s.', $this->buildKeyPath($key)));
238    }
239
240    private function buildKeyPath(string $key): string
241    {
242        return $this->path === '' ? $key : $this->path . '.' . $key;
243    }
244}