use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use std::sync::OnceLock;
static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
#[doc(hidden)]
pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
let _ = MACRO_IS_DEBUG.set(is_debug);
}
pub trait Env {
fn var(&self, key: &str) -> Result<String, std::env::VarError>;
}
#[derive(Clone, Default)]
pub struct OsEnv;
impl Env for OsEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
if key == "AUTUMN_MANIFEST_DIR" {
if let Some(dir) = MACRO_MANIFEST_DIR.get() {
return Ok(dir.clone());
}
} else if key == "AUTUMN_IS_DEBUG" {
if let Some(is_debug) = MACRO_IS_DEBUG.get() {
return Ok(if *is_debug {
"1".to_string()
} else {
"0".to_string()
});
}
}
std::env::var(key)
}
}
#[derive(Clone, Default)]
pub struct MockEnv {
vars: std::collections::HashMap<String, String>,
}
impl MockEnv {
#[must_use]
pub fn new() -> Self {
Self {
vars: std::collections::HashMap::new(),
}
}
#[must_use]
pub fn with(mut self, key: &str, value: &str) -> Self {
self.vars.insert(key.to_owned(), value.to_owned());
self
}
#[must_use]
pub fn without(mut self, key: &str) -> Self {
self.vars.remove(key);
self
}
}
impl Env for MockEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.vars
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
let candidate = PathBuf::from(manifest_dir).join(filename);
if candidate.exists() {
return candidate;
}
}
PathBuf::from(filename)
}
fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let table = toml::from_str::<toml::Table>(&contents)?;
Ok(Some(toml::Value::Table(table)))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ConfigError::Io(e)),
}
}
pub(crate) fn resolve_profile(env: &dyn Env) -> String {
let selected_profile_input = resolve_profile_input(env);
normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
}
fn resolve_profile_input(env: &dyn Env) -> String {
if let Ok(profile) = env.var("AUTUMN_ENV") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
if let Ok(profile) = env.var("AUTUMN_PROFILE") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
let args: Vec<String> = std::env::args().collect();
for (i, arg) in args.iter().enumerate() {
if arg == "--profile" {
if let Some(profile) = args.get(i + 1) {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
}
if let Some(profile) = arg.strip_prefix("--profile=") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
}
if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
return "prod".to_owned();
}
"dev".to_owned()
}
fn normalize_profile_name(profile: &str) -> Option<String> {
let trimmed = profile.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("production") {
return Some("prod".to_owned());
}
if trimmed.eq_ignore_ascii_case("development") {
return Some("dev".to_owned());
}
if trimmed.eq_ignore_ascii_case("prod") {
return Some("prod".to_owned());
}
if trimmed.eq_ignore_ascii_case("dev") {
return Some("dev".to_owned());
}
Some(trimmed.to_owned())
}
fn profile_lookup_names(profile: &str) -> Vec<&str> {
match profile {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
fn profile_override_file_lookup_names(profile: &str, selected_profile_input: &str) -> Vec<String> {
match profile {
"prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
vec!["production".to_owned(), "prod".to_owned()]
}
"prod" => vec!["prod".to_owned(), "production".to_owned()],
"dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
vec!["development".to_owned(), "dev".to_owned()]
}
"dev" => vec!["dev".to_owned(), "development".to_owned()],
other => vec![other.to_owned()],
}
}
fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
base.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()))
}
fn profile_defaults_as_toml(profile: &str) -> toml::Value {
let mut table = toml::map::Map::new();
match profile {
"dev" => {
let mut log = toml::map::Map::new();
log.insert("level".into(), "debug".into());
log.insert("format".into(), "Pretty".into());
table.insert("log".into(), toml::Value::Table(log));
let mut telemetry = toml::map::Map::new();
telemetry.insert("environment".into(), "development".into());
table.insert("telemetry".into(), toml::Value::Table(telemetry));
let mut server = toml::map::Map::new();
server.insert("host".into(), "127.0.0.1".into());
server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(1));
table.insert("server".into(), toml::Value::Table(server));
let mut health = toml::map::Map::new();
health.insert("detailed".into(), toml::Value::Boolean(true));
table.insert("health".into(), toml::Value::Table(health));
let mut actuator = toml::map::Map::new();
actuator.insert("sensitive".into(), toml::Value::Boolean(true));
table.insert("actuator".into(), toml::Value::Table(actuator));
let mut cors = toml::map::Map::new();
cors.insert(
"allowed_origins".into(),
toml::Value::Array(vec![toml::Value::String("*".to_owned())]),
);
table.insert("cors".into(), toml::Value::Table(cors));
}
"prod" => {
let mut log = toml::map::Map::new();
log.insert("level".into(), "info".into());
log.insert("format".into(), "Json".into());
table.insert("log".into(), toml::Value::Table(log));
let mut telemetry = toml::map::Map::new();
telemetry.insert("environment".into(), "production".into());
table.insert("telemetry".into(), toml::Value::Table(telemetry));
let mut server = toml::map::Map::new();
server.insert("host".into(), "0.0.0.0".into());
server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(30));
table.insert("server".into(), toml::Value::Table(server));
let mut health = toml::map::Map::new();
health.insert("detailed".into(), toml::Value::Boolean(false));
table.insert("health".into(), toml::Value::Table(health));
let mut security = toml::map::Map::new();
let mut headers = toml::map::Map::new();
headers.insert(
"strict_transport_security".into(),
toml::Value::Boolean(true),
);
security.insert("headers".into(), toml::Value::Table(headers));
let mut csrf = toml::map::Map::new();
csrf.insert("enabled".into(), toml::Value::Boolean(true));
security.insert("csrf".into(), toml::Value::Table(csrf));
table.insert("security".into(), toml::Value::Table(security));
let mut session = toml::map::Map::new();
session.insert("secure".into(), toml::Value::Boolean(true));
table.insert("session".into(), toml::Value::Table(session));
}
_ => {} }
toml::Value::Table(table)
}
const MAX_MERGE_DEPTH: usize = 16;
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
deep_merge_with_depth(base, overlay, 0);
}
fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
if depth > MAX_MERGE_DEPTH {
eprintln!(
"Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
);
return;
}
let toml::Value::Table(overlay_table) = overlay else {
return;
};
let Some(base_table) = base.as_table_mut() else {
return;
};
for (key, overlay_val) in overlay_table {
let is_recursive_merge =
overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
if is_recursive_merge {
if let Some(base_val) = base_table.get_mut(&key) {
deep_merge_with_depth(base_val, overlay_val, depth + 1);
}
} else {
base_table.insert(key, overlay_val);
}
}
}
fn suggest_profile(profile: &str) -> Option<&'static str> {
let known = ["dev", "prod"];
let mut suggestions: Vec<(&str, usize)> = known
.iter()
.map(|k| (*k, levenshtein(profile, k)))
.filter(|(_, d)| *d <= 2)
.collect();
suggestions.sort_by_key(|(_, d)| *d);
suggestions.first().map(|(name, _)| *name)
}
fn warn_profile_typo(profile: &str) {
if let Some(suggestion) = suggest_profile(profile) {
eprintln!(
"Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
and no smart defaults. Did you mean \"{suggestion}\"?"
);
}
}
fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
profile != "dev" && profile != "prod" && !has_inline_profile_section
}
fn levenshtein(a: &str, b: &str) -> usize {
let n = b.chars().count();
let mut prev: Vec<usize> = (0..=n).collect();
for (i, a_ch) in a.chars().enumerate() {
let mut prev_diag = prev[0];
prev[0] = i + 1;
for (j, b_ch) in b.chars().enumerate() {
let old_prev = prev[j + 1];
let cost = usize::from(a_ch != b_ch);
prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
prev_diag = old_prev;
}
}
prev[n]
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("failed to read autumn.toml: {0}")]
Io(#[from] std::io::Error),
#[error("invalid autumn.toml: {0}")]
Parse(#[from] toml::de::Error),
#[error("configuration error: {0}")]
Validation(String),
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AutumnConfig {
#[serde(skip)]
pub profile: Option<String>,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub health: HealthConfig,
#[serde(default)]
pub actuator: ActuatorConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub session: crate::session::SessionConfig,
#[serde(default)]
pub auth: crate::auth::AuthConfig,
#[serde(default)]
pub security: crate::security::config::SecurityConfig,
}
impl AutumnConfig {
pub fn load() -> Result<Self, ConfigError> {
Self::load_with_env(&OsEnv)
}
pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
let selected_profile_input = resolve_profile_input(env);
let profile =
normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
let mut has_inline_profile_section = false;
let mut merged = profile_defaults_as_toml(&profile);
if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
deep_merge(&mut merged, base.clone());
for profile_name in profile_lookup_names(&profile) {
if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
deep_merge(&mut merged, inline_profile);
has_inline_profile_section = true;
}
}
}
let mut has_profile_file = false;
for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
if let Some(profile_toml) = load_raw_toml(&profile_path)? {
deep_merge(&mut merged, profile_toml);
has_profile_file = true;
break;
}
}
if !has_profile_file
&& should_warn_missing_profile_file(&profile, has_inline_profile_section)
{
warn_profile_typo(&profile);
}
let toml_str =
toml::to_string(&merged).expect("internal error: failed to serialize merged config");
let mut config: Self = toml::from_str(&toml_str)?;
config.profile = Some(profile);
config.apply_env_overrides_with_env(env);
config.validate()?;
Ok(config)
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let config: Self = toml::from_str(&contents)?;
config.validate()?;
Ok(config)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(ConfigError::Io(e)),
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.database.validate()?;
self.cors.validate()?;
Ok(())
}
pub fn apply_env_overrides(&mut self) {
self.apply_env_overrides_with_env(&OsEnv);
}
pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
self.apply_server_env_overrides_with_env(env);
self.apply_database_env_overrides_with_env(env);
self.apply_log_env_overrides_with_env(env);
self.apply_telemetry_env_overrides_with_env(env);
self.apply_health_env_overrides_with_env(env);
self.apply_cors_env_overrides_with_env(env);
self.apply_session_env_overrides_with_env(env);
self.apply_auth_env_overrides_with_env(env);
self.apply_security_env_overrides_with_env(env);
}
fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
parse_env(
env,
"AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
&mut self.server.shutdown_timeout_secs,
);
}
fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
self.database.url = Some(val);
}
parse_env(
env,
"AUTUMN_DATABASE__POOL_SIZE",
&mut self.database.pool_size,
);
parse_env(
env,
"AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
&mut self.database.connect_timeout_secs,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
&mut self.database.auto_migrate_in_production,
);
}
fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
match val.as_str() {
"Auto" => self.log.format = LogFormat::Auto,
"Pretty" => self.log.format = LogFormat::Pretty,
"Json" => self.log.format = LogFormat::Json,
_ => eprintln!(
"Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
(expected Auto, Pretty, or Json), ignoring"
),
}
}
}
fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_TELEMETRY__ENABLED",
&mut self.telemetry.enabled,
);
parse_env_string(
env,
"AUTUMN_TELEMETRY__SERVICE_NAME",
&mut self.telemetry.service_name,
);
parse_env_option_string(
env,
"AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
&mut self.telemetry.service_namespace,
);
parse_env_string(
env,
"AUTUMN_TELEMETRY__SERVICE_VERSION",
&mut self.telemetry.service_version,
);
parse_env_string(
env,
"AUTUMN_TELEMETRY__ENVIRONMENT",
&mut self.telemetry.environment,
);
parse_env_option_string(
env,
"AUTUMN_TELEMETRY__OTLP_ENDPOINT",
&mut self.telemetry.otlp_endpoint,
);
if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
match TelemetryProtocol::from_env_value(&val) {
Some(protocol) => self.telemetry.protocol = protocol,
None => eprintln!(
"Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
(expected Grpc or HttpProtobuf), ignoring"
),
}
}
parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
}
fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
parse_env_string(
env,
"AUTUMN_HEALTH__READY_PATH",
&mut self.health.ready_path,
);
parse_env_string(
env,
"AUTUMN_HEALTH__STARTUP_PATH",
&mut self.health.startup_path,
);
parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
}
fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_csv(
env,
"AUTUMN_CORS__ALLOWED_ORIGINS",
&mut self.cors.allowed_origins,
);
parse_env_csv(
env,
"AUTUMN_CORS__ALLOWED_METHODS",
&mut self.cors.allowed_methods,
);
parse_env_csv(
env,
"AUTUMN_CORS__ALLOWED_HEADERS",
&mut self.cors.allowed_headers,
);
parse_env_bool(
env,
"AUTUMN_CORS__ALLOW_CREDENTIALS",
&mut self.cors.allow_credentials,
);
parse_env(
env,
"AUTUMN_CORS__MAX_AGE_SECS",
&mut self.cors.max_age_secs,
);
}
fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_SESSION__COOKIE_NAME",
&mut self.session.cookie_name,
);
if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
match crate::session::SessionBackend::from_env_value(&val) {
Some(backend) => self.session.backend = backend,
None => eprintln!(
"Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
(expected memory or redis), ignoring"
),
}
}
parse_env(
env,
"AUTUMN_SESSION__MAX_AGE_SECS",
&mut self.session.max_age_secs,
);
parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
parse_env_string(
env,
"AUTUMN_SESSION__SAME_SITE",
&mut self.session.same_site,
);
parse_env_bool(
env,
"AUTUMN_SESSION__HTTP_ONLY",
&mut self.session.http_only,
);
parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
parse_env_bool(
env,
"AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
&mut self.session.allow_memory_in_production,
);
parse_env_option_string(
env,
"AUTUMN_SESSION__REDIS__URL",
&mut self.session.redis.url,
);
parse_env_string(
env,
"AUTUMN_SESSION__REDIS__KEY_PREFIX",
&mut self.session.redis.key_prefix,
);
}
fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
}
fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
&mut self.security.headers.x_frame_options,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
&mut self.security.headers.x_content_type_options,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
&mut self.security.headers.strict_transport_security,
);
parse_env(
env,
"AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
&mut self.security.headers.hsts_max_age_secs,
);
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
&mut self.security.headers.content_security_policy,
);
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
&mut self.security.headers.referrer_policy,
);
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
&mut self.security.headers.permissions_policy,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__CSRF__ENABLED",
&mut self.security.csrf.enabled,
);
parse_env_string(
env,
"AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
&mut self.security.csrf.token_header,
);
parse_env_string(
env,
"AUTUMN_SECURITY__CSRF__COOKIE_NAME",
&mut self.security.csrf.cookie_name,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
&mut self.security.rate_limit.enabled,
);
parse_env(
env,
"AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
&mut self.security.rate_limit.requests_per_second,
);
parse_env(
env,
"AUTUMN_SECURITY__RATE_LIMIT__BURST",
&mut self.security.rate_limit.burst,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
&mut self.security.rate_limit.trust_forwarded_headers,
);
parse_env(
env,
"AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
&mut self.security.upload.max_request_size_bytes,
);
parse_env(
env,
"AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
&mut self.security.upload.max_file_size_bytes,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
&mut self.security.upload.allowed_mime_types,
);
}
#[must_use]
pub fn profile_name(&self) -> Option<&str> {
self.profile.as_deref()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
#[serde(default = "default_shutdown_timeout")]
pub shutdown_timeout_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_pool_size")]
pub pool_size: usize,
#[serde(default = "default_connect_timeout")]
pub connect_timeout_secs: u64,
#[serde(default)]
pub auto_migrate_in_production: bool,
}
impl DatabaseConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if let Some(ref url) = self.url {
if !url.starts_with("postgres://") && !url.starts_with("postgresql://") {
return Err(ConfigError::Validation(format!(
"Invalid database URL: must start with postgres:// or postgresql://, got {url:?}"
)));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum LogFormat {
#[default]
Auto,
Pretty,
Json,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TelemetryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_telemetry_service_name")]
pub service_name: String,
#[serde(default)]
pub service_namespace: Option<String>,
#[serde(default = "default_telemetry_service_version")]
pub service_version: String,
#[serde(default = "default_telemetry_environment")]
pub environment: String,
#[serde(default)]
pub otlp_endpoint: Option<String>,
#[serde(default)]
pub protocol: TelemetryProtocol,
#[serde(default)]
pub strict: bool,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub enum TelemetryProtocol {
#[serde(alias = "grpc", alias = "GRPC")]
#[default]
Grpc,
#[serde(
alias = "http-protobuf",
alias = "http_protobuf",
alias = "HTTP_PROTOBUF"
)]
HttpProtobuf,
}
impl TelemetryProtocol {
fn from_env_value(value: &str) -> Option<Self> {
match value {
"Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
"HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
| "httpprotobuf" => Some(Self::HttpProtobuf),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HealthConfig {
#[serde(default = "default_health_path")]
pub path: String,
#[serde(default = "default_live_path")]
pub live_path: String,
#[serde(default = "default_ready_path")]
pub ready_path: String,
#[serde(default = "default_startup_path")]
pub startup_path: String,
#[serde(default)]
pub detailed: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActuatorConfig {
#[serde(default = "default_actuator_prefix")]
pub prefix: String,
#[serde(default)]
pub sensitive: bool,
}
impl Default for ActuatorConfig {
fn default() -> Self {
Self {
prefix: default_actuator_prefix(),
sensitive: false,
}
}
}
fn default_actuator_prefix() -> String {
"/actuator".to_owned()
}
#[derive(Debug, Clone, Deserialize)]
pub struct CorsConfig {
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default = "default_cors_methods")]
pub allowed_methods: Vec<String>,
#[serde(default = "default_cors_headers")]
pub allowed_headers: Vec<String>,
#[serde(default)]
pub allow_credentials: bool,
#[serde(default = "default_cors_max_age")]
pub max_age_secs: u64,
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
allowed_origins: Vec::new(),
allowed_methods: default_cors_methods(),
allowed_headers: default_cors_headers(),
allow_credentials: false,
max_age_secs: default_cors_max_age(),
}
}
}
impl CorsConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
return Err(ConfigError::Validation(
"CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
list explicit origins instead (browsers reject the wildcard+credentials combo)"
.to_owned(),
));
}
Ok(())
}
}
fn default_cors_methods() -> Vec<String> {
vec![
"GET".to_owned(),
"POST".to_owned(),
"PUT".to_owned(),
"DELETE".to_owned(),
"PATCH".to_owned(),
"OPTIONS".to_owned(),
]
}
fn default_cors_headers() -> Vec<String> {
vec!["Content-Type".to_owned(), "Authorization".to_owned()]
}
const fn default_cors_max_age() -> u64 {
86400
}
fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
if let Ok(val) = env.var(key) {
match val.parse::<T>() {
Ok(v) => *target = v,
Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
}
}
}
fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
if let Ok(val) = env.var(key) {
*target = if val.is_empty() { None } else { Some(val) };
}
}
fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
if let Ok(val) = env.var(key) {
*target = val;
}
}
fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
if let Ok(val) = env.var(key) {
match val.as_str() {
"true" | "1" => *target = true,
"false" | "0" => *target = false,
_ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
}
}
}
fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
if let Ok(val) = env.var(key) {
*target = val.split(',').map(|s| s.trim().to_owned()).collect();
}
}
const fn default_port() -> u16 {
3000
}
fn default_host() -> String {
"127.0.0.1".to_owned()
}
const fn default_shutdown_timeout() -> u64 {
30
}
const fn default_pool_size() -> usize {
10
}
const fn default_connect_timeout() -> u64 {
5
}
fn default_log_level() -> String {
"info".to_owned()
}
fn default_telemetry_service_name() -> String {
"autumn-app".to_owned()
}
fn default_telemetry_service_version() -> String {
"unknown".to_owned()
}
fn default_telemetry_environment() -> String {
"development".to_owned()
}
fn default_health_path() -> String {
"/health".to_owned()
}
fn default_live_path() -> String {
"/live".to_owned()
}
fn default_ready_path() -> String {
"/ready".to_owned()
}
fn default_startup_path() -> String {
"/startup".to_owned()
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: default_port(),
host: default_host(),
shutdown_timeout_secs: default_shutdown_timeout(),
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: None,
pool_size: default_pool_size(),
connect_timeout_secs: default_connect_timeout(),
auto_migrate_in_production: false,
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::default(),
}
}
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
enabled: false,
service_name: default_telemetry_service_name(),
service_namespace: None,
service_version: default_telemetry_service_version(),
environment: default_telemetry_environment(),
otlp_endpoint: None,
protocol: TelemetryProtocol::default(),
strict: false,
}
}
}
impl Default for HealthConfig {
fn default() -> Self {
Self {
path: default_health_path(),
live_path: default_live_path(),
ready_path: default_ready_path(),
startup_path: default_startup_path(),
detailed: false,
}
}
}
pub trait ConfigLoader: Send + Sync + 'static {
fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
}
#[derive(Debug, Default, Clone, Copy)]
pub struct TomlEnvConfigLoader;
impl TomlEnvConfigLoader {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl ConfigLoader for TomlEnvConfigLoader {
async fn load(&self) -> Result<AutumnConfig, ConfigError> {
AutumnConfig::load_with_env(&OsEnv)
}
}
#[cfg(test)]
mod tests {
use super::*;
struct MockConfigLoader {
config: AutumnConfig,
}
impl ConfigLoader for MockConfigLoader {
async fn load(&self) -> Result<AutumnConfig, ConfigError> {
Ok(self.config.clone())
}
}
#[tokio::test]
async fn config_loader_trait_returns_supplied_config() {
let mut custom = AutumnConfig::default();
custom.server.port = 9999;
custom.profile = Some("integration-test".to_owned());
let loader = MockConfigLoader {
config: custom.clone(),
};
let resolved = loader.load().await.expect("mock loader should succeed");
assert_eq!(resolved.server.port, 9999);
assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
}
#[test]
fn validate_does_not_error_on_redis_backend_without_url() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
config.session.redis.url = None;
config.validate().expect(
"validate() must accept redis-backend-without-url so custom \
session store overrides aren't blocked at boot",
);
}
#[tokio::test]
async fn default_toml_env_loader_succeeds_without_files() {
let loader = TomlEnvConfigLoader::new();
let resolved = loader.load().await.expect("default loader should succeed");
assert_eq!(resolved.server.port, 3000);
}
#[test]
fn database_config_validate_none() {
let config = DatabaseConfig {
url: None,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_config_validate_valid_postgres() {
let config = DatabaseConfig {
url: Some("postgres://user:pass@localhost:5432/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_config_validate_valid_postgresql() {
let config = DatabaseConfig {
url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_config_validate_invalid_scheme() {
let config = DatabaseConfig {
url: Some("mysql://user:pass@localhost:3306/db".to_string()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
match result {
Err(ConfigError::Validation(msg)) => {
assert!(msg.contains("must start with postgres:// or postgresql://"));
}
_ => panic!("Expected ConfigError::Validation"),
}
}
#[test]
fn server_defaults() {
let config = ServerConfig::default();
assert_eq!(config.port, 3000);
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.shutdown_timeout_secs, 30);
}
#[test]
fn database_defaults() {
let config = DatabaseConfig::default();
assert!(config.url.is_none());
assert_eq!(config.pool_size, 10);
assert_eq!(config.connect_timeout_secs, 5);
}
#[test]
fn database_validate_none_url_is_ok() {
let config = DatabaseConfig {
url: None,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_validate_postgres_url_is_ok() {
let config = DatabaseConfig {
url: Some("postgres://user:pass@localhost/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_validate_postgresql_url_is_ok() {
let config = DatabaseConfig {
url: Some("postgresql://user:pass@localhost/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_validate_invalid_url_is_err() {
let config = DatabaseConfig {
url: Some("mysql://user:pass@localhost/db".to_string()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
if let Err(ConfigError::Validation(msg)) = result {
assert!(msg.contains("Invalid database URL"));
assert!(msg.contains("must start with postgres:// or postgresql://"));
} else {
panic!("Expected ConfigError::Validation");
}
}
#[test]
fn database_validate_url_edge_cases() {
let invalid_urls = vec![
"POSTGRES://localhost/db",
"postgres:/localhost/db",
"postgres:localhost/db",
"http://postgres",
" postgres://localhost/db",
"",
];
for invalid_url in invalid_urls {
let config = DatabaseConfig {
url: Some(invalid_url.to_string()),
..Default::default()
};
assert!(
config.validate().is_err(),
"URL should be invalid: {invalid_url}"
);
}
}
#[test]
fn autumn_config_validate_ok() {
let config = AutumnConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
config.session.redis.url = None;
config
.validate()
.expect("validate() must accept invalid session backend so custom store can override");
}
#[test]
fn autumn_config_validate_database_err() {
let mut config = AutumnConfig::default();
config.database.url = Some("mysql://localhost/test".to_string());
assert!(config.validate().is_err());
}
#[test]
fn log_defaults() {
let config = LogConfig::default();
assert_eq!(config.level, "info");
assert_eq!(config.format, LogFormat::Auto);
}
#[test]
fn telemetry_defaults() {
let config = TelemetryConfig::default();
assert!(!config.enabled);
assert_eq!(config.service_name, "autumn-app");
assert!(config.service_namespace.is_none());
assert_eq!(config.service_version, "unknown");
assert_eq!(config.environment, "development");
assert!(config.otlp_endpoint.is_none());
assert_eq!(config.protocol, TelemetryProtocol::Grpc);
assert!(!config.strict);
}
#[test]
fn health_defaults() {
let config = HealthConfig::default();
assert_eq!(config.path, "/health");
assert_eq!(config.live_path, "/live");
assert_eq!(config.ready_path, "/ready");
assert_eq!(config.startup_path, "/startup");
assert!(!config.detailed);
}
#[test]
fn top_level_default_populates_all_sections() {
let config = AutumnConfig::default();
assert_eq!(config.server.port, 3000);
assert!(config.database.url.is_none());
assert_eq!(config.log.level, "info");
assert_eq!(config.health.path, "/health");
}
#[test]
fn deserialize_empty_object_uses_all_defaults() {
let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
assert_eq!(config.server.port, 3000);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.shutdown_timeout_secs, 30);
assert!(config.database.url.is_none());
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.database.connect_timeout_secs, 5);
assert!(!config.database.auto_migrate_in_production);
assert_eq!(config.log.level, "info");
assert_eq!(config.log.format, LogFormat::Auto);
assert_eq!(config.health.path, "/health");
}
#[test]
fn deserialize_partial_config_merges_with_defaults() {
let json = r#"{"server": {"port": 8080}}"#;
let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.log.level, "info");
}
#[test]
fn log_format_variants_deserialize() {
let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
assert_eq!(auto, LogFormat::Auto);
assert_eq!(pretty, LogFormat::Pretty);
assert_eq!(json, LogFormat::Json);
}
#[test]
fn load_missing_file_returns_defaults() {
let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
assert_eq!(config.server.port, 3000);
assert!(config.database.url.is_none());
}
#[test]
fn load_valid_full_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(
&path,
r#"
[server]
port = 8080
host = "0.0.0.0"
shutdown_timeout_secs = 60
[database]
url = "postgres://user:pass@db:5432/myapp"
pool_size = 20
connect_timeout_secs = 10
auto_migrate_in_production = true
[log]
level = "debug"
format = "Json"
[health]
path = "/healthz"
"#,
)
.unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.shutdown_timeout_secs, 60);
assert_eq!(
config.database.url.as_deref(),
Some("postgres://user:pass@db:5432/myapp")
);
assert_eq!(config.database.pool_size, 20);
assert_eq!(config.database.connect_timeout_secs, 10);
assert!(config.database.auto_migrate_in_production);
assert_eq!(config.log.level, "debug");
assert_eq!(config.log.format, LogFormat::Json);
assert_eq!(config.health.path, "/healthz");
}
#[test]
fn load_partial_config_merges_with_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert_eq!(config.server.port, 9090);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.log.level, "info");
}
#[test]
fn load_invalid_toml_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "not valid [[[toml").unwrap();
let result = AutumnConfig::load_from(&path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("invalid autumn.toml"));
}
#[test]
fn load_empty_file_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "").unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert_eq!(config.server.port, 3000);
}
#[test]
fn env_override_database_url() {
let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.url.as_deref(),
Some("postgres://override:5432/test")
);
}
#[test]
fn env_override_pool_size() {
let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.pool_size, 25);
}
#[test]
fn env_override_connect_timeout() {
let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.connect_timeout_secs, 15);
}
#[test]
fn env_override_invalid_pool_size_ignored() {
let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.pool_size, 10);
}
#[test]
fn env_override_database_auto_migrate_in_production() {
let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.database.auto_migrate_in_production);
}
#[test]
fn env_override_server_port() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.port, 8080);
}
#[test]
fn parse_env_works() {
let env = MockEnv::new().with("SOME_NUM", "123");
let mut target: u32 = 0;
parse_env(&env, "SOME_NUM", &mut target);
assert_eq!(target, 123);
let env_err = MockEnv::new().with("SOME_NUM", "abc");
let mut target_err: u32 = 0;
parse_env(&env_err, "SOME_NUM", &mut target_err);
assert_eq!(target_err, 0); }
#[test]
fn parse_env_option_string_works() {
let env = MockEnv::new().with("SOME_OPT", "val");
let mut target = None;
parse_env_option_string(&env, "SOME_OPT", &mut target);
assert_eq!(target, Some("val".to_string()));
let env_empty = MockEnv::new().with("SOME_OPT", "");
let mut target_empty = Some("old".to_string());
parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
assert_eq!(target_empty, None);
}
#[test]
fn parse_env_string_works() {
let env = MockEnv::new().with("SOME_STR", "val");
let mut target = "old".to_string();
parse_env_string(&env, "SOME_STR", &mut target);
assert_eq!(target, "val");
}
#[test]
fn parse_env_bool_works() {
let env = MockEnv::new().with("SOME_BOOL", "true");
let mut target = false;
parse_env_bool(&env, "SOME_BOOL", &mut target);
assert!(target);
let env2 = MockEnv::new().with("SOME_BOOL", "1");
let mut target2 = false;
parse_env_bool(&env2, "SOME_BOOL", &mut target2);
assert!(target2);
let env3 = MockEnv::new().with("SOME_BOOL", "0");
let mut target3 = true;
parse_env_bool(&env3, "SOME_BOOL", &mut target3);
assert!(!target3);
let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
let mut target_err = true;
parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
assert!(target_err); }
#[test]
fn parse_env_csv_works() {
let env = MockEnv::new().with("SOME_CSV", "a, b,c");
let mut target = vec![];
parse_env_csv(&env, "SOME_CSV", &mut target);
assert_eq!(target, vec!["a", "b", "c"]);
}
#[test]
fn env_override_server_host() {
let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.host, "0.0.0.0");
}
#[test]
fn env_override_server_shutdown_timeout() {
let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.shutdown_timeout_secs, 60);
}
#[test]
fn env_override_invalid_server_port_ignored() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.port, 3000);
}
#[test]
fn env_override_invalid_shutdown_timeout_ignored() {
let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.shutdown_timeout_secs, 30);
}
#[test]
fn env_override_log_level() {
let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.level, "debug");
}
#[test]
fn env_override_log_format_json() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Json);
}
#[test]
fn env_override_log_format_pretty() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Pretty);
}
#[test]
fn env_override_invalid_log_format_ignored() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Auto);
}
#[test]
fn env_override_telemetry_fields() {
let env = MockEnv::new()
.with("AUTUMN_TELEMETRY__ENABLED", "true")
.with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
.with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
.with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
.with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
.with(
"AUTUMN_TELEMETRY__OTLP_ENDPOINT",
"http://otel-collector:4317",
)
.with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
.with("AUTUMN_TELEMETRY__STRICT", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.telemetry.enabled);
assert_eq!(config.telemetry.service_name, "orders-api");
assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
assert_eq!(config.telemetry.service_version, "1.2.3");
assert_eq!(config.telemetry.environment, "production");
assert_eq!(
config.telemetry.otlp_endpoint.as_deref(),
Some("http://otel-collector:4317")
);
assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
assert!(config.telemetry.strict);
}
#[test]
fn env_override_invalid_telemetry_protocol_ignored() {
let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
}
#[test]
fn env_override_health_path() {
let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.health.path, "/healthz");
}
#[test]
fn env_override_probe_paths() {
let env = MockEnv::new()
.with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
.with("AUTUMN_HEALTH__READY_PATH", "/readyz")
.with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.health.live_path, "/livez");
assert_eq!(config.health.ready_path, "/readyz");
assert_eq!(config.health.startup_path, "/startupz");
}
#[test]
fn env_overrides_toml_values() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
let mut config = AutumnConfig::load_from(&path).unwrap();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.port, 9999); }
#[test]
fn validate_rejects_invalid_url_scheme() {
let config = DatabaseConfig {
url: Some("mysql://localhost/test".to_owned()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must start with postgres://")
);
}
#[test]
fn validate_accepts_postgres_url() {
let config = DatabaseConfig {
url: Some("postgres://localhost/test".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn validate_accepts_postgresql_url() {
let config = DatabaseConfig {
url: Some("postgresql://localhost/test".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn validate_accepts_no_url() {
let config = DatabaseConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn resolve_profile_from_autumn_env() {
let env = MockEnv::new().with("AUTUMN_ENV", "prod");
let profile = resolve_profile(&env);
assert_eq!(profile, "prod");
}
#[test]
fn resolve_profile_from_legacy_env() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
let profile = resolve_profile(&env);
assert_eq!(profile, "staging");
}
#[test]
fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
let env = MockEnv::new()
.with("AUTUMN_ENV", "dev")
.with("AUTUMN_PROFILE", "prod");
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn resolve_profile_normalizes_production_alias() {
let env = MockEnv::new().with("AUTUMN_ENV", "production");
let profile = resolve_profile(&env);
assert_eq!(profile, "prod");
}
#[test]
fn resolve_profile_normalizes_development_alias_with_whitespace() {
let env = MockEnv::new().with("AUTUMN_ENV", " development ");
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn resolve_profile_normalizes_uppercase_dev_and_prod() {
let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
let prod = resolve_profile(&prod_env);
assert_eq!(prod, "prod");
let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
let dev = resolve_profile(&dev_env);
assert_eq!(dev, "dev");
}
#[test]
fn resolve_profile_preserves_case_for_custom_profiles() {
let env = MockEnv::new().with("AUTUMN_ENV", "QA");
let profile = resolve_profile(&env);
assert_eq!(profile, "QA");
}
#[test]
fn resolve_profile_auto_detect_debug() {
let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn resolve_profile_auto_detect_release() {
let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
let profile = resolve_profile(&env);
assert_eq!(profile, "prod");
}
#[test]
fn resolve_profile_defaults_to_dev_when_no_signal_present() {
let env = MockEnv::new();
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn dev_profile_smart_defaults() {
let defaults = profile_defaults_as_toml("dev");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.log.level, "debug");
assert_eq!(config.log.format, LogFormat::Pretty);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.shutdown_timeout_secs, 1);
assert_eq!(config.telemetry.environment, "development");
assert!(config.health.detailed);
assert_eq!(config.cors.allowed_origins, vec!["*"]);
}
#[test]
fn prod_profile_smart_defaults() {
let defaults = profile_defaults_as_toml("prod");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.log.level, "info");
assert_eq!(config.log.format, LogFormat::Json);
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.shutdown_timeout_secs, 30);
assert_eq!(config.telemetry.environment, "production");
assert!(!config.health.detailed);
assert!(
config.security.headers.strict_transport_security,
"prod profile must auto-enable Strict-Transport-Security"
);
assert_eq!(config.security.headers.x_frame_options, "DENY");
assert!(config.security.headers.x_content_type_options);
assert!(!config.security.headers.content_security_policy.is_empty());
}
#[test]
fn dev_profile_does_not_auto_enable_hsts() {
let defaults = profile_defaults_as_toml("dev");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert!(
!config.security.headers.strict_transport_security,
"dev profile must not force HSTS on (local http development)"
);
}
#[test]
fn custom_profile_no_smart_defaults() {
let defaults = profile_defaults_as_toml("staging");
assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
}
#[test]
fn deep_merge_tables() {
let mut base: toml::Value = toml::from_str(
r#"
[server]
port = 3000
host = "127.0.0.1"
[database]
pool_size = 10
"#,
)
.unwrap();
let overlay: toml::Value = toml::from_str(
r#"
[server]
port = 8080
[database]
url = "postgres://localhost/test"
"#,
)
.unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
assert_eq!(
base["server"]["host"],
toml::Value::String("127.0.0.1".into())
);
assert_eq!(
base["database"]["url"],
toml::Value::String("postgres://localhost/test".into())
);
assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
}
#[test]
fn profile_toml_overrides_base_toml() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("autumn.toml");
let dev_path = dir.path().join("autumn-dev.toml");
std::fs::write(
&base_path,
r"
[server]
port = 3000
[database]
pool_size = 10
",
)
.unwrap();
std::fs::write(
&dev_path,
r#"
[database]
url = "postgres://localhost/myapp_dev"
"#,
)
.unwrap();
let mut merged = toml::Value::Table(toml::map::Map::new());
let base = load_raw_toml(&base_path).unwrap().unwrap();
deep_merge(&mut merged, base);
let profile = load_raw_toml(&dev_path).unwrap().unwrap();
deep_merge(&mut merged, profile);
let toml_str = toml::to_string(&merged).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.server.port, 3000); assert_eq!(config.database.pool_size, 10); assert_eq!(
config.database.url.as_deref(),
Some("postgres://localhost/myapp_dev")
); }
#[test]
fn inline_profile_section_overrides_base_toml() {
let mut merged = toml::Value::Table(toml::map::Map::new());
let base: toml::Value = toml::from_str(
r#"
[server]
port = 3000
[log]
level = "info"
[profile.dev.log]
level = "debug"
"#,
)
.unwrap();
deep_merge(&mut merged, base.clone());
let inline = profile_section_from_base_toml(&base, "dev").unwrap();
deep_merge(&mut merged, inline);
let toml_str = toml::to_string(&merged).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.server.port, 3000);
assert_eq!(config.log.level, "debug");
}
#[test]
fn levenshtein_basic() {
assert_eq!(levenshtein("dev", "dev"), 0);
assert_eq!(levenshtein("dev", "dve"), 2); assert_eq!(levenshtein("prod", "prodd"), 1);
assert_eq!(levenshtein("prod", "prd"), 1);
assert_eq!(levenshtein("staging", "dev"), 7);
}
#[test]
fn env_override_health_detailed() {
let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.health.detailed);
}
#[test]
fn profile_name_accessor() {
let mut config = AutumnConfig::default();
assert!(config.profile_name().is_none());
config.profile = Some("dev".to_owned());
assert_eq!(config.profile_name(), Some("dev"));
}
#[test]
fn find_config_file_falls_back_to_cwd() {
let env = MockEnv::new();
let path = find_config_file_named("autumn.toml", &env);
assert_eq!(path, PathBuf::from("autumn.toml"));
}
#[test]
fn find_config_file_uses_manifest_dir_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("autumn.toml");
std::fs::write(&config_path, "").unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let path = find_config_file_named("autumn.toml", &env);
assert_eq!(path, config_path);
}
#[test]
fn find_config_file_falls_back_when_manifest_dir_missing_file() {
let dir = tempfile::tempdir().unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let path = find_config_file_named("nonexistent.toml", &env);
assert_eq!(path, PathBuf::from("nonexistent.toml"));
}
#[test]
fn resolve_profile_cli_flag_exact_match() {
let env = MockEnv::new();
let profile = resolve_profile(&env);
drop(profile);
}
#[test]
fn deep_merge_non_table_overlay_replaces_base() {
let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
let overlay = toml::Value::String("not_a_table".into());
deep_merge(&mut base, overlay);
assert!(base.is_table());
assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
}
#[test]
fn deep_merge_when_base_not_table() {
let mut base = toml::Value::String("original".into());
let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base, toml::Value::String("original".into()));
}
#[test]
fn suggest_profile_close_match() {
assert_eq!(suggest_profile("dve"), Some("dev"));
}
#[test]
fn suggest_profile_no_match_when_distant() {
assert_eq!(suggest_profile("xyz"), None);
}
#[test]
fn suggest_profile_exact_known_profile() {
assert_eq!(suggest_profile("dev"), Some("dev"));
assert_eq!(suggest_profile("prod"), Some("prod"));
}
#[test]
fn suggest_profile_prd() {
assert_eq!(suggest_profile("prd"), Some("prod"));
}
#[test]
fn warn_profile_typo_runs_without_panic() {
warn_profile_typo("dve");
warn_profile_typo("xyz");
}
#[test]
fn should_warn_missing_profile_file_custom_without_inline() {
assert!(should_warn_missing_profile_file("staging", false));
}
#[test]
fn should_not_warn_missing_profile_file_custom_with_inline() {
assert!(!should_warn_missing_profile_file("staging", true));
}
#[test]
fn should_not_warn_missing_profile_file_dev_or_prod() {
assert!(!should_warn_missing_profile_file("dev", false));
assert!(!should_warn_missing_profile_file("prod", false));
}
#[test]
fn levenshtein_threshold_in_warn_profile_typo() {
assert!(levenshtein("dve", "dev") <= 2);
assert!(levenshtein("xyz", "dev") > 2);
assert!(levenshtein("xyz", "prod") > 2);
}
#[test]
fn env_override_cors_allowed_origins() {
let env = MockEnv::new().with(
"AUTUMN_CORS__ALLOWED_ORIGINS",
"https://a.com, https://b.com",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.cors.allowed_origins,
vec!["https://a.com", "https://b.com"]
);
}
#[test]
fn env_override_cors_allow_credentials() {
let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.cors.allow_credentials);
}
#[test]
fn env_override_cors_max_age() {
let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.cors.max_age_secs, 3600);
}
#[test]
fn cors_validate_rejects_wildcard_with_credentials() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["*".to_owned()];
config.cors.allow_credentials = true;
let result = config.validate();
match result {
Err(ConfigError::Validation(msg)) => {
assert!(
msg.contains("allow_credentials") && msg.contains('*'),
"message should mention credentials and wildcard, got: {msg}"
);
}
other => panic!("expected ConfigError::Validation, got {other:?}"),
}
}
#[test]
fn cors_validate_accepts_wildcard_without_credentials() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["*".to_owned()];
config.cors.allow_credentials = false;
assert!(config.validate().is_ok());
}
#[test]
fn cors_validate_accepts_explicit_origins_with_credentials() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
config.cors.allow_credentials = true;
assert!(config.validate().is_ok());
}
#[test]
fn load_uses_profile_layering() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("dev"));
assert_eq!(config.log.level, "debug"); assert_eq!(config.log.format, LogFormat::Pretty); assert!(config.health.detailed); }
#[test]
fn load_custom_profile_without_toml_warns() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("staging"));
assert_eq!(config.server.port, 3000);
assert_eq!(config.log.level, "info");
}
#[test]
fn load_dev_profile_no_profile_toml_no_warn() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("dev"));
}
#[test]
fn load_custom_profile_uses_inline_profile_without_legacy_file() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("autumn.toml");
std::fs::write(
&base_path,
r"
[server]
port = 3000
[profile.staging.server]
port = 4100
",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "staging")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("staging"));
assert_eq!(config.server.port, 4100);
}
#[test]
fn load_production_profile_reads_inline_profile_production_section() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("autumn.toml");
std::fs::write(
&base_path,
r"
[profile.production.server]
port = 4200
",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "production")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4200);
}
#[test]
fn load_production_profile_reads_legacy_autumn_production_toml() {
let dir = tempfile::tempdir().unwrap();
let production_path = dir.path().join("autumn-production.toml");
std::fs::write(
&production_path,
r"
[server]
port = 4300
",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "production")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4300);
}
#[test]
fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
let dir = tempfile::tempdir().unwrap();
let prod_path = dir.path().join("autumn-prod.toml");
let production_path = dir.path().join("autumn-production.toml");
std::fs::write(
&prod_path,
r"
[server]
port = 4400
",
)
.unwrap();
std::fs::write(&production_path, "[server\nport = 4500").unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "prod")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4400);
}
#[test]
fn load_production_prefers_autumn_production_toml_before_prod_alias() {
let dir = tempfile::tempdir().unwrap();
let prod_path = dir.path().join("autumn-prod.toml");
let production_path = dir.path().join("autumn-production.toml");
std::fs::write(
&production_path,
r"
[server]
port = 4500
",
)
.unwrap();
std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "production")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4500);
}
#[test]
fn load_from_io_error_is_not_swallowed() {
let dir = tempfile::tempdir().unwrap();
let result = AutumnConfig::load_from(dir.path());
assert!(result.is_err());
}
#[test]
fn load_raw_toml_missing_file_returns_none() {
let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
assert!(result.is_none());
}
#[test]
fn load_raw_toml_directory_returns_io_error() {
let dir = tempfile::tempdir().unwrap();
let result = load_raw_toml(dir.path());
assert!(result.is_err());
}
#[test]
fn load_raw_toml_valid_file_returns_some() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.toml");
std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
let result = load_raw_toml(&path).unwrap();
assert!(result.is_some());
assert_eq!(
result.unwrap()["server"]["port"],
toml::Value::Integer(3000)
);
}
#[test]
fn env_override_log_format_auto() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
let mut config = AutumnConfig::default();
config.log.format = LogFormat::Json;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Auto);
}
#[test]
fn env_override_health_detailed_false() {
let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
let mut config = AutumnConfig::default();
config.health.detailed = true; config.apply_env_overrides_with_env(&env);
assert!(!config.health.detailed);
}
#[test]
fn env_override_health_detailed_zero() {
let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
let mut config = AutumnConfig::default();
config.health.detailed = true;
config.apply_env_overrides_with_env(&env);
assert!(!config.health.detailed);
}
#[test]
fn cors_defaults() {
let cors = CorsConfig::default();
assert!(cors.allowed_origins.is_empty());
assert_eq!(cors.allowed_methods.len(), 6);
assert!(cors.allowed_methods.contains(&"GET".to_owned()));
assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
assert!(!cors.allow_credentials);
assert_eq!(cors.max_age_secs, 86400);
}
#[test]
fn cors_in_full_config_defaults() {
let config = AutumnConfig::default();
assert!(config.cors.allowed_origins.is_empty());
}
#[test]
fn actuator_defaults() {
let config = ActuatorConfig::default();
assert_eq!(config.prefix, "/actuator");
assert!(!config.sensitive);
}
#[test]
fn actuator_prefix_in_full_config() {
let config = AutumnConfig::default();
assert_eq!(config.actuator.prefix, "/actuator");
}
#[test]
fn deep_merge_handles_deep_nesting() {
let mut base = toml::Value::Table(toml::map::Map::new());
let mut overlay = toml::Value::Table(toml::map::Map::new());
let mut current_base = &mut base;
let mut current_overlay = &mut overlay;
for _ in 0..10_000 {
if let toml::Value::Table(t) = current_base {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_base = t.get_mut("x").unwrap();
}
if let toml::Value::Table(t) = current_overlay {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_overlay = t.get_mut("x").unwrap();
}
}
if let toml::Value::Table(t) = current_overlay {
t.insert("y".to_owned(), toml::Value::Integer(42));
}
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(move || {
deep_merge(&mut base, overlay);
std::mem::forget(base);
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn deep_merge_stops_at_max_depth() {
let mut base = toml::Value::Table(toml::map::Map::new());
let mut overlay = toml::Value::Table(toml::map::Map::new());
let mut current_base = &mut base;
let mut current_overlay = &mut overlay;
for _ in 0..=MAX_MERGE_DEPTH {
if let toml::Value::Table(t) = current_base {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_base = t.get_mut("x").unwrap();
}
if let toml::Value::Table(t) = current_overlay {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_overlay = t.get_mut("x").unwrap();
}
}
if let toml::Value::Table(t) = current_overlay {
t.insert("deep_value".to_owned(), toml::Value::Integer(123));
}
deep_merge(&mut base, overlay);
let mut current_base_check = &base;
for _ in 0..=MAX_MERGE_DEPTH {
if let toml::Value::Table(t) = current_base_check {
current_base_check = t.get("x").unwrap();
}
}
if let toml::Value::Table(t) = current_base_check {
assert!(
!t.contains_key("deep_value"),
"Value beyond MAX_MERGE_DEPTH should not be merged"
);
} else {
panic!("Expected a table");
}
}
}