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}
31
32/// Provider of module-specific configuration (raw JSON sections only).
33pub trait ConfigProvider: Send + Sync {
34    /// Returns raw JSON section for the module, if any.
35    fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value>;
36}
37
38/// Lenient configuration loader that falls back to defaults.
39///
40/// This function provides forgiving behavior for modules that don't require configuration:
41/// - If the module is not present in config → returns `Ok(T::default())`
42/// - If the module value is not an object → returns `Ok(T::default())`
43/// - If the module has no "config" field → returns `Ok(T::default())`
44/// - If "config" is present but invalid → returns `Err(ConfigError::InvalidConfig)`
45///
46/// Use this for modules that can operate with default configuration.
47///
48/// # Errors
49/// Returns `ConfigError::InvalidConfig` if the config section exists but cannot be deserialized.
50pub fn module_config_or_default<T: DeserializeOwned + Default>(
51    provider: &dyn ConfigProvider,
52    module_name: &str,
53) -> Result<T, ConfigError> {
54    // If module not found, use defaults
55    let Some(module_raw) = provider.get_module_config(module_name) else {
56        return Ok(T::default());
57    };
58
59    // If module is not an object, use defaults
60    let Some(obj) = module_raw.as_object() else {
61        return Ok(T::default());
62    };
63
64    // If no config section, use defaults
65    let Some(config_section) = obj.get("config") else {
66        return Ok(T::default());
67    };
68
69    // Config section exists, try to parse it
70    let config: T =
71        serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
72            module: module_name.to_owned(),
73            source: e,
74        })?;
75
76    Ok(config)
77}
78
79/// Strict configuration loader that requires configuration to be present.
80///
81/// This function enforces that configuration must exist and be valid:
82/// - If the module is not present → returns `Err(ConfigError::ModuleNotFound)`
83/// - If the module value is not an object → returns `Err(ConfigError::InvalidModuleStructure)`
84/// - If the module has no "config" field → returns `Err(ConfigError::MissingConfigSection)`
85/// - If "config" is present but invalid → returns `Err(ConfigError::InvalidConfig)`
86///
87/// Use this for modules that cannot operate without explicit configuration.
88///
89/// # Errors
90/// Returns `ConfigError` if the module is not found, has invalid structure, or config is invalid.
91pub fn module_config_required<T: DeserializeOwned>(
92    provider: &dyn ConfigProvider,
93    module_name: &str,
94) -> Result<T, ConfigError> {
95    let module_raw =
96        provider
97            .get_module_config(module_name)
98            .ok_or_else(|| ConfigError::ModuleNotFound {
99                module: module_name.to_owned(),
100            })?;
101
102    // Extract config section from: modules.<name> = { database: ..., config: ... }
103    let obj = module_raw
104        .as_object()
105        .ok_or_else(|| ConfigError::InvalidModuleStructure {
106            module: module_name.to_owned(),
107        })?;
108
109    let config_section = obj
110        .get("config")
111        .ok_or_else(|| ConfigError::MissingConfigSection {
112            module: module_name.to_owned(),
113        })?;
114
115    let config: T =
116        serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
117            module: module_name.to_owned(),
118            source: e,
119        })?;
120
121    Ok(config)
122}
123
124#[cfg(test)]
125#[cfg_attr(coverage_nightly, coverage(off))]
126mod tests {
127    use super::*;
128    use serde::Deserialize;
129    use serde_json::json;
130    use std::collections::HashMap;
131
132    #[derive(Debug, PartialEq, Deserialize, Default)]
133    struct TestConfig {
134        #[serde(default)]
135        api_key: String,
136        #[serde(default)]
137        timeout_ms: u64,
138        #[serde(default)]
139        enabled: bool,
140    }
141
142    struct MockConfigProvider {
143        modules: HashMap<String, serde_json::Value>,
144    }
145
146    impl MockConfigProvider {
147        fn new() -> Self {
148            let mut modules = HashMap::new();
149
150            // Valid module config
151            modules.insert(
152                "test_module".to_owned(),
153                json!({
154                    "database": {
155                        "url": "postgres://localhost/test"
156                    },
157                    "config": {
158                        "api_key": "secret123",
159                        "timeout_ms": 5000,
160                        "enabled": true
161                    }
162                }),
163            );
164
165            // Module without config section
166            modules.insert(
167                "no_config_module".to_owned(),
168                json!({
169                    "database": {
170                        "url": "postgres://localhost/test"
171                    }
172                }),
173            );
174
175            // Module with invalid structure (not an object)
176            modules.insert("invalid_module".to_owned(), json!("not an object"));
177
178            Self { modules }
179        }
180    }
181
182    impl ConfigProvider for MockConfigProvider {
183        fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
184            self.modules.get(module_name)
185        }
186    }
187
188    // ========== Tests for lenient loading (module_config_or_default) ==========
189
190    #[test]
191    fn test_lenient_success() {
192        let provider = MockConfigProvider::new();
193        let result: Result<TestConfig, ConfigError> =
194            module_config_or_default(&provider, "test_module");
195
196        assert!(result.is_ok());
197        let config = result.unwrap();
198        assert_eq!(config.api_key, "secret123");
199        assert_eq!(config.timeout_ms, 5000);
200        assert!(config.enabled);
201    }
202
203    #[test]
204    fn test_lenient_module_not_found_returns_default() {
205        let provider = MockConfigProvider::new();
206        let result: Result<TestConfig, ConfigError> =
207            module_config_or_default(&provider, "nonexistent");
208
209        assert!(result.is_ok());
210        let config = result.unwrap();
211        assert_eq!(config, TestConfig::default());
212    }
213
214    #[test]
215    fn test_lenient_missing_config_section_returns_default() {
216        let provider = MockConfigProvider::new();
217        let result: Result<TestConfig, ConfigError> =
218            module_config_or_default(&provider, "no_config_module");
219
220        assert!(result.is_ok());
221        let config = result.unwrap();
222        assert_eq!(config, TestConfig::default());
223    }
224
225    #[test]
226    fn test_lenient_invalid_structure_returns_default() {
227        let provider = MockConfigProvider::new();
228        let result: Result<TestConfig, ConfigError> =
229            module_config_or_default(&provider, "invalid_module");
230
231        assert!(result.is_ok());
232        let config = result.unwrap();
233        assert_eq!(config, TestConfig::default());
234    }
235
236    #[test]
237    fn test_lenient_invalid_config_returns_error() {
238        let mut provider = MockConfigProvider::new();
239        // Add module with invalid config structure
240        provider.modules.insert(
241            "bad_config_module".to_owned(),
242            json!({
243                "config": {
244                    "api_key": "secret123",
245                    "timeout_ms": "not_a_number", // Should be u64
246                    "enabled": true
247                }
248            }),
249        );
250
251        let result: Result<TestConfig, ConfigError> =
252            module_config_or_default(&provider, "bad_config_module");
253
254        assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
255        if let Err(ConfigError::InvalidConfig { module, .. }) = result {
256            assert_eq!(module, "bad_config_module");
257        }
258    }
259
260    #[test]
261    fn test_lenient_helper_with_multiple_scenarios() {
262        let provider = MockConfigProvider::new();
263
264        // Module not found should return default
265        let result: Result<TestConfig, ConfigError> =
266            module_config_or_default(&provider, "nonexistent");
267        assert!(result.is_ok());
268        assert_eq!(result.unwrap(), TestConfig::default());
269
270        // Valid config should parse correctly
271        let result: Result<TestConfig, ConfigError> =
272            module_config_or_default(&provider, "test_module");
273        assert!(result.is_ok());
274        let config = result.unwrap();
275        assert_eq!(config.api_key, "secret123");
276    }
277
278    // ========== Tests for strict loading (module_config_required) ==========
279
280    #[test]
281    fn test_strict_success() {
282        let provider = MockConfigProvider::new();
283        let result: Result<TestConfig, ConfigError> =
284            module_config_required(&provider, "test_module");
285
286        assert!(result.is_ok());
287        let config = result.unwrap();
288        assert_eq!(config.api_key, "secret123");
289        assert_eq!(config.timeout_ms, 5000);
290        assert!(config.enabled);
291    }
292
293    #[test]
294    fn test_strict_module_not_found() {
295        let provider = MockConfigProvider::new();
296        let result: Result<TestConfig, ConfigError> =
297            module_config_required(&provider, "nonexistent");
298
299        assert!(matches!(result, Err(ConfigError::ModuleNotFound { .. })));
300        if let Err(ConfigError::ModuleNotFound { module }) = result {
301            assert_eq!(module, "nonexistent");
302        }
303    }
304
305    #[test]
306    fn test_strict_missing_config_section() {
307        let provider = MockConfigProvider::new();
308        let result: Result<TestConfig, ConfigError> =
309            module_config_required(&provider, "no_config_module");
310
311        assert!(matches!(
312            result,
313            Err(ConfigError::MissingConfigSection { .. })
314        ));
315        if let Err(ConfigError::MissingConfigSection { module }) = result {
316            assert_eq!(module, "no_config_module");
317        }
318    }
319
320    #[test]
321    fn test_strict_invalid_structure() {
322        let provider = MockConfigProvider::new();
323        let result: Result<TestConfig, ConfigError> =
324            module_config_required(&provider, "invalid_module");
325
326        assert!(matches!(
327            result,
328            Err(ConfigError::InvalidModuleStructure { .. })
329        ));
330        if let Err(ConfigError::InvalidModuleStructure { module }) = result {
331            assert_eq!(module, "invalid_module");
332        }
333    }
334
335    #[test]
336    fn test_strict_invalid_config() {
337        let mut provider = MockConfigProvider::new();
338        // Add module with invalid config structure
339        provider.modules.insert(
340            "bad_config_module".to_owned(),
341            json!({
342                "config": {
343                    "api_key": "secret123",
344                    "timeout_ms": "not_a_number", // Should be u64
345                    "enabled": true
346                }
347            }),
348        );
349
350        let result: Result<TestConfig, ConfigError> =
351            module_config_required(&provider, "bad_config_module");
352
353        assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
354        if let Err(ConfigError::InvalidConfig { module, .. }) = result {
355            assert_eq!(module, "bad_config_module");
356        }
357    }
358
359    // ========== Tests for ConfigError display messages ==========
360
361    #[test]
362    fn test_config_error_messages() {
363        let module_not_found = ConfigError::ModuleNotFound {
364            module: "test".to_owned(),
365        };
366        assert_eq!(module_not_found.to_string(), "module 'test' not found");
367
368        let invalid_structure = ConfigError::InvalidModuleStructure {
369            module: "test".to_owned(),
370        };
371        assert_eq!(
372            invalid_structure.to_string(),
373            "module 'test' config must be an object"
374        );
375
376        let missing_config = ConfigError::MissingConfigSection {
377            module: "test".to_owned(),
378        };
379        assert_eq!(
380            missing_config.to_string(),
381            "missing 'config' section in module 'test'"
382        );
383    }
384}