use std::{collections::HashMap, env};
use crate::{SettingsError, StoredValue};
pub type DeserializeFallback<T> = fn(&str) -> Result<T, String>;
pub struct FieldResolveOptions<'a, T> {
pub env: Option<&'a str>,
pub toml: Option<&'a toml::Table>,
pub key: &'a str,
pub default: T,
}
pub struct PersistResolveOptions<'a, T> {
pub stored: &'a HashMap<String, StoredValue>,
pub key: &'a str,
pub deserialize_fallback: Option<DeserializeFallback<T>>,
pub fallback: FieldResolveOptions<'a, T>,
}
pub fn resolve_readonly_field<T>(options: FieldResolveOptions<T>) -> Result<T, SettingsError>
where
T: serde::de::DeserializeOwned,
{
if let Some(env_name) = options.env
&& let Ok(env_var) = env::var(env_name)
{
return parse_env_value(env_name, &env_var);
} else if let Some(table) = options.toml
&& let Some(table_value) = table.get(options.key)
{
let field_value =
table_value
.clone()
.try_into::<T>()
.map_err(|e| SettingsError::ConfigValueParse {
key: options.key.to_string(),
source: e,
})?;
return Ok(field_value);
};
Ok(options.default)
}
pub fn resolve_persist_field<T>(options: PersistResolveOptions<T>) -> Result<T, SettingsError>
where
T: serde::de::DeserializeOwned,
{
if let Some(stored_value) = options.stored.get(options.key) {
return decode_persist_value(options.key, stored_value, options.deserialize_fallback);
}
resolve_readonly_field(options.fallback)
}
pub fn decode_persist_value<T>(
key: &str,
stored_value: &StoredValue,
deserialize_fallback: Option<DeserializeFallback<T>>,
) -> Result<T, SettingsError>
where
T: serde::de::DeserializeOwned,
{
match stored_value.decode::<T>() {
Ok(value) => Ok(value),
Err(source) => match deserialize_fallback {
Some(fallback) => fallback(stored_value.as_str()).map_err(|error| {
SettingsError::PersistFallbackParse {
key: key.to_string(),
error,
}
}),
None => Err(SettingsError::PersistValueParse {
key: key.to_string(),
source,
}),
},
}
}
fn parse_env_value<T>(name: &str, raw: &str) -> Result<T, SettingsError>
where
T: serde::de::DeserializeOwned,
{
let parsed = raw
.parse::<toml::Value>()
.unwrap_or_else(|_| toml::Value::String(raw.to_string()));
parsed
.clone()
.try_into::<T>()
.or_else(|_| toml::Value::String(raw.to_string()).try_into::<T>())
.map_err(|source| SettingsError::EnvParse {
name: name.to_string(),
source,
})
}
#[cfg(test)]
mod test {
use super::*;
use std::assert_matches;
#[test]
fn test_resolve_readonly_field_env_wins_over_toml_and_default() {
let env_var_key = "TEST_APP_PORT";
let table = "port = 3000".parse::<toml::Table>().unwrap();
let options = FieldResolveOptions::<u16> {
env: Some(env_var_key),
toml: Some(&table),
key: "port",
default: 1000,
};
temp_env::with_var(env_var_key, Some("8080"), || {
let field_value = resolve_readonly_field(options).unwrap();
assert_eq!(field_value, 8080);
});
}
#[test]
fn test_resolve_readonly_field_toml_wins_over_default_when_env_is_absent() {
let table = "port = 3000".parse::<toml::Table>().unwrap();
let options = FieldResolveOptions::<u16> {
env: Some("TEST_APP_MISSING_PORT"),
toml: Some(&table),
key: "port",
default: 1000,
};
temp_env::with_var("TEST_APP_MISSING_PORT", None::<&str>, || {
let field_value = resolve_readonly_field(options).unwrap();
assert_eq!(field_value, 3000);
});
}
#[test]
fn test_resolve_readonly_field_uses_default_when_env_and_toml_are_absent() {
let table = "other = 3000".parse::<toml::Table>().unwrap();
let options = FieldResolveOptions::<u16> {
env: Some("TEST_APP_DEFAULT_PORT"),
toml: Some(&table),
key: "port",
default: 1000,
};
temp_env::with_var("TEST_APP_DEFAULT_PORT", None::<&str>, || {
let field_value = resolve_readonly_field(options).unwrap();
assert_eq!(field_value, 1000);
});
}
#[test]
fn test_resolve_readonly_field_returns_env_parse_error_for_invalid_env_value() {
let env_var_key = "TEST_APP_BAD_PORT";
let table = "port = 3000".parse::<toml::Table>().unwrap();
let options = FieldResolveOptions::<u16> {
env: Some(env_var_key),
toml: Some(&table),
key: "port",
default: 1000,
};
temp_env::with_var(env_var_key, Some("\"not-a-number\""), || {
let error = resolve_readonly_field(options).unwrap_err();
assert_matches!(error, SettingsError::EnvParse { name, .. } if name == env_var_key);
});
}
#[test]
fn test_resolve_readonly_field_returns_config_value_parse_error_for_bad_toml_type() {
let table = "port = 'not-a-number'".parse::<toml::Table>().unwrap();
let options = FieldResolveOptions::<u16> {
env: None,
toml: Some(&table),
key: "port",
default: 1000,
};
let error = resolve_readonly_field(options).unwrap_err();
assert_matches!(error, SettingsError::ConfigValueParse { key, .. } if key == "port");
}
#[test]
fn test_resolve_readonly_field_parses_unquoted_env_string() {
let env_var_key = "TEST_APP_THEME";
let options = FieldResolveOptions::<String> {
env: Some(env_var_key),
toml: None,
key: "theme",
default: "system".to_string(),
};
temp_env::with_var(env_var_key, Some("dark"), || {
let field_value = resolve_readonly_field(options).unwrap();
assert_eq!(field_value, "dark");
});
}
#[test]
fn test_resolve_readonly_field_parses_toml_boolean_env_value() {
let env_var_key = "TEST_APP_DEBUG";
let options = FieldResolveOptions::<bool> {
env: Some(env_var_key),
toml: None,
key: "debug",
default: false,
};
temp_env::with_var(env_var_key, Some("true"), || {
let field_value = resolve_readonly_field(options).unwrap();
assert!(field_value);
});
}
#[test]
fn test_resolve_persist_field_uses_stored_value_when_present() {
let mut stored = HashMap::new();
stored.insert(
"theme".to_string(),
StoredValue::encode(&"dark".to_string()).unwrap(),
);
let table = "theme = 'light'".parse::<toml::Table>().unwrap();
let options = PersistResolveOptions {
stored: &stored,
key: "theme",
deserialize_fallback: None,
fallback: FieldResolveOptions::<String> {
env: Some("TEST_APP_PERSIST_THEME"),
toml: Some(&table),
key: "theme",
default: "system".to_string(),
},
};
temp_env::with_var("TEST_APP_PERSIST_THEME", Some("env-dark"), || {
let field_value = resolve_persist_field(options).unwrap();
assert_eq!(field_value, "dark");
});
}
#[test]
fn test_resolve_persist_field_falls_back_to_env_when_stored_value_is_missing() {
let stored = HashMap::new();
let table = "theme = 'light'".parse::<toml::Table>().unwrap();
let options = PersistResolveOptions {
stored: &stored,
key: "theme",
deserialize_fallback: None,
fallback: FieldResolveOptions::<String> {
env: Some("TEST_APP_PERSIST_ENV_THEME"),
toml: Some(&table),
key: "theme",
default: "system".to_string(),
},
};
temp_env::with_var("TEST_APP_PERSIST_ENV_THEME", Some("env-dark"), || {
let field_value = resolve_persist_field(options).unwrap();
assert_eq!(field_value, "env-dark");
});
}
#[test]
fn test_resolve_persist_field_falls_back_to_toml_when_env_and_stored_value_are_missing() {
let stored = HashMap::new();
let table = "theme = 'light'".parse::<toml::Table>().unwrap();
let options = PersistResolveOptions {
stored: &stored,
key: "theme",
deserialize_fallback: None,
fallback: FieldResolveOptions::<String> {
env: Some("TEST_APP_PERSIST_MISSING_THEME"),
toml: Some(&table),
key: "theme",
default: "system".to_string(),
},
};
temp_env::with_var("TEST_APP_PERSIST_MISSING_THEME", None::<&str>, || {
let field_value = resolve_persist_field(options).unwrap();
assert_eq!(field_value, "light");
});
}
#[test]
fn test_resolve_persist_field_falls_back_to_default_when_other_sources_are_missing() {
let stored = HashMap::new();
let table = "other = 'light'".parse::<toml::Table>().unwrap();
let options = PersistResolveOptions {
stored: &stored,
key: "theme",
deserialize_fallback: None,
fallback: FieldResolveOptions::<String> {
env: Some("TEST_APP_PERSIST_DEFAULT_THEME"),
toml: Some(&table),
key: "theme",
default: "system".to_string(),
},
};
temp_env::with_var("TEST_APP_PERSIST_DEFAULT_THEME", None::<&str>, || {
let field_value = resolve_persist_field(options).unwrap();
assert_eq!(field_value, "system");
});
}
#[test]
fn test_resolve_persist_field_returns_error_for_bad_stored_value() {
let mut stored = HashMap::new();
stored.insert(
"port".to_string(),
StoredValue::from_raw("\"not-a-number\"".to_string()),
);
let options = PersistResolveOptions {
stored: &stored,
key: "port",
deserialize_fallback: None,
fallback: FieldResolveOptions::<u16> {
env: None,
toml: None,
key: "port",
default: 8080,
},
};
let error = resolve_persist_field(options).unwrap_err();
assert_matches!(error, SettingsError::PersistValueParse { key, .. } if key == "port");
}
#[test]
fn test_resolve_persist_field_uses_deserialize_fallback_for_bad_stored_value() {
fn legacy_port(raw: &str) -> Result<u16, String> {
match raw {
"\"legacy-port\"" => Ok(9000),
other => Err(format!("unknown legacy port: {other}")),
}
}
let mut stored = HashMap::new();
stored.insert(
"port".to_string(),
StoredValue::encode(&"legacy-port".to_string()).unwrap(),
);
let options = PersistResolveOptions {
stored: &stored,
key: "port",
deserialize_fallback: Some(legacy_port),
fallback: FieldResolveOptions::<u16> {
env: None,
toml: None,
key: "port",
default: 8080,
},
};
let field_value = resolve_persist_field(options).unwrap();
assert_eq!(field_value, 9000);
}
#[test]
fn test_resolve_persist_field_returns_fallback_error_when_fallback_fails() {
fn legacy_port(raw: &str) -> Result<u16, String> {
Err(format!("unknown legacy port: {raw}"))
}
let mut stored = HashMap::new();
stored.insert(
"port".to_string(),
StoredValue::encode(&"unknown".to_string()).unwrap(),
);
let options = PersistResolveOptions {
stored: &stored,
key: "port",
deserialize_fallback: Some(legacy_port),
fallback: FieldResolveOptions::<u16> {
env: None,
toml: None,
key: "port",
default: 8080,
},
};
let error = resolve_persist_field(options).unwrap_err();
assert_matches!(error, SettingsError::PersistFallbackParse { key, error } if key == "port" && error.contains("unknown legacy port"));
}
}