use serde::de::DeserializeOwned;
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
#[error("module '{module}' not found")]
ModuleNotFound { module: String },
#[error("module '{module}' config must be an object")]
InvalidModuleStructure { module: String },
#[error("missing 'config' section in module '{module}'")]
MissingConfigSection { module: String },
#[error("invalid config for module '{module}': {source}")]
InvalidConfig {
module: String,
#[source]
source: serde_json::Error,
},
#[error("variable expansion failed for module '{module}': {source}")]
VarExpand {
module: String,
#[source]
source: modkit_utils::var_expand::ExpandVarsError,
},
}
pub trait ConfigProvider: Send + Sync {
fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value>;
}
pub fn module_config_or_default<T: DeserializeOwned + Default>(
provider: &dyn ConfigProvider,
module_name: &str,
) -> Result<T, ConfigError> {
let Some(module_raw) = provider.get_module_config(module_name) else {
return Ok(T::default());
};
let Some(obj) = module_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 {
module: module_name.to_owned(),
source: e,
})?;
Ok(config)
}
pub fn module_config_required<T: DeserializeOwned>(
provider: &dyn ConfigProvider,
module_name: &str,
) -> Result<T, ConfigError> {
let module_raw =
provider
.get_module_config(module_name)
.ok_or_else(|| ConfigError::ModuleNotFound {
module: module_name.to_owned(),
})?;
let obj = module_raw
.as_object()
.ok_or_else(|| ConfigError::InvalidModuleStructure {
module: module_name.to_owned(),
})?;
let config_section = obj
.get("config")
.ok_or_else(|| ConfigError::MissingConfigSection {
module: module_name.to_owned(),
})?;
let config: T =
serde_json::from_value(config_section.clone()).map_err(|e| ConfigError::InvalidConfig {
module: module_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 {
modules: HashMap<String, serde_json::Value>,
}
impl MockConfigProvider {
fn new() -> Self {
let mut modules = HashMap::new();
modules.insert(
"test_module".to_owned(),
json!({
"database": {
"url": "postgres://localhost/test"
},
"config": {
"api_key": "secret123",
"timeout_ms": 5000,
"enabled": true
}
}),
);
modules.insert(
"no_config_module".to_owned(),
json!({
"database": {
"url": "postgres://localhost/test"
}
}),
);
modules.insert("invalid_module".to_owned(), json!("not an object"));
Self { modules }
}
}
impl ConfigProvider for MockConfigProvider {
fn get_module_config(&self, module_name: &str) -> Option<&serde_json::Value> {
self.modules.get(module_name)
}
}
#[test]
fn test_lenient_success() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
module_config_or_default(&provider, "test_module");
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_module_not_found_returns_default() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
module_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> =
module_config_or_default(&provider, "no_config_module");
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> =
module_config_or_default(&provider, "invalid_module");
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.modules.insert(
"bad_config_module".to_owned(),
json!({
"config": {
"api_key": "secret123",
"timeout_ms": "not_a_number", "enabled": true
}
}),
);
let result: Result<TestConfig, ConfigError> =
module_config_or_default(&provider, "bad_config_module");
assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
if let Err(ConfigError::InvalidConfig { module, .. }) = result {
assert_eq!(module, "bad_config_module");
}
}
#[test]
fn test_lenient_helper_with_multiple_scenarios() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
module_config_or_default(&provider, "nonexistent");
assert!(result.is_ok());
assert_eq!(result.unwrap(), TestConfig::default());
let result: Result<TestConfig, ConfigError> =
module_config_or_default(&provider, "test_module");
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> =
module_config_required(&provider, "test_module");
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_module_not_found() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
module_config_required(&provider, "nonexistent");
assert!(matches!(result, Err(ConfigError::ModuleNotFound { .. })));
if let Err(ConfigError::ModuleNotFound { module }) = result {
assert_eq!(module, "nonexistent");
}
}
#[test]
fn test_strict_missing_config_section() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
module_config_required(&provider, "no_config_module");
assert!(matches!(
result,
Err(ConfigError::MissingConfigSection { .. })
));
if let Err(ConfigError::MissingConfigSection { module }) = result {
assert_eq!(module, "no_config_module");
}
}
#[test]
fn test_strict_invalid_structure() {
let provider = MockConfigProvider::new();
let result: Result<TestConfig, ConfigError> =
module_config_required(&provider, "invalid_module");
assert!(matches!(
result,
Err(ConfigError::InvalidModuleStructure { .. })
));
if let Err(ConfigError::InvalidModuleStructure { module }) = result {
assert_eq!(module, "invalid_module");
}
}
#[test]
fn test_strict_invalid_config() {
let mut provider = MockConfigProvider::new();
provider.modules.insert(
"bad_config_module".to_owned(),
json!({
"config": {
"api_key": "secret123",
"timeout_ms": "not_a_number", "enabled": true
}
}),
);
let result: Result<TestConfig, ConfigError> =
module_config_required(&provider, "bad_config_module");
assert!(matches!(result, Err(ConfigError::InvalidConfig { .. })));
if let Err(ConfigError::InvalidConfig { module, .. }) = result {
assert_eq!(module, "bad_config_module");
}
}
#[test]
fn test_config_error_messages() {
let module_not_found = ConfigError::ModuleNotFound {
module: "test".to_owned(),
};
assert_eq!(module_not_found.to_string(), "module 'test' not found");
let invalid_structure = ConfigError::InvalidModuleStructure {
module: "test".to_owned(),
};
assert_eq!(
invalid_structure.to_string(),
"module 'test' config must be an object"
);
let missing_config = ConfigError::MissingConfigSection {
module: "test".to_owned(),
};
assert_eq!(
missing_config.to_string(),
"missing 'config' section in module 'test'"
);
}
}