use super::composed::ComposedSettings;
use super::profile::Profile;
use super::sources::{ConfigSource, DotEnvSource, EnvSource, SourceError};
use indexmap::IndexMap;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::sync::Arc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum MergeStrategy {
Shallow,
Deep,
}
pub struct SettingsBuilder {
sources: Vec<Box<dyn ConfigSource>>,
profile: Option<Profile>,
strict: bool,
typed_coercion: bool,
merge_strategy: Option<MergeStrategy>,
}
impl SettingsBuilder {
pub fn new() -> Self {
Self {
sources: Vec::new(),
profile: None,
strict: false,
typed_coercion: true,
merge_strategy: None,
}
}
pub fn profile(mut self, profile: Profile) -> Self {
self.profile = Some(profile);
self
}
pub fn strict(mut self, enabled: bool) -> Self {
self.strict = enabled;
self
}
pub fn with_typed_coercion(mut self, enable: bool) -> Self {
self.typed_coercion = enable;
self
}
pub fn with_merge_strategy(mut self, strategy: MergeStrategy) -> Self {
self.merge_strategy = Some(strategy);
self
}
pub fn add_source<S: ConfigSource + 'static>(mut self, source: S) -> Self {
self.sources.push(Box::new(source));
self
}
pub fn with_env(self, prefix: Option<&str>) -> Self {
let mut source = EnvSource::new();
if let Some(p) = prefix {
source = source.with_prefix(p);
}
self.add_source(source)
}
pub fn with_dotenv(self) -> Self {
let mut source = DotEnvSource::new();
if let Some(profile) = &self.profile {
source = source.with_profile(*profile);
}
self.add_source(source)
}
pub fn build_composed<T: ComposedSettings>(mut self) -> Result<T, BuildError> {
if self.merge_strategy.is_none() {
self.merge_strategy = Some(MergeStrategy::Deep);
}
let typed_coercion = self.typed_coercion;
let merged = self.build()?;
T::validate_requirements(merged.as_map())?;
if typed_coercion {
use crate::settings::typed_deserializer::TypedSettingsDeserializer;
let json_value = Value::Object(
merged
.as_map()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
let de = TypedSettingsDeserializer::new(&json_value);
T::deserialize(de).map_err(BuildError::Coercion)
} else {
let settings: T = merged.into_typed().map_err(BuildError::from)?;
Ok(settings)
}
}
pub fn build(mut self) -> Result<MergedSettings, BuildError> {
self.sources.sort_by_key(|a| a.priority());
let strategy = self.merge_strategy.unwrap_or(MergeStrategy::Shallow);
let mut merged = IndexMap::new();
let mut per_source: Vec<(String, IndexMap<String, Value>)> =
Vec::with_capacity(self.sources.len());
for source in &self.sources {
let description = source.description();
let config = source.load().map_err(|e| BuildError::Source {
description: description.clone(),
error: e,
})?;
match strategy {
MergeStrategy::Shallow => {
for (key, value) in &config {
merged.insert(key.clone(), value.clone());
}
}
MergeStrategy::Deep => {
super::merge::deep_merge(&mut merged, config.clone());
}
}
per_source.push((description, config));
}
if let Some(overrides) = super::testing::overrides::current_overrides() {
super::merge::deep_merge(&mut merged, overrides);
}
for (description, config) in &per_source {
if is_default_source_description(description) {
continue;
}
for warning in flat_core_warnings(config, description) {
eprintln!("{warning}");
}
}
Ok(MergedSettings {
data: Arc::new(merged),
profile: self.profile,
typed_coercion: self.typed_coercion,
})
}
}
const CORE_SETTINGS_FIELDS: &[&str] = &[
"debug",
"secret_key",
"allowed_hosts",
"installed_apps",
"middleware",
"databases",
"static_url",
"media_url",
"language_code",
"time_zone",
];
const DEFAULT_SOURCE_DESCRIPTION: &str = "Default values";
fn is_default_source_description(description: &str) -> bool {
description == DEFAULT_SOURCE_DESCRIPTION
}
fn flat_core_warnings(
source_map: &IndexMap<String, Value>,
source_description: &str,
) -> Vec<String> {
let mut warnings = Vec::new();
for &field in CORE_SETTINGS_FIELDS {
if source_map.contains_key(field) {
warnings.push(format!(
"[reinhardt-conf] Warning: settings source '{source_description}' contains top-level key '{field}' outside any section.\n\
This key is part of CoreSettings and must be placed under [core] to take effect.\n\
Hint: wrap the key in a [core] section header."
));
}
}
warnings
}
impl Default for SettingsBuilder {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone)]
pub struct MergedSettings {
data: Arc<IndexMap<String, Value>>,
profile: Option<Profile>,
typed_coercion: bool,
}
impl MergedSettings {
pub fn get<T: DeserializeOwned>(&self, key: &str) -> Result<T, GetError> {
let value = self
.data
.get(key)
.ok_or_else(|| GetError::MissingKey(key.to_string()))?;
serde_json::from_value(value.clone()).map_err(|e| GetError::Deserialize {
key: key.to_string(),
error: e,
})
}
pub fn get_or<T: DeserializeOwned>(&self, key: &str, default: T) -> T {
self.get(key).unwrap_or(default)
}
pub fn get_optional<T: DeserializeOwned>(&self, key: &str) -> Option<T> {
self.get(key).ok()
}
pub fn get_raw(&self, key: &str) -> Option<&Value> {
self.data.get(key)
}
pub fn contains_key(&self, key: &str) -> bool {
self.data.contains_key(key)
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.data.keys()
}
pub fn profile(&self) -> Option<Profile> {
self.profile
}
pub fn into_typed<T: DeserializeOwned>(self) -> Result<T, GetError> {
let json_value = Value::Object(
self.data
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
);
if self.typed_coercion {
use crate::settings::typed_deserializer::TypedSettingsDeserializer;
use serde::de::Error as _;
let de = TypedSettingsDeserializer::new(&json_value);
T::deserialize(de).map_err(|e| GetError::Deserialize {
key: "<root>".to_string(),
error: serde_json::Error::custom(e.to_string()),
})
} else {
serde_json::from_value(json_value).map_err(|e| GetError::Deserialize {
key: "<root>".to_string(),
error: e,
})
}
}
pub fn as_map(&self) -> &IndexMap<String, Value> {
&self.data
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum BuildError {
#[error("Source error in '{description}': {error}")]
Source {
description: String,
error: SourceError,
},
#[error("Validation error: {0}")]
Validation(String),
#[error(
"missing required field `{field}` in section `[{section}]`. \
Provide it via TOML, environment variable, or .set()"
)]
MissingRequiredField {
section: &'static str,
field: &'static str,
},
#[error(
"missing required settings path `{path}`. \
Provide it via TOML, environment variable, or .set()"
)]
MissingRequiredPath {
path: crate::settings::schema::SettingsPathBuf,
},
#[error("settings deserialization failed: {0}")]
Deserialization(String),
#[error(transparent)]
Coercion(#[from] crate::settings::typed_deserializer::CoercionError),
}
impl From<GetError> for BuildError {
fn from(err: GetError) -> Self {
BuildError::Deserialization(err.to_string())
}
}
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum GetError {
#[error("Missing required key: {0}")]
MissingKey(String),
#[error("Failed to deserialize key '{key}': {error}")]
Deserialize {
key: String,
error: serde_json::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::sources::DefaultSource;
use rstest::rstest;
use serde::Deserialize;
#[test]
fn test_settings_builder_basic() {
let settings = SettingsBuilder::new()
.add_source(
DefaultSource::new()
.with_value("debug", Value::Bool(true))
.with_value("secret_key", Value::String("test-key".to_string())),
)
.build()
.unwrap();
assert!(settings.get::<bool>("debug").unwrap());
assert_eq!(settings.get::<String>("secret_key").unwrap(), "test-key");
}
#[test]
fn test_builder_merge_priority() {
let settings = SettingsBuilder::new()
.add_source(
DefaultSource::new().with_value("key", Value::String("low-priority".to_string())),
)
.add_source(EnvSource::new())
.build()
.unwrap();
assert!(settings.contains_key("key"));
}
#[test]
fn test_get_optional() {
let settings = SettingsBuilder::new()
.add_source(
DefaultSource::new().with_value("existing", Value::String("value".to_string())),
)
.build()
.unwrap();
assert_eq!(
settings.get_optional::<String>("existing").unwrap(),
"value"
);
assert!(settings.get_optional::<String>("nonexistent").is_none());
}
#[test]
fn test_get_or() {
let settings = SettingsBuilder::new().build().unwrap();
assert_eq!(
settings.get_or("nonexistent", "default".to_string()),
"default"
);
}
#[test]
fn test_into_typed() {
#[derive(Debug, Deserialize, PartialEq)]
struct Config {
debug: bool,
port: u16,
}
let settings = SettingsBuilder::new()
.add_source(
DefaultSource::new()
.with_value("debug", Value::Bool(true))
.with_value("port", Value::Number(8080.into())),
)
.build()
.unwrap();
let config: Config = settings.into_typed().unwrap();
assert_eq!(
config,
Config {
debug: true,
port: 8080
}
);
}
#[test]
fn test_contains_key() {
let settings = SettingsBuilder::new()
.add_source(DefaultSource::new().with_value("key1", Value::String("value".to_string())))
.build()
.unwrap();
assert!(settings.contains_key("key1"));
assert!(!settings.contains_key("key2"));
}
#[rstest]
fn test_build_error_missing_required_field_message() {
let error = BuildError::MissingRequiredField {
section: "core",
field: "secret_key",
};
let message = error.to_string();
assert!(message.contains("missing required field `secret_key`"));
assert!(message.contains("section `[core]`"));
}
#[rstest]
fn test_build_composed_missing_required_field() {
use crate::settings::composed::ComposedSettings;
use crate::settings::profile::Profile;
use crate::settings::validation::ValidationResult;
use serde::Serialize;
#[derive(Clone, Debug, Serialize, Deserialize)]
struct MinimalComposed {
#[serde(default)]
optional_field: String,
}
impl ComposedSettings for MinimalComposed {
fn validate_requirements(merged: &IndexMap<String, Value>) -> Result<(), BuildError> {
if !merged.contains_key("secret_key") {
return Err(BuildError::MissingRequiredField {
section: "test",
field: "secret_key",
});
}
Ok(())
}
fn validate_fragments(&self, _profile: &Profile) -> ValidationResult {
Ok(())
}
}
let result = SettingsBuilder::new().build_composed::<MinimalComposed>();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
matches!(
err,
BuildError::MissingRequiredField {
section: "test",
field: "secret_key"
}
),
"expected MissingRequiredField, got: {err:?}"
);
}
#[rstest]
fn test_build_composed_success() {
use crate::settings::composed::ComposedSettings;
use crate::settings::profile::Profile;
use crate::settings::validation::ValidationResult;
use serde::Serialize;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
struct SimpleComposed {
#[serde(default)]
name: String,
}
impl ComposedSettings for SimpleComposed {
fn validate_requirements(_merged: &IndexMap<String, Value>) -> Result<(), BuildError> {
Ok(())
}
fn validate_fragments(&self, _profile: &Profile) -> ValidationResult {
Ok(())
}
}
let result = SettingsBuilder::new()
.add_source(DefaultSource::new().with_value("name", Value::String("app".to_string())))
.build_composed::<SimpleComposed>();
assert!(result.is_ok());
let composed = result.unwrap();
assert_eq!(composed.name, "app");
}
#[rstest]
fn test_flat_core_warnings_detects_flat_core_key() {
let mut source_map: IndexMap<String, Value> = IndexMap::new();
source_map.insert(
"secret_key".to_string(),
Value::String("flat-key".to_string()),
);
source_map.insert("port".to_string(), Value::Number(8080.into()));
let warnings = flat_core_warnings(&source_map, "TOML file: local.toml");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("'secret_key'"));
assert!(warnings[0].contains("TOML file: local.toml"));
}
#[rstest]
fn test_flat_core_warnings_silent_when_properly_nested() {
let mut source_map: IndexMap<String, Value> = IndexMap::new();
source_map.insert(
"core".to_string(),
serde_json::json!({"secret_key": "properly-nested", "debug": false}),
);
let warnings = flat_core_warnings(&source_map, "TOML file: local.toml");
assert!(warnings.is_empty());
}
#[rstest]
fn test_default_source_alone_produces_no_warnings() {
let mut default_map: IndexMap<String, Value> = IndexMap::new();
default_map.insert("debug".to_string(), Value::Bool(false));
default_map.insert(
"secret_key".to_string(),
Value::String("default".to_string()),
);
default_map.insert("installed_apps".to_string(), serde_json::json!([]));
assert!(is_default_source_description("Default values"));
let warnings_if_evaluated = flat_core_warnings(&default_map, "Default values");
assert!(!warnings_if_evaluated.is_empty());
}
#[rstest]
fn test_user_source_with_properly_nested_core_produces_no_warnings() {
let mut user_map: IndexMap<String, Value> = IndexMap::new();
user_map.insert(
"core".to_string(),
serde_json::json!({
"secret_key": "user-secret",
"debug": true,
"allowed_hosts": ["localhost"],
}),
);
let warnings = flat_core_warnings(&user_map, "TOML file: settings.toml");
assert!(warnings.is_empty());
}
#[rstest]
fn test_user_source_with_flat_secret_key_produces_one_warning() {
let mut user_map: IndexMap<String, Value> = IndexMap::new();
user_map.insert(
"secret_key".to_string(),
Value::String("flat-user-secret".to_string()),
);
let warnings = flat_core_warnings(&user_map, "TOML file: settings.toml");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("secret_key"));
assert!(warnings[0].contains("TOML file: settings.toml"));
}
#[test]
fn test_settings_builder_keys() {
let settings = SettingsBuilder::new()
.add_source(
DefaultSource::new()
.with_value("key1", Value::String("value1".to_string()))
.with_value("key2", Value::String("value2".to_string())),
)
.build()
.unwrap();
let keys: Vec<_> = settings.keys().collect();
assert_eq!(keys.len(), 2);
assert!(keys.contains(&&"key1".to_string()));
assert!(keys.contains(&&"key2".to_string()));
}
}