use glob::glob;
use serde::{
de::{DeserializeOwned, Error as DeError},
Deserialize, Deserializer,
};
use serde_json::Value;
use std::{collections::HashMap, hash::Hash, path::PathBuf, str::FromStr};
pub trait ConfigLoader: DeserializeOwned + Sized {
const FILE_NAME: &'static str;
const ENV_PREFIX: &'static str;
fn load_config(path_override: Option<PathBuf>) -> Result<Self, eyre::Error> {
let _ = crate::config::dotenv::init();
let builder = config::Config::builder();
let config = match path_override {
Some(path) => builder.add_source(config::File::from(path).format(config::FileFormat::Toml)),
None => {
let cwd = std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
let home_dir = std::env::var("HOME").unwrap_or_default();
let mut search_dirs = vec![
cwd.clone(),
format!("{}/config", cwd),
format!("{}/crates/{}", cwd, Self::FILE_NAME),
format!("{}/.newton", home_dir),
];
#[cfg(debug_assertions)]
search_dirs.push(format!("{}/crates/{}", env!("WORKSPACE_ROOT"), Self::FILE_NAME));
let files: Vec<_> = search_dirs
.iter()
.flat_map(|dir| {
glob(format!("{}/{}.toml", dir, Self::FILE_NAME).as_str())
.unwrap()
.filter_map(|path| path.ok())
})
.map(config::File::from)
.collect();
builder.add_source(files)
}
}
.add_source(
config::Environment::with_prefix(Self::ENV_PREFIX)
.separator("__")
.try_parsing(false)
.ignore_empty(true),
)
.build()?;
config
.try_deserialize::<Self>()
.map_err(|e| eyre::eyre!("Failed to deserialize configuration: {e}"))
}
}
pub fn deserialize_hashmap_from_json_or_map<'de, D, K, V>(deserializer: D) -> Result<HashMap<K, V>, D::Error>
where
D: Deserializer<'de>,
K: FromStr + Hash + Eq,
K::Err: std::fmt::Display,
V: Deserialize<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => {
let trimmed = s.trim();
let json_value: Value = serde_json::from_str(trimmed)
.map_err(|e| DeError::custom(format!("Failed to parse JSON string (value: {:?}): {}", trimmed, e)))?;
match json_value {
Value::Object(map) => {
let mut result = HashMap::new();
for (k_str, v) in map {
let key = K::from_str(&k_str)
.map_err(|e| DeError::custom(format!("Failed to parse key '{}': {}", k_str, e)))?;
let value = V::deserialize(v).map_err(|e| {
DeError::custom(format!("Failed to deserialize value for key '{}': {}", k_str, e))
})?;
result.insert(key, value);
}
Ok(result)
}
_ => Err(DeError::custom(format!("Expected JSON object, got: {:?}", json_value))),
}
}
Value::Object(map) => {
let mut result = HashMap::new();
for (k_str, v) in map {
let key = K::from_str(&k_str)
.map_err(|e| DeError::custom(format!("Failed to parse key '{}': {}", k_str, e)))?;
let value = V::deserialize(v)
.map_err(|e| DeError::custom(format!("Failed to deserialize value for key '{}': {}", k_str, e)))?;
result.insert(key, value);
}
Ok(result)
}
_ => Err(DeError::custom(format!(
"Expected JSON string (from env var) or object (from TOML) for HashMap, got: {:?}",
value
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use std::env;
use tempfile::NamedTempFile;
use tracing::info;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct TestNested {
pub private_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct TestConfig {
pub signer: TestNested,
pub bls: TestNested,
pub top_level: String,
}
impl ConfigLoader for TestConfig {
const FILE_NAME: &'static str = "test-config";
const ENV_PREFIX: &'static str = "TEST";
}
#[test]
fn test_nested_env_var_override_investigation() {
unsafe {
env::remove_var("TEST_SIGNER__PRIVATE_KEY");
env::remove_var("TEST_BLS__PRIVATE_KEY");
env::remove_var("TEST_TOP__LEVEL");
env::remove_var("SIGNER__PRIVATE_KEY");
}
let toml_content = r#"
top_level = "from_file"
[signer]
private_key = "file_signer_key"
[bls]
private_key = "file_bls_key"
"#;
let temp_file = NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), toml_content).unwrap();
unsafe {
env::set_var("SIGNER__PRIVATE_KEY", "env_signer_no_prefix");
}
let config1 = config::Config::builder()
.add_source(config::File::from(temp_file.path()).format(config::FileFormat::Toml))
.add_source(config::Environment::default().separator("__").try_parsing(true))
.build()
.unwrap()
.try_deserialize::<TestConfig>()
.unwrap();
info!(
"Test 1 (no prefix, SIGNER__PRIVATE_KEY): {:?}",
config1.signer.private_key
);
unsafe {
env::remove_var("SIGNER__PRIVATE_KEY");
}
unsafe {
env::set_var("TEST_SIGNER_PRIVATE_KEY", "env_signer_single");
}
let config2 = config::Config::builder()
.add_source(config::File::from(temp_file.path()).format(config::FileFormat::Toml))
.add_source(
config::Environment::with_prefix("TEST")
.separator("_")
.try_parsing(true),
)
.build()
.unwrap()
.try_deserialize::<TestConfig>()
.unwrap();
info!(
"Test 2 (prefix TEST, single _, TEST_SIGNER_PRIVATE_KEY): {:?}",
config2.signer.private_key
);
unsafe {
env::remove_var("TEST_SIGNER_PRIVATE_KEY");
}
unsafe {
env::set_var("TEST_SIGNER__PRIVATE_KEY", "env_signer_double");
}
let config3 = config::Config::builder()
.add_source(config::File::from(temp_file.path()).format(config::FileFormat::Toml))
.add_source(
config::Environment::with_prefix("TEST")
.separator("__")
.try_parsing(true),
)
.build()
.unwrap()
.try_deserialize::<TestConfig>()
.unwrap();
info!(
"Test 3 (prefix TEST, double __, TEST_SIGNER__PRIVATE_KEY): {:?}",
config3.signer.private_key
);
unsafe {
env::set_var("TEST_SIGNER__PRIVATE_KEY", "env_signer_double_v2");
}
let config_env_only = config::Config::builder()
.add_source(
config::Environment::with_prefix("TEST")
.separator("__")
.try_parsing(true),
)
.build()
.unwrap();
if let Ok(all_keys) = config_env_only.get::<toml::Value>("") {
info!("All keys from env-only config: {:?}", all_keys);
}
info!(
"Test 4a - Direct path 'signer.private_key': {:?}",
config_env_only.get::<String>("signer.private_key")
);
info!(
"Test 4b - Direct path 'signer__private_key': {:?}",
config_env_only.get::<String>("signer__private_key")
);
unsafe {
env::remove_var("TEST_SIGNER__PRIVATE_KEY");
}
unsafe {
env::set_var("TEST__SIGNER__PRIVATE_KEY", "env_signer_with_prefix_sep");
}
let config5 = config::Config::builder()
.add_source(config::File::from(temp_file.path()).format(config::FileFormat::Toml))
.add_source(
config::Environment::with_prefix("TEST")
.separator("__")
.try_parsing(true),
)
.build()
.unwrap()
.try_deserialize::<TestConfig>()
.unwrap();
info!(
"Test 5 (TEST__SIGNER__PRIVATE_KEY with prefix separator): {:?}",
config5.signer.private_key
);
unsafe {
env::remove_var("TEST__SIGNER__PRIVATE_KEY");
}
}
#[test]
fn test_nested_env_var_override_works() {
unsafe {
env::remove_var("TEST__SIGNER__PRIVATE_KEY");
env::remove_var("TEST__BLS__PRIVATE_KEY");
env::remove_var("TEST__TOP__LEVEL");
}
let toml_content = r#"
top_level = "from_file"
[signer]
private_key = "file_signer_key"
[bls]
private_key = "file_bls_key"
"#;
let temp_file = NamedTempFile::new().unwrap();
std::fs::write(temp_file.path(), toml_content).unwrap();
unsafe {
env::set_var("TEST__SIGNER__PRIVATE_KEY", "env_signer_key");
env::set_var("TEST__BLS__PRIVATE_KEY", "env_bls_key");
}
unsafe {
env::set_var("TEST__TOP__LEVEL", "env_top_level");
}
let config = TestConfig::load_config(Some(temp_file.path().to_path_buf())).unwrap();
assert_eq!(
config.signer.private_key,
Some("env_signer_key".to_string()),
"Nested env var override should work with TEST__SIGNER__PRIVATE_KEY"
);
assert_eq!(
config.bls.private_key,
Some("env_bls_key".to_string()),
"Nested env var override should work with TEST__BLS__PRIVATE_KEY"
);
unsafe {
env::remove_var("TEST__SIGNER__PRIVATE_KEY");
env::remove_var("TEST__BLS__PRIVATE_KEY");
env::remove_var("TEST__TOP__LEVEL");
}
}
}