use serde::de::DeserializeOwned;
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("gear '{gear}' not found")]
GearNotFound { gear: String },
#[error("gear '{gear}' config must be an object")]
InvalidGearStructure { gear: String },
#[error("missing 'config' section in gear '{gear}'")]
MissingConfigSection { gear: String },
#[error("invalid config for gear '{gear}': {source}")]
InvalidConfig {
gear: String,
#[source]
source: serde_json::Error,
},
#[error("variable expansion failed for gear '{gear}': {source}")]
VarExpand {
gear: String,
#[source]
source: toolkit_utils::var_expand::ExpandVarsError,
},
}
pub trait ConfigProvider: Send + Sync {
fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value>;
}
pub fn gear_config_or_default<T: DeserializeOwned + Default>(
provider: &dyn ConfigProvider,
gear_name: &str,
) -> Result<T, ConfigError> {
let Some(gear_raw) = provider.get_gear_config(gear_name) else {
return Ok(T::default());
};
let Some(obj) = gear_raw.as_object() else {
return Ok(T::default());
};
let Some(config_section) = obj.get("config") else {
return Ok(T::default());
};
let config: T =
serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
gear: gear_name.to_owned(),
source: e,
})?;
Ok(config)
}
pub fn gear_config_required<T: DeserializeOwned>(
provider: &dyn ConfigProvider,
gear_name: &str,
) -> Result<T, ConfigError> {
let gear_raw =
provider
.get_gear_config(gear_name)
.ok_or_else(|| ConfigError::GearNotFound {
gear: gear_name.to_owned(),
})?;
let obj = gear_raw
.as_object()
.ok_or_else(|| ConfigError::InvalidGearStructure {
gear: gear_name.to_owned(),
})?;
let config_section = obj
.get("config")
.ok_or_else(|| ConfigError::MissingConfigSection {
gear: gear_name.to_owned(),
})?;
let config: T =
serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
gear: gear_name.to_owned(),
source: e,
})?;
Ok(config)
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
#[derive(Debug, PartialEq, Deserialize, Default)]
struct TestConfig {
#[serde(default)]
api_key: String,
#[serde(default)]
timeout_ms: u64,
#[serde(default)]
enabled: bool,
}
struct MockConfigProvider {
gears: HashMap<String, serde_json::Value>,
}
impl MockConfigProvider {
fn new() -> Self {
let mut gears = HashMap::new();
gears.insert(
"test_gear".to_owned(),
json!({
"database": {
"url": "postgres://localhost/test"
},
"config": {
"api_key": "secret123",
"timeout_ms": 5000,
"enabled": true
}
}),
);
gears.insert(
"no_config_gear".to_owned(),
json!({
"database": {
"url": "postgres://localhost/test"
}
}),
);
gears.insert("invalid_gear".to_owned(), json!("not an object"));
Self { gears }
}
}
impl ConfigProvider for MockConfigProvider {
fn get_gear_config(&self, gear_name: &str) -> Option<&serde_json::Value> {
self.gears.get(gear_name)
}
}
#[test]
fn test_lenient_success() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "test_gear");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.api_key, "secret123");
assert_eq!(config.timeout_ms, 5000);
assert!(config.enabled);
}
#[test]
fn test_lenient_gear_not_found_returns_default() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "nonexistent");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config, TestConfig::default());
}
#[test]
fn test_lenient_missing_config_section_returns_default() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "no_config_gear");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config, TestConfig::default());
}
#[test]
fn test_lenient_invalid_structure_returns_default() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "invalid_gear");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config, TestConfig::default());
}
#[test]
fn test_lenient_invalid_config_returns_error() {
let mut provider = MockConfigProvider::new();
provider.gears.insert(
"bad_config_gear".to_owned(),
json!({
"config": {
"api_key": "secret123",
"timeout_ms": "not_a_number", "enabled": true
}
}),
);
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "bad_config_gear");
assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
if let Err(ConfigError::InvalidConfig { gear, .. }) = result {
assert_eq!(gear, "bad_config_gear");
}
}
#[test]
fn test_lenient_helper_with_multiple_scenarios() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "nonexistent");
assert!(result.is_ok());
assert_eq!(result.unwrap(), TestConfig::default());
let result: Result<TestConfig, ConfigError> =
gear_config_or_default(&provider, "test_gear");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.api_key, "secret123");
}
#[test]
fn test_strict_success() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> = gear_config_required(&provider, "test_gear");
assert!(result.is_ok());
let config = result.unwrap();
assert_eq!(config.api_key, "secret123");
assert_eq!(config.timeout_ms, 5000);
assert!(config.enabled);
}
#[test]
fn test_strict_gear_not_found() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_required(&provider, "nonexistent");
assert!(matches!(result, Err(ConfigError::GearNotFound { .. })));
if let Err(ConfigError::GearNotFound { gear }) = result {
assert_eq!(gear, "nonexistent");
}
}
#[test]
fn test_strict_missing_config_section() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_required(&provider, "no_config_gear");
assert!(matches!(
result,
Err(ConfigError::MissingConfigSection { .. })
));
if let Err(ConfigError::MissingConfigSection { gear }) = result {
assert_eq!(gear, "no_config_gear");
}
}
#[test]
fn test_strict_invalid_structure() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
gear_config_required(&provider, "invalid_gear");
assert!(matches!(
result,
Err(ConfigError::InvalidGearStructure { .. })
));
if let Err(ConfigError::InvalidGearStructure { gear }) = result {
assert_eq!(gear, "invalid_gear");
}
}
#[test]
fn test_strict_invalid_config() {
let mut provider = MockConfigProvider::new();
provider.gears.insert(
"bad_config_gear".to_owned(),
json!({
"config": {
"api_key": "secret123",
"timeout_ms": "not_a_number", "enabled": true
}
}),
);
let result: Result<TestConfig, ConfigError> =
gear_config_required(&provider, "bad_config_gear");
assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
if let Err(ConfigError::InvalidConfig { gear, .. }) = result {
assert_eq!(gear, "bad_config_gear");
}
}
#[test]
fn test_config_error_messages() {
let gear_not_found = ConfigError::GearNotFound {
gear: "test".to_owned(),
};
assert_eq!(gear_not_found.to_string(), "gear 'test' not found");
let invalid_structure = ConfigError::InvalidGearStructure {
gear: "test".to_owned(),
};
assert_eq!(
invalid_structure.to_string(),
"gear 'test' config must be an object"
);
let missing_config = ConfigError::MissingConfigSection {
gear: "test".to_owned(),
};
assert_eq!(
missing_config.to_string(),
"missing 'config' section in gear 'test'"
);
}
}