Skip to main content

modkit/
config.rs

1//! Configuration module for typed module configuration access.
2//!
3//! This module provides two distinct mechanisms for loading module configuration:
4//!
5//! 1. **Lenient loading** (default): Falls back to `T::default()` when configuration is missing.
6//!    - Used by `module_config_or_default`
7//!    - Allows modules to exist without configuration sections in the main config file
8//!
9//! 2. **Strict loading**: Requires configuration to be present and valid.
10//!    - Used by `module_config_required`
11//!    - Returns errors when configuration is missing or invalid
12
13use serde::de::DeserializeOwned;
14
15/// Configuration error for typed config operations
16#[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
38/// Provider of module-specific configuration (raw JSON sections only).
39pub trait ConfigProvider: Send + Sync {
40    /// Returns raw JSON section for the module, if any.
41    fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value>;
42}
43
44/// Lenient configuration loader that falls back to defaults.
45///
46/// This function provides forgiving behavior for modules that don't require configuration:
47/// - If the module is not present in config → returns `Ok(T::default())`
48/// - If the module value is not an object → returns `Ok(T::default())`
49/// - If the module has no "config" field → returns `Ok(T::default())`
50/// - If "config" is present but invalid → returns `Err(ConfigError::InvalidConfig)`
51///
52/// Use this for modules that can operate with default configuration.
53///
54/// # Errors
55/// Returns `ConfigError::InvalidConfig` if the config section exists but cannot be deserialized.
56pub fn module_config_or_default<T: DeserializeOwned + Default>(
57    provider: &dyn ConfigProvider,
58    module_name: &str,
59) -> Result<T, ConfigError> {
60    // If module not found, use defaults
61    let Some(module_raw) = provider.get_module_config(module_name) else {
62        return Ok(T::default());
63    };
64
65    // If module is not an object, use defaults
66    let Some(obj) = module_raw.as_object() else {
67        return Ok(T::default());
68    };
69
70    // If no config section, use defaults
71    let Some(config_section) = obj.get("config") else {
72        return Ok(T::default());
73    };
74
75    // Config section exists, try to parse it
76    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
85/// Strict configuration loader that requires configuration to be present.
86///
87/// This function enforces that configuration must exist and be valid:
88/// - If the module is not present → returns `Err(ConfigError::ModuleNotFound)`
89/// - If the module value is not an object → returns `Err(ConfigError::InvalidModuleStructure)`
90/// - If the module has no "config" field → returns `Err(ConfigError::MissingConfigSection)`
91/// - If "config" is present but invalid → returns `Err(ConfigError::InvalidConfig)`
92///
93/// Use this for modules that cannot operate without explicit configuration.
94///
95/// # Errors
96/// Returns `ConfigError` if the module is not found, has invalid structure, or config is invalid.
97pub 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    // Extract config section from: modules.<name> = { database: ..., config: ... }
109    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            // Valid module config
157            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            // Module without config section
172            modules.insert(
173                "no_config_module".to_owned(),
174                json!({
175                    "database": {
176                        "url": "postgres://localhost/test"
177                    }
178                }),
179            );
180
181            // Module with invalid structure (not an object)
182            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    // ========== Tests for lenient loading (module_config_or_default) ==========
195
196    #[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        // Add module with invalid config structure
246        provider.modules.insert(
247            "bad_config_module".to_owned(),
248            json!({
249                "config": {
250                    "api_key": "secret123",
251                    "timeout_ms": "not_a_number", // Should be u64
252                    "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        // Module not found should return default
271        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        // Valid config should parse correctly
277        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    // ========== Tests for strict loading (module_config_required) ==========
285
286    #[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        // Add module with invalid config structure
345        provider.modules.insert(
346            "bad_config_module".to_owned(),
347            json!({
348                "config": {
349                    "api_key": "secret123",
350                    "timeout_ms": "not_a_number", // Should be u64
351                    "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    // ========== Tests for ConfigError display messages ==========
366
367    #[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}