1use serde::de::DeserializeOwned;
14
15#[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 #[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
37pub trait ConfigProvider: Send + Sync {
39 fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value>;
41}
42
43pub fn gear_config_or_default<T: DeserializeOwned + Default>(
56 provider: &dyn ConfigProvider,
57 gear_name: &str,
58) -> Result<T, ConfigError> {
59 let Some(gear_raw) = provider.get_gear_config(gear_name) else {
61 return Ok(T::default());
62 };
63
64 let Some(obj) = gear_raw.as_object() else {
66 return Ok(T::default());
67 };
68
69 let Some(config_section) = obj.get("config") else {
71 return Ok(T::default());
72 };
73
74 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
84pub 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 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 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 gears.insert(
172 "no_config_gear".to_owned(),
173 json!({
174 "database": {
175 "url": "postgres://localhost/test"
176 }
177 }),
178 );
179
180 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 #[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 provider.gears.insert(
246 "bad_config_gear".to_owned(),
247 json!({
248 "config": {
249 "api_key": "secret123",
250 "timeout_ms": "not_a_number", "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 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 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 #[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 provider.gears.insert(
344 "bad_config_gear".to_owned(),
345 json!({
346 "config": {
347 "api_key": "secret123",
348 "timeout_ms": "not_a_number", "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 #[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}