Skip to main content

toolkit/
config.rs

1//! Configuration gear for typed gear configuration access.
2//!
3//! This gear provides two distinct mechanisms for loading gear configuration:
4//!
5//! 1. **Lenient loading** (default): Falls back to `T::default()` when configuration is missing.
6//!    - Used by `gear_config_or_default`
7//!    - Allows gears 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 `gear_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("gear '{gear}' not found")]
19    GearNotFound { gear: String },
20    #[error("gear '{gear}' config must be an object")]
21    InvalidGearStructure { gear: String },
22    #[error("missing 'config' section in gear '{gear}'")]
23    MissingConfigSection { gear: String },
24    // Intentionally not named `source`; doing so would duplicate chained error output.
25    #[error("invalid config for gear '{gear}': {cause}")]
26    InvalidConfig {
27        gear: String,
28        cause: serde_json::Error,
29    },
30    #[error("variable expansion failed for gear '{gear}': {cause}")]
31    VarExpand {
32        gear: String,
33        cause: toolkit_utils::var_expand::ExpandVarsError,
34    },
35}
36
37/// Provider of gear-specific configuration (raw JSON sections only).
38pub trait ConfigProvider: Send + Sync {
39    /// Returns raw JSON section for the gear, if any.
40    fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value>;
41}
42
43/// Lenient configuration loader that falls back to defaults.
44///
45/// This function provides forgiving behavior for gears that don't require configuration:
46/// - If the gear is not present in config → returns `Ok(T::default())`
47/// - If the gear value is not an object → returns `Ok(T::default())`
48/// - If the gear has no "config" field → returns `Ok(T::default())`
49/// - If "config" is present but invalid → returns `Err(ConfigError::InvalidConfig)`
50///
51/// Use this for gears that can operate with default configuration.
52///
53/// # Errors
54/// Returns `ConfigError::InvalidConfig` if the config section exists but cannot be deserialized.
55pub fn gear_config_or_default<T: DeserializeOwned + Default>(
56    provider: &dyn ConfigProvider,
57    gear_name: &str,
58) -> Result<T, ConfigError> {
59    // If gear not found, use defaults
60    let Some(gear_raw) = provider.get_gear_config(gear_name) else {
61        return Ok(T::default());
62    };
63
64    // If gear is not an object, use defaults
65    let Some(obj) = gear_raw.as_object() else {
66        return Ok(T::default());
67    };
68
69    // If no config section, use defaults
70    let Some(config_section) = obj.get("config") else {
71        return Ok(T::default());
72    };
73
74    // Config section exists, try to parse it
75    let config: T =
76        serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
77            gear: gear_name.to_owned(),
78            cause: e,
79        })?;
80
81    Ok(config)
82}
83
84/// Strict configuration loader that requires configuration to be present.
85///
86/// This function enforces that configuration must exist and be valid:
87/// - If the gear is not present → returns `Err(ConfigError::GearNotFound)`
88/// - If the gear value is not an object → returns `Err(ConfigError::InvalidGearStructure)`
89/// - If the gear has no "config" field → returns `Err(ConfigError::MissingConfigSection)`
90/// - If "config" is present but invalid → returns `Err(ConfigError::InvalidConfig)`
91///
92/// Use this for gears that cannot operate without explicit configuration.
93///
94/// # Errors
95/// Returns `ConfigError` if the gear is not found, has invalid structure, or config is invalid.
96pub fn gear_config_required<T: DeserializeOwned>(
97    provider: &dyn ConfigProvider,
98    gear_name: &str,
99) -> Result<T, ConfigError> {
100    let gear_raw =
101        provider
102            .get_gear_config(gear_name)
103            .ok_or_else(|| ConfigError::GearNotFound {
104                gear: gear_name.to_owned(),
105            })?;
106
107    // Extract config section from: gears.<name> = { database: ..., config: ... }
108    let obj = gear_raw
109        .as_object()
110        .ok_or_else(|| ConfigError::InvalidGearStructure {
111            gear: gear_name.to_owned(),
112        })?;
113
114    let config_section = obj
115        .get("config")
116        .ok_or_else(|| ConfigError::MissingConfigSection {
117            gear: gear_name.to_owned(),
118        })?;
119
120    let config: T =
121        serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
122            gear: gear_name.to_owned(),
123            cause: e,
124        })?;
125
126    Ok(config)
127}
128
129#[cfg(test)]
130#[cfg_attr(coverage_nightly, coverage(off))]
131mod tests {
132    use super::*;
133    use serde::Deserialize;
134    use serde_json::json;
135    use std::collections::HashMap;
136
137    #[derive(Debug, PartialEq, Deserialize, Default)]
138    struct TestConfig {
139        #[serde(default)]
140        api_key: String,
141        #[serde(default)]
142        timeout_ms: u64,
143        #[serde(default)]
144        enabled: bool,
145    }
146
147    struct MockConfigProvider {
148        gears: HashMap<String, serde_json::Value>,
149    }
150
151    impl MockConfigProvider {
152        fn new() -> Self {
153            let mut gears = HashMap::new();
154
155            // Valid gear config
156            gears.insert(
157                "test_gear".to_owned(),
158                json!({
159                    "database": {
160                        "url": "postgres://localhost/test"
161                    },
162                    "config": {
163                        "api_key": "secret123",
164                        "timeout_ms": 5000,
165                        "enabled": true
166                    }
167                }),
168            );
169
170            // Gear without config section
171            gears.insert(
172                "no_config_gear".to_owned(),
173                json!({
174                    "database": {
175                        "url": "postgres://localhost/test"
176                    }
177                }),
178            );
179
180            // Gear with invalid structure (not an object)
181            gears.insert("invalid_gear".to_owned(), json!("not an object"));
182
183            Self { gears }
184        }
185    }
186
187    impl ConfigProvider for MockConfigProvider {
188        fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
189            self.gears.get(gear_name)
190        }
191    }
192
193    // ========== Tests for lenient loading (gear_config_or_default) ==========
194
195    #[test]
196    fn test_lenient_success() {
197        let provider = MockConfigProvider::new();
198        let result: Result<TestConfig, ConfigError> =
199            gear_config_or_default(&provider, "test_gear");
200
201        assert!(result.is_ok());
202        let config = result.unwrap();
203        assert_eq!(config.api_key, "secret123");
204        assert_eq!(config.timeout_ms, 5000);
205        assert!(config.enabled);
206    }
207
208    #[test]
209    fn test_lenient_gear_not_found_returns_default() {
210        let provider = MockConfigProvider::new();
211        let result: Result<TestConfig, ConfigError> =
212            gear_config_or_default(&provider, "nonexistent");
213
214        assert!(result.is_ok());
215        let config = result.unwrap();
216        assert_eq!(config, TestConfig::default());
217    }
218
219    #[test]
220    fn test_lenient_missing_config_section_returns_default() {
221        let provider = MockConfigProvider::new();
222        let result: Result<TestConfig, ConfigError> =
223            gear_config_or_default(&provider, "no_config_gear");
224
225        assert!(result.is_ok());
226        let config = result.unwrap();
227        assert_eq!(config, TestConfig::default());
228    }
229
230    #[test]
231    fn test_lenient_invalid_structure_returns_default() {
232        let provider = MockConfigProvider::new();
233        let result: Result<TestConfig, ConfigError> =
234            gear_config_or_default(&provider, "invalid_gear");
235
236        assert!(result.is_ok());
237        let config = result.unwrap();
238        assert_eq!(config, TestConfig::default());
239    }
240
241    #[test]
242    fn test_lenient_invalid_config_returns_error() {
243        let mut provider = MockConfigProvider::new();
244        // Add gear with invalid config structure
245        provider.gears.insert(
246            "bad_config_gear".to_owned(),
247            json!({
248                "config": {
249                    "api_key": "secret123",
250                    "timeout_ms": "not_a_number", // Should be u64
251                    "enabled": true
252                }
253            }),
254        );
255
256        let result: Result<TestConfig, ConfigError> =
257            gear_config_or_default(&provider, "bad_config_gear");
258
259        assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
260        if let Err(ConfigError::InvalidConfig { gear, .. }) = result {
261            assert_eq!(gear, "bad_config_gear");
262        }
263    }
264
265    #[test]
266    fn test_lenient_helper_with_multiple_scenarios() {
267        let provider = MockConfigProvider::new();
268
269        // Gear not found should return default
270        let result: Result<TestConfig, ConfigError> =
271            gear_config_or_default(&provider, "nonexistent");
272        assert!(result.is_ok());
273        assert_eq!(result.unwrap(), TestConfig::default());
274
275        // Valid config should parse correctly
276        let result: Result<TestConfig, ConfigError> =
277            gear_config_or_default(&provider, "test_gear");
278        assert!(result.is_ok());
279        let config = result.unwrap();
280        assert_eq!(config.api_key, "secret123");
281    }
282
283    // ========== Tests for strict loading (gear_config_required) ==========
284
285    #[test]
286    fn test_strict_success() {
287        let provider = MockConfigProvider::new();
288        let result: Result<TestConfig, ConfigError> = gear_config_required(&provider, "test_gear");
289
290        assert!(result.is_ok());
291        let config = result.unwrap();
292        assert_eq!(config.api_key, "secret123");
293        assert_eq!(config.timeout_ms, 5000);
294        assert!(config.enabled);
295    }
296
297    #[test]
298    fn test_strict_gear_not_found() {
299        let provider = MockConfigProvider::new();
300        let result: Result<TestConfig, ConfigError> =
301            gear_config_required(&provider, "nonexistent");
302
303        assert!(matches!(result, Err(ConfigError::GearNotFound { .. })));
304        if let Err(ConfigError::GearNotFound { gear }) = result {
305            assert_eq!(gear, "nonexistent");
306        }
307    }
308
309    #[test]
310    fn test_strict_missing_config_section() {
311        let provider = MockConfigProvider::new();
312        let result: Result<TestConfig, ConfigError> =
313            gear_config_required(&provider, "no_config_gear");
314
315        assert!(matches!(
316            result,
317            Err(ConfigError::MissingConfigSection { .. })
318        ));
319        if let Err(ConfigError::MissingConfigSection { gear }) = result {
320            assert_eq!(gear, "no_config_gear");
321        }
322    }
323
324    #[test]
325    fn test_strict_invalid_structure() {
326        let provider = MockConfigProvider::new();
327        let result: Result<TestConfig, ConfigError> =
328            gear_config_required(&provider, "invalid_gear");
329
330        assert!(matches!(
331            result,
332            Err(ConfigError::InvalidGearStructure { .. })
333        ));
334        if let Err(ConfigError::InvalidGearStructure { gear }) = result {
335            assert_eq!(gear, "invalid_gear");
336        }
337    }
338
339    #[test]
340    fn test_strict_invalid_config() {
341        let mut provider = MockConfigProvider::new();
342        // Add gear with invalid config structure
343        provider.gears.insert(
344            "bad_config_gear".to_owned(),
345            json!({
346                "config": {
347                    "api_key": "secret123",
348                    "timeout_ms": "not_a_number", // Should be u64
349                    "enabled": true
350                }
351            }),
352        );
353
354        let result: Result<TestConfig, ConfigError> =
355            gear_config_required(&provider, "bad_config_gear");
356
357        assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
358        if let Err(ConfigError::InvalidConfig { gear, .. }) = result {
359            assert_eq!(gear, "bad_config_gear");
360        }
361    }
362
363    // ========== Tests for ConfigError display messages ==========
364
365    #[test]
366    fn test_config_error_messages() {
367        let gear_not_found = ConfigError::GearNotFound {
368            gear: "test".to_owned(),
369        };
370        assert_eq!(gear_not_found.to_string(), "gear 'test' not found");
371
372        let invalid_structure = ConfigError::InvalidGearStructure {
373            gear: "test".to_owned(),
374        };
375        assert_eq!(
376            invalid_structure.to_string(),
377            "gear 'test' config must be an object"
378        );
379
380        let missing_config = ConfigError::MissingConfigSection {
381            gear: "test".to_owned(),
382        };
383        assert_eq!(
384            missing_config.to_string(),
385            "missing 'config' section in gear 'test'"
386        );
387    }
388
389    #[test]
390    fn test_invalid_config_message_is_not_duplicated_by_source_chain() {
391        let mut provider = MockConfigProvider::new();
392        provider.gears.insert(
393            "bad_config_gear".to_owned(),
394            json!({
395                "config": {
396                    "api_key": "secret123",
397                    "timeout_ms": "not_a_number",
398                    "enabled": true
399                }
400            }),
401        );
402        let result: Result<TestConfig, ConfigError> =
403            gear_config_required(&provider, "bad_config_gear");
404        let err = result.unwrap_err();
405
406        assert_eq!(
407            err.to_string(),
408            "invalid config for gear 'bad_config_gear': invalid type: string \"not_a_number\", expected u64"
409        );
410
411        assert!(std::error::Error::source(&err).is_none());
412        let anyhow_err: anyhow::Error = err.into();
413        assert_eq!(
414            format!("{anyhow_err:#}"),
415            "invalid config for gear 'bad_config_gear': invalid type: string \"not_a_number\", expected u64"
416        );
417    }
418
419    #[test]
420    fn test_var_expand_message_is_not_duplicated_by_source_chain() {
421        let err = ConfigError::VarExpand {
422            gear: "test".to_owned(),
423            cause: toolkit_utils::var_expand::ExpandVarsError::Regex("boom".to_owned()),
424        };
425
426        assert_eq!(
427            err.to_string(),
428            "variable expansion failed for gear 'test': env expansion regex error: boom"
429        );
430        assert!(std::error::Error::source(&err).is_none());
431    }
432}