|
1 | | -<?php |
| 1 | +<?php declare(strict_types=1); |
| 2 | + |
2 | 3 | namespace Cognesy\Config; |
3 | 4 |
|
| 5 | +use Adbar\Dot; |
4 | 6 | use Cognesy\Config\Exceptions\MissingSettingException; |
5 | 7 | use Cognesy\Config\Exceptions\NoSettingsFileException; |
6 | | -use Exception; |
7 | | -use InvalidArgumentException; |
8 | | -use RuntimeException; |
9 | 8 |
|
10 | | -class Settings |
| 9 | +/** |
| 10 | + * Lightweight, read-only config loader. |
| 11 | + * |
| 12 | + * Search order (first match wins): |
| 13 | + * 1. Path set via Settings::setPath('/dir') ← highest priority |
| 14 | + * 2. INSTRUCTOR_CONFIG_PATHS or INSTRUCTOR_CONFIG_PATH (comma-separated) |
| 15 | + * 3. Internal defaults listed in DEFAULT_PATHS |
| 16 | + * |
| 17 | + * Usage: |
| 18 | + * Settings::setPath(__DIR__.'/config'); // optional override |
| 19 | + * $dsn = Settings::get('database', 'dsn'); // dot-notation supported |
| 20 | + * |
| 21 | + * Implementation notes: |
| 22 | + * • All data is cached per-group in static $cache (Dot objects). |
| 23 | + * • Settings::flush() clears both cache _and_ custom override. |
| 24 | + * • locate() walks the final path list once per group; O(N) worst-case. |
| 25 | + * • Throws: |
| 26 | + * – NoSettingsFileException when group file missing |
| 27 | + * – MissingSettingException when key absent and no default given |
| 28 | + */ |
| 29 | +final class Settings |
11 | 30 | { |
12 | | - /** |
13 | | - * @var ?string The path to the configuration files. |
14 | | - */ |
15 | | - static private ?string $path = null; |
16 | | - |
17 | | - /** |
18 | | - * @var array<string> The default path to the configuration files. |
19 | | - */ |
20 | | - static private array $defaultPaths = [ |
| 31 | + private const DEFAULT_PATHS = [ |
21 | 32 | 'config/', |
22 | 33 | 'vendor/cognesy/instructor-php/config/', |
| 34 | + 'vendor/cognesy/instructor-struct/config/', |
| 35 | + 'vendor/cognesy/instructor-polyglot/config/', |
| 36 | + 'vendor/cognesy/instructor-config/config/', |
23 | 37 | ]; |
24 | 38 |
|
25 | | - /** |
26 | | - * @var array The loaded settings. |
27 | | - */ |
28 | | - static private array $settings = []; |
29 | | - |
30 | | - // STATIC //////////////////////////////////////////////////////////////////// |
| 39 | + private static array $customPaths = []; |
| 40 | + private static array $cache = []; // group => Dot |
31 | 41 |
|
32 | | - /** |
33 | | - * Sets the path to the configuration files and clears the loaded settings. |
34 | | - * |
35 | | - * @param string $path The new path to the configuration files. |
36 | | - */ |
37 | | - public static function setPath(string $path) : void { |
38 | | - self::$path = self::resolvePath($path); |
39 | | - self::$settings = []; |
| 42 | + /** Highest-priority override */ |
| 43 | + public static function setPath(string $dir): void { |
| 44 | + self::$customPaths = [self::dir($dir)]; |
| 45 | + self::$cache = []; |
40 | 46 | } |
41 | 47 |
|
42 | | - /** |
43 | | - * Gets the current path to the configuration files. |
44 | | - * |
45 | | - * @return string The current path to the configuration files. |
46 | | - */ |
47 | | - public static function getPath(?string $group = null): string { |
48 | | - return self::$path |
49 | | - ?? self::getFirstValidPath( |
50 | | - paths: $_ENV['INSTRUCTOR_CONFIG_PATHS'] |
51 | | - ?? $_ENV['INSTRUCTOR_CONFIG_PATH'] |
52 | | - ?? self::$defaultPaths, |
53 | | - group: $group, |
54 | | - ); |
| 48 | + /** Drop all cached groups (tests, hot-reload) */ |
| 49 | + public static function flush(): void { |
| 50 | + self::$cache = []; |
| 51 | + self::$customPaths = []; |
55 | 52 | } |
56 | 53 |
|
57 | | - /** |
58 | | - * Gets the default path to the configuration files. |
59 | | - * @return array<string> The default paths to the configuration files. |
60 | | - * @throws Exception |
61 | | - */ |
62 | | - public static function getDefaultPaths(): array { |
63 | | - return self::$defaultPaths; |
64 | | - } |
65 | | - |
66 | | - /** |
67 | | - * Gets a setting value by group and key. |
68 | | - * |
69 | | - * @param string $group The settings group. |
70 | | - * @param string $key The settings key. |
71 | | - * @param mixed $default The default value if the key is not found. |
72 | | - * @return mixed The setting value. |
73 | | - * @throws Exception If the group is not provided or the key is not found and no default value is provided. |
74 | | - */ |
75 | | - public static function get(string $group, string $key, mixed $default = null) : mixed { |
76 | | - if (empty($group)) { |
77 | | - throw new InvalidArgumentException("Settings group not provided"); |
| 54 | + public static function has(string $group, ?string $key = null): bool { |
| 55 | + if ($key === null) { |
| 56 | + return self::locate($group) !== null; |
78 | 57 | } |
79 | 58 |
|
80 | | - if (!self::isGroupLoaded($group)) { |
81 | | - self::$settings[$group] = dot(self::loadGroup($group)); |
82 | | - } |
83 | | - |
84 | | - if ($default === null && !self::has($group, $key)) { |
85 | | - throw new MissingSettingException("Settings key not found: $key in group: $group and no default value provided"); |
86 | | - } |
87 | | - return self::$settings[$group]->get($key, $default); |
| 59 | + $dot = self::load($group, false); |
| 60 | + return $dot?->has($key) ?? false; |
88 | 61 | } |
89 | 62 |
|
90 | | - /** |
91 | | - * Checks if a setting exists by group and key. |
92 | | - * If key is not provided, it checks if the group exists. |
93 | | - * |
94 | | - * @param string $group The settings group. |
95 | | - * @param ?string $key The settings key. |
96 | | - * @return bool True if the setting exists, false otherwise. |
97 | | - * @throws Exception If the group is not provided. |
98 | | - */ |
99 | | - public static function has(string $group, ?string $key = null) : bool { |
100 | | - if (empty($group)) { |
101 | | - throw new RuntimeException("Settings group not provided"); |
102 | | - } |
103 | | - |
104 | | - if (empty($key)) { |
105 | | - return self::hasGroup($group); |
106 | | - } |
107 | | - |
108 | | - if (!self::isGroupLoaded($group)) { |
109 | | - self::$settings[$group] = dot(self::loadGroup($group)); |
110 | | - } |
111 | | - |
112 | | - return self::$settings[$group]->has($key); |
113 | | - } |
114 | | - |
115 | | - /** |
116 | | - * Checks if a settings group exists. |
117 | | - * |
118 | | - * @param string $group The settings group. |
119 | | - * @return bool True if the group exists, false otherwise. |
120 | | - * @throws Exception If the group is not provided. |
121 | | - */ |
122 | | - public static function hasGroup(string $group) : bool { |
123 | | - if (empty($group)) { |
124 | | - throw new RuntimeException("Settings group not provided"); |
125 | | - } |
126 | | - |
127 | | - if (!self::isGroupLoaded($group)) { |
128 | | - self::$settings[$group] = dot(self::loadGroup($group)); |
| 63 | + public static function get(string $group, string $key, mixed $default = null): mixed { |
| 64 | + $dot = self::load($group); |
| 65 | + if (!$dot->has($key)) { |
| 66 | + if (func_num_args() === 3) { |
| 67 | + return $default; |
| 68 | + } |
| 69 | + throw new MissingSettingException("Key '$key' missing in '$group'"); |
129 | 70 | } |
130 | | - |
131 | | - return !empty(self::$settings[$group]); |
| 71 | + return $dot->get($key); |
132 | 72 | } |
133 | 73 |
|
134 | | - /** |
135 | | - * Gets a settings group. |
136 | | - * |
137 | | - * @param string $group The settings group. |
138 | | - * @return mixed The settings group. |
139 | | - * @throws Exception If the group is not provided or the settings file is not found. |
140 | | - */ |
141 | | - public static function getGroup(string $group) : mixed { |
142 | | - if (empty($group)) { |
143 | | - throw new RuntimeException("Settings group not provided"); |
144 | | - } |
| 74 | + // ---------------------------------------------------------------- |
145 | 75 |
|
146 | | - if (!self::isGroupLoaded($group)) { |
147 | | - self::$settings[$group] = dot(self::loadGroup($group)); |
| 76 | + private static function load(string $group, bool $throw = true): ?Dot { |
| 77 | + if (isset(self::$cache[$group])) { |
| 78 | + return self::$cache[$group]; |
148 | 79 | } |
149 | | - |
150 | | - return self::$settings[$group]; |
151 | | - } |
152 | | - |
153 | | - /** |
154 | | - * Sets a setting value by group and key. |
155 | | - * |
156 | | - * @param string $group The settings group. |
157 | | - * @param string $key The settings key. |
158 | | - * @param mixed $value The value to set. |
159 | | - */ |
160 | | - public static function set(string $group, string $key, mixed $value) : void { |
161 | | - if (!self::isGroupLoaded($group)) { |
162 | | - self::$settings[$group] = dot(self::loadGroup($group)); |
| 80 | + $file = self::locate($group); |
| 81 | + if ($file === null) { |
| 82 | + if ($throw) { |
| 83 | + throw new NoSettingsFileException("Config '$group' not found in any search path"); |
| 84 | + } |
| 85 | + return null; |
163 | 86 | } |
164 | | - |
165 | | - self::$settings[$group] = self::$settings[$group]->set($key, $value); |
| 87 | + /** @var array $data */ |
| 88 | + $data = require $file; |
| 89 | + return self::$cache[$group] = new Dot($data); |
166 | 90 | } |
167 | 91 |
|
168 | | - /** |
169 | | - * Unsets a settings group. |
170 | | - * |
171 | | - * @param string $group The settings group. |
172 | | - */ |
173 | | - public static function unset(string $group) : void { |
174 | | - if (self::isGroupLoaded($group)) { |
175 | | - self::$settings[$group] = []; |
| 92 | + /** Return full path to file or null */ |
| 93 | + private static function locate(string $group): ?string { |
| 94 | + foreach (self::paths() as $dir) { |
| 95 | + $file = $dir . $group . '.php'; |
| 96 | + if (is_file($file)) { |
| 97 | + return $file; |
| 98 | + } |
176 | 99 | } |
| 100 | + return null; |
177 | 101 | } |
178 | 102 |
|
179 | | - // INTERNAL ////////////////////////////////////////////////////////////////// |
180 | | - |
181 | | - /** |
182 | | - * Checks if a settings group is loaded. |
183 | | - * |
184 | | - * @param string $group The settings group. |
185 | | - * @return bool True if the group is loaded, false otherwise. |
186 | | - */ |
187 | | - private static function isGroupLoaded(string $group) : bool { |
188 | | - return isset(self::$settings[$group]) && (self::$settings[$group] !== null); |
189 | | - } |
190 | | - |
191 | | - /** |
192 | | - * Loads a settings group from a file. |
193 | | - * |
194 | | - * @param string $group The settings group. |
195 | | - * @return array The loaded settings group. |
196 | | - * @throws Exception If the settings file is not found. |
197 | | - */ |
198 | | - private static function loadGroup(string $group) : array { |
199 | | - $rootPath = self::getPath($group); |
200 | | - |
201 | | - // Ensure the rootPath ends with a directory separator |
202 | | - $rootPath = rtrim($rootPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
203 | | - |
204 | | - $path = $rootPath . $group . '.php'; |
205 | | - |
206 | | - if (!file_exists($path)) { |
207 | | - throw new NoSettingsFileException("Settings file not found: $path"); |
| 103 | + /** Final search path list, in order */ |
| 104 | + private static function paths(): array { |
| 105 | + if (self::$customPaths) { |
| 106 | + return self::$customPaths; |
208 | 107 | } |
209 | | - |
210 | | - return require $path; |
211 | | - } |
212 | | - |
213 | | - /** |
214 | | - * Resolves a given path to an absolute path. |
215 | | - * |
216 | | - * @param string $path The path to resolve. |
217 | | - * @return string The resolved absolute path. |
218 | | - */ |
219 | | - private static function resolvePath(string $path): string { |
220 | | - $path = self::isAbsolutePath($path) ? $path : BasePath::get($path); |
221 | | - // if path does not end with DIRECTORY_SEPARATOR, add it |
222 | | - return rtrim($path, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
| 108 | + $env = getenv('INSTRUCTOR_CONFIG_PATHS') |
| 109 | + ?: ($_ENV['INSTRUCTOR_CONFIG_PATHS'] ?? '') |
| 110 | + ?: getenv('INSTRUCTOR_CONFIG_PATH') |
| 111 | + ?: ($_ENV['INSTRUCTOR_CONFIG_PATH'] ?? ''); |
| 112 | + $envPaths = array_filter(array_map(fn($p) => self::dir($p), explode(',', $env))); |
| 113 | + return array_unique([...$envPaths, ...array_map(fn($p) => self::dir($p), self::DEFAULT_PATHS)]); |
223 | 114 | } |
224 | 115 |
|
225 | | - private static function getFirstValidPath(string|array $paths, ?string $group = null): string { |
226 | | - $paths = is_array($paths) ? $paths : explode(',', $paths); |
227 | | - if (empty($paths)) { |
228 | | - throw new InvalidArgumentException("No settings paths provided"); |
| 116 | + /** Normalise dir string, always with trailing slash */ |
| 117 | + private static function dir(string $dir): string { |
| 118 | + $dir = rtrim($dir); |
| 119 | + if ($dir === '') { |
| 120 | + return ''; |
229 | 121 | } |
230 | | - |
231 | | - foreach ($paths as $path) { |
232 | | - $resolvedPath = self::resolvePath($path); |
233 | | - if (is_dir($resolvedPath)) { |
234 | | - // if $group is not provided, return the resolved path |
235 | | - if (empty($group)) { |
236 | | - return $resolvedPath; |
237 | | - } |
238 | | - // check if group file exists |
239 | | - $groupPath = $resolvedPath . $group . '.php'; |
240 | | - if (file_exists($groupPath)) { |
241 | | - return $resolvedPath; |
242 | | - } |
243 | | - } |
| 122 | + // relative → project base |
| 123 | + if (!str_starts_with($dir, '/') && !preg_match('/^[A-Z]:\\\\/i', $dir)) { |
| 124 | + $dir = BasePath::get($dir); |
244 | 125 | } |
245 | | - |
246 | | - throw new NoSettingsFileException("No valid settings path found in: " . implode(', ', $paths)); |
247 | | - } |
248 | | - |
249 | | - /** |
250 | | - * Checks if a given path is absolute. |
251 | | - * |
252 | | - * @param string $path The path to check. |
253 | | - * @return bool True if the path is absolute, false otherwise. |
254 | | - */ |
255 | | - private static function isAbsolutePath(string $path): bool { |
256 | | - return str_starts_with($path, '/') || preg_match('/^[a-zA-Z]:\\\\/', $path) === 1; |
| 126 | + return rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; |
257 | 127 | } |
258 | 128 | } |
0 commit comments