1use serde::de::DeserializeOwned;
14
15#[derive(thiserror::Error, Debug)]
17pub enum ConfigError {
18 #[error("module '{module}' not found")]
19 ModuleNotFound { module: String },
20 #[error("module '{module}' config must be an object")]
21 InvalidModuleStructure { module: String },
22 #[error("missing 'config' section in module '{module}'")]
23 MissingConfigSection { module: String },
24 #[error("invalid config for module '{module}': {source}")]
25 InvalidConfig {
26 module: String,
27 #[source]
28 source: serde_json::Error,
29 },
30 #[error("variable expansion failed for module '{module}': {source}")]
31 VarExpand {
32 module: String,
33 #[source]
34 source: modkit_utils::var_expand::ExpandVarsError,
35 },
36}
37
38pub trait ConfigProvider: Send + Sync {
40 fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value>;
42}
43
44pub fn module_config_or_default<T: DeserializeOwned + Default>(
57 provider: &dyn ConfigProvider,
58 module_name: &str,
59) -> Result<T, ConfigError> {
60 let Some(module_raw) = provider.get_module_config(module_name) else {
62 return Ok(T::default());
63 };
64
65 let Some(obj) = module_raw.as_object() else {
67 return Ok(T::default());
68 };
69
70 let Some(config_section) = obj.get("config") else {
72 return Ok(T::default());
73 };
74
75 let config: T =
77 serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
78 module: module_name.to_owned(),
79 source: e,
80 })?;
81
82 Ok(config)
83}
84
85pub fn module_config_required<T: DeserializeOwned>(
98 provider: &dyn ConfigProvider,
99 module_name: &str,
100) -> Result<T, ConfigError> {
101 let module_raw =
102 provider
103 .get_module_config(module_name)
104 .ok_or_else(|| ConfigError::ModuleNotFound {
105 module: module_name.to_owned(),
106 })?;
107
108 let obj = module_raw
110 .as_object()
111 .ok_or_else(|| ConfigError::InvalidModuleStructure {
112 module: module_name.to_owned(),
113 })?;
114
115 let config_section = obj
116 .get("config")
117 .ok_or_else(|| ConfigError::MissingConfigSection {
118 module: module_name.to_owned(),
119 })?;
120
121 let config: T =
122 serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
123 module: module_name.to_owned(),
124 source: e,
125 })?;
126
127 Ok(config)
128}
129
130#[cfg(test)]
131#[cfg_attr(coverage_nightly, coverage(off))]
132mod tests {
133 use super::*;
134 use serde::Deserialize;
135 use serde_json::json;
136 use std::collections::HashMap;
137
138 #[derive(Debug, PartialEq, Deserialize, Default)]
139 struct TestConfig {
140 #[serde(default)]
141 api_key: String,
142 #[serde(default)]
143 timeout_ms: u64,
144 #[serde(default)]
145 enabled: bool,
146 }
147
148 struct MockConfigProvider {
149 modules: HashMap<String, serde_json::Value>,
150 }
151
152 impl MockConfigProvider {
153 fn new() -> Self {
154 let mut modules = HashMap::new();
155
156 modules.insert(
158 "test_module".to_owned(),
159 json!({
160 "database": {
161 "url": "postgres://localhost/test"
162 },
163 "config": {
164 "api_key": "secret123",
165 "timeout_ms": 5000,
166 "enabled": true
167 }
168 }),
169 );
170
171 modules.insert(
173 "no_config_module".to_owned(),
174 json!({
175 "database": {
176 "url": "postgres://localhost/test"
177 }
178 }),
179 );
180
181 modules.insert("invalid_module".to_owned(), json!("not an object"));
183
184 Self { modules }
185 }
186 }
187
188 impl ConfigProvider for MockConfigProvider {
189 fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
190 self.modules.get(module_name)
191 }
192 }
193
194 #[test]
197 fn test_lenient_success() {
198 let provider = MockConfigProvider::new();
199 let result: Result<TestConfig, ConfigError> =
200 module_config_or_default(&provider, "test_module");
201
202 assert!(result.is_ok());
203 let config = result.unwrap();
204 assert_eq!(config.api_key, "secret123");
205 assert_eq!(config.timeout_ms, 5000);
206 assert!(config.enabled);
207 }
208
209 #[test]
210 fn test_lenient_module_not_found_returns_default() {
211 let provider = MockConfigProvider::new();
212 let result: Result<TestConfig, ConfigError> =
213 module_config_or_default(&provider, "nonexistent");
214
215 assert!(result.is_ok());
216 let config = result.unwrap();
217 assert_eq!(config, TestConfig::default());
218 }
219
220 #[test]
221 fn test_lenient_missing_config_section_returns_default() {
222 let provider = MockConfigProvider::new();
223 let result: Result<TestConfig, ConfigError> =
224 module_config_or_default(&provider, "no_config_module");
225
226 assert!(result.is_ok());
227 let config = result.unwrap();
228 assert_eq!(config, TestConfig::default());
229 }
230
231 #[test]
232 fn test_lenient_invalid_structure_returns_default() {
233 let provider = MockConfigProvider::new();
234 let result: Result<TestConfig, ConfigError> =
235 module_config_or_default(&provider, "invalid_module");
236
237 assert!(result.is_ok());
238 let config = result.unwrap();
239 assert_eq!(config, TestConfig::default());
240 }
241
242 #[test]
243 fn test_lenient_invalid_config_returns_error() {
244 let mut provider = MockConfigProvider::new();
245 provider.modules.insert(
247 "bad_config_module".to_owned(),
248 json!({
249 "config": {
250 "api_key": "secret123",
251 "timeout_ms": "not_a_number", "enabled": true
253 }
254 }),
255 );
256
257 let result: Result<TestConfig, ConfigError> =
258 module_config_or_default(&provider, "bad_config_module");
259
260 assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
261 if let Err(ConfigError::InvalidConfig { module, .. }) = result {
262 assert_eq!(module, "bad_config_module");
263 }
264 }
265
266 #[test]
267 fn test_lenient_helper_with_multiple_scenarios() {
268 let provider = MockConfigProvider::new();
269
270 let result: Result<TestConfig, ConfigError> =
272 module_config_or_default(&provider, "nonexistent");
273 assert!(result.is_ok());
274 assert_eq!(result.unwrap(), TestConfig::default());
275
276 let result: Result<TestConfig, ConfigError> =
278 module_config_or_default(&provider, "test_module");
279 assert!(result.is_ok());
280 let config = result.unwrap();
281 assert_eq!(config.api_key, "secret123");
282 }
283
284 #[test]
287 fn test_strict_success() {
288 let provider = MockConfigProvider::new();
289 let result: Result<TestConfig, ConfigError> =
290 module_config_required(&provider, "test_module");
291
292 assert!(result.is_ok());
293 let config = result.unwrap();
294 assert_eq!(config.api_key, "secret123");
295 assert_eq!(config.timeout_ms, 5000);
296 assert!(config.enabled);
297 }
298
299 #[test]
300 fn test_strict_module_not_found() {
301 let provider = MockConfigProvider::new();
302 let result: Result<TestConfig, ConfigError> =
303 module_config_required(&provider, "nonexistent");
304
305 assert!(matches!(result, Err(ConfigError::ModuleNotFound { .. })));
306 if let Err(ConfigError::ModuleNotFound { module }) = result {
307 assert_eq!(module, "nonexistent");
308 }
309 }
310
311 #[test]
312 fn test_strict_missing_config_section() {
313 let provider = MockConfigProvider::new();
314 let result: Result<TestConfig, ConfigError> =
315 module_config_required(&provider, "no_config_module");
316
317 assert!(matches!(
318 result,
319 Err(ConfigError::MissingConfigSection { .. })
320 ));
321 if let Err(ConfigError::MissingConfigSection { module }) = result {
322 assert_eq!(module, "no_config_module");
323 }
324 }
325
326 #[test]
327 fn test_strict_invalid_structure() {
328 let provider = MockConfigProvider::new();
329 let result: Result<TestConfig, ConfigError> =
330 module_config_required(&provider, "invalid_module");
331
332 assert!(matches!(
333 result,
334 Err(ConfigError::InvalidModuleStructure { .. })
335 ));
336 if let Err(ConfigError::InvalidModuleStructure { module }) = result {
337 assert_eq!(module, "invalid_module");
338 }
339 }
340
341 #[test]
342 fn test_strict_invalid_config() {
343 let mut provider = MockConfigProvider::new();
344 provider.modules.insert(
346 "bad_config_module".to_owned(),
347 json!({
348 "config": {
349 "api_key": "secret123",
350 "timeout_ms": "not_a_number", "enabled": true
352 }
353 }),
354 );
355
356 let result: Result<TestConfig, ConfigError> =
357 module_config_required(&provider, "bad_config_module");
358
359 assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
360 if let Err(ConfigError::InvalidConfig { module, .. }) = result {
361 assert_eq!(module, "bad_config_module");
362 }
363 }
364
365 #[test]
368 fn test_config_error_messages() {
369 let module_not_found = ConfigError::ModuleNotFound {
370 module: "test".to_owned(),
371 };
372 assert_eq!(module_not_found.to_string(), "module 'test' not found");
373
374 let invalid_structure = ConfigError::InvalidModuleStructure {
375 module: "test".to_owned(),
376 };
377 assert_eq!(
378 invalid_structure.to_string(),
379 "module 'test' config must be an object"
380 );
381
382 let missing_config = ConfigError::MissingConfigSection {
383 module: "test".to_owned(),
384 };
385 assert_eq!(
386 missing_config.to_string(),
387 "missing 'config' section in module 'test'"
388 );
389 }
390}