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 Ok(override_val) = std::env::var(key) {
return Ok(override_val);
}
if let Some(dir) = MACRO_MANIFEST_DIR.get() {
return Ok(dir.clone());
}
} else if key == "AUTUMN_IS_DEBUG"
&& 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"
&& 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()
}
#[must_use]
pub 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],
}
}
#[must_use]
pub 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));
server.insert("prestop_grace_secs".into(), toml::Value::Integer(0));
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));
let mut storage = toml::map::Map::new();
storage.insert("backend".into(), "local".into());
table.insert("storage".into(), toml::Value::Table(storage));
let mut trusted_proxies = toml::map::Map::new();
trusted_proxies.insert("trust_forwarded_headers".into(), toml::Value::Boolean(true));
trusted_proxies.insert(
"ranges".into(),
toml::Value::Array(vec![
toml::Value::String("127.0.0.0/8".to_owned()),
toml::Value::String("::1/128".to_owned()),
]),
);
let mut security = toml::map::Map::new();
security.insert(
"trusted_proxies".into(),
toml::Value::Table(trusted_proxies),
);
table.insert("security".into(), toml::Value::Table(security));
}
"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));
let mut timeouts = toml::map::Map::new();
timeouts.insert("request_timeout_ms".into(), toml::Value::Integer(30_000));
server.insert("timeouts".into(), toml::Value::Table(timeouts));
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)
}
#[cfg(feature = "mail")]
fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
merged
.get("mail")
.and_then(toml::Value::as_table)
.is_some_and(|mail| mail.contains_key("transport"))
|| env
.var("AUTUMN_MAIL__TRANSPORT")
.ok()
.as_deref()
.is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
}
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
}
#[must_use]
pub 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, Clone, Copy, PartialEq, Eq)]
pub struct DeprecatedKey {
pub path: &'static str,
pub replacement: Option<&'static str>,
pub since: &'static str,
pub remove_in: &'static str,
}
pub static DEPRECATED_CONFIG_KEYS: &[DeprecatedKey] = &[
DeprecatedKey {
path: "security.rate_limit.trusted_proxies",
replacement: Some("security.trusted_proxies.ranges"),
since: "0.5.0",
remove_in: "1.0.0",
},
DeprecatedKey {
path: "security.rate_limit.trust_forwarded_headers",
replacement: Some("security.trusted_proxies.trust_forwarded_headers"),
since: "0.5.0",
remove_in: "1.0.0",
},
];
#[must_use]
pub fn deprecated_config_keys() -> &'static [DeprecatedKey] {
DEPRECATED_CONFIG_KEYS
}
#[must_use]
pub fn deprecated_env_var_name(path: &str) -> String {
format!("AUTUMN_{}", path.to_uppercase().replace('.', "__"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeprecationSource {
Toml,
Env,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeprecationFinding {
pub path: String,
pub replacement: Option<String>,
pub since: String,
pub remove_in: String,
pub source: DeprecationSource,
}
fn toml_path_present(table: &toml::Table, path: &str) -> bool {
let mut current_table = table;
let mut segments = path.split('.').peekable();
while let Some(segment) = segments.next() {
if segments.peek().is_none() {
return current_table.contains_key(segment);
}
match current_table.get(segment) {
Some(toml::Value::Table(next)) => current_table = next,
_ => return false,
}
}
false
}
#[must_use]
pub fn detect_deprecated_keys(
merged: &toml::Table,
env: &dyn Env,
registry: &[DeprecatedKey],
) -> Vec<DeprecationFinding> {
let mut findings = Vec::new();
for entry in registry {
let in_toml = toml_path_present(merged, entry.path);
let env_name = deprecated_env_var_name(entry.path);
let in_env = env.var(&env_name).is_ok();
let source = match (in_toml, in_env) {
(false, false) => continue,
(true, false) => DeprecationSource::Toml,
(false, true) => DeprecationSource::Env,
(true, true) => DeprecationSource::Both,
};
findings.push(DeprecationFinding {
path: entry.path.to_owned(),
replacement: entry.replacement.map(str::to_owned),
since: entry.since.to_owned(),
remove_in: entry.remove_in.to_owned(),
source,
});
}
findings
}
#[must_use]
pub fn detect_deprecated_keys_for(
profile: &str,
file_table: &toml::Table,
env: &dyn Env,
registry: &[DeprecatedKey],
) -> Vec<DeprecationFinding> {
let mut merged = profile_defaults_as_toml(profile);
deep_merge(&mut merged, toml::Value::Table(file_table.clone()));
let empty_table = toml::Table::new();
let merged_table = merged.as_table().unwrap_or(&empty_table);
detect_deprecated_keys(merged_table, env, registry)
}
#[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),
#[error("credentials error: {0}")]
Credentials(String),
#[error("dotenv error: {0}")]
Dotenv(String),
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BackupConfig {
#[serde(default)]
pub offsite: Option<Box<OffsiteBackupConfig>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OffsiteBackupConfig {
#[serde(default)]
pub s3: OffsiteS3Config,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub keep: Option<usize>,
#[serde(default)]
pub auto_upload: bool,
#[serde(default)]
pub allow_shared_bucket: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OffsiteS3Config {
#[serde(default)]
pub bucket: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub access_key_id_env: Option<String>,
#[serde(default)]
pub secret_access_key_env: Option<String>,
#[serde(default)]
pub force_path_style: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AutumnConfig {
#[serde(skip)]
pub profile: Option<String>,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub deploy: Option<DeployConfig>,
#[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 cache: CacheConfig,
#[serde(default)]
pub tenancy: TenancyConfig,
#[serde(default)]
pub idempotency: IdempotencyConfig,
#[serde(default)]
pub channels: ChannelConfig,
#[serde(default)]
pub jobs: JobConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub role: ProcessRole,
#[serde(default)]
pub auth: crate::auth::AuthConfig,
#[serde(default)]
pub security: crate::security::config::SecurityConfig,
#[cfg(feature = "i18n")]
#[serde(default)]
pub i18n: crate::i18n::I18nConfig,
#[serde(default)]
pub time_zone: crate::time_zone::TimeZoneConfig,
#[cfg(feature = "storage")]
#[serde(default)]
pub storage: crate::storage::StorageConfig,
#[serde(default)]
pub backup: BackupConfig,
#[cfg(feature = "mail")]
#[serde(default)]
pub mail: crate::mail::MailConfig,
#[serde(default, rename = "openapi")]
pub openapi_runtime: OpenApiRuntimeConfig,
#[serde(skip)]
pub credentials: crate::credentials::CredentialsStore,
#[cfg(feature = "http-client")]
#[serde(default, rename = "http")]
pub http: HttpConfig,
#[serde(default)]
pub dev: DevConfig,
#[cfg(feature = "maud")]
#[serde(default)]
pub stories: crate::stories::StoriesConfig,
#[cfg(feature = "reporting")]
#[serde(default)]
pub reporting: ReportingConfig,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub bot_protection: crate::security::captcha::BotProtectionConfig,
#[serde(default)]
pub resilience: ResilienceConfig,
#[serde(default)]
pub seo: SeoConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub alerts: Box<crate::alerts::AlertConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DeployTlsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeployConfig {
#[serde(default)]
pub host: Option<String>,
#[serde(default = "default_deploy_user")]
pub user: String,
#[serde(default = "default_deploy_ssh_port")]
pub ssh_port: u16,
#[serde(default)]
pub app_name: Option<String>,
#[serde(default)]
pub app_dir: Option<String>,
#[serde(default)]
pub service_name: Option<String>,
#[serde(default = "default_deploy_readiness_timeout_secs")]
pub readiness_timeout_secs: u64,
#[serde(default = "default_deploy_keep_releases")]
pub keep_releases: u32,
#[serde(default = "default_deploy_profile")]
pub profile: String,
#[serde(default)]
pub tls: DeployTlsConfig,
}
impl Default for DeployConfig {
fn default() -> Self {
Self {
host: None,
user: default_deploy_user(),
ssh_port: default_deploy_ssh_port(),
app_name: None,
app_dir: None,
service_name: None,
readiness_timeout_secs: default_deploy_readiness_timeout_secs(),
keep_releases: default_deploy_keep_releases(),
profile: default_deploy_profile(),
tls: DeployTlsConfig::default(),
}
}
}
impl DeployConfig {
pub fn validate(&self) -> Result<(), String> {
match self.host.as_deref() {
Some(host) if !host.trim().is_empty() => Ok(()),
_ => Err(
"[deploy] requires a target host: set `[deploy] host = \"<address>\"` in \
autumn.toml to the SSH-reachable hostname or IP of your server"
.to_owned(),
),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
pub struct ObservabilityConfig {
#[serde(default)]
pub server_timing: Option<bool>,
}
pub(crate) fn server_timing_enabled(cfg: &AutumnConfig) -> bool {
if let Some(explicit) = cfg.observability.server_timing {
return explicit;
}
matches!(cfg.profile.as_deref(), Some("dev" | "development"))
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SeoConfig {
pub base_url: Option<String>,
#[serde(default)]
pub robots: RobotsConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RobotsConfig {
pub allow_all: Option<bool>,
#[serde(default)]
pub additional_rules: Vec<String>,
pub sitemap_url: Option<String>,
}
#[cfg(feature = "reporting")]
#[derive(Debug, Clone, Deserialize)]
pub struct ReportingConfig {
#[serde(default = "default_reporting_enabled")]
pub enabled: bool,
#[serde(default = "default_reporting_sample_rate")]
pub sample_rate: f64,
}
#[cfg(feature = "reporting")]
impl Default for ReportingConfig {
fn default() -> Self {
Self {
enabled: default_reporting_enabled(),
sample_rate: default_reporting_sample_rate(),
}
}
}
#[cfg(feature = "reporting")]
const fn default_reporting_enabled() -> bool {
true
}
#[cfg(feature = "reporting")]
const fn default_reporting_sample_rate() -> f64 {
1.0
}
#[derive(Debug, Clone, Deserialize)]
pub struct DevConfig {
#[serde(default = "default_inspector_path")]
pub inspector_path: String,
#[serde(default = "default_inspector_capacity")]
pub inspector_capacity: usize,
#[serde(default = "default_inspector_n_plus_one_threshold")]
pub inspector_n_plus_one_threshold: usize,
}
impl Default for DevConfig {
fn default() -> Self {
Self {
inspector_path: default_inspector_path(),
inspector_capacity: default_inspector_capacity(),
inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
}
}
}
fn default_inspector_path() -> String {
"/_autumn/inspect".to_owned()
}
const fn default_inspector_capacity() -> usize {
100
}
const fn default_inspector_n_plus_one_threshold() -> usize {
crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct HttpConfig {
#[serde(default)]
pub client: HttpClientConfig,
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, Deserialize)]
pub struct HttpClientConfig {
#[serde(default = "default_http_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_http_max_retries")]
pub max_retries: u32,
#[serde(default = "default_http_max_retry_after_secs")]
pub max_retry_after_secs: u64,
#[serde(default)]
pub base_urls: std::collections::HashMap<String, String>,
}
#[cfg(feature = "http-client")]
const fn default_http_timeout_secs() -> u64 {
30
}
#[cfg(feature = "http-client")]
const fn default_http_max_retries() -> u32 {
3
}
#[cfg(feature = "http-client")]
const fn default_http_max_retry_after_secs() -> u64 {
10
}
#[cfg(feature = "http-client")]
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
timeout_secs: default_http_timeout_secs(),
max_retries: default_http_max_retries(),
max_retry_after_secs: default_http_max_retry_after_secs(),
base_urls: std::collections::HashMap::new(),
}
}
}
impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
type Rejection = crate::AutumnError;
async fn from_request_parts(
_parts: &mut http::request::Parts,
state: &crate::AppState,
) -> Result<Self, Self::Rejection> {
state
.extension::<Self>()
.as_deref()
.cloned()
.ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelBackend {
#[serde(alias = "local", alias = "memory")]
#[default]
InProcess,
Redis,
}
impl ChannelBackend {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
"redis" => Some(Self::Redis),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelConfig {
#[serde(default)]
pub backend: ChannelBackend,
#[serde(default = "default_channel_capacity")]
pub capacity: usize,
#[serde(default = "default_channel_replay_buffer")]
pub replay_buffer: usize,
#[serde(default)]
pub redis: ChannelRedisConfig,
}
impl Default for ChannelConfig {
fn default() -> Self {
Self {
backend: ChannelBackend::default(),
capacity: default_channel_capacity(),
replay_buffer: default_channel_replay_buffer(),
redis: ChannelRedisConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelRedisConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_channels_redis_prefix")]
pub key_prefix: String,
}
impl Default for ChannelRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_channels_redis_prefix(),
}
}
}
const fn default_channel_capacity() -> usize {
32
}
const fn default_channel_replay_buffer() -> usize {
256
}
fn default_channels_redis_prefix() -> String {
"autumn:channels".to_owned()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CacheBackend {
#[default]
Memory,
Redis,
}
impl CacheBackend {
pub(crate) fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"memory" => Some(Self::Memory),
"redis" => Some(Self::Redis),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct CacheConfig {
#[serde(default)]
pub backend: CacheBackend,
#[serde(default)]
pub redis: CacheRedisConfig,
}
impl CacheConfig {
#[must_use]
pub fn is_memory(&self) -> bool {
self.backend == CacheBackend::Memory
}
#[must_use]
pub fn is_redis(&self) -> bool {
self.backend == CacheBackend::Redis
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CacheRedisConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_cache_redis_key_prefix")]
pub key_prefix: String,
}
impl Default for CacheRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_cache_redis_key_prefix(),
}
}
}
fn default_cache_redis_key_prefix() -> String {
"autumn:cache".to_owned()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerBackend {
#[serde(alias = "local", alias = "memory")]
#[default]
InProcess,
Postgres,
}
impl SchedulerBackend {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
"postgres" | "postgresql" => Some(Self::Postgres),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProcessRole {
#[serde(
alias = "all",
alias = "combined",
alias = "web_and_worker",
alias = "server_and_worker"
)]
#[default]
Combined,
#[serde(alias = "server", alias = "http")]
Web,
#[serde(alias = "jobs", alias = "worker_only")]
Worker,
}
impl ProcessRole {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"combined" | "all" | "web_and_worker" | "server_and_worker" => Some(Self::Combined),
"web" | "server" | "http" => Some(Self::Web),
"worker" | "jobs" | "worker_only" => Some(Self::Worker),
_ => None,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Combined => "combined",
Self::Web => "web",
Self::Worker => "worker",
}
}
#[must_use]
pub const fn serves_http(self) -> bool {
matches!(self, Self::Combined | Self::Web)
}
#[must_use]
pub const fn runs_workers(self) -> bool {
matches!(self, Self::Combined | Self::Worker)
}
}
#[must_use]
pub fn split_role_requires_durable_backend(role: ProcessRole, jobs_backend: &str) -> bool {
role != ProcessRole::Combined && !matches!(jobs_backend, "postgres" | "redis")
}
#[derive(Debug, Clone, Deserialize)]
pub struct SchedulerConfig {
#[serde(default)]
pub backend: SchedulerBackend,
#[serde(default = "default_scheduler_lease_ttl_secs")]
pub lease_ttl_secs: u64,
#[serde(default)]
pub replica_id: Option<String>,
#[serde(default = "default_scheduler_key_prefix")]
pub key_prefix: String,
}
impl SchedulerConfig {
#[must_use]
pub fn resolved_replica_id(&self) -> String {
self.replica_id
.as_ref()
.filter(|id| !id.trim().is_empty())
.cloned()
.or_else(|| std::env::var("FLY_MACHINE_ID").ok())
.or_else(|| std::env::var("HOSTNAME").ok())
.unwrap_or_else(|| format!("pid-{}", std::process::id()))
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.lease_ttl_secs == 0 {
return Err(ConfigError::Validation(
"scheduler.lease_ttl_secs must be greater than zero".to_owned(),
));
}
if self.key_prefix.trim().is_empty() {
return Err(ConfigError::Validation(
"scheduler.key_prefix must not be empty".to_owned(),
));
}
Ok(())
}
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
backend: SchedulerBackend::default(),
lease_ttl_secs: default_scheduler_lease_ttl_secs(),
replica_id: None,
key_prefix: default_scheduler_key_prefix(),
}
}
}
const fn default_scheduler_lease_ttl_secs() -> u64 {
300
}
fn default_scheduler_key_prefix() -> String {
"autumn:scheduler".to_owned()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum IdempotencyBackend {
#[default]
Memory,
Redis,
}
impl IdempotencyBackend {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"memory" | "mem" => Some(Self::Memory),
"redis" => Some(Self::Redis),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct IdempotencyRedisConfig {
pub url: Option<String>,
#[serde(default = "default_idempotency_redis_key_prefix")]
pub key_prefix: String,
}
impl Default for IdempotencyRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_idempotency_redis_key_prefix(),
}
}
}
fn default_idempotency_redis_key_prefix() -> String {
"autumn:idempotency".to_owned()
}
#[derive(Debug, Clone, Deserialize)]
pub struct IdempotencyConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: IdempotencyBackend,
#[serde(default = "default_idempotency_ttl_secs")]
pub ttl_secs: u64,
#[serde(default = "default_idempotency_in_flight_ttl_secs")]
pub in_flight_ttl_secs: u64,
#[serde(default)]
pub allow_memory_in_production: bool,
#[serde(default)]
pub redis: IdempotencyRedisConfig,
}
impl Default for IdempotencyConfig {
fn default() -> Self {
Self {
enabled: None,
backend: IdempotencyBackend::default(),
ttl_secs: default_idempotency_ttl_secs(),
in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
allow_memory_in_production: false,
redis: IdempotencyRedisConfig::default(),
}
}
}
const fn default_idempotency_ttl_secs() -> u64 {
86_400
}
const fn default_idempotency_in_flight_ttl_secs() -> u64 {
86_400
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiRuntimeConfig {
#[serde(default = "default_openapi_enabled")]
pub enabled: bool,
#[serde(default = "default_openapi_path")]
pub path: String,
}
impl Default for OpenApiRuntimeConfig {
fn default() -> Self {
Self {
enabled: default_openapi_enabled(),
path: default_openapi_path(),
}
}
}
const fn default_openapi_enabled() -> bool {
true
}
fn default_openapi_path() -> String {
"/openapi.json".to_owned()
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobConfig {
#[serde(default = "default_job_backend")]
pub backend: String,
#[serde(default = "default_job_workers")]
pub workers: usize,
#[serde(default = "default_job_max_attempts")]
pub max_attempts: u32,
#[serde(default = "default_job_backoff_ms")]
pub initial_backoff_ms: u64,
#[serde(default)]
pub queues: JobQueuesConfig,
#[serde(default)]
pub pin: Vec<String>,
#[serde(default)]
pub redis: JobRedisConfig,
#[serde(default)]
pub postgres: JobPostgresConfig,
#[serde(default)]
pub tracking: JobTrackingConfig,
}
impl Default for JobConfig {
fn default() -> Self {
Self {
backend: default_job_backend(),
workers: default_job_workers(),
max_attempts: default_job_max_attempts(),
initial_backoff_ms: default_job_backoff_ms(),
queues: JobQueuesConfig::default(),
pin: Vec::new(),
redis: JobRedisConfig::default(),
postgres: JobPostgresConfig::default(),
tracking: JobTrackingConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobQueue {
pub name: String,
pub weight: u32,
pub concurrency: Option<usize>,
pub reserved: Option<usize>,
}
impl JobQueue {
#[must_use]
pub fn new(name: impl Into<String>, weight: u32) -> Self {
Self {
name: name.into(),
weight,
concurrency: None,
reserved: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobQueuesConfig {
pub queues: Vec<JobQueue>,
pub strict: bool,
}
impl Default for JobQueuesConfig {
fn default() -> Self {
Self::single_default()
}
}
impl JobQueuesConfig {
#[must_use]
pub fn single_default() -> Self {
Self {
queues: vec![JobQueue::new("default", 1)],
strict: true,
}
}
#[must_use]
pub fn strict_list<I, S>(names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let queues: Vec<JobQueue> = names
.into_iter()
.map(|name| JobQueue::new(name, 1))
.collect();
if queues.is_empty() {
Self::single_default()
} else {
Self {
queues,
strict: true,
}
}
}
#[must_use]
pub fn weighted<I, S>(entries: I) -> Self
where
I: IntoIterator<Item = (S, u32)>,
S: Into<String>,
{
let queues: Vec<JobQueue> = entries
.into_iter()
.map(|(name, weight)| JobQueue::new(name, weight.max(1)))
.collect();
if queues.is_empty() {
Self::single_default()
} else {
Self {
queues,
strict: false,
}
}
}
#[must_use]
pub fn weighted_specs(queues: Vec<JobQueue>) -> Self {
if queues.is_empty() {
Self::single_default()
} else {
Self {
queues: queues
.into_iter()
.map(|mut q| {
q.weight = q.weight.max(1);
q
})
.collect(),
strict: false,
}
}
}
}
#[derive(Debug, Clone)]
enum JobQueueValue {
Weight(u32),
Spec {
weight: Option<u32>,
concurrency: Option<usize>,
reserved: Option<usize>,
},
}
impl<'de> serde::Deserialize<'de> for JobQueueValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{MapAccess, Visitor};
use std::fmt;
struct ValueVisitor;
impl<'de> Visitor<'de> for ValueVisitor {
type Value = JobQueueValue;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(
"a queue weight (e.g. critical = 4) or a queue table \
(e.g. critical = { weight = 4, concurrency = 8, reserved = 2 })",
)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(JobQueueValue::Weight(
u32::try_from(v).map_err(|_| E::custom("queue weight is too large"))?,
))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
if v < 0 {
return Err(E::custom("queue weight must not be negative"));
}
self.visit_u64(u64::try_from(v).unwrap_or(0))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut weight = None;
let mut concurrency = None;
let mut reserved = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"weight" => weight = Some(map.next_value::<u32>()?),
"concurrency" => concurrency = Some(map.next_value::<usize>()?),
"reserved" => reserved = Some(map.next_value::<usize>()?),
other => {
return Err(serde::de::Error::custom(format!(
"unknown queue setting '{other}' (expected weight, concurrency, \
or reserved)"
)));
}
}
}
Ok(JobQueueValue::Spec {
weight,
concurrency,
reserved,
})
}
}
d.deserialize_any(ValueVisitor)
}
}
impl<'de> serde::Deserialize<'de> for JobQueuesConfig {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;
struct JobQueuesVisitor;
impl<'de> Visitor<'de> for JobQueuesVisitor {
type Value = JobQueuesConfig;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(
"an ordered list of queue names (e.g. queues = [\"critical\", \"default\"]) \
or a weight table (e.g. [jobs.queues] critical = 4, default = 1)",
)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut names = Vec::new();
let mut seen = std::collections::HashSet::new();
while let Some(name) = seq.next_element::<String>()? {
if !seen.insert(name.clone()) {
return Err(serde::de::Error::custom(format!(
"duplicate queue name '{name}' in queues list"
)));
}
names.push(name);
}
Ok(JobQueuesConfig::strict_list(names))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut queues: Vec<JobQueue> = Vec::new();
while let Some((k, value)) = map.next_entry::<String, JobQueueValue>()? {
let (weight, concurrency, reserved) = match value {
JobQueueValue::Weight(w) => (w, None, None),
JobQueueValue::Spec {
weight,
concurrency,
reserved,
} => (weight.unwrap_or(1), concurrency, reserved),
};
if weight == 0 {
return Err(serde::de::Error::custom(format!(
"queue '{k}' weight must be at least 1 (got 0); \
to disable a queue remove it from the list"
)));
}
queues.push(JobQueue {
name: k,
weight,
concurrency,
reserved,
});
}
Ok(JobQueuesConfig::weighted_specs(queues))
}
}
d.deserialize_any(JobQueuesVisitor)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobRedisConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_jobs_redis_prefix")]
pub key_prefix: String,
#[serde(default = "default_jobs_redis_visibility_timeout_ms")]
pub visibility_timeout_ms: u64,
}
impl Default for JobRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_jobs_redis_prefix(),
visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobPostgresConfig {
#[serde(default = "default_jobs_pg_visibility_timeout_ms")]
pub visibility_timeout_ms: u64,
}
impl Default for JobPostgresConfig {
fn default() -> Self {
Self {
visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobTrackingConfig {
#[serde(default = "default_jobs_tracking_ttl_secs")]
pub ttl_secs: u64,
#[serde(default = "default_jobs_tracking_route_enabled")]
pub route_enabled: bool,
}
impl Default for JobTrackingConfig {
fn default() -> Self {
Self {
ttl_secs: default_jobs_tracking_ttl_secs(),
route_enabled: default_jobs_tracking_route_enabled(),
}
}
}
const fn default_jobs_tracking_ttl_secs() -> u64 {
86_400
}
const fn default_jobs_tracking_route_enabled() -> bool {
true
}
const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
30_000
}
fn default_job_backend() -> String {
"local".to_owned()
}
const fn default_job_workers() -> usize {
1
}
const fn default_job_max_attempts() -> u32 {
5
}
const fn default_job_backoff_ms() -> u64 {
250
}
fn default_jobs_redis_prefix() -> String {
"autumn:jobs".to_owned()
}
const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
30_000
}
const PRE_1890_STRICT_PARENTS: &[&str] = &[
"",
"database",
"deploy",
"server",
"server.timeouts",
"server.tls",
"server.tls.acme",
];
fn unknown_key_was_previously_strict(schema_parent: &str) -> bool {
schema_parent == "profile" || PRE_1890_STRICT_PARENTS.contains(&schema_parent)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UnknownRootPolicy {
Strict,
LenientWarn,
}
const MANUAL_SCHEMA_SECTIONS: &[(&str, &[&str])] = &[
("time_zone", &["identifier", "sources"]),
];
impl AutumnConfig {
#[must_use]
#[allow(clippy::significant_drop_tightening)]
pub fn get_schema_keys() -> HashMap<String, HashSet<String>> {
const MAX_PASSES: usize = 8;
let de = SchemaDeserializer::new();
let mut prev_rejected: Vec<String> = Vec::new();
for _ in 0..MAX_PASSES {
de.rejected
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
let _ = Self::deserialize(de.clone());
let mut rejected: Vec<String> = std::mem::take(
&mut de
.rejected
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
rejected.sort();
rejected.dedup();
if rejected.is_empty() {
break;
}
let mut advanced = false;
{
let mut probes = de
.any_probe
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for p in &rejected {
let cur = probes.get(p).copied().unwrap_or(AnyProbe::Str);
let next = match cur {
AnyProbe::Str => AnyProbe::Map,
AnyProbe::Map | AnyProbe::Seq => AnyProbe::Seq,
};
if next != cur {
advanced = true;
}
probes.insert(p.clone(), next);
}
}
if !advanced && rejected == prev_rejected {
break;
}
prev_rejected = rejected;
}
{
let mut schema = de
.schema
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (section, keys) in MANUAL_SCHEMA_SECTIONS {
let entry = schema.entry((*section).to_owned()).or_default();
for k in *keys {
entry.insert((*k).to_owned());
}
}
}
de.into_schema()
}
#[must_use]
pub fn schema_leaf_paths() -> std::collections::BTreeSet<String> {
let schema = Self::get_schema_keys();
let mut leaves = std::collections::BTreeSet::new();
for (parent, fields) in &schema {
for field in fields {
let leaf = if parent.is_empty() {
field.clone()
} else {
format!("{parent}.{field}")
};
leaves.insert(leaf);
}
}
leaves
}
#[must_use]
pub fn validate_toml(
content: &str,
schema: &HashMap<String, HashSet<String>>,
) -> Vec<(String, Option<String>)> {
Self::validate_toml_detailed(content, schema, &BTreeSet::new())
.into_iter()
.map(|(path, sug, _parent, _is_table, _is_top_level)| (path, sug))
.collect()
}
#[must_use]
pub(crate) fn validate_toml_detailed(
content: &str,
schema: &HashMap<String, HashSet<String>>,
plugin_config_roots: &BTreeSet<String>,
) -> Vec<(String, Option<String>, String, bool, bool)> {
let Ok(table) = toml::from_str::<toml::Table>(content) else {
return Vec::new();
};
let mut errors = Vec::new();
let mut path = Vec::new();
Self::validate_toml_table(&table, &mut path, schema, plugin_config_roots, &mut errors);
errors
}
#[allow(clippy::too_many_lines)]
fn validate_toml_table(
table: &toml::Table,
path: &mut Vec<String>,
schema: &HashMap<String, HashSet<String>>,
plugin_config_roots: &BTreeSet<String>,
errors: &mut Vec<(String, Option<String>, String, bool, bool)>,
) {
let mut schema_path_parts = Vec::new();
if path.len() >= 2 && path[0] == "profile" {
schema_path_parts.extend(path[2..].iter().cloned());
} else {
schema_path_parts.extend(path.iter().cloned());
}
let schema_path = schema_path_parts.join(".");
if let Some(valid_keys) = schema.get(&schema_path) {
for (k, val) in table {
if path.is_empty() && k == "profile" {
path.push(k.clone());
match val {
toml::Value::Table(t) => {
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
}
toml::Value::Array(arr) => {
for item in arr {
if let toml::Value::Table(t) = item {
Self::validate_toml_table(
t,
path,
schema,
plugin_config_roots,
errors,
);
}
}
}
_ => {}
}
path.pop();
continue;
}
if valid_keys.contains(k) {
path.push(k.clone());
match val {
toml::Value::Table(t) => {
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
}
toml::Value::Array(arr) => {
for item in arr {
if let toml::Value::Table(t) = item {
Self::validate_toml_table(
t,
path,
schema,
plugin_config_roots,
errors,
);
}
}
}
_ => {}
}
path.pop();
} else if path.is_empty() && plugin_config_roots.contains(k) && val.is_table() {
} else {
let mut full_path_parts = path.clone();
full_path_parts.push(k.clone());
let full_path = full_path_parts.join(".");
let mut closest: Option<&str> = None;
let mut min_dist = usize::MAX;
for valid_key in valid_keys {
let dist = levenshtein(k, valid_key);
if dist <= 2 && dist < min_dist {
min_dist = dist;
closest = Some(valid_key);
}
}
let suggestion = closest.map(|c| {
let mut sug_parts = path.clone();
sug_parts.push(c.to_string());
sug_parts.join(".")
});
errors.push((
full_path,
suggestion,
schema_path.clone(),
val.is_table(),
path.is_empty(),
));
}
}
} else if path.len() == 1 && path[0] == "profile" {
for (k, val) in table {
if let toml::Value::Table(t) = val {
path.push(k.clone());
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
path.pop();
} else {
let mut full_path_parts = path.clone();
full_path_parts.push(k.clone());
errors.push((
full_path_parts.join("."),
None,
schema_path.clone(),
val.is_table(),
path.is_empty(),
));
}
}
} else if path.is_empty() {
let root_keys = schema.get("").cloned().unwrap_or_default();
for (k, val) in table {
if k == "profile" || root_keys.contains(k) {
path.push(k.clone());
match val {
toml::Value::Table(t) => {
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
}
toml::Value::Array(arr) => {
for item in arr {
if let toml::Value::Table(t) = item {
Self::validate_toml_table(
t,
path,
schema,
plugin_config_roots,
errors,
);
}
}
}
_ => {}
}
path.pop();
} else if plugin_config_roots.contains(k) && val.is_table() {
} else {
let mut closest: Option<&str> = None;
let mut min_dist = usize::MAX;
for valid_key in &root_keys {
let dist = levenshtein(k, valid_key);
if dist <= 2 && dist < min_dist {
min_dist = dist;
closest = Some(valid_key);
}
}
errors.push((
k.clone(),
closest.map(String::from),
schema_path.clone(),
val.is_table(),
path.is_empty(),
));
}
}
}
}
#[must_use]
pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
&self.credentials
}
pub fn load() -> Result<Self, ConfigError> {
Self::load_policy(UnknownRootPolicy::Strict)
}
pub fn load_lenient_unknown_roots() -> Result<Self, ConfigError> {
Self::load_policy(UnknownRootPolicy::LenientWarn)
}
fn load_policy(root_policy: UnknownRootPolicy) -> Result<Self, ConfigError> {
let base = OsEnv;
let profile = resolve_profile(&base);
let dir = crate::dotenv::dotenv_base_dir(&base);
let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
.map_err(|e| ConfigError::Dotenv(e.to_string()))?;
let env = crate::dotenv::DotenvEnv::new(&base, vars);
Self::load_with_env_and_plugin_roots_policy(&env, &BTreeSet::new(), root_policy)
}
pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
Self::load_with_env_and_plugin_roots_policy(
env,
&BTreeSet::new(),
UnknownRootPolicy::Strict,
)
}
pub fn load_with_env_and_plugin_roots(
env: &dyn Env,
plugin_config_roots: &BTreeSet<String>,
) -> Result<Self, ConfigError> {
Self::load_with_env_and_plugin_roots_policy(
env,
plugin_config_roots,
UnknownRootPolicy::Strict,
)
}
pub fn load_with_env_lenient_unknown_roots(env: &dyn Env) -> Result<Self, ConfigError> {
Self::load_with_env_and_plugin_roots_policy(
env,
&BTreeSet::new(),
UnknownRootPolicy::LenientWarn,
)
}
fn load_with_env_and_plugin_roots_policy(
env: &dyn Env,
plugin_config_roots: &BTreeSet<String>,
root_policy: UnknownRootPolicy,
) -> 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);
let is_strict_env = env
.var("AUTUMN_SERVER__STRICT_CONFIG")
.is_ok_and(|v| v == "true" || v == "1");
if config.server.strict_config || is_strict_env {
let enforce_all = config.server.strict_config_enforce_all
|| env
.var("AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL")
.is_ok_and(|v| v == "true" || v == "1");
Self::run_strict_unknown_key_check(
&toml_str,
enforce_all,
plugin_config_roots,
root_policy,
)?;
}
let empty_table = toml::Table::new();
let merged_table = merged.as_table().unwrap_or(&empty_table);
for f in detect_deprecated_keys(merged_table, env, DEPRECATED_CONFIG_KEYS) {
eprintln!(
"Warning: deprecated configuration key `{}` is still honored but will be removed \
in {}; deprecated since {} (replacement: {}; source: {:?})",
f.path,
f.remove_in,
f.since,
f.replacement.as_deref().unwrap_or("none — remove this key"),
f.source,
);
tracing::warn!(
deprecated_key = f.path.as_str(),
replacement = f.replacement.as_deref().unwrap_or("none; remove this key"),
since = f.since.as_str(),
remove_in = f.remove_in.as_str(),
source = ?f.source,
"deprecated configuration key in use; it is still honored but scheduled for removal"
);
}
#[cfg(feature = "mail")]
if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
config.mail.transport = crate::mail::Transport::Log;
}
config.validate()?;
let base_dir: PathBuf = env
.var("AUTUMN_MANIFEST_DIR")
.map_or_else(|_| PathBuf::from("."), PathBuf::from);
let cred_profile = config.profile.as_deref().unwrap_or("dev");
let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
config.credentials = crate::credentials::load_credentials_with_key_override(
cred_profile,
&base_dir,
master_key_override.as_deref(),
)
.map_err(|e| ConfigError::Credentials(e.to_string()))?;
#[cfg(feature = "oauth2")]
{
config.expand_oauth2_providers();
}
Ok(config)
}
fn run_strict_unknown_key_check(
toml_str: &str,
enforce_all: bool,
plugin_config_roots: &BTreeSet<String>,
root_policy: UnknownRootPolicy,
) -> Result<(), ConfigError> {
let schema = Self::get_schema_keys();
let errors = Self::validate_toml_detailed(toml_str, &schema, plugin_config_roots);
let mut hard_errors = Vec::new();
let mut warn_only = Vec::new();
let mut opaque_roots = Vec::new();
for (path, sug, schema_parent, is_table, is_top_level) in errors {
if root_policy == UnknownRootPolicy::LenientWarn
&& schema_parent.is_empty()
&& is_top_level
&& is_table
{
opaque_roots.push(path);
continue;
}
if enforce_all || unknown_key_was_previously_strict(&schema_parent) {
hard_errors.push((path, sug));
} else {
warn_only.push((path, sug));
}
}
if !opaque_roots.is_empty() {
let roots = opaque_roots.join(", ");
let count = opaque_roots.len();
eprintln!(
"deploy config: accepting {count} unknown top-level config section(s) as \
opaque — the deployed app runs the authoritative strict check, so each must \
be a section the app declares (e.g. a plugin config table) or the app will \
reject it at boot: {roots}. A typo here will make the app fail to start."
);
tracing::warn!(
unknown_top_level_roots = roots.as_str(),
count,
"deploy config: accepting unknown top-level config section(s) as opaque; the \
deployed app runs the authoritative strict check, so each must be a section \
the app declares (e.g. a plugin config table) or it will reject it at boot — \
a typo here will make the app fail to start (#2063)"
);
}
for (path, sug) in &warn_only {
let hint = sug
.as_deref()
.map_or_else(String::new, |s| format!(" — did you mean \"{s}\"?"));
eprintln!(
"Warning: unknown configuration key \"{path}\"{hint}. It is ignored and \
falls back to defaults. This will become a hard error in a future \
release; set server.strict_config_enforce_all = true to enforce now."
);
tracing::warn!(
unknown_key = path.as_str(),
suggestion = sug.as_deref().unwrap_or(""),
"unknown configuration key in a section newly covered by strict \
validation; ignored for now (warn-first rollout, #1890), will hard-fail \
once enforcement is promoted"
);
}
if !hard_errors.is_empty() {
let err_messages: Vec<String> = hard_errors
.into_iter()
.map(|(path, sug)| {
sug.map_or_else(
|| format!("unknown key \"{path}\""),
|s| format!("unknown key \"{path}\" — did you mean \"{s}\"?"),
)
})
.collect();
return Err(ConfigError::Validation(format!(
"Strict config check failed. Unknown keys in configuration: {}",
err_messages.join(", ")
)));
}
Ok(())
}
#[cfg(feature = "oauth2")]
fn expand_oauth2_providers(&mut self) {
let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
for name in provider_names {
if let (Some(preset), Some(p)) = (
crate::auth::provider_preset(&name),
self.auth.oauth2.providers.get_mut(&name),
) {
if p.authorize_url.is_empty() {
p.authorize_url = preset.authorize_url;
}
if p.token_url.is_empty() {
p.token_url = preset.token_url;
}
if p.userinfo_url.is_none() {
p.userinfo_url = preset.userinfo_url;
}
if p.scope.is_empty() || p.scope == "default" {
p.scope = preset.scope;
}
if p.issuer.is_none() {
p.issuer = preset.issuer;
}
if p.jwks_url.is_none() {
p.jwks_url = preset.jwks_url;
}
if p.discovery_url.is_none() {
p.discovery_url = preset.discovery_url;
}
}
if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
let normalized_name = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_lowercase();
let id_key = format!("oauth2_{normalized_name}_client_id");
if p.client_id.is_empty() {
if let Some(id) = self.credentials.get::<String>(&id_key) {
p.client_id = id;
} else if let Some(id) = self
.credentials
.get::<String>(&format!("oauth2_{name}_client_id"))
{
p.client_id = id;
}
}
let secret_key = format!("oauth2_{normalized_name}_client_secret");
if p.client_secret.is_empty() {
if let Some(secret) = self.credentials.get::<String>(&secret_key) {
p.client_secret = secret;
} else if let Some(secret) = self
.credentials
.get::<String>(&format!("oauth2_{name}_client_secret"))
{
p.client_secret = secret;
}
}
}
}
}
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()?;
self.scheduler.validate()?;
if self.database.has_shards()
&& self.database.effective_primary_url().is_none()
&& (self.scheduler.backend == SchedulerBackend::Postgres
|| self.jobs.backend == "postgres")
{
return Err(ConfigError::Validation(
"jobs/scheduler require a control database: set database.primary_url (or \
database.url) alongside [[database.shards]] — framework state such as \
autumn_jobs and scheduler locks is not sharded (see docs/guide/sharding.md)"
.to_owned(),
));
}
let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
self.security
.webhooks
.validate(is_production)
.map_err(|error| ConfigError::Validation(error.to_string()))?;
#[cfg(feature = "mail")]
self.mail.validate(self.profile.as_deref())?;
self.time_zone.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_deploy_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_cache_env_overrides_with_env(env);
self.apply_channels_env_overrides_with_env(env);
self.apply_jobs_env_overrides_with_env(env);
self.apply_scheduler_env_overrides_with_env(env);
self.apply_role_env_overrides_with_env(env);
self.apply_auth_env_overrides_with_env(env);
self.apply_security_env_overrides_with_env(env);
self.apply_bot_protection_env_overrides_with_env(env);
self.apply_idempotency_env_overrides_with_env(env);
self.apply_dev_env_overrides_with_env(env);
self.apply_observability_env_overrides_with_env(env);
self.apply_compression_env_overrides_with_env(env);
self.apply_actuator_env_overrides_with_env(env);
#[cfg(feature = "reporting")]
self.apply_reporting_env_overrides_with_env(env);
#[cfg(feature = "storage")]
self.apply_storage_env_overrides_with_env(env);
self.apply_backup_env_overrides_with_env(env);
#[cfg(feature = "mail")]
self.apply_mail_env_overrides_with_env(env);
#[cfg(feature = "maud")]
self.apply_stories_env_overrides_with_env(env);
self.apply_resilience_env_overrides_with_env(env);
self.apply_time_zone_env_overrides_with_env(env);
self.apply_alerts_env_overrides_with_env(env);
self.apply_tenancy_env_overrides_with_env(env);
}
fn apply_tenancy_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_TENANCY__ENABLED", &mut self.tenancy.enabled);
parse_env_string(env, "AUTUMN_TENANCY__SOURCE", &mut self.tenancy.source);
parse_env_string(
env,
"AUTUMN_TENANCY__HEADER_NAME",
&mut self.tenancy.header_name,
);
parse_env_string(
env,
"AUTUMN_TENANCY__SESSION_KEY",
&mut self.tenancy.session_key,
);
parse_env_string(
env,
"AUTUMN_TENANCY__JWT_CLAIM",
&mut self.tenancy.jwt_claim,
);
parse_env_option_secret(
env,
"AUTUMN_TENANCY__JWT_SECRET",
&mut self.tenancy.jwt_secret,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__JWT_ISSUER",
&mut self.tenancy.jwt_issuer,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__JWT_AUDIENCE",
&mut self.tenancy.jwt_audience,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__BASE_DOMAIN",
&mut self.tenancy.base_domain,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__LOGIN_REDIRECT",
&mut self.tenancy.login_redirect,
);
parse_env_csv(
env,
"AUTUMN_TENANCY__PUBLIC_PATHS",
&mut self.tenancy.public_paths,
);
parse_env(
env,
"AUTUMN_TENANCY__QUOTA_BYTES",
&mut self.tenancy.quota_bytes,
);
parse_env(
env,
"AUTUMN_TENANCY__MAX_CELLS",
&mut self.tenancy.max_cells,
);
parse_env(
env,
"AUTUMN_TENANCY__IDLE_TTL_SECS",
&mut self.tenancy.idle_ttl_secs,
);
}
fn apply_alerts_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_ALERTS__ENABLED", &mut self.alerts.enabled);
parse_env_option_string(env, "AUTUMN_ALERTS__EMAIL", &mut self.alerts.email);
parse_env_option_string(
env,
"AUTUMN_ALERTS__WEBHOOK_URL",
&mut self.alerts.webhook_url,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__WEBHOOK_SECRET",
&mut self.alerts.webhook_secret,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY",
&mut self.alerts.pagerduty_routing_key,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__PAGERDUTY_URL",
&mut self.alerts.pagerduty_url,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__SLACK_WEBHOOK_URL",
&mut self.alerts.slack_webhook_url,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__DISCORD_WEBHOOK_URL",
&mut self.alerts.discord_webhook_url,
);
parse_env(
env,
"AUTUMN_ALERTS__PAGERDUTY_SEVERITIES",
&mut self.alerts.pagerduty_severities,
);
parse_env(
env,
"AUTUMN_ALERTS__SLACK_SEVERITIES",
&mut self.alerts.slack_severities,
);
parse_env(
env,
"AUTUMN_ALERTS__DISCORD_SEVERITIES",
&mut self.alerts.discord_severities,
);
parse_env_bool(
env,
"AUTUMN_ALERTS__CUSTOM_CHANNEL",
&mut self.alerts.custom_channel,
);
parse_env(
env,
"AUTUMN_ALERTS__DEDUP_WINDOW_SECS",
&mut self.alerts.dedup_window_secs,
);
parse_env(
env,
"AUTUMN_ALERTS__HEALTH_GRACE_SECS",
&mut self.alerts.health_grace_secs,
);
parse_env(
env,
"AUTUMN_ALERTS__ERROR_RATE_THRESHOLD",
&mut self.alerts.error_rate_threshold,
);
parse_env(
env,
"AUTUMN_ALERTS__ERROR_RATE_MIN_REQUESTS",
&mut self.alerts.error_rate_min_requests,
);
parse_env(
env,
"AUTUMN_ALERTS__EVAL_INTERVAL_SECS",
&mut self.alerts.eval_interval_secs,
);
}
fn apply_time_zone_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_TIME_ZONE__IDENTIFIER",
&mut self.time_zone.identifier,
);
}
#[cfg(feature = "reporting")]
fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_REPORTING__ENABLED",
&mut self.reporting.enabled,
);
parse_env(
env,
"AUTUMN_REPORTING__SAMPLE_RATE",
&mut self.reporting.sample_rate,
);
}
fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_DEV__INSPECTOR_PATH",
&mut self.dev.inspector_path,
);
parse_env(
env,
"AUTUMN_DEV__INSPECTOR_CAPACITY",
&mut self.dev.inspector_capacity,
);
parse_env(
env,
"AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
&mut self.dev.inspector_n_plus_one_threshold,
);
}
fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_COMPRESSION__ENABLED",
&mut self.compression.enabled,
);
}
fn apply_observability_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_option_bool(
env,
"AUTUMN_OBSERVABILITY__SERVER_TIMING",
&mut self.observability.server_timing,
);
}
#[cfg(feature = "maud")]
fn apply_stories_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_STORIES__ENABLED", &mut self.stories.enabled);
}
fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
parse_env_bool(
env,
"AUTUMN_ACTUATOR__SENSITIVE",
&mut self.actuator.sensitive,
);
parse_env_bool(
env,
"AUTUMN_ACTUATOR__PROMETHEUS",
&mut self.actuator.prometheus,
);
}
fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_option_bool(
env,
"AUTUMN_IDEMPOTENCY__ENABLED",
&mut self.idempotency.enabled,
);
if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
match IdempotencyBackend::from_env_value(&val) {
Some(backend) => self.idempotency.backend = backend,
None => eprintln!(
"Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
),
}
}
parse_env(
env,
"AUTUMN_IDEMPOTENCY__TTL_SECS",
&mut self.idempotency.ttl_secs,
);
parse_env(
env,
"AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
&mut self.idempotency.in_flight_ttl_secs,
);
parse_env_bool(
env,
"AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
&mut self.idempotency.allow_memory_in_production,
);
parse_env_string(
env,
"AUTUMN_IDEMPOTENCY__REDIS__URL",
self.idempotency.redis.url.get_or_insert_with(String::new),
);
parse_env_string(
env,
"AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
&mut self.idempotency.redis.key_prefix,
);
}
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,
);
parse_env(
env,
"AUTUMN_SERVER__PRESTOP_GRACE_SECS",
&mut self.server.prestop_grace_secs,
);
parse_env_option(
env,
"AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
&mut self.server.timeouts.request_timeout_ms,
);
parse_env_option_string(
env,
"AUTUMN_SERVER__UNIX_SOCKET",
&mut self.server.unix_socket,
);
parse_env_option(
env,
"AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS",
&mut self.server.max_concurrent_requests,
);
let tls_cert = env.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
let tls_key = env.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
let tls_reload = env.var("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS").ok();
let tls_handshake = env.var("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS").ok();
if tls_cert.is_some()
|| tls_key.is_some()
|| tls_reload.is_some()
|| tls_handshake.is_some()
{
let tls = self.server.tls.get_or_insert_with(TlsConfig::empty_for_env);
if let Some(cert) = tls_cert {
tls.cert_path = Some(PathBuf::from(cert));
}
if let Some(key) = tls_key {
tls.key_path = Some(PathBuf::from(key));
}
if let Some(reload) = tls_reload.and_then(|v| v.trim().parse::<u64>().ok()) {
tls.reload_interval_secs = reload;
}
if let Some(handshake) = tls_handshake.and_then(|v| v.trim().parse::<u64>().ok()) {
tls.handshake_timeout_secs = handshake;
}
}
}
fn apply_deploy_env_overrides_with_env(&mut self, env: &dyn Env) {
apply_deploy_env_overrides(&mut self.deploy, env);
}
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);
self.database.primary_url = None;
}
parse_env_option_string(
env,
"AUTUMN_DATABASE__PRIMARY_URL",
&mut self.database.primary_url,
);
parse_env_option_string(
env,
"AUTUMN_DATABASE__REPLICA_URL",
&mut self.database.replica_url,
);
parse_env(
env,
"AUTUMN_DATABASE__POOL_SIZE",
&mut self.database.pool_size,
);
parse_env_option(
env,
"AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
&mut self.database.primary_pool_size,
);
parse_env_option(
env,
"AUTUMN_DATABASE__REPLICA_POOL_SIZE",
&mut self.database.replica_pool_size,
);
parse_env(
env,
"AUTUMN_DATABASE__REPLICA_FALLBACK",
&mut self.database.replica_fallback,
);
parse_env(
env,
"AUTUMN_DATABASE__READ_YOUR_WRITES",
&mut self.database.read_your_writes,
);
parse_env(
env,
"AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS",
&mut self.database.pin_after_write_secs,
);
parse_env(
env,
"AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
&mut self.database.connect_timeout_secs,
);
parse_env(
env,
"AUTUMN_DATABASE__STARTUP_WAIT_SECS",
&mut self.database.startup_wait_secs,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
&mut self.database.auto_migrate_in_production,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__DIRECTORY_SHARD_ROUTER",
&mut self.database.directory_shard_router,
);
self.apply_shard_env_overrides(env);
}
fn apply_shard_env_overrides(&mut self, env: &dyn Env) {
const MAX_ENV_SHARDS: usize = 64;
for i in 0..MAX_ENV_SHARDS {
let key = |field: &str| format!("AUTUMN_DATABASE__SHARDS__{i}__{field}");
if i >= self.database.shards.len() {
let (Ok(name), Ok(primary_url)) =
(env.var(&key("NAME")), env.var(&key("PRIMARY_URL")))
else {
break;
};
self.database.shards.push(ShardConfig {
name,
primary_url,
slots: None,
replica_url: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
});
}
let shard = &mut self.database.shards[i];
parse_env_string(env, &key("NAME"), &mut shard.name);
parse_env_string(env, &key("PRIMARY_URL"), &mut shard.primary_url);
if let Ok(val) = env.var(&key("SLOTS")) {
shard.slots = Some(
val.split(',')
.map(|token| SlotSpec::Range(token.trim().to_owned()))
.collect(),
);
}
parse_env_option_string(env, &key("REPLICA_URL"), &mut shard.replica_url);
parse_env_option(env, &key("PRIMARY_POOL_SIZE"), &mut shard.primary_pool_size);
parse_env_option(env, &key("REPLICA_POOL_SIZE"), &mut shard.replica_pool_size);
parse_env_option(env, &key("REPLICA_FALLBACK"), &mut shard.replica_fallback);
}
}
fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
parse_env_csv(
env,
"AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
&mut self.log.access_log_exclude,
);
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_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
match CacheBackend::from_env_value(&val) {
Some(backend) => self.cache.backend = backend,
None => eprintln!(
"Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
(expected memory or redis), ignoring"
),
}
}
parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
parse_env_string(
env,
"AUTUMN_CACHE__REDIS__KEY_PREFIX",
&mut self.cache.redis.key_prefix,
);
}
fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
match ChannelBackend::from_env_value(&val) {
Some(backend) => self.channels.backend = backend,
None => eprintln!(
"Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
(expected in_process or redis), ignoring"
),
}
}
parse_env(
env,
"AUTUMN_CHANNELS__CAPACITY",
&mut self.channels.capacity,
);
parse_env(
env,
"AUTUMN_CHANNELS__REPLAY_BUFFER",
&mut self.channels.replay_buffer,
);
parse_env_option_string(
env,
"AUTUMN_CHANNELS__REDIS__URL",
&mut self.channels.redis.url,
);
parse_env_string(
env,
"AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
&mut self.channels.redis.key_prefix,
);
}
fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
if let Ok(val) = env.var("AUTUMN_JOBS__PIN") {
self.jobs.pin = val
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
}
parse_env(
env,
"AUTUMN_JOBS__MAX_ATTEMPTS",
&mut self.jobs.max_attempts,
);
parse_env(
env,
"AUTUMN_JOBS__INITIAL_BACKOFF_MS",
&mut self.jobs.initial_backoff_ms,
);
parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
parse_env_string(
env,
"AUTUMN_JOBS__REDIS__KEY_PREFIX",
&mut self.jobs.redis.key_prefix,
);
parse_env(
env,
"AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
&mut self.jobs.redis.visibility_timeout_ms,
);
parse_env(
env,
"AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
&mut self.jobs.postgres.visibility_timeout_ms,
);
parse_env(
env,
"AUTUMN_JOBS__TRACKING__TTL_SECS",
&mut self.jobs.tracking.ttl_secs,
);
parse_env_bool(
env,
"AUTUMN_JOBS__TRACKING__ROUTE_ENABLED",
&mut self.jobs.tracking.route_enabled,
);
}
fn apply_role_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_ROLE") {
match ProcessRole::from_env_value(&val) {
Some(role) => self.role = role,
None => eprintln!(
"Warning: AUTUMN_ROLE={val:?} is not valid \
(expected combined, web, or worker), ignoring"
),
}
}
}
fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
match SchedulerBackend::from_env_value(&val) {
Some(backend) => self.scheduler.backend = backend,
None => eprintln!(
"Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
(expected in_process or postgres), ignoring"
),
}
}
parse_env(
env,
"AUTUMN_SCHEDULER__LEASE_TTL_SECS",
&mut self.scheduler.lease_ttl_secs,
);
parse_env_option_string(
env,
"AUTUMN_SCHEDULER__REPLICA_ID",
&mut self.scheduler.replica_id,
);
parse_env_string(
env,
"AUTUMN_SCHEDULER__KEY_PREFIX",
&mut self.scheduler.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);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__ENABLED",
&mut self.auth.lockout.enabled,
);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__THRESHOLD",
&mut self.auth.lockout.threshold,
);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
&mut self.auth.lockout.window_secs,
);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
&mut self.auth.lockout.cooloff_secs,
);
parse_env(
env,
"AUTUMN_AUTH__PASSWORD__MIN_LENGTH",
&mut self.auth.password.min_length,
);
parse_env_bool(
env,
"AUTUMN_AUTH__PASSWORD__REJECT_COMMON",
&mut self.auth.password.reject_common,
);
if let Ok(val) = env.var("AUTUMN_AUTH__PASSWORD__BREACH_CHECK") {
match val.as_str() {
"off" => self.auth.password.breach_check = crate::auth::BreachCheck::Off,
"fail_open" => self.auth.password.breach_check = crate::auth::BreachCheck::FailOpen,
"fail_closed" => {
self.auth.password.breach_check = crate::auth::BreachCheck::FailClosed;
}
other => eprintln!(
"Warning: AUTUMN_AUTH__PASSWORD__BREACH_CHECK={other:?} is not valid \
(expected off, fail_open, or fail_closed), ignoring"
),
}
}
parse_env_bool(
env,
"AUTUMN_AUTH__REMEMBER__ENABLED",
&mut self.auth.remember.enabled,
);
parse_env(
env,
"AUTUMN_AUTH__REMEMBER__DURATION_SECS",
&mut self.auth.remember.duration_secs,
);
parse_env_string(
env,
"AUTUMN_AUTH__REMEMBER__COOKIE_NAME",
&mut self.auth.remember.cookie_name,
);
parse_env(
env,
"AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES",
&mut self.auth.magic_link.ttl_minutes,
);
parse_env(
env,
"AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS",
&mut self.auth.magic_link.email_cooldown_secs,
);
#[cfg(feature = "oauth2")]
{
let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
for name in provider_names {
let upper = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_uppercase();
let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
if let Ok(id) = env.var(&client_id_var)
&& !id.is_empty()
&& let Some(p) = self.auth.oauth2.providers.get_mut(&name)
{
p.client_id = id;
}
let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
if let Ok(secret) = env.var(&client_secret_var)
&& !secret.is_empty()
&& let Some(p) = self.auth.oauth2.providers.get_mut(&name)
{
p.client_secret = secret;
}
}
}
}
#[allow(clippy::too_many_lines)]
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(
env,
"AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES",
&mut self.security.csrf.token_scan_bytes,
);
self.apply_rate_limit_env_overrides_with_env(env);
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,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
&mut self.security.upload.reject_on_content_type_mismatch,
);
if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
match value.parse::<crate::authorization::ForbiddenResponse>() {
Ok(parsed) => self.security.forbidden_response = parsed,
Err(err) => tracing::warn!(
"ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
),
}
}
parse_env_bool(
env,
"AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
&mut self.security.allow_unauthorized_repository_api,
);
parse_env_option_string(
env,
"AUTUMN_SECURITY__SIGNING_SECRET",
&mut self.security.signing_secret.secret,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
&mut self.security.trusted_hosts.hosts,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
&mut self.security.trusted_proxies.ranges,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
&mut self.security.trusted_proxies.trust_forwarded_headers,
);
if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
if let Ok(hops) = val.trim().parse::<u32>() {
self.security.trusted_proxies.trusted_hops = Some(hops);
} else {
tracing::warn!(
"ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
expected a non-negative integer"
);
}
}
self.security.webhooks.apply_env_overrides_with_env(env);
}
fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_BOT_PROTECTION__ENABLED",
&mut self.bot_protection.enabled,
);
parse_env_bool(
env,
"AUTUMN_BOT_PROTECTION__DEV_BYPASS",
&mut self.bot_protection.dev_bypass,
);
if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
match val.to_lowercase().as_str() {
"turnstile" => {
self.bot_protection.provider =
crate::security::captcha::CaptchaProviderKind::Turnstile;
}
"hcaptcha" => {
self.bot_protection.provider =
crate::security::captcha::CaptchaProviderKind::HCaptcha;
}
_ => tracing::warn!(
"ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
expected \"turnstile\" or \"hcaptcha\""
),
}
}
parse_env_option_string(
env,
"AUTUMN_BOT_PROTECTION__SITE_KEY",
&mut self.bot_protection.site_key,
);
parse_env_option_string(
env,
"AUTUMN_BOT_PROTECTION__SECRET_KEY",
&mut self.bot_protection.secret_key,
);
parse_env_option_string(
env,
"AUTUMN_BOT_PROTECTION__FORM_FIELD",
&mut self.bot_protection.form_field,
);
}
fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
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_csv(
env,
"AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
&mut self.security.rate_limit.trusted_proxies,
);
if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
match crate::security::config::KeyStrategy::from_env_value(&val) {
Some(strategy) => self.security.rate_limit.key_strategy = strategy,
None => eprintln!(
"Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
(expected ip, api_token, or authenticated_principal), ignoring"
),
}
}
if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
match crate::security::config::RateLimitBackend::from_env_value(&val) {
Some(backend) => self.security.rate_limit.backend = backend,
None => eprintln!(
"Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
(expected memory or redis), ignoring"
),
}
}
#[cfg(feature = "redis")]
{
use crate::security::config::RateLimitBackendFailure;
if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
match RateLimitBackendFailure::from_env_value(&val) {
Some(mode) => self.security.rate_limit.on_backend_failure = mode,
None => eprintln!(
"Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
valid (expected fail_open or fail_closed), ignoring"
),
}
}
parse_env_option_string(
env,
"AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
&mut self.security.rate_limit.redis.url,
);
parse_env_string(
env,
"AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
&mut self.security.rate_limit.redis.key_prefix,
);
}
}
#[cfg(feature = "storage")]
fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
match crate::storage::StorageBackend::from_env_value(&val) {
Some(backend) => self.storage.backend = backend,
None => eprintln!(
"Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
(expected disabled, local, or s3), ignoring"
),
}
}
parse_env_string(
env,
"AUTUMN_STORAGE__DEFAULT_PROVIDER",
&mut self.storage.default_provider,
);
parse_env_bool(
env,
"AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
&mut self.storage.allow_local_in_production,
);
if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
self.storage.local.root = PathBuf::from(val);
}
parse_env_string(
env,
"AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
&mut self.storage.local.mount_path,
);
parse_env(
env,
"AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
&mut self.storage.local.default_url_expiry_secs,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
&mut self.storage.local.signing_key,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__BUCKET",
&mut self.storage.s3.bucket,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__REGION",
&mut self.storage.s3.region,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__ENDPOINT",
&mut self.storage.s3.endpoint,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
&mut self.storage.s3.public_base_url,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
&mut self.storage.s3.access_key_id_env,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
&mut self.storage.s3.secret_access_key_env,
);
parse_env_bool(
env,
"AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
&mut self.storage.s3.force_path_style,
);
parse_env(
env,
"AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
&mut self.storage.s3.default_url_expiry_secs,
);
parse_env(
env,
"AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
&mut self.storage.variants.max_source_bytes,
);
parse_env(
env,
"AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
&mut self.storage.variants.max_source_width,
);
parse_env(
env,
"AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
&mut self.storage.variants.max_source_height,
);
}
fn apply_backup_env_overrides_with_env(&mut self, env: &dyn Env) {
const OFFSITE_DEST_KEYS: &[&str] = &[
"AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
"AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
];
let has_dest_key = OFFSITE_DEST_KEYS.iter().any(|k| env.var(k).is_ok());
let auto_upload_truthy = env
.var("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD")
.ok()
.is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
if self.backup.offsite.is_none() && !has_dest_key && !auto_upload_truthy {
return;
}
let offsite = self
.backup
.offsite
.get_or_insert_with(|| Box::new(OffsiteBackupConfig::default()));
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
&mut offsite.s3.bucket,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__REGION",
&mut offsite.s3.region,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
&mut offsite.s3.endpoint,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
&mut offsite.s3.access_key_id_env,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
&mut offsite.s3.secret_access_key_env,
);
parse_env_bool(
env,
"AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE",
&mut offsite.s3.force_path_style,
);
parse_env_option_string(env, "AUTUMN_BACKUP__OFFSITE__PREFIX", &mut offsite.prefix);
parse_env_option(env, "AUTUMN_BACKUP__OFFSITE__KEEP", &mut offsite.keep);
parse_env_bool(
env,
"AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
&mut offsite.auto_upload,
);
parse_env_bool(
env,
"AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
&mut offsite.allow_shared_bucket,
);
}
#[cfg(feature = "mail")]
fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
match crate::mail::Transport::from_env_value(&val) {
Some(transport) => self.mail.transport = transport,
None => eprintln!(
"Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
(expected log, file, smtp, or disabled), ignoring"
),
}
}
parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
parse_env_bool(
env,
"AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
&mut self.mail.allow_log_in_production,
);
parse_env_bool(
env,
"AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
&mut self.mail.allow_in_process_deliver_later_in_production,
);
parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
parse_env_option_string(
env,
"AUTUMN_MAIL__UNSUBSCRIBE_BASE_URL",
&mut self.mail.unsubscribe_base_url,
);
parse_env_option_string(
env,
"AUTUMN_MAIL__UNSUBSCRIBE_MAILTO",
&mut self.mail.unsubscribe_mailto,
);
if let Ok(val) = env.var("AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS") {
match val.parse::<i64>() {
Ok(days) => self.mail.unsubscribe_token_ttl_days = days,
Err(_) => eprintln!(
"Warning: AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS={val:?} is not a valid integer, ignoring"
),
}
}
parse_env_bool(
env,
"AUTUMN_MAIL__MOUNT_UNSUBSCRIBE_ENDPOINT",
&mut self.mail.mount_unsubscribe_endpoint,
);
parse_env_bool(env, "AUTUMN_MAIL__INLINE_CSS", &mut self.mail.inline_css);
if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
self.mail.file_dir = PathBuf::from(val);
}
parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
match val.parse::<u16>() {
Ok(port) => self.mail.smtp.port = Some(port),
Err(_) => {
eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
}
}
}
parse_env_option_string(
env,
"AUTUMN_MAIL__SMTP__USERNAME",
&mut self.mail.smtp.username,
);
parse_env_option_string(
env,
"AUTUMN_MAIL__SMTP__PASSWORD_ENV",
&mut self.mail.smtp.password_env,
);
if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
match crate::mail::TlsMode::from_env_value(&val) {
Some(tls) => self.mail.smtp.tls = tls,
None => eprintln!(
"Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
(expected disabled, starttls, or tls), ignoring"
),
}
}
}
#[must_use]
pub fn profile_name(&self) -> Option<&str> {
self.profile.as_deref()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RequestTimeoutsConfig {
#[serde(default)]
pub request_timeout_ms: Option<u64>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
#[serde(default)]
pub strict_config: bool,
#[serde(default)]
pub strict_config_enforce_all: bool,
#[serde(default = "default_shutdown_timeout")]
pub shutdown_timeout_secs: u64,
#[serde(default = "default_prestop_grace")]
pub prestop_grace_secs: u64,
#[serde(default)]
pub timeouts: RequestTimeoutsConfig,
#[serde(default)]
pub unix_socket: Option<String>,
#[serde(default)]
pub max_concurrent_requests: Option<usize>,
#[serde(default)]
pub tls: Option<TlsConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TlsConfig {
#[serde(default)]
pub cert_path: Option<PathBuf>,
#[serde(default)]
pub key_path: Option<PathBuf>,
#[serde(default = "default_tls_reload_interval_secs")]
pub reload_interval_secs: u64,
#[serde(default = "default_tls_handshake_timeout_secs")]
pub handshake_timeout_secs: u64,
#[serde(default)]
pub acme: Option<AcmeConfig>,
}
impl TlsConfig {
const fn empty_for_env() -> Self {
Self {
cert_path: None,
key_path: None,
reload_interval_secs: default_tls_reload_interval_secs(),
handshake_timeout_secs: default_tls_handshake_timeout_secs(),
acme: None,
}
}
pub fn validate(&self) -> Result<(), String> {
let has_cert = self.cert_path.is_some();
let has_key = self.key_path.is_some();
let static_configured = has_cert || has_key;
let acme_configured = self.acme.is_some();
match (static_configured, acme_configured) {
(true, true) => {
return Err(
"[server.tls] sets a static cert_path/key_path AND [server.tls.acme]; \
choose exactly one — remove the static cert to use ACME, or remove \
[server.tls.acme] to serve the static certificate"
.to_owned(),
);
}
(false, false) => {
return Err(
"[server.tls] must configure exactly one of: a static certificate \
(cert_path AND key_path) or automatic provisioning ([server.tls.acme] \
with domains + contact_email)"
.to_owned(),
);
}
(true, false) => {
if !(has_cert && has_key) {
return Err("[server.tls] cert_path and key_path must be set together; \
set both, or configure [server.tls.acme] instead"
.to_owned());
}
}
(false, true) => {}
}
if let Some(acme) = &self.acme {
acme.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AcmeConfig {
pub domains: Vec<String>,
pub contact_email: String,
#[serde(default)]
pub directory: AcmeDirectory,
#[serde(default = "default_acme_cache_dir")]
pub cache_dir: PathBuf,
#[serde(default = "default_acme_http_challenge_port")]
pub http_challenge_port: u16,
#[serde(default = "default_acme_renew_before_days")]
pub renew_before_days: u32,
}
impl AcmeConfig {
pub fn validate(&self) -> Result<(), String> {
if self.domains.is_empty() {
return Err(
"[server.tls.acme] domains must list at least one domain to request a \
certificate for"
.to_owned(),
);
}
if self.contact_email.trim().is_empty() {
return Err(
"[server.tls.acme] contact_email must be set (the ACME CA requires an account \
contact for expiry notifications)"
.to_owned(),
);
}
if self.http_challenge_port == 0 {
return Err(
"[server.tls.acme] http_challenge_port must not be 0: port 0 binds an ephemeral \
OS-assigned port that the ACME HTTP-01 validator (which always connects on port \
80) can never reach, so every issuance fails. Use 80, or the port a front-end \
forwards `:80` to"
.to_owned(),
);
}
if self.renew_before_days >= 90 {
return Err(format!(
"[server.tls.acme] renew_before_days ({}) must be less than 90: it is compared \
against the issued certificate's remaining validity, and publicly-trusted CAs \
(e.g. Let's Encrypt) issue certificates that live at most ~90 days. A value >= \
the certificate lifetime keeps the cert perpetually inside its renew-before \
window, so the renewal loop would order a fresh certificate every hour and burn \
the CA's rate limits. Use a smaller value (default 30)",
self.renew_before_days
));
}
for (index, domain) in self.domains.iter().enumerate() {
let trimmed = domain.trim();
if trimmed.is_empty() {
return Err(format!(
"[server.tls.acme] domains must not contain blank entries (entry at index \
{index} is empty or whitespace-only)"
));
}
if trimmed.starts_with("*.") {
return Err(format!(
"[server.tls.acme] wildcard domain `{trimmed}` is not supported: wildcards \
require the DNS-01 challenge, which is out of scope here (tracked in #1620). \
List explicit hostnames instead"
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AcmeDirectory {
#[default]
Staging,
Production,
Custom {
url: String,
},
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReplicaFallback {
#[default]
FailReadiness,
Primary,
}
impl std::str::FromStr for ReplicaFallback {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
"primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReadYourWrites {
#[default]
Off,
Request,
Session,
}
impl std::str::FromStr for ReadYourWrites {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"off" => Ok(Self::Off),
"request" => Ok(Self::Request),
"session" => Ok(Self::Session),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum SlotSpec {
Index(u16),
Range(String),
}
impl SlotSpec {
pub fn expand(&self) -> Result<Vec<u16>, String> {
match self {
Self::Index(slot) => Ok(vec![*slot]),
Self::Range(spec) => {
let spec = spec.trim();
let parse = |s: &str| {
s.trim()
.parse::<u16>()
.map_err(|_| format!("invalid slot {s:?} in {spec:?}"))
};
match spec.split_once('-') {
None => Ok(vec![parse(spec)?]),
Some((start, end)) => {
let (start, end) = (parse(start)?, parse(end)?);
if start > end {
return Err(format!("inverted slot range {spec:?}"));
}
Ok((start..=end).collect())
}
}
}
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ShardConfig {
pub name: String,
pub primary_url: String,
#[serde(default)]
pub slots: Option<Vec<SlotSpec>>,
#[serde(default)]
pub replica_url: Option<String>,
#[serde(default)]
pub primary_pool_size: Option<usize>,
#[serde(default)]
pub replica_pool_size: Option<usize>,
#[serde(default)]
pub replica_fallback: Option<ReplicaFallback>,
}
impl ShardConfig {
#[must_use]
pub fn effective_primary_pool_size(&self, defaults: &DatabaseConfig) -> usize {
self.primary_pool_size
.unwrap_or_else(|| defaults.effective_primary_pool_size())
}
#[must_use]
pub fn effective_replica_pool_size(&self, defaults: &DatabaseConfig) -> usize {
self.replica_pool_size
.unwrap_or_else(|| defaults.effective_replica_pool_size())
}
#[must_use]
pub fn effective_replica_fallback(&self, defaults: &DatabaseConfig) -> ReplicaFallback {
self.replica_fallback.unwrap_or(defaults.replica_fallback)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatabaseBackend {
Postgres,
Sqlite,
}
impl DatabaseBackend {
#[must_use]
pub fn detect(target: &str) -> Option<Self> {
if is_sqlite_target(target) {
Some(Self::Sqlite)
} else if is_pg_connection_string(target) {
Some(Self::Postgres)
} else {
None
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::Sqlite => "sqlite",
}
}
}
impl std::fmt::Display for DatabaseBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn is_sqlite_target(s: &str) -> bool {
s.starts_with("sqlite:") || s.starts_with("file:")
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub primary_url: Option<String>,
#[serde(default)]
pub replica_url: Option<String>,
#[serde(default = "default_pool_size")]
pub pool_size: usize,
#[serde(default)]
pub primary_pool_size: Option<usize>,
#[serde(default)]
pub replica_pool_size: Option<usize>,
#[serde(default)]
pub replica_fallback: ReplicaFallback,
#[serde(default)]
pub read_your_writes: ReadYourWrites,
#[serde(default = "default_pin_after_write_secs")]
pub pin_after_write_secs: u64,
#[serde(default = "default_connect_timeout")]
pub connect_timeout_secs: u64,
#[serde(default)]
pub startup_wait_secs: u64,
#[serde(default)]
pub auto_migrate_in_production: bool,
#[serde(deserialize_with = "deserialize_option_duration", default)]
pub statement_timeout: Option<std::time::Duration>,
#[serde(
deserialize_with = "deserialize_duration",
default = "default_slow_query_threshold"
)]
pub slow_query_threshold: std::time::Duration,
#[serde(default)]
pub shards: Vec<ShardConfig>,
#[serde(default)]
pub directory_shard_router: bool,
#[serde(default = "default_max_connections_warn_threshold")]
pub max_connections_warn_threshold: usize,
}
pub(crate) const fn should_warn_total_connections(total: usize, threshold: usize) -> bool {
threshold != 0 && total >= threshold
}
fn format_slot_ranges(slots: &[usize]) -> String {
fn render(start: usize, end: usize) -> String {
if start == end {
start.to_string()
} else {
format!("{start}-{end}")
}
}
let mut ranges: Vec<String> = Vec::new();
let mut iter = slots.iter().copied();
let Some(mut start) = iter.next() else {
return String::new();
};
let mut end = start;
for slot in iter {
if slot != end + 1 {
ranges.push(render(start, end));
start = slot;
}
end = slot;
}
ranges.push(render(start, end));
ranges.join(", ")
}
pub const SLOT_COUNT: u16 = 16384;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShardSlotAssignment {
pub name: String,
pub ranges: String,
}
pub fn check_stored_slot_map(
auto_split: bool,
computed: &[ShardSlotAssignment],
stored: Option<&[ShardSlotAssignment]>,
) -> Result<(), String> {
fn to_map(assignments: &[ShardSlotAssignment]) -> std::collections::BTreeMap<&str, &str> {
assignments
.iter()
.map(|a| (a.name.as_str(), a.ranges.as_str()))
.collect()
}
if !auto_split {
return Ok(());
}
let Some(stored) = stored else {
return Ok(());
};
if to_map(computed) == to_map(stored) {
return Ok(());
}
let computed_names: Vec<&str> = computed.iter().map(|a| a.name.as_str()).collect();
let stored_names: Vec<&str> = stored.iter().map(|a| a.name.as_str()).collect();
Err(format!(
"shard slot map mismatch — auto-split with {} shards ({}) produces a different \
map than the stored map ({} shards: {}). Set explicit [[database.shards]] slot \
ranges matching the stored map, then move data between shards deliberately \
before changing the topology.",
computed.len(),
computed_names.join(", "),
stored.len(),
stored_names.join(", "),
))
}
pub fn database_backend_consistency(
url: Option<&str>,
primary_url: Option<&str>,
replica_url: Option<&str>,
has_shards: bool,
) -> Result<(), String> {
let Some(primary_backend) = primary_url.or(url).and_then(DatabaseBackend::detect) else {
return Ok(());
};
if primary_backend == DatabaseBackend::Sqlite {
if replica_url.is_some() {
return Err(
"database.replica_url is set but the primary target is SQLite; \
read replicas require the postgres backend"
.to_owned(),
);
}
if has_shards {
return Err(
"database.shards are configured but the primary target is SQLite; \
database shards require the postgres backend"
.to_owned(),
);
}
}
for (field, url) in [("database.url", url), ("database.replica_url", replica_url)] {
if let Some(url) = url
&& DatabaseBackend::detect(url) != Some(primary_backend)
{
return Err(format!(
"{field} does not match the primary database backend \
({primary_backend}); every configured database role must use \
the same backend"
));
}
}
Ok(())
}
impl DatabaseConfig {
#[must_use]
pub fn effective_primary_url(&self) -> Option<&str> {
self.primary_url.as_deref().or(self.url.as_deref())
}
#[must_use]
pub fn effective_primary_pool_size(&self) -> usize {
self.primary_pool_size.unwrap_or(self.pool_size)
}
#[must_use]
pub fn effective_replica_pool_size(&self) -> usize {
self.replica_pool_size.unwrap_or(self.pool_size)
}
#[must_use]
pub const fn has_shards(&self) -> bool {
!self.shards.is_empty()
}
pub fn resolved_slot_map(&self) -> Result<Vec<usize>, ConfigError> {
let slot_count = usize::from(SLOT_COUNT);
if self.shards.is_empty() {
return Ok(Vec::new());
}
let declared = self.shards.iter().filter(|s| s.slots.is_some()).count();
if declared != 0 && declared != self.shards.len() {
return Err(ConfigError::Validation(
"database.shards: either every shard must declare `slots` or none may \
(mixing explicit and auto-assigned slots is ambiguous)"
.to_owned(),
));
}
if declared == 0 {
if self.shards.len() > slot_count {
return Err(ConfigError::Validation(format!(
"database.shards: at most {slot_count} shards are supported \
(one per logical slot), got {}",
self.shards.len()
)));
}
let n = self.shards.len();
return Ok((0..slot_count).map(|slot| slot * n / slot_count).collect());
}
let mut map: Vec<Option<usize>> = vec![None; slot_count];
for (idx, shard) in self.shards.iter().enumerate() {
let specs = shard.slots.as_deref().unwrap_or_default();
for spec in specs {
let slots = spec.expand().map_err(|e| {
ConfigError::Validation(format!("database.shards[{idx}].slots: {e}"))
})?;
for slot in slots {
if usize::from(slot) >= slot_count {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].slots: slot {slot} is out of range \
(slots are 0..{slot_count})"
)));
}
if let Some(owner) = map[usize::from(slot)] {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].slots: slot {slot} is already owned \
by shard {:?}",
self.shards[owner].name
)));
}
map[usize::from(slot)] = Some(idx);
}
}
}
let unassigned: Vec<usize> = map
.iter()
.enumerate()
.filter_map(|(slot, owner)| owner.is_none().then_some(slot))
.collect();
if !unassigned.is_empty() {
return Err(ConfigError::Validation(format!(
"database.shards: slot map must cover every slot in 0..{slot_count}; \
unassigned slots: {}",
format_slot_ranges(&unassigned)
)));
}
Ok(map.into_iter().flatten().collect())
}
#[must_use]
pub fn shards_auto_split(&self) -> bool {
self.has_shards() && self.shards.iter().all(|s| s.slots.is_none())
}
pub fn resolved_shard_assignments(&self) -> Result<Vec<ShardSlotAssignment>, ConfigError> {
let slot_map = self.resolved_slot_map()?;
let n = self.shards.len();
let mut per_shard: Vec<Vec<usize>> = vec![Vec::new(); n];
for (slot, &owner) in slot_map.iter().enumerate() {
per_shard[owner].push(slot);
}
Ok(self
.shards
.iter()
.enumerate()
.map(|(idx, shard)| ShardSlotAssignment {
name: shard.name.clone(),
ranges: format_slot_ranges(&per_shard[idx]),
})
.collect())
}
fn validate_backend_consistency(&self) -> Result<(), ConfigError> {
database_backend_consistency(
self.url.as_deref(),
self.primary_url.as_deref(),
self.replica_url.as_deref(),
!self.shards.is_empty(),
)
.map_err(ConfigError::Validation)
}
pub fn validate(&self) -> Result<(), ConfigError> {
for (field, url) in [
("database.url", self.url.as_deref()),
("database.primary_url", self.primary_url.as_deref()),
("database.replica_url", self.replica_url.as_deref()),
] {
if let Some(url) = url
&& DatabaseBackend::detect(url).is_none()
{
let label = if field == "database.url" {
"database URL"
} else {
field
};
return Err(ConfigError::Validation(format!(
"Invalid {label}: must start with postgres:// or postgresql://, or be a \
keyword/value connection string \
(e.g. \"host=db user=app dbname=app sslmode=require\"), got {url:?}"
)));
}
}
if self.replica_url.is_some() && self.effective_primary_url().is_none() {
return Err(ConfigError::Validation(
"database.replica_url requires database.primary_url or database.url".to_owned(),
));
}
self.validate_backend_consistency()?;
let mut seen_names = std::collections::HashSet::new();
for (idx, shard) in self.shards.iter().enumerate() {
if shard.name.is_empty() {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].name must not be empty"
)));
}
if !shard
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].name {:?} is invalid: shard names are used in \
metric tags and health component names and must match [a-z0-9_-]",
shard.name
)));
}
if !seen_names.insert(shard.name.as_str()) {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].name {:?} is declared more than once; \
shard names must be unique",
shard.name
)));
}
for (field, url) in [
("primary_url", Some(shard.primary_url.as_str())),
("replica_url", shard.replica_url.as_deref()),
] {
if let Some(url) = url
&& !is_pg_connection_string(url)
{
return Err(ConfigError::Validation(format!(
"Invalid database.shards[{idx}].{field}: must start with \
postgres:// or postgresql://, or be a keyword/value \
connection string \
(e.g. \"host=db user=app dbname=app sslmode=require\"), got {url:?}"
)));
}
}
}
self.resolved_slot_map()?;
Ok(())
}
}
fn is_pg_connection_string(s: &str) -> bool {
crate::pg_conn_str::is_url(s) || crate::pg_conn_str::is_keyword_value(s)
}
#[derive(Debug, Clone, Deserialize)]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
#[serde(default)]
pub filter_parameters: Vec<String>,
#[serde(default)]
pub unfilter_parameters: Vec<String>,
#[serde(default = "default_access_log")]
pub access_log: bool,
#[serde(default = "default_access_log_exclude")]
pub access_log_exclude: Vec<String>,
#[serde(default)]
pub capture: crate::log::capture::LogCaptureConfig,
}
#[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,
#[serde(default = "default_actuator_prometheus")]
pub prometheus: bool,
}
impl Default for ActuatorConfig {
fn default() -> Self {
Self {
prefix: default_actuator_prefix(),
sensitive: false,
prometheus: default_actuator_prometheus(),
}
}
}
fn default_actuator_prefix() -> String {
"/actuator".to_owned()
}
const fn default_actuator_prometheus() -> bool {
true
}
#[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
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CompressionConfig {
#[serde(default)]
pub enabled: bool,
}
#[doc(hidden)]
pub fn apply_deploy_env_overrides(deploy: &mut Option<DeployConfig>, env: &dyn Env) {
const KEYS: [&str; 11] = [
"AUTUMN_DEPLOY__HOST",
"AUTUMN_DEPLOY__USER",
"AUTUMN_DEPLOY__SSH_PORT",
"AUTUMN_DEPLOY__APP_NAME",
"AUTUMN_DEPLOY__APP_DIR",
"AUTUMN_DEPLOY__SERVICE_NAME",
"AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
"AUTUMN_DEPLOY__KEEP_RELEASES",
"AUTUMN_DEPLOY__PROFILE",
"AUTUMN_DEPLOY__TLS__ENABLED",
"AUTUMN_DEPLOY__TLS__HOST",
];
if !KEYS.iter().any(|key| env.var(key).is_ok()) {
return;
}
let deploy = deploy.get_or_insert_with(DeployConfig::default);
parse_env_option_string(env, "AUTUMN_DEPLOY__HOST", &mut deploy.host);
parse_env_string(env, "AUTUMN_DEPLOY__USER", &mut deploy.user);
parse_env(env, "AUTUMN_DEPLOY__SSH_PORT", &mut deploy.ssh_port);
parse_env_option_string(env, "AUTUMN_DEPLOY__APP_NAME", &mut deploy.app_name);
parse_env_option_string(env, "AUTUMN_DEPLOY__APP_DIR", &mut deploy.app_dir);
parse_env_option_string(env, "AUTUMN_DEPLOY__SERVICE_NAME", &mut deploy.service_name);
parse_env(
env,
"AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
&mut deploy.readiness_timeout_secs,
);
parse_env(
env,
"AUTUMN_DEPLOY__KEEP_RELEASES",
&mut deploy.keep_releases,
);
parse_env_string(env, "AUTUMN_DEPLOY__PROFILE", &mut deploy.profile);
parse_env_bool(env, "AUTUMN_DEPLOY__TLS__ENABLED", &mut deploy.tls.enabled);
parse_env_option_string(env, "AUTUMN_DEPLOY__TLS__HOST", &mut deploy.tls.host);
}
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_option_secret(env: &dyn Env, key: &str, target: &mut Option<secrecy::SecretString>) {
if let Ok(val) = env.var(key) {
let trimmed = val.trim();
*target = if trimmed.is_empty() {
None
} else {
Some(secrecy::SecretString::from(trimmed.to_owned()))
};
}
}
fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
if let Ok(val) = env.var(key) {
if val.is_empty() {
*target = None;
} else {
match val.parse::<T>() {
Ok(v) => *target = Some(v),
Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
}
}
}
}
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_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
if let Ok(val) = env.var(key) {
match val.as_str() {
"true" | "1" => *target = Some(true),
"false" | "0" => *target = Some(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_prestop_grace() -> u64 {
5
}
const fn default_pool_size() -> usize {
10
}
const fn default_max_connections_warn_threshold() -> usize {
100
}
const fn default_connect_timeout() -> u64 {
5
}
const fn default_pin_after_write_secs() -> u64 {
5
}
fn default_log_level() -> String {
"info".to_owned()
}
const fn default_access_log() -> bool {
true
}
fn default_access_log_exclude() -> Vec<String> {
vec![
"/health".to_owned(),
"/live".to_owned(),
"/ready".to_owned(),
"/startup".to_owned(),
"/actuator".to_owned(),
"/static".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()
}
const fn default_tls_reload_interval_secs() -> u64 {
60
}
const fn default_tls_handshake_timeout_secs() -> u64 {
10
}
fn default_deploy_user() -> String {
"root".to_owned()
}
const fn default_deploy_ssh_port() -> u16 {
22
}
const fn default_deploy_readiness_timeout_secs() -> u64 {
60
}
const fn default_deploy_keep_releases() -> u32 {
3
}
fn default_deploy_profile() -> String {
"prod".to_owned()
}
fn default_acme_cache_dir() -> PathBuf {
PathBuf::from("config/acme")
}
const fn default_acme_http_challenge_port() -> u16 {
80
}
const fn default_acme_renew_before_days() -> u32 {
30
}
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(),
strict_config: false,
strict_config_enforce_all: false,
shutdown_timeout_secs: default_shutdown_timeout(),
prestop_grace_secs: default_prestop_grace(),
timeouts: RequestTimeoutsConfig::default(),
unix_socket: None,
max_concurrent_requests: None,
tls: None,
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: None,
primary_url: None,
replica_url: None,
pool_size: default_pool_size(),
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: ReplicaFallback::default(),
read_your_writes: ReadYourWrites::default(),
pin_after_write_secs: default_pin_after_write_secs(),
connect_timeout_secs: default_connect_timeout(),
startup_wait_secs: 0,
auto_migrate_in_production: false,
statement_timeout: None,
slow_query_threshold: default_slow_query_threshold(),
shards: Vec::new(),
directory_shard_router: false,
max_connections_warn_threshold: default_max_connections_warn_threshold(),
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::default(),
filter_parameters: Vec::new(),
unfilter_parameters: Vec::new(),
access_log: default_access_log(),
access_log_exclude: default_access_log_exclude(),
capture: crate::log::capture::LogCaptureConfig::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)]
pub struct TomlEnvConfigLoader {
allowed_plugin_roots: BTreeSet<String>,
}
impl TomlEnvConfigLoader {
#[must_use]
pub const fn new() -> Self {
Self {
allowed_plugin_roots: BTreeSet::new(),
}
}
#[must_use]
pub fn with_plugin_config_roots(mut self, roots: BTreeSet<String>) -> Self {
self.allowed_plugin_roots = roots;
self
}
}
impl ConfigLoader for TomlEnvConfigLoader {
async fn load(&self) -> Result<AutumnConfig, ConfigError> {
let base = OsEnv;
let profile = resolve_profile(&base);
let dir = crate::dotenv::dotenv_base_dir(&base);
let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
.map_err(|e| ConfigError::Dotenv(e.to_string()))?;
let env = crate::dotenv::DotenvEnv::new(&base, vars);
AutumnConfig::load_with_env_and_plugin_roots(&env, &self.allowed_plugin_roots)
}
}
const fn default_slow_query_threshold() -> std::time::Duration {
std::time::Duration::from_millis(500)
}
pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
if s.is_empty() {
return Err("duration string is empty".to_owned());
}
if let Ok(ms) = s.parse::<u64>() {
return Ok(std::time::Duration::from_millis(ms));
}
if let Some(val_str) = s.strip_suffix("ms") {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
return Ok(std::time::Duration::from_millis(val));
}
if let Some(val_str) = s.strip_suffix('s') {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
return Ok(std::time::Duration::from_secs(val));
}
if let Some(val_str) = s.strip_suffix('m') {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
let secs = val.checked_mul(60).ok_or_else(|| {
format!("duration overflow: '{s}' exceeds maximum representable value")
})?;
return Ok(std::time::Duration::from_secs(secs));
}
if let Some(val_str) = s.strip_suffix('h') {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
let secs = val.checked_mul(3600).ok_or_else(|| {
format!("duration overflow: '{s}' exceeds maximum representable value")
})?;
return Ok(std::time::Duration::from_secs(secs));
}
Err(format!("invalid duration format: '{s}'"))
}
pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum DurationOrStr {
String(String),
Integer(u64),
}
match DurationOrStr::deserialize(deserializer)? {
DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
}
}
pub fn deserialize_option_duration<'de, D>(
deserializer: D,
) -> Result<Option<std::time::Duration>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
}
#[derive(Debug, Clone, Deserialize)]
pub struct TenancyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_tenancy_source")]
pub source: String,
#[serde(default = "default_tenancy_header_name")]
pub header_name: String,
#[serde(default = "default_tenancy_session_key")]
pub session_key: String,
#[serde(default = "default_tenancy_jwt_claim")]
pub jwt_claim: String,
#[serde(default)]
pub jwt_secret: Option<secrecy::SecretString>,
#[serde(default)]
pub jwt_issuer: Option<String>,
#[serde(default)]
pub jwt_audience: Option<String>,
#[serde(default)]
pub base_domain: Option<String>,
#[serde(default)]
pub public_paths: Vec<String>,
#[serde(default)]
pub login_redirect: Option<String>,
#[serde(default)]
pub quota_bytes: usize,
#[serde(default)]
pub max_cells: usize,
#[serde(default)]
pub idle_ttl_secs: u64,
}
fn default_tenancy_source() -> String {
"header".to_string()
}
fn default_tenancy_header_name() -> String {
"x-tenant-id".to_string()
}
fn default_tenancy_session_key() -> String {
"tenant_id".to_string()
}
fn default_tenancy_jwt_claim() -> String {
"tenant_id".to_string()
}
impl Default for TenancyConfig {
fn default() -> Self {
Self {
enabled: false,
source: default_tenancy_source(),
header_name: default_tenancy_header_name(),
session_key: default_tenancy_session_key(),
jwt_claim: default_tenancy_jwt_claim(),
jwt_secret: None,
jwt_issuer: None,
jwt_audience: None,
base_domain: None,
public_paths: Vec::new(),
login_redirect: None,
quota_bytes: 0,
max_cells: 0,
idle_ttl_secs: 0,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ResilienceConfig {
#[serde(default)]
pub circuit_breaker: CircuitBreakerConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CircuitBreakerConfig {
#[serde(default)]
pub defaults: CircuitBreakerPolicyConfig,
#[serde(default)]
pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CircuitBreakerPolicyConfig {
pub failure_ratio_threshold: Option<f64>,
pub sample_window_secs: Option<u64>,
pub minimum_sample_count: Option<u64>,
pub open_duration_secs: Option<u64>,
pub half_open_trial_count: Option<u64>,
}
impl AutumnConfig {
fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
&mut self
.resilience
.circuit_breaker
.defaults
.failure_ratio_threshold,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
&mut self.resilience.circuit_breaker.defaults.sample_window_secs,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
&mut self
.resilience
.circuit_breaker
.defaults
.minimum_sample_count,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
&mut self.resilience.circuit_breaker.defaults.open_duration_secs,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
&mut self
.resilience
.circuit_breaker
.defaults
.half_open_trial_count,
);
}
}
use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex};
#[derive(Clone, Copy, PartialEq, Eq)]
enum AnyProbe {
Str,
Map,
Seq,
}
#[derive(Clone)]
pub struct SchemaDeserializer {
path: Vec<String>,
schema: Arc<Mutex<HashMap<String, HashSet<String>>>>,
any_probe: Arc<Mutex<HashMap<String, AnyProbe>>>,
rejected: Arc<Mutex<Vec<String>>>,
}
impl Default for SchemaDeserializer {
fn default() -> Self {
Self::new()
}
}
impl SchemaDeserializer {
#[must_use]
pub fn new() -> Self {
Self {
path: Vec::new(),
schema: Arc::new(Mutex::new(HashMap::new())),
any_probe: Arc::new(Mutex::new(HashMap::new())),
rejected: Arc::new(Mutex::new(Vec::new())),
}
}
#[must_use]
pub fn into_schema(self) -> HashMap<String, HashSet<String>> {
let lock = self
.schema
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
lock.clone()
}
}
impl<'de> de::Deserializer<'de> for SchemaDeserializer {
type Error = serde::de::value::Error;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let path = self.path.join(".");
let probe = self
.any_probe
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&path)
.copied()
.unwrap_or(AnyProbe::Str);
let result = match probe {
AnyProbe::Str => visitor.visit_str("0"),
AnyProbe::Map => visitor.visit_map(SchemaMapAccess {
fields: [].iter(),
current_field: None,
deserializer: self.clone(),
}),
AnyProbe::Seq => visitor.visit_seq(SchemaSeqAccess {
done: true,
deserializer: self.clone(),
}),
};
if result.is_err() {
self.rejected
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path);
}
result
}
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_bool(false)
}
fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i8(0)
}
fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i16(0)
}
fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i32(0)
}
fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i64(0)
}
fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u8(0)
}
fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u16(0)
}
fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u32(0)
}
fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u64(0)
}
fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_f32(0.0)
}
fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_f64(0.0)
}
fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_char('\0')
}
fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_str("")
}
fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_string(String::new())
}
fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_bytes(&[])
}
fn deserialize_byte_buf<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_byte_buf(Vec::new())
}
fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_some(self)
}
fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn deserialize_unit_struct<V>(
self,
_name: &'static str,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn deserialize_newtype_struct<V>(
self,
_name: &'static str,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_newtype_struct(self)
}
fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_seq(SchemaSeqAccess {
done: false,
deserializer: self,
})
}
fn deserialize_tuple<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.deserialize_seq(visitor)
}
fn deserialize_tuple_struct<V>(
self,
_name: &'static str,
_len: usize,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.deserialize_seq(visitor)
}
fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_map(SchemaMapAccess {
fields: [].iter(),
current_field: None,
deserializer: self,
})
}
fn deserialize_struct<V>(
self,
_name: &'static str,
fields: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let path_str = self.path.join(".");
{
let mut schema = self.schema.lock().unwrap();
schema.insert(path_str, fields.iter().map(|&s| s.to_string()).collect());
}
visitor.visit_map(SchemaMapAccess {
fields: fields.iter(),
current_field: None,
deserializer: self,
})
}
fn deserialize_enum<V>(
self,
_name: &'static str,
variants: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_enum(SchemaEnumAccess {
variant: variants.first().copied().unwrap_or_default(),
})
}
fn deserialize_identifier<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_str("")
}
fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
}
struct SchemaSeqAccess {
done: bool,
deserializer: SchemaDeserializer,
}
impl<'de> SeqAccess<'de> for SchemaSeqAccess {
type Error = serde::de::value::Error;
fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
where
T: DeserializeSeed<'de>,
{
if self.done {
Ok(None)
} else {
self.done = true;
seed.deserialize(self.deserializer.clone()).map(Some)
}
}
}
struct SchemaMapAccess {
fields: std::slice::Iter<'static, &'static str>,
current_field: Option<&'static str>,
deserializer: SchemaDeserializer,
}
impl<'de> MapAccess<'de> for SchemaMapAccess {
type Error = serde::de::value::Error;
fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
where
K: DeserializeSeed<'de>,
{
if let Some(&field) = self.fields.next() {
self.current_field = Some(field);
seed.deserialize(de::value::StrDeserializer::new(field))
.map(Some)
} else {
Ok(None)
}
}
fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
where
V: DeserializeSeed<'de>,
{
let field = self.current_field.take().unwrap();
let mut new_path = self.deserializer.path.clone();
new_path.push(field.to_string());
let nested = SchemaDeserializer {
path: new_path,
schema: self.deserializer.schema.clone(),
any_probe: self.deserializer.any_probe.clone(),
rejected: self.deserializer.rejected.clone(),
};
seed.deserialize(nested)
}
}
struct SchemaEnumAccess {
variant: &'static str,
}
impl<'de> de::EnumAccess<'de> for SchemaEnumAccess {
type Error = serde::de::value::Error;
type Variant = Self;
fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
where
V: de::DeserializeSeed<'de>,
{
let val = seed.deserialize(de::value::StrDeserializer::new(self.variant))?;
Ok((val, self))
}
}
impl<'de> de::VariantAccess<'de> for SchemaEnumAccess {
type Error = serde::de::value::Error;
fn unit_variant(self) -> Result<(), Self::Error> {
Ok(())
}
fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
where
T: de::DeserializeSeed<'de>,
{
seed.deserialize(SchemaDeserializer::new())
}
fn tuple_variant<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn struct_variant<V>(
self,
_fields: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeEnv(std::collections::HashMap<String, String>);
impl Env for FakeEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.0
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
#[test]
fn test_schema_extractor() {
let keys = AutumnConfig::get_schema_keys();
assert!(keys.contains_key(""));
let root_keys = &keys[""];
assert!(root_keys.contains("server"));
assert!(root_keys.contains("database"));
assert!(keys.contains_key("server"));
assert!(keys["server"].contains("port"));
assert!(keys["server"].contains("host"));
assert!(keys.contains_key("database"));
assert!(keys["database"].contains("primary_url"));
}
#[cfg(feature = "acme")]
#[test]
fn acme_enum_field_does_not_truncate_schema_traversal() {
let keys = AutumnConfig::get_schema_keys();
assert!(
keys.contains_key("server.tls.acme"),
"acme section must be in the schema"
);
assert!(
keys.contains_key("database"),
"database schema dropped: the acme enum truncated traversal"
);
assert!(keys["database"].contains("primary_url"));
let errs = AutumnConfig::validate_toml("[database]\nprimry_url = \"x\"\n", &keys);
assert_eq!(
errs,
vec![(
"database.primry_url".to_owned(),
Some("database.primary_url".to_owned())
)]
);
}
#[test]
fn test_strict_config_startup_fails_on_typo() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[database]\nprimry_url = \"postgres://localhost/db\"",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(res.is_err());
let err_str = format!("{:?}", res.err().unwrap());
assert!(err_str.contains("primry_url"));
}
fn strict_prod_env_2063(temp: &std::path::Path) -> FakeEnv {
FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.to_str().unwrap().to_owned(),
),
]
.into(),
)
}
#[test]
fn deploy_cli_lenient_accepts_plugin_owned_top_level_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[media]\nmediamtx_host = \"cdn.example\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let strict = AutumnConfig::load_with_env(&env);
assert!(
strict.is_err(),
"app boot must stay strict for unknown plugin roots: {strict:?}"
);
let strict_err = format!("{:?}", strict.err().unwrap());
assert!(
strict_err.contains("media"),
"strict error should name the unknown root: {strict_err}"
);
let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
lenient.is_ok(),
"deploy CLI must accept plugin-owned [media] under strict_config: {lenient:?}"
);
}
#[test]
fn deploy_cli_lenient_accepts_arbitrary_unknown_top_level_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[definitely_not_a_root]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
assert!(
AutumnConfig::load_with_env(&env).is_err(),
"app boot must reject an unknown top-level root"
);
assert!(
AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_ok(),
"deploy CLI must accept an unknown top-level root as opaque"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_known_section_typo() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[database]\nprimry_url = \"postgres://localhost/db\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"known-section typo must still hard-fail under the lenient CLI load: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("primry_url"),
"error should name the known-section typo: {err}"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_malformed_toml() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\nthis is not = = valid toml\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
assert!(
AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_err(),
"malformed TOML must still fail under the lenient CLI load"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_profile_prefixed_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[profile.prod.media]\nmediamtx_host = \"cdn.example\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"a profile-prefixed root ([profile.prod.media]) must stay strict under \
the lenient CLI load — it is not a true top-level root and the deployed \
app rejects it at boot: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("media"),
"error should name the profile-prefixed root: {err}"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_profile_prefixed_unknown_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[profile.prod.definitely_unknown]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"a profile-prefixed unknown root must stay strict under the lenient CLI \
load: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("definitely_unknown"),
"error should name the profile-prefixed unknown root: {err}"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_non_table_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\nmedia = \"enabled\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"a SCALAR top-level root (media = \"enabled\") must hard-fail under the \
lenient CLI load — it is not a table and the deployed app rejects it at \
boot: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("media"),
"error should name the non-table root: {err}"
);
let temp2 = tempfile::tempdir().unwrap();
std::fs::write(
temp2.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\nmedia = [\"a\", \"b\"]\n",
)
.unwrap();
let env2 = strict_prod_env_2063(temp2.path());
let res2 = AutumnConfig::load_with_env_lenient_unknown_roots(&env2);
assert!(
res2.is_err(),
"an ARRAY top-level root (media = [\"a\", \"b\"]) must hard-fail under the \
lenient CLI load — it is not a table and the deployed app rejects it at \
boot: {res2:?}"
);
let err2 = format!("{:?}", res2.err().unwrap());
assert!(
err2.contains("media"),
"error should name the non-table root: {err2}"
);
}
#[test]
fn deploy_cli_lenient_accepts_quoted_dotted_top_level_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[\"my.plugin\"]\nenabled = true\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
lenient.is_ok(),
"deploy CLI must leniently accept a quoted-dotted TOP-LEVEL table root \
([\"my.plugin\"]) — it is a true top-level plugin root the app accepts at \
boot, and top-level-ness is structural (empty parent path), not \
`path.contains('.')`: {lenient:?}"
);
let temp2 = tempfile::tempdir().unwrap();
std::fs::write(
temp2.path().join("autumn.toml"),
"\"my.plugin\" = { enabled = true }\n\n[server]\nstrict_config = true\n",
)
.unwrap();
let env2 = strict_prod_env_2063(temp2.path());
assert!(
AutumnConfig::load_with_env_lenient_unknown_roots(&env2).is_ok(),
"deploy CLI must accept the inline-table quoted-dotted top-level root too"
);
}
#[test]
fn post_database_section_typo_warns_but_does_not_fail() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[log]\nbogus_zzz = true\n").unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_ok(),
"a post-database section typo must warn (not fail) under warn-first rollout: {res:?}"
);
}
#[test]
fn post_database_section_typo_fails_under_enforce_all() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n[log]\nbogus_zzz = true\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"strict_config_enforce_all must hard-fail the post-database typo"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("bogus_zzz"),
"error should name the key: {err_str}"
);
}
#[test]
fn pre_database_section_typo_still_hard_fails() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[server]\nbogus_zzz = true\n").unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"an unknown [server] key must still hard-fail (pre-fix strictness preserved)"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("bogus_zzz"),
"error should name the key: {err_str}"
);
}
fn plugin_roots(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| (*s).to_owned()).collect()
}
fn strict_prod_env(dir: &std::path::Path, enforce_all: bool) -> FakeEnv {
let mut vars = vec![
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
dir.to_str().unwrap().to_owned(),
),
];
if enforce_all {
vars.push((
"AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL".to_owned(),
"true".to_owned(),
));
}
FakeEnv(vars.into_iter().collect())
}
#[test]
fn strict_config_accepts_registered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n[media.mediamtx]\napi_base = \"http://localhost:9997\"\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_ok(),
"a registered [media] root must boot under strict_config: {res:?}"
);
}
#[test]
fn strict_config_accepts_quoted_dotted_registered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[\"my.plugin\"]\nenabled = true\n[\"my.plugin\".nested]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["my.plugin"]));
assert!(
res.is_ok(),
"a registered quoted-dotted top-level root ([\"my.plugin\"]) must boot \
under strict_config, exactly as deploy leniency accepts it: {res:?}"
);
}
#[test]
fn strict_config_rejects_non_table_registered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("autumn.toml"), "media = \"enabled\"\n").unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"a scalar-valued registered root (media = \"enabled\") must hard-fail \
under strict_config, not be exempted as an opaque table: {res:?}"
);
assert!(
format!("{:?}", res.err().unwrap()).contains("media"),
"error should name the malformed media root"
);
let temp_arr = tempfile::tempdir().unwrap();
std::fs::write(
temp_arr.path().join("autumn.toml"),
"media = [\"a\", \"b\"]\n",
)
.unwrap();
let env_arr = strict_prod_env(temp_arr.path(), false);
let res_arr =
AutumnConfig::load_with_env_and_plugin_roots(&env_arr, &plugin_roots(&["media"]));
assert!(
res_arr.is_err(),
"an array-valued registered root (media = [\"a\", \"b\"]) must hard-fail \
under strict_config, not be exempted as an opaque table: {res_arr:?}"
);
assert!(
format!("{:?}", res_arr.err().unwrap()).contains("media"),
"error should name the malformed media array root"
);
}
#[test]
fn strict_config_rejects_unregistered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
assert!(
res.is_err(),
"an unregistered [media] root must still hard-fail under strict_config"
);
assert!(format!("{:?}", res.err().unwrap()).contains("media"));
}
#[test]
fn strict_config_still_rejects_other_unknown_root_when_plugin_registered() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n\n[definitely_not_a_root]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"an unrelated unknown root must still hard-fail even with [media] registered"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("definitely_not_a_root"),
"error should name the unknown root: {err_str}"
);
}
#[test]
fn registered_plugin_root_is_opaque_under_enforce_all() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nwholly_made_up = true\n[media.deeply.nested]\nalso_bogus = 42\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), true);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_ok(),
"enforce_all must NOT flag children of a registered opaque root: {res:?}"
);
}
#[test]
fn strict_config_still_rejects_profile_prefixed_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[profile.prod.media]\nwholly_made_up = true\n\
[profile.prod.media.deeply.nested]\nalso_bogus = 42\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), true);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"a profile-prefixed plugin root ([profile.prod.media]) must stay strict \
and be rejected — the plugin reads only the top-level [media] table, so \
exempting the profile layer would boot silently on default config: {res:?}"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("media"),
"error should name the media/profile root: {err_str}"
);
}
#[test]
fn strict_config_rejects_profile_prefixed_unknown_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[profile.prod.definitely_not_a_root]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"a profile-prefixed genuinely-unknown root must still hard-fail even \
with [media] registered (the fix must not blanket-allow profile subtrees)"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("definitely_not_a_root"),
"error should name the unknown root: {err_str}"
);
}
#[test]
fn non_strict_config_tolerates_media_root_without_registration() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
assert!(
res.is_ok(),
"non-strict config must tolerate an unregistered [media] root: {res:?}"
);
}
#[test]
fn malformed_profile_entry_still_hard_fails() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[profile]\ndev = \"prod\"\n").unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"a malformed [profile] entry is a structural error that must keep \
hard-failing under strict_config (not be demoted to warn-only): {res:?}"
);
assert!(
matches!(res.err().unwrap(), ConfigError::Validation(_)),
"malformed profile entry must fail as a validation error"
);
let temp2 = tempfile::tempdir().unwrap();
let config_path2 = temp2.path().join("autumn.toml");
std::fs::write(&config_path2, "[resilience]\nboguz = 1\n").unwrap();
let env2 = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp2.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res2 = AutumnConfig::load_with_env(&env2);
assert!(
res2.is_ok(),
"a newly-#1890-covered section typo must still only warn under \
strict_config (enforce_all off), proving the profile fix is narrow: {res2:?}"
);
}
#[test]
fn dotted_profile_name_preserves_strictness() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[profile.\"prod.eu\".server]\nbogus_zzz = true\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"a [server] typo under a quoted dotted profile name must hard-fail \
(pre-#1890 strictness must not be downgraded by string-splitting the \
joined path): {res:?}"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("server") && err_str.contains("bogus_zzz"),
"hard-fail must be for the [server] typo (right reason): {err_str}"
);
let temp2 = tempfile::tempdir().unwrap();
let config_path2 = temp2.path().join("autumn.toml");
std::fs::write(
&config_path2,
"[profile.\"prod.eu\".resilience]\nboguz = 1\n",
)
.unwrap();
let env2 = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp2.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res2 = AutumnConfig::load_with_env(&env2);
assert!(
res2.is_ok(),
"a newly-#1890-covered section typo under a quoted dotted profile name \
must still only warn under strict_config (enforce_all off): {res2:?}"
);
}
#[test]
fn statement_timeout_duration_field_loads() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[database]\nstatement_timeout = \"30s\"\n").unwrap();
let env = FakeEnv(
[(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
)]
.into(),
);
let config =
AutumnConfig::load_with_env(&env).expect("config with duration string must load");
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::from_secs(30))
);
std::fs::write(&config_path, "[database]\nstatement_timeout = 250\n").unwrap();
let config =
AutumnConfig::load_with_env(&env).expect("config with integer duration must load");
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::from_millis(250))
);
}
#[test]
fn should_warn_total_connections_at_and_above_threshold() {
assert!(should_warn_total_connections(100, 100));
assert!(should_warn_total_connections(250, 100));
assert!(!should_warn_total_connections(99, 100));
}
#[test]
fn should_warn_total_connections_zero_threshold_disables() {
assert!(!should_warn_total_connections(0, 0));
assert!(!should_warn_total_connections(10_000, 0));
}
#[test]
fn database_config_default_warn_threshold_is_100() {
assert_eq!(
DatabaseConfig::default().max_connections_warn_threshold,
100
);
}
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_topology_deserializes_primary_and_replica_urls() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
primary_url = "postgres://primary.example/app"
replica_url = "postgres://replica.example/app"
primary_pool_size = 12
replica_pool_size = 4
replica_fallback = "primary"
"#,
)
.expect("database topology config should parse");
assert_eq!(
config.database.primary_url.as_deref(),
Some("postgres://primary.example/app")
);
assert_eq!(
config.database.replica_url.as_deref(),
Some("postgres://replica.example/app")
);
assert_eq!(config.database.primary_pool_size, Some(12));
assert_eq!(config.database.replica_pool_size, Some(4));
assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://primary.example/app")
);
assert_eq!(config.database.effective_primary_pool_size(), 12);
assert_eq!(config.database.effective_replica_pool_size(), 4);
}
#[test]
fn database_topology_keeps_url_as_single_primary_compatibility_path() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
url = "postgres://single.example/app"
pool_size = 7
"#,
)
.expect("legacy database.url config should parse");
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://single.example/app")
);
assert_eq!(config.database.effective_primary_pool_size(), 7);
assert_eq!(config.database.effective_replica_pool_size(), 7);
assert!(config.database.replica_url.is_none());
}
#[test]
fn database_topology_rejects_replica_without_primary() {
let config = DatabaseConfig {
replica_url: Some("postgres://replica.example/app".to_owned()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let Err(ConfigError::Validation(message)) = result else {
panic!("expected database topology validation error");
};
assert!(message.contains("database.replica_url"));
assert!(message.contains("database.primary_url"));
}
#[test]
fn time_zone_identifier_env_override_applies() {
let env = MockEnv::new().with("AUTUMN_TIME_ZONE__IDENTIFIER", "America/New_York");
let mut config = AutumnConfig::default();
assert_eq!(config.time_zone.identifier, "UTC");
config.apply_env_overrides_with_env(&env);
assert_eq!(config.time_zone.identifier, "America/New_York");
assert!(config.time_zone.validate().is_ok());
}
#[test]
fn alerts_severities_env_overrides_apply() {
let env = MockEnv::new()
.with("AUTUMN_ALERTS__SLACK_SEVERITIES", "critical")
.with("AUTUMN_ALERTS__PAGERDUTY_SEVERITIES", "all")
.with("AUTUMN_ALERTS__DISCORD_SEVERITIES", "critical");
let mut config = AutumnConfig::default();
assert_eq!(
config.alerts.slack_severities,
crate::alerts::AlertRouting::All
);
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.alerts.slack_severities,
crate::alerts::AlertRouting::Critical,
"AUTUMN_ALERTS__SLACK_SEVERITIES=critical must set the Slack channel routing"
);
assert_eq!(
config.alerts.discord_severities,
crate::alerts::AlertRouting::Critical
);
assert_eq!(
config.alerts.pagerduty_severities,
crate::alerts::AlertRouting::All
);
}
#[test]
fn database_topology_env_overrides_role_fields() {
let env = MockEnv::new()
.with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
.with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
.with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
.with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
.with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.primary_url.as_deref(),
Some("postgres://primary.env/app")
);
assert_eq!(
config.database.replica_url.as_deref(),
Some("postgres://replica.env/app")
);
assert_eq!(config.database.primary_pool_size, Some(9));
assert_eq!(config.database.replica_pool_size, Some(3));
assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
}
#[test]
fn database_shards_parse_from_toml_with_effective_fallbacks() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
primary_url = "postgres://control.example/app"
pool_size = 8
replica_fallback = "primary"
[[database.shards]]
name = "shard0"
primary_url = "postgres://shard0.example/app"
[[database.shards]]
name = "shard1"
primary_url = "postgres://shard1.example/app"
replica_url = "postgres://shard1-ro.example/app"
primary_pool_size = 3
replica_pool_size = 2
replica_fallback = "fail_readiness"
"#,
)
.expect("sharded database config should parse");
let db = &config.database;
assert!(db.has_shards());
assert_eq!(db.shards.len(), 2);
let shard0 = &db.shards[0];
assert_eq!(shard0.name, "shard0");
assert_eq!(shard0.primary_url, "postgres://shard0.example/app");
assert!(shard0.replica_url.is_none());
assert_eq!(shard0.effective_primary_pool_size(db), 8);
assert_eq!(shard0.effective_replica_pool_size(db), 8);
assert_eq!(
shard0.effective_replica_fallback(db),
ReplicaFallback::Primary
);
let shard1 = &db.shards[1];
assert_eq!(shard1.effective_primary_pool_size(db), 3);
assert_eq!(shard1.effective_replica_pool_size(db), 2);
assert_eq!(
shard1.effective_replica_fallback(db),
ReplicaFallback::FailReadiness
);
config.validate().expect("sharded config should validate");
}
#[test]
fn database_shards_default_to_empty() {
let config = AutumnConfig::default();
assert!(!config.database.has_shards());
assert!(config.database.shards.is_empty());
}
#[test]
fn database_shard_env_overrides_existing_entry_fields() {
let mut config: AutumnConfig = toml::from_str(
r#"
[[database.shards]]
name = "shard0"
primary_url = "postgres://toml.example/app"
"#,
)
.expect("config should parse");
let env = MockEnv::new()
.with(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
"postgres://env.example/app",
)
.with(
"AUTUMN_DATABASE__SHARDS__0__REPLICA_URL",
"postgres://env-ro.example/app",
)
.with("AUTUMN_DATABASE__SHARDS__0__PRIMARY_POOL_SIZE", "5")
.with("AUTUMN_DATABASE__SHARDS__0__REPLICA_FALLBACK", "primary");
config.apply_env_overrides_with_env(&env);
let shard = &config.database.shards[0];
assert_eq!(shard.name, "shard0");
assert_eq!(shard.primary_url, "postgres://env.example/app");
assert_eq!(
shard.replica_url.as_deref(),
Some("postgres://env-ro.example/app")
);
assert_eq!(shard.primary_pool_size, Some(5));
assert_eq!(shard.replica_fallback, Some(ReplicaFallback::Primary));
}
#[test]
fn database_shard_env_appends_new_entry_when_name_and_primary_url_present() {
let mut config = AutumnConfig::default();
let env = MockEnv::new()
.with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0")
.with(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
"postgres://shard0.env/app",
)
.with("AUTUMN_DATABASE__SHARDS__1__NAME", "shard1")
.with(
"AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
"postgres://shard1.env/app",
)
.with("AUTUMN_DATABASE__SHARDS__3__NAME", "orphan")
.with(
"AUTUMN_DATABASE__SHARDS__3__PRIMARY_URL",
"postgres://orphan.env/app",
);
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.shards.len(), 2);
assert_eq!(config.database.shards[0].name, "shard0");
assert_eq!(config.database.shards[1].name, "shard1");
}
#[test]
fn database_shard_env_does_not_append_incomplete_entry() {
let mut config = AutumnConfig::default();
let env = MockEnv::new().with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0");
config.apply_env_overrides_with_env(&env);
assert!(config.database.shards.is_empty());
}
fn shard(name: &str, primary_url: &str) -> ShardConfig {
ShardConfig {
name: name.to_owned(),
primary_url: primary_url.to_owned(),
slots: None,
replica_url: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}
}
fn shard_with_slots(name: &str, primary_url: &str, slots: &[&str]) -> ShardConfig {
let mut config = shard(name, primary_url);
config.slots = Some(
slots
.iter()
.map(|spec| SlotSpec::Range((*spec).to_owned()))
.collect(),
);
config
}
#[test]
fn slot_spec_expands_indices_and_ranges() {
assert_eq!(SlotSpec::Index(5).expand().unwrap(), vec![5]);
assert_eq!(SlotSpec::Range("7".to_owned()).expand().unwrap(), vec![7]);
assert_eq!(
SlotSpec::Range("3-6".to_owned()).expand().unwrap(),
vec![3, 4, 5, 6]
);
assert!(SlotSpec::Range("6-3".to_owned()).expand().is_err());
assert!(SlotSpec::Range("x-3".to_owned()).expand().is_err());
assert!(SlotSpec::Range(String::new()).expand().is_err());
}
#[test]
fn slot_map_auto_splits_contiguously_by_declaration_order() {
let config = DatabaseConfig {
shards: vec![
shard("a", "postgres://a/app"),
shard("b", "postgres://b/app"),
shard("c", "postgres://c/app"),
],
..Default::default()
};
let map = config
.resolved_slot_map()
.expect("auto-split should resolve");
assert_eq!(map.len(), usize::from(SLOT_COUNT));
assert_eq!((map[0], map[5461]), (0, 0));
assert_eq!((map[5462], map[10922]), (1, 1));
assert_eq!((map[10923], map[16383]), (2, 2));
assert!(map.windows(2).all(|w| w[0] <= w[1]), "must be contiguous");
for owner in 0..3 {
let count = map.iter().filter(|&&o| o == owner).count();
assert!(
(5461..=5462).contains(&count),
"shard {owner} owns {count} slots (expected near-even split)"
);
}
}
#[test]
fn slot_map_uses_explicit_assignments_regardless_of_order() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("late", "postgres://late/app", &["8192-16383"]),
shard_with_slots("early", "postgres://early/app", &["0-8191"]),
],
..Default::default()
};
let map = config
.resolved_slot_map()
.expect("explicit map should resolve");
assert!(map[..8192].iter().all(|&owner| owner == 1));
assert!(map[8192..].iter().all(|&owner| owner == 0));
}
#[test]
fn slot_map_allows_drained_shard_with_empty_slots() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("live", "postgres://live/app", &["0-16383"]),
shard_with_slots("drained", "postgres://drained/app", &[]),
],
..Default::default()
};
let map = config
.resolved_slot_map()
.expect("drained shard is allowed");
assert_eq!(map.len(), usize::from(SLOT_COUNT));
assert!(map.iter().all(|&owner| owner == 0));
}
#[test]
fn slot_map_rejects_mixed_declared_and_undeclared_slots() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-16383"]),
shard("b", "postgres://b/app"),
],
..Default::default()
};
assert!(config.resolved_slot_map().is_err());
}
#[test]
fn slot_map_rejects_overlap_gap_and_out_of_range() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-8192"]),
shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
],
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
panic!("overlapping slots should fail");
};
assert!(message.contains("already owned"));
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-8000"]),
shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
],
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
panic!("uncovered slots should fail");
};
assert!(message.contains("unassigned"));
assert!(message.contains("8001-8191"), "got: {message}");
let config = DatabaseConfig {
shards: vec![shard_with_slots("a", "postgres://a/app", &["0-16384"])],
..Default::default()
};
assert!(config.resolved_slot_map().is_err());
}
#[test]
fn slot_map_rejects_more_shards_than_slots() {
let config = DatabaseConfig {
shards: (0..=usize::from(SLOT_COUNT))
.map(|i| shard(&format!("s{i}"), "postgres://s/app"))
.collect(),
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
panic!("more shards than slots cannot auto-split");
};
assert!(message.contains("at most"), "got: {message}");
}
#[test]
fn slots_parse_from_toml_ints_and_ranges() {
let config: AutumnConfig = toml::from_str(
r#"
[[database.shards]]
name = "a"
primary_url = "postgres://a/app"
slots = ["0-8191", 8192, "8193"]
[[database.shards]]
name = "b"
primary_url = "postgres://b/app"
slots = ["8194-16383"]
"#,
)
.expect("slots config should parse");
let map = config
.database
.resolved_slot_map()
.expect("mixed int/range specs should resolve");
assert!(map[..8194].iter().all(|&owner| owner == 0));
assert!(map[8194..].iter().all(|&owner| owner == 1));
config.validate().expect("config should validate");
}
#[test]
fn slot_env_overrides_assignments() {
let mut config = AutumnConfig::default();
let env = MockEnv::new()
.with("AUTUMN_DATABASE__SHARDS__0__NAME", "a")
.with(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
"postgres://a/app",
)
.with("AUTUMN_DATABASE__SHARDS__0__SLOTS", "0-8191, 12288-16383")
.with("AUTUMN_DATABASE__SHARDS__1__NAME", "b")
.with(
"AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
"postgres://b/app",
)
.with("AUTUMN_DATABASE__SHARDS__1__SLOTS", "8192-12287");
config.apply_env_overrides_with_env(&env);
let map = config
.database
.resolved_slot_map()
.expect("env slot specs should resolve");
assert!(map[..8192].iter().all(|&owner| owner == 0));
assert!(map[8192..12288].iter().all(|&owner| owner == 1));
assert!(map[12288..].iter().all(|&owner| owner == 0));
}
#[test]
fn slot_ranges_format_compactly() {
assert_eq!(format_slot_ranges(&[]), "");
assert_eq!(format_slot_ranges(&[3]), "3");
assert_eq!(format_slot_ranges(&[0, 1, 2, 5, 7, 8]), "0-2, 5, 7-8");
}
#[test]
fn database_shard_validation_rejects_bad_names() {
for bad_name in ["", "Shard0", "shard 0", "shard:0", "shärd"] {
let config = DatabaseConfig {
shards: vec![shard(bad_name, "postgres://s0.example/app")],
..Default::default()
};
assert!(
config.validate().is_err(),
"shard name should be rejected: {bad_name:?}"
);
}
}
#[test]
fn database_shard_validation_rejects_duplicate_names() {
let config = DatabaseConfig {
shards: vec![
shard("shard0", "postgres://a.example/app"),
shard("shard0", "postgres://b.example/app"),
],
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.validate() else {
panic!("duplicate shard names should fail validation");
};
assert!(message.contains("unique"));
}
#[test]
fn database_shard_validation_rejects_bad_urls() {
let config = DatabaseConfig {
shards: vec![shard("shard0", "mysql://s0.example/app")],
..Default::default()
};
assert!(config.validate().is_err());
let mut with_bad_replica = shard("shard0", "postgres://s0.example/app");
with_bad_replica.replica_url = Some("http://s0-ro.example/app".to_owned());
let config = DatabaseConfig {
shards: vec![with_bad_replica],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn database_shards_without_control_role_are_allowed() {
let config = DatabaseConfig {
shards: vec![shard("shard0", "postgres://s0.example/app")],
..Default::default()
};
config
.validate()
.expect("shards without a control role should validate");
}
#[test]
fn postgres_scheduler_with_shards_requires_control_database() {
let mut config = AutumnConfig::default();
config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
config.scheduler.backend = SchedulerBackend::Postgres;
let Err(ConfigError::Validation(message)) = config.validate() else {
panic!("postgres scheduler without a control database should fail validation");
};
assert!(message.contains("control database"));
config.database.primary_url = Some("postgres://control.example/app".to_owned());
config
.validate()
.expect("control role should satisfy the scheduler requirement");
}
#[test]
fn postgres_jobs_with_shards_requires_control_database() {
let mut config = AutumnConfig::default();
config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
config.jobs.backend = "postgres".to_owned();
assert!(config.validate().is_err());
config.database.url = Some("postgres://control.example/app".to_owned());
config
.validate()
.expect("legacy url should satisfy the jobs requirement");
}
#[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 access_log_defaults_on_with_probe_and_asset_exclusions() {
let log = LogConfig::default();
assert!(log.access_log);
assert_eq!(
log.access_log_exclude,
vec![
"/health",
"/live",
"/ready",
"/startup",
"/actuator",
"/static"
]
);
}
#[test]
fn env_override_access_log_off() {
let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(!config.log.access_log);
}
#[test]
fn env_override_access_log_exclude_csv() {
let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
}
#[test]
fn access_log_is_configurable_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(
&path,
"[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
)
.unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert!(!config.log.access_log);
assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
}
#[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_actuator_prometheus_disables() {
let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
let mut config = AutumnConfig::default();
assert!(config.actuator.prometheus, "default should be enabled");
config.apply_env_overrides_with_env(&env);
assert!(
!config.actuator.prometheus,
"AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
);
}
#[test]
fn env_override_actuator_sensitive() {
let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
let mut config = AutumnConfig::default();
assert!(!config.actuator.sensitive);
config.apply_env_overrides_with_env(&env);
assert!(config.actuator.sensitive);
}
#[test]
fn env_override_upload_reject_on_content_type_mismatch() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
"true",
);
let mut config = AutumnConfig::default();
assert!(!config.security.upload.reject_on_content_type_mismatch);
config.apply_env_overrides_with_env(&env);
assert!(config.security.upload.reject_on_content_type_mismatch);
}
#[test]
fn env_override_actuator_prefix() {
let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.actuator.prefix, "/ops");
}
#[test]
fn env_override_database_url_wins_over_file_primary_url() {
let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
let mut config = AutumnConfig::default();
config.database.primary_url = Some("postgres://file.example/app".to_owned());
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://env.example/app")
);
assert!(config.database.primary_url.is_none());
}
#[test]
fn env_override_database_primary_url_wins_over_legacy_database_url() {
let env = MockEnv::new()
.with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
.with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
let mut config = AutumnConfig::default();
config.database.primary_url = Some("postgres://file.example/app".to_owned());
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://primary.env/app")
);
assert_eq!(
config.database.url.as_deref(),
Some("postgres://legacy.env/app")
);
}
#[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);
}
#[cfg(feature = "reporting")]
#[test]
fn env_override_reporting() {
let env = MockEnv::new()
.with("AUTUMN_REPORTING__ENABLED", "false")
.with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
let mut config = AutumnConfig::default();
assert!(config.reporting.enabled);
assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
config.apply_env_overrides_with_env(&env);
assert!(!config.reporting.enabled);
assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
}
#[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_read_your_writes() {
let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "request");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.read_your_writes, ReadYourWrites::Request);
}
#[test]
fn env_override_read_your_writes_session() {
let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "session");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.read_your_writes, ReadYourWrites::Session);
}
#[test]
fn env_override_pin_after_write_secs() {
let env = MockEnv::new().with("AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS", "10");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.pin_after_write_secs, 10);
}
#[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_magic_link_ttl_minutes() {
let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 45);
}
#[test]
fn env_override_magic_link_email_cooldown_secs() {
let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
}
#[test]
fn env_override_magic_link_overrides_toml_value() {
let env = MockEnv::new()
.with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45")
.with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
let mut config = AutumnConfig::default();
config.auth.magic_link.ttl_minutes = 30;
config.auth.magic_link.email_cooldown_secs = 200;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 45);
assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
}
#[test]
fn env_unset_leaves_magic_link_toml_value_intact() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.auth.magic_link.ttl_minutes = 30;
config.auth.magic_link.email_cooldown_secs = 200;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 30);
assert_eq!(config.auth.magic_link.email_cooldown_secs, 200);
}
#[test]
fn env_override_invalid_magic_link_ttl_minutes_ignored() {
let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "not_a_number");
let mut config = AutumnConfig::default();
config.auth.magic_link.ttl_minutes = 30;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 30);
}
#[test]
fn startup_wait_secs_default_is_zero() {
assert_eq!(DatabaseConfig::default().startup_wait_secs, 0);
}
#[test]
fn env_override_startup_wait_secs() {
let env = MockEnv::new().with("AUTUMN_DATABASE__STARTUP_WAIT_SECS", "60");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.startup_wait_secs, 60);
}
#[test]
fn startup_wait_secs_parses_from_toml() {
let config: AutumnConfig = toml::from_str("[database]\nstartup_wait_secs = 30").unwrap();
assert_eq!(config.database.startup_wait_secs, 30);
}
#[cfg(feature = "storage")]
#[test]
fn env_override_storage_fields() {
let env = MockEnv::new()
.with("AUTUMN_STORAGE__BACKEND", "s3")
.with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
.with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
.with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
.with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
.with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
.with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
.with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
.with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
.with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
.with(
"AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
"https://cdn.example.test",
)
.with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
.with(
"AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
"AWS_SECRET_ACCESS_KEY",
)
.with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
.with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
.with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
.with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
.with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
assert_eq!(config.storage.default_provider, "media");
assert!(config.storage.allow_local_in_production);
assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
assert_eq!(config.storage.local.mount_path, "/files");
assert_eq!(config.storage.local.default_url_expiry_secs, 42);
assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
assert_eq!(
config.storage.s3.endpoint.as_deref(),
Some("https://s3.example.test")
);
assert_eq!(
config.storage.s3.public_base_url.as_deref(),
Some("https://cdn.example.test")
);
assert_eq!(
config.storage.s3.access_key_id_env.as_deref(),
Some("AWS_ACCESS_KEY_ID")
);
assert_eq!(
config.storage.s3.secret_access_key_env.as_deref(),
Some("AWS_SECRET_ACCESS_KEY")
);
assert!(config.storage.s3.force_path_style);
assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
assert_eq!(config.storage.variants.max_source_width, 2_000);
assert_eq!(config.storage.variants.max_source_height, 1_500);
}
#[test]
fn backup_offsite_parses_from_toml() {
let toml = r#"
[backup.offsite]
prefix = "db"
keep = 5
auto_upload = true
allow_shared_bucket = true
[backup.offsite.s3]
bucket = "offsite-backups"
region = "auto"
endpoint = "https://minio.example.test"
access_key_id_env = "OFFSITE_KEY_ID"
secret_access_key_env = "OFFSITE_SECRET"
force_path_style = true
"#;
let config: AutumnConfig = toml::from_str(toml).unwrap();
let offsite = config.backup.offsite.expect("offsite section present");
assert_eq!(offsite.prefix.as_deref(), Some("db"));
assert_eq!(offsite.keep, Some(5));
assert!(offsite.auto_upload);
assert!(offsite.allow_shared_bucket);
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite-backups"));
assert_eq!(offsite.s3.region.as_deref(), Some("auto"));
assert_eq!(
offsite.s3.endpoint.as_deref(),
Some("https://minio.example.test")
);
assert_eq!(
offsite.s3.access_key_id_env.as_deref(),
Some("OFFSITE_KEY_ID")
);
assert_eq!(
offsite.s3.secret_access_key_env.as_deref(),
Some("OFFSITE_SECRET")
);
assert!(offsite.s3.force_path_style);
}
#[test]
fn backup_offsite_defaults_to_none() {
let config = AutumnConfig::default();
assert!(config.backup.offsite.is_none());
}
#[test]
fn env_override_backup_offsite_fields() {
let env = MockEnv::new()
.with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
.with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
.with(
"AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
"https://s3.offsite.test",
)
.with("AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV", "OFF_KEY")
.with(
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
"OFF_SECRET",
)
.with("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true")
.with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
.with("AUTUMN_BACKUP__OFFSITE__KEEP", "3")
.with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true")
.with("AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config.backup.offsite.expect("materialized from env");
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
assert_eq!(
offsite.s3.endpoint.as_deref(),
Some("https://s3.offsite.test")
);
assert_eq!(offsite.s3.access_key_id_env.as_deref(), Some("OFF_KEY"));
assert_eq!(
offsite.s3.secret_access_key_env.as_deref(),
Some("OFF_SECRET")
);
assert!(offsite.s3.force_path_style);
assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
assert_eq!(offsite.keep, Some(3));
assert!(offsite.auto_upload);
assert!(offsite.allow_shared_bucket);
}
#[test]
fn env_override_backup_offsite_absent_stays_none() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.backup.offsite.is_none());
}
#[test]
fn env_override_backup_offsite_lone_opt_out_toggle_stays_none() {
for key in [
"AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
"AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
] {
let env = MockEnv::new().with(key, "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(
config.backup.offsite.is_none(),
"{key}=false must not materialize an offsite section",
);
}
}
#[test]
fn env_override_backup_offsite_truthy_auto_upload_materializes() {
let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config
.backup
.offsite
.expect("auto_upload=true materializes offsite");
assert!(offsite.auto_upload);
}
#[test]
fn env_override_backup_offsite_destination_key_materializes() {
let env = MockEnv::new()
.with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
.with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config
.backup
.offsite
.expect("a bucket key materializes offsite");
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
assert!(!offsite.auto_upload);
}
#[test]
fn env_override_backup_offsite_lone_optional_key_stays_none() {
for (key, val) in [
("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-east-1"),
("AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT", "https://s3.test"),
("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true"),
("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly"),
("AUTUMN_BACKUP__OFFSITE__KEEP", "3"),
] {
let env = MockEnv::new().with(key, val);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(
config.backup.offsite.is_none(),
"{key} is optional-only and must not materialize an offsite section",
);
}
}
#[test]
fn env_override_backup_offsite_credential_key_materializes() {
for key in [
"AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
] {
let env = MockEnv::new().with(key, "SOME_ENV_NAME");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(
config.backup.offsite.is_some(),
"{key} is a required credential signal and must materialize offsite",
);
}
}
#[test]
fn env_override_backup_offsite_bucket_only_materializes() {
let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config
.backup
.offsite
.expect("a bucket key materializes offsite");
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
}
#[test]
fn env_override_backup_offsite_region_only_applied_when_materialized() {
let env = MockEnv::new()
.with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
.with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
.with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
.with("AUTUMN_BACKUP__OFFSITE__KEEP", "5");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config.backup.offsite.expect("bucket materializes offsite");
assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
assert_eq!(offsite.keep, Some(5));
}
#[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_jobs_fields() {
let env = MockEnv::new()
.with("AUTUMN_JOBS__BACKEND", "redis")
.with("AUTUMN_JOBS__WORKERS", "8")
.with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
.with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
.with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
.with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
.with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.backend, "redis");
assert_eq!(config.jobs.workers, 8);
assert_eq!(config.jobs.max_attempts, 12);
assert_eq!(config.jobs.initial_backoff_ms, 750);
assert_eq!(
config.jobs.redis.url.as_deref(),
Some("redis://jobs:6379/2")
);
assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
}
#[test]
fn job_tracking_config_defaults_ttl_86400_and_route_enabled() {
let config = AutumnConfig::default();
assert_eq!(config.jobs.tracking.ttl_secs, 86_400);
assert!(config.jobs.tracking.route_enabled);
}
#[test]
fn env_override_jobs_tracking_fields() {
let env = MockEnv::new()
.with("AUTUMN_JOBS__TRACKING__TTL_SECS", "3600")
.with("AUTUMN_JOBS__TRACKING__ROUTE_ENABLED", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.tracking.ttl_secs, 3_600);
assert!(!config.jobs.tracking.route_enabled);
}
#[test]
fn jobs_toml_deserializes_tracking_fields() {
let config: AutumnConfig = toml::from_str(
r"
[jobs.tracking]
ttl_secs = 7200
route_enabled = false
",
)
.unwrap();
assert_eq!(config.jobs.tracking.ttl_secs, 7_200);
assert!(!config.jobs.tracking.route_enabled);
}
#[test]
fn jobs_toml_deserializes_redis_visibility_timeout() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "redis"
[jobs.redis]
url = "redis://localhost:6379/5"
key_prefix = "demo:jobs"
visibility_timeout_ms = 15000
"#,
)
.unwrap();
assert_eq!(config.jobs.backend, "redis");
assert_eq!(
config.jobs.redis.url.as_deref(),
Some("redis://localhost:6379/5")
);
assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
}
#[test]
fn job_queues_defaults_to_single_default_queue() {
let config = AutumnConfig::default();
assert!(config.jobs.queues.strict);
assert_eq!(config.jobs.queues.queues.len(), 1);
assert_eq!(config.jobs.queues.queues[0].name, "default");
assert_eq!(config.jobs.queues.queues[0].weight, 1);
}
#[test]
fn jobs_without_queues_key_keeps_single_default_queue() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "local"
workers = 4
"#,
)
.unwrap();
assert!(config.jobs.queues.strict);
assert_eq!(config.jobs.queues.queues.len(), 1);
assert_eq!(config.jobs.queues.queues[0].name, "default");
}
#[test]
fn job_queues_parse_ordered_list_as_strict_priority() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "local"
queues = ["critical", "default", "low"]
"#,
)
.unwrap();
assert!(config.jobs.queues.strict, "list form is strict priority");
let names: Vec<&str> = config
.jobs
.queues
.queues
.iter()
.map(|q| q.name.as_str())
.collect();
assert_eq!(names, ["critical", "default", "low"]);
assert!(config.jobs.queues.queues.iter().all(|q| q.weight == 1));
}
#[test]
fn job_queues_parse_weight_map_as_weighted() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "local"
[jobs.queues]
critical = 4
default = 2
low = 1
"#,
)
.unwrap();
assert!(!config.jobs.queues.strict, "map form is weighted");
let weight = |name: &str| {
config
.jobs
.queues
.queues
.iter()
.find(|q| q.name == name)
.map(|q| q.weight)
};
assert_eq!(weight("critical"), Some(4));
assert_eq!(weight("default"), Some(2));
assert_eq!(weight("low"), Some(1));
}
#[test]
fn job_queues_strict_list_rejects_duplicate_names() {
let err = toml::from_str::<AutumnConfig>(
r#"
[jobs]
queues = ["critical", "default", "critical"]
"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("duplicate queue name") && err.contains("critical"),
"unexpected error: {err}"
);
}
#[test]
fn job_queues_table_form_parses_caps_and_reserved_slots() {
let config: AutumnConfig = toml::from_str(
r"
[jobs.queues]
critical = { weight = 3, reserved = 2 }
bulk = { weight = 1, concurrency = 4 }
default = 2
",
)
.unwrap();
assert!(!config.jobs.queues.strict, "table form is weighted");
let find = |name: &str| {
config
.jobs
.queues
.queues
.iter()
.find(|q| q.name == name)
.cloned()
.unwrap()
};
let critical = find("critical");
assert_eq!(critical.weight, 3);
assert_eq!(critical.reserved, Some(2));
assert_eq!(critical.concurrency, None);
let bulk = find("bulk");
assert_eq!(bulk.weight, 1);
assert_eq!(bulk.concurrency, Some(4));
assert_eq!(bulk.reserved, None);
let default = find("default");
assert_eq!(default.weight, 2);
assert_eq!(default.concurrency, None);
assert_eq!(default.reserved, None);
}
#[test]
fn job_queues_table_form_defaults_weight_to_one() {
let config: AutumnConfig = toml::from_str(
r"
[jobs.queues]
critical = { reserved = 1 }
",
)
.unwrap();
let critical = &config.jobs.queues.queues[0];
assert_eq!(critical.weight, 1, "omitted weight defaults to 1");
assert_eq!(critical.reserved, Some(1));
}
#[test]
fn job_queues_table_form_rejects_zero_weight() {
let err = toml::from_str::<AutumnConfig>(
r"
[jobs.queues]
critical = { weight = 0, reserved = 1 }
",
)
.unwrap_err()
.to_string();
assert!(
err.contains("weight must be at least 1") && err.contains("critical"),
"unexpected error: {err}"
);
}
#[test]
fn job_queues_table_form_rejects_unknown_setting() {
let err = toml::from_str::<AutumnConfig>(
r"
[jobs.queues]
critical = { weight = 1, bogus = 3 }
",
)
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "unexpected error: {err}");
}
#[test]
fn jobs_pin_defaults_empty_and_parses_from_toml() {
let default = AutumnConfig::default();
assert!(default.jobs.pin.is_empty(), "pin is empty by default (AC4)");
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
pin = ["critical", "default"]
"#,
)
.unwrap();
assert_eq!(config.jobs.pin, vec!["critical", "default"]);
}
#[test]
fn jobs_pin_env_override_is_comma_separated() {
let env = MockEnv::new().with("AUTUMN_JOBS__PIN", "critical, bulk ,");
let mut config = AutumnConfig::default();
config.apply_jobs_env_overrides_with_env(&env);
assert_eq!(
config.jobs.pin,
vec!["critical".to_string(), "bulk".to_string()],
"trims whitespace and drops empty entries"
);
}
#[test]
fn job_queues_weighted_rejects_zero_weight() {
let err = toml::from_str::<AutumnConfig>(
r"
[jobs.queues]
critical = 4
default = 0
",
)
.unwrap_err()
.to_string();
assert!(
err.contains("weight must be at least 1") && err.contains("default"),
"unexpected error: {err}"
);
}
#[test]
fn channels_defaults_to_in_process_backend() {
let config = AutumnConfig::default();
assert_eq!(config.channels.backend, ChannelBackend::InProcess);
assert_eq!(config.channels.capacity, 32);
assert_eq!(config.channels.replay_buffer, 256);
assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
assert!(config.channels.redis.url.is_none());
}
#[test]
fn channels_env_overrides_fields() {
let env = MockEnv::new()
.with("AUTUMN_CHANNELS__BACKEND", "redis")
.with("AUTUMN_CHANNELS__CAPACITY", "128")
.with("AUTUMN_CHANNELS__REPLAY_BUFFER", "512")
.with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
.with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.channels.backend, ChannelBackend::Redis);
assert_eq!(config.channels.capacity, 128);
assert_eq!(config.channels.replay_buffer, 512);
assert_eq!(
config.channels.redis.url.as_deref(),
Some("redis://channels:6379/4")
);
assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
}
#[test]
fn channels_toml_deserializes_redis_backend() {
let config: AutumnConfig = toml::from_str(
r#"
[channels]
backend = "redis"
capacity = 64
[channels.redis]
url = "redis://localhost:6379/5"
key_prefix = "demo:channels"
"#,
)
.unwrap();
assert_eq!(config.channels.backend, ChannelBackend::Redis);
assert_eq!(config.channels.capacity, 64);
assert_eq!(
config.channels.redis.url.as_deref(),
Some("redis://localhost:6379/5")
);
assert_eq!(config.channels.redis.key_prefix, "demo:channels");
}
#[test]
fn env_override_invalid_jobs_numeric_values_ignored() {
let env = MockEnv::new()
.with("AUTUMN_JOBS__WORKERS", "many")
.with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
.with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.workers, 1);
assert_eq!(config.jobs.max_attempts, 5);
assert_eq!(config.jobs.initial_backoff_ms, 250);
}
#[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");
}
fn cfg_with_profile(profile: Option<&str>) -> AutumnConfig {
AutumnConfig {
profile: profile.map(str::to_owned),
..Default::default()
}
}
#[test]
fn server_timing_defaults_on_in_dev_profile() {
let cfg = cfg_with_profile(Some("dev"));
assert!(server_timing_enabled(&cfg));
let cfg = cfg_with_profile(Some("development"));
assert!(server_timing_enabled(&cfg));
}
#[test]
fn server_timing_defaults_off_in_prod_and_test_profiles() {
let cfg = cfg_with_profile(Some("prod"));
assert!(!server_timing_enabled(&cfg));
let cfg = cfg_with_profile(Some("production"));
assert!(!server_timing_enabled(&cfg));
let cfg = cfg_with_profile(Some("test"));
assert!(!server_timing_enabled(&cfg));
let cfg = cfg_with_profile(None);
assert!(!server_timing_enabled(&cfg));
}
#[test]
fn server_timing_explicit_config_overrides_profile_default() {
let mut cfg = cfg_with_profile(Some("prod"));
cfg.observability.server_timing = Some(true);
assert!(server_timing_enabled(&cfg));
let mut cfg = cfg_with_profile(Some("dev"));
cfg.observability.server_timing = Some(false);
assert!(!server_timing_enabled(&cfg));
}
#[test]
fn server_timing_env_override_wires_into_dispatcher() {
let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "true");
let mut config = cfg_with_profile(Some("prod"));
config.apply_env_overrides_with_env(&env);
assert_eq!(config.observability.server_timing, Some(true));
assert!(server_timing_enabled(&config));
let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "false");
let mut config = cfg_with_profile(Some("dev"));
config.apply_env_overrides_with_env(&env);
assert_eq!(config.observability.server_timing, Some(false));
assert!(!server_timing_enabled(&config));
}
#[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_tenancy_quota_bytes() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.quota_bytes, 0);
let env = MockEnv::new().with("AUTUMN_TENANCY__QUOTA_BYTES", "1048576");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.quota_bytes, 1_048_576);
}
#[test]
fn env_override_tenancy_enabled() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(!config.tenancy.enabled);
let env = MockEnv::new().with("AUTUMN_TENANCY__ENABLED", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.tenancy.enabled);
}
#[test]
fn env_override_tenancy_string_fields() {
let env = MockEnv::new()
.with("AUTUMN_TENANCY__SOURCE", "jwt")
.with("AUTUMN_TENANCY__HEADER_NAME", "x-org")
.with("AUTUMN_TENANCY__SESSION_KEY", "org_id")
.with("AUTUMN_TENANCY__JWT_CLAIM", "org")
.with("AUTUMN_TENANCY__JWT_ISSUER", "https://issuer.example")
.with("AUTUMN_TENANCY__JWT_AUDIENCE", "autumn-api")
.with("AUTUMN_TENANCY__BASE_DOMAIN", "apps.example.com")
.with("AUTUMN_TENANCY__LOGIN_REDIRECT", "/login")
.with("AUTUMN_TENANCY__PUBLIC_PATHS", "/login, /signup ,/assets");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.source, "jwt");
assert_eq!(config.tenancy.header_name, "x-org");
assert_eq!(config.tenancy.session_key, "org_id");
assert_eq!(config.tenancy.jwt_claim, "org");
assert_eq!(
config.tenancy.jwt_issuer.as_deref(),
Some("https://issuer.example")
);
assert_eq!(config.tenancy.jwt_audience.as_deref(), Some("autumn-api"));
assert_eq!(
config.tenancy.base_domain.as_deref(),
Some("apps.example.com")
);
assert_eq!(config.tenancy.login_redirect.as_deref(), Some("/login"));
assert_eq!(
config.tenancy.public_paths,
vec!["/login", "/signup", "/assets"]
);
}
#[test]
fn env_override_tenancy_eviction_knobs() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.max_cells, 0);
assert_eq!(config.tenancy.idle_ttl_secs, 0);
let env = MockEnv::new()
.with("AUTUMN_TENANCY__MAX_CELLS", "512")
.with("AUTUMN_TENANCY__IDLE_TTL_SECS", "900");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.max_cells, 512);
assert_eq!(config.tenancy.idle_ttl_secs, 900);
}
#[test]
fn env_override_tenancy_secret() {
use secrecy::ExposeSecret;
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.tenancy.jwt_secret.is_none());
let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " s3cr3t-signing-key ");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config
.tenancy
.jwt_secret
.as_ref()
.map(|s| s.expose_secret().to_owned()),
Some("s3cr3t-signing-key".to_string())
);
let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " ");
let mut config = AutumnConfig::default();
config.tenancy.jwt_secret = Some(secrecy::SecretString::from("preexisting".to_string()));
config.apply_env_overrides_with_env(&env);
assert!(config.tenancy.jwt_secret.is_none());
}
#[test]
fn env_override_rate_limit_trusted_proxies() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
"10.0.0.10, 203.0.113.0/24",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.trusted_proxies,
vec!["10.0.0.10", "203.0.113.0/24"]
);
}
#[test]
fn env_override_rate_limit_backend_redis() {
use crate::security::config::RateLimitBackend;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
}
#[test]
fn env_override_rate_limit_backend_memory() {
use crate::security::config::RateLimitBackend;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
}
#[test]
fn env_override_rate_limit_backend_invalid_ignored() {
use crate::security::config::RateLimitBackend;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_on_backend_failure_fail_closed() {
use crate::security::config::RateLimitBackendFailure;
let env = MockEnv::new().with(
"AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
"fail_closed",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.on_backend_failure,
RateLimitBackendFailure::FailClosed
);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
use crate::security::config::RateLimitBackendFailure;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.on_backend_failure,
RateLimitBackendFailure::FailOpen
);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_redis_url() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
"redis://myhost:6379",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.redis.url.as_deref(),
Some("redis://myhost:6379")
);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_redis_key_prefix() {
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
}
#[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 server_config_defaults_unix_socket_none() {
let config = AutumnConfig::default();
assert!(config.server.unix_socket.is_none());
}
#[test]
fn env_override_server_unix_socket() {
let env = MockEnv::new().with("AUTUMN_SERVER__UNIX_SOCKET", "/run/autumn/app.sock");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.server.unix_socket.as_deref(),
Some("/run/autumn/app.sock")
);
}
#[test]
fn unix_socket_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server]
unix_socket = "/tmp/autumn.sock"
"#,
)
.expect("config with server.unix_socket should parse");
assert_eq!(
config.server.unix_socket.as_deref(),
Some("/tmp/autumn.sock")
);
}
#[test]
fn server_config_defaults_tls_none() {
let config = AutumnConfig::default();
assert!(config.server.tls.is_none());
}
#[test]
fn server_tls_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "/etc/autumn/tls/fullchain.pem"
key_path = "/etc/autumn/tls/privkey.pem"
"#,
)
.expect("config with [server.tls] should parse");
let tls = config.server.tls.expect("tls configured");
assert_eq!(
tls.cert_path,
Some(std::path::PathBuf::from("/etc/autumn/tls/fullchain.pem"))
);
assert_eq!(
tls.key_path,
Some(std::path::PathBuf::from("/etc/autumn/tls/privkey.pem"))
);
assert_eq!(tls.reload_interval_secs, 60);
assert_eq!(tls.handshake_timeout_secs, 10);
assert!(tls.acme.is_none());
assert!(tls.validate().is_ok());
}
#[test]
fn server_tls_handshake_timeout_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
handshake_timeout_secs = 25
"#,
)
.expect("config with [server.tls] handshake_timeout_secs should parse");
assert_eq!(config.server.tls.unwrap().handshake_timeout_secs, 25);
}
#[test]
fn server_tls_reload_interval_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
reload_interval_secs = 120
"#,
)
.expect("config with [server.tls] reload_interval_secs should parse");
assert_eq!(config.server.tls.unwrap().reload_interval_secs, 120);
}
#[test]
fn env_override_materializes_server_tls() {
let env = MockEnv::new()
.with("AUTUMN_SERVER__TLS__CERT_PATH", "/env/cert.pem")
.with("AUTUMN_SERVER__TLS__KEY_PATH", "/env/key.pem")
.with("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS", "90")
.with("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS", "5");
let mut config = AutumnConfig::default();
assert!(config.server.tls.is_none());
config.apply_env_overrides_with_env(&env);
let tls = config.server.tls.expect("env should materialize tls");
assert_eq!(
tls.cert_path,
Some(std::path::PathBuf::from("/env/cert.pem"))
);
assert_eq!(tls.key_path, Some(std::path::PathBuf::from("/env/key.pem")));
assert_eq!(tls.reload_interval_secs, 90);
assert_eq!(tls.handshake_timeout_secs, 5);
}
#[test]
fn env_override_updates_existing_server_tls_cert() {
let mut config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "toml-cert.pem"
key_path = "toml-key.pem"
"#,
)
.unwrap();
let env = MockEnv::new().with("AUTUMN_SERVER__TLS__CERT_PATH", "override-cert.pem");
config.apply_env_overrides_with_env(&env);
let tls = config.server.tls.expect("tls configured");
assert_eq!(
tls.cert_path,
Some(std::path::PathBuf::from("override-cert.pem"))
);
assert_eq!(tls.key_path, Some(std::path::PathBuf::from("toml-key.pem")));
}
#[test]
fn no_tls_env_leaves_tls_none() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.server.tls.is_none());
}
#[test]
fn deploy_absent_is_none() {
let config = AutumnConfig::default();
assert!(config.deploy.is_none());
let parsed: AutumnConfig = toml::from_str("[server]\nport = 3000\n")
.expect("config without [deploy] should parse");
assert!(parsed.deploy.is_none());
}
#[test]
fn deploy_defaults_from_bare_table() {
let config: AutumnConfig =
toml::from_str("[deploy]\n").expect("bare [deploy] table should parse");
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.host, None);
assert_eq!(deploy.user, "root");
assert_eq!(deploy.ssh_port, 22);
assert_eq!(deploy.app_name, None);
assert_eq!(deploy.app_dir, None);
assert_eq!(deploy.service_name, None);
assert_eq!(deploy.readiness_timeout_secs, 60);
assert_eq!(deploy.keep_releases, 3);
}
#[test]
fn deploy_full_table_parses() {
let config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
user = "deploy"
ssh_port = 2222
app_name = "myapp"
app_dir = "/srv/myapp"
service_name = "myapp-web"
readiness_timeout_secs = 90
keep_releases = 5
"#,
)
.expect("full [deploy] table should parse");
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
assert_eq!(deploy.user, "deploy");
assert_eq!(deploy.ssh_port, 2222);
assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
assert_eq!(deploy.readiness_timeout_secs, 90);
assert_eq!(deploy.keep_releases, 5);
assert!(deploy.validate().is_ok());
}
#[test]
fn deploy_validate_rejects_missing_host() {
let missing = DeployConfig::default();
let err = missing
.validate()
.expect_err("missing host must be rejected");
assert!(
err.contains("host"),
"error should name the missing key: {err}"
);
let blank = DeployConfig {
host: Some(" ".to_owned()),
..DeployConfig::default()
};
assert!(blank.validate().is_err());
let ok = DeployConfig {
host: Some("example.com".to_owned()),
..DeployConfig::default()
};
assert!(ok.validate().is_ok());
}
#[test]
fn env_override_materializes_deploy() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__HOST", "203.0.113.10")
.with("AUTUMN_DEPLOY__USER", "deploy")
.with("AUTUMN_DEPLOY__SSH_PORT", "2222")
.with("AUTUMN_DEPLOY__APP_NAME", "myapp")
.with("AUTUMN_DEPLOY__APP_DIR", "/srv/myapp")
.with("AUTUMN_DEPLOY__SERVICE_NAME", "myapp-web")
.with("AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS", "90")
.with("AUTUMN_DEPLOY__KEEP_RELEASES", "5")
.with("AUTUMN_DEPLOY__PROFILE", "staging");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
assert_eq!(deploy.user, "deploy");
assert_eq!(deploy.ssh_port, 2222);
assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
assert_eq!(deploy.readiness_timeout_secs, 90);
assert_eq!(deploy.keep_releases, 5);
assert_eq!(deploy.profile, "staging");
assert!(deploy.validate().is_ok());
}
#[test]
fn env_override_sets_deploy_tls_enabled_and_host() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__TLS__ENABLED", "true")
.with("AUTUMN_DEPLOY__TLS__HOST", "app.example.com");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert!(deploy.tls.enabled);
assert_eq!(deploy.tls.host.as_deref(), Some("app.example.com"));
}
#[test]
fn env_override_wins_over_toml_deploy_tls_host() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
[deploy.tls]
enabled = true
host = "toml.example.com"
"#,
)
.unwrap();
assert_eq!(
config.deploy.as_ref().unwrap().tls.host.as_deref(),
Some("toml.example.com"),
);
let env = MockEnv::new().with("AUTUMN_DEPLOY__TLS__HOST", "env.example.com");
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.unwrap();
assert!(deploy.tls.enabled);
assert_eq!(deploy.tls.host.as_deref(), Some("env.example.com"));
}
#[test]
fn deploy_profile_defaults_to_production() {
let config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
"#,
)
.unwrap();
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.profile, "prod");
assert_eq!(DeployConfig::default().profile, "prod");
}
#[test]
fn deploy_profile_honors_toml_and_env_override() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "toml-host"
profile = "staging"
"#,
)
.unwrap();
assert_eq!(config.deploy.as_ref().unwrap().profile, "staging");
let env = MockEnv::new().with("AUTUMN_DEPLOY__PROFILE", "prod");
config.apply_env_overrides_with_env(&env);
assert_eq!(config.deploy.unwrap().profile, "prod");
}
#[test]
fn env_override_materializes_deploy_from_single_host() {
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "198.51.100.7");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert_eq!(deploy.host.as_deref(), Some("198.51.100.7"));
assert_eq!(deploy.user, "root");
assert_eq!(deploy.ssh_port, 22);
assert_eq!(deploy.readiness_timeout_secs, 60);
assert_eq!(deploy.keep_releases, 3);
}
#[test]
fn env_override_updates_existing_deploy_host() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "toml-host"
user = "deploy"
ssh_port = 2200
"#,
)
.unwrap();
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "env-host");
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.host.as_deref(), Some("env-host"));
assert_eq!(deploy.user, "deploy");
assert_eq!(deploy.ssh_port, 2200);
}
#[test]
fn env_override_parses_deploy_ssh_port_u16() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__HOST", "example.com")
.with("AUTUMN_DEPLOY__SSH_PORT", "65535");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert_eq!(deploy.ssh_port, 65_535_u16);
}
#[test]
fn no_deploy_env_leaves_deploy_none() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.deploy.is_none());
}
fn tls_static(cert: Option<&str>, key: Option<&str>) -> TlsConfig {
TlsConfig {
cert_path: cert.map(PathBuf::from),
key_path: key.map(PathBuf::from),
reload_interval_secs: default_tls_reload_interval_secs(),
handshake_timeout_secs: default_tls_handshake_timeout_secs(),
acme: None,
}
}
fn acme_cfg(domains: &[&str], email: &str) -> AcmeConfig {
AcmeConfig {
domains: domains.iter().map(|d| (*d).to_owned()).collect(),
contact_email: email.to_owned(),
directory: AcmeDirectory::Staging,
cache_dir: default_acme_cache_dir(),
http_challenge_port: default_acme_http_challenge_port(),
renew_before_days: default_acme_renew_before_days(),
}
}
#[test]
fn acme_parses_from_toml_with_defaults() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls.acme]
domains = ["app.example.com"]
contact_email = "ops@example.com"
"#,
)
.expect("config with [server.tls.acme] should parse");
let tls = config.server.tls.expect("tls configured");
let acme = tls.acme.as_ref().expect("acme configured");
assert_eq!(acme.domains, vec!["app.example.com".to_owned()]);
assert_eq!(acme.contact_email, "ops@example.com");
assert_eq!(acme.directory, AcmeDirectory::Staging);
assert_eq!(acme.cache_dir, PathBuf::from("config/acme"));
assert_eq!(acme.http_challenge_port, 80);
assert_eq!(acme.renew_before_days, 30);
assert!(tls.validate().is_ok());
}
#[test]
fn acme_directory_custom_parses() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls.acme]
domains = ["a.example.com"]
contact_email = "ops@example.com"
directory = { custom = { url = "https://pebble.test/dir" } }
"#,
)
.expect("custom directory should parse");
let acme = config.server.tls.unwrap().acme.unwrap();
assert_eq!(
acme.directory,
AcmeDirectory::Custom {
url: "https://pebble.test/dir".to_owned()
}
);
}
#[test]
fn validate_static_only_ok() {
assert!(tls_static(Some("c.pem"), Some("k.pem")).validate().is_ok());
}
#[test]
fn validate_acme_only_ok() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
assert!(cfg.validate().is_ok());
}
#[test]
fn validate_both_static_and_acme_rejected() {
let mut cfg = tls_static(Some("c.pem"), Some("k.pem"));
cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("choose exactly one"), "got: {err}");
}
#[test]
fn validate_neither_static_nor_acme_rejected() {
let err = tls_static(None, None).validate().unwrap_err();
assert!(err.contains("exactly one of"), "got: {err}");
}
#[test]
fn validate_cert_without_key_rejected() {
let err = tls_static(Some("c.pem"), None).validate().unwrap_err();
assert!(err.contains("set together"), "got: {err}");
}
#[test]
fn validate_acme_empty_domains_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("at least one domain"), "got: {err}");
}
#[test]
fn validate_acme_empty_email_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["app.example.com"], " "));
let err = cfg.validate().unwrap_err();
assert!(err.contains("contact_email"), "got: {err}");
}
#[test]
fn validate_acme_wildcard_domain_rejected_mentions_1620() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["*.example.com"], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("#1620"), "got: {err}");
assert!(err.contains("wildcard"), "got: {err}");
}
#[test]
fn validate_acme_blank_domain_entry_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[""], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("blank entries"), "got: {err}");
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[" "], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("blank entries"), "got: {err}");
}
#[test]
fn validate_acme_zero_http_challenge_port_rejected() {
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.http_challenge_port = 0;
cfg.acme = Some(acme);
let err = cfg.validate().unwrap_err();
assert!(err.contains("http_challenge_port"), "got: {err}");
}
#[test]
fn validate_acme_renew_before_days_at_or_above_cert_lifetime_rejected() {
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 100;
cfg.acme = Some(acme);
let err = cfg.validate().unwrap_err();
assert!(err.contains("renew_before_days"), "got: {err}");
assert!(err.contains("rate limits"), "got: {err}");
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 90;
cfg.acme = Some(acme);
assert!(
cfg.validate().is_err(),
"renew_before_days == 90 must be rejected"
);
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 30;
cfg.acme = Some(acme);
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 89;
cfg.acme = Some(acme);
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
#[test]
fn validate_acme_valid_domains_and_port_ok() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(
&["app.example.com", "www.example.com"],
"ops@example.com",
));
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
#[test]
fn server_config_defaults_max_concurrent_requests_none() {
let config = AutumnConfig::default();
assert!(config.server.max_concurrent_requests.is_none());
}
#[test]
fn max_concurrent_requests_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r"
[server]
max_concurrent_requests = 64
",
)
.expect("config with server.max_concurrent_requests should parse");
assert_eq!(config.server.max_concurrent_requests, Some(64));
}
#[test]
fn env_override_server_max_concurrent_requests() {
let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "128");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.max_concurrent_requests, Some(128));
}
#[test]
fn env_override_invalid_max_concurrent_requests_ignored() {
let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "not_a_number");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.server.max_concurrent_requests.is_none());
}
#[test]
fn env_override_empty_max_concurrent_requests_clears_to_none() {
let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "");
let mut config = AutumnConfig::default();
config.server.max_concurrent_requests = Some(64);
config.apply_env_overrides_with_env(&env);
assert!(config.server.max_concurrent_requests.is_none());
}
#[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 validate_accepts_keyword_value_connection_strings() {
for url in [
"host=db user=app dbname=app",
"host=db user=app sslmode=require",
"host=db sslmode = require",
"host=db password='p w' sslmode='verify-full'",
"host=db password=https://looks-like-a-url sslmode=require",
] {
let config = DatabaseConfig {
url: Some(url.to_owned()),
..Default::default()
};
assert!(
config.validate().is_ok(),
"keyword/value string must validate: {url}"
);
}
let config = DatabaseConfig {
primary_url: Some("host=db user=app sslmode=require".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok());
let config = DatabaseConfig {
primary_url: Some("postgres://db-control/app".to_owned()),
shards: vec![ShardConfig {
name: "s0".to_owned(),
primary_url: "host=db-shard0 user=app dbname=app".to_owned(),
replica_url: None,
slots: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}],
..Default::default()
};
assert!(
config.validate().is_ok(),
"shard URLs accept the keyword form too: {:?}",
config.validate()
);
}
#[test]
fn validate_still_rejects_garbage_connection_strings() {
for url in [
"mysql://localhost/test",
"mysql://localhost/test?a=b",
"not a connection string",
"localhost",
"host=",
"host='unterminated",
] {
let config = DatabaseConfig {
url: Some(url.to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err(&format!("garbage must be rejected: {url:?}"))
.to_string();
assert!(
err.contains("must start with postgres:// or postgresql://"),
"the error must stay clear about accepted forms, got: {err}"
);
}
}
#[test]
fn detect_backend_postgres_urls() {
for url in [
"postgres://localhost/app",
"postgresql://user:pass@db:5432/app",
] {
assert_eq!(
DatabaseBackend::detect(url),
Some(DatabaseBackend::Postgres),
"{url} should detect as postgres"
);
}
}
#[test]
fn detect_backend_postgres_keyword_value() {
assert_eq!(
DatabaseBackend::detect("host=db user=app sslmode=require"),
Some(DatabaseBackend::Postgres)
);
}
#[test]
fn detect_backend_sqlite_schemes() {
for url in [
"sqlite:///var/lib/app.db", "sqlite://./relative.db",
"sqlite::memory:",
"sqlite:app.db", "file:app.db", ] {
assert_eq!(
DatabaseBackend::detect(url),
Some(DatabaseBackend::Sqlite),
"{url} should detect as sqlite"
);
}
}
#[test]
fn detect_backend_bare_path_is_unrecognized() {
for target in ["/var/lib/app.db", "./app.db", "app.db", "C:\\db\\app.db"] {
assert_eq!(
DatabaseBackend::detect(target),
None,
"{target} must not be auto-detected as a backend"
);
}
}
#[test]
fn detect_backend_garbage_is_unrecognized() {
for target in ["mysql://localhost/app", "not a connection string", "host="] {
assert_eq!(DatabaseBackend::detect(target), None, "{target}");
}
}
#[test]
fn validate_accepts_sqlite_url() {
let config = DatabaseConfig {
url: Some("sqlite:///var/lib/app.db".to_owned()),
..Default::default()
};
assert!(
config.validate().is_ok(),
"a sqlite:// target must be accepted as valid config: {:?}",
config.validate()
);
}
#[test]
fn validate_accepts_sqlite_primary_url() {
let config = DatabaseConfig {
primary_url: Some("sqlite::memory:".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok(), "{:?}", config.validate());
}
#[test]
fn validate_rejects_replica_url_on_sqlite() {
let config = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err("read replicas must be refused on sqlite")
.to_string();
assert!(
err.contains("read replicas require the postgres backend"),
"message must name the postgres requirement, got: {err}"
);
}
#[test]
fn validate_rejects_shards_on_sqlite() {
let config = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
shards: vec![ShardConfig {
name: "s0".to_owned(),
primary_url: "postgres://db-shard0/app".to_owned(),
replica_url: None,
slots: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}],
..Default::default()
};
let err = config
.validate()
.expect_err("shards must be refused on sqlite")
.to_string();
assert!(
err.contains("database shards require the postgres backend"),
"message must name the postgres requirement, got: {err}"
);
}
#[test]
fn validate_rejects_backend_mismatch_across_roles() {
let config = DatabaseConfig {
primary_url: Some("postgres://db-primary/app".to_owned()),
replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err("mixed backends must be refused")
.to_string();
assert!(
err.contains("database.replica_url")
&& err.contains("does not match the primary database backend"),
"message must name the offending field and the mismatch, got: {err}"
);
}
#[test]
fn validate_rejects_sqlite_primary_with_postgres_url() {
let config = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
url: Some("postgres://db-primary/app".to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err("mixed backends must be refused")
.to_string();
assert!(
err.contains("database.url")
&& err.contains("does not match the primary database backend"),
"got: {err}"
);
}
#[test]
fn validate_postgres_app_with_replica_still_valid() {
let config = DatabaseConfig {
primary_url: Some("postgres://db-primary/app".to_owned()),
replica_url: Some("postgres://db-replica/app".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok(), "{:?}", config.validate());
}
#[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.server.prestop_grace_secs, 0,
"dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
);
assert_eq!(config.telemetry.environment, "development");
assert!(config.health.detailed);
assert_eq!(config.cors.allowed_origins, vec!["*"]);
assert!(
config.security.trusted_proxies.trust_forwarded_headers,
"dev profile must trust forwarded headers from loopback"
);
assert!(
config
.security
.trusted_proxies
.ranges
.contains(&"127.0.0.0/8".to_owned()),
"dev profile must include 127.0.0.0/8 as trusted proxy range"
);
assert!(
config
.security
.trusted_proxies
.ranges
.contains(&"::1/128".to_owned()),
"dev profile must include ::1/128 as trusted proxy range"
);
}
#[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);
assert!(config.prometheus);
}
#[test]
fn actuator_prometheus_can_be_disabled_via_toml() {
let toml = r"
sensitive = false
prometheus = false
";
let config: ActuatorConfig = toml::from_str(toml).unwrap();
assert!(!config.sensitive);
assert!(!config.prometheus);
}
#[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");
}
}
#[test]
fn env_override_forbidden_response_403() {
let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.forbidden_response,
crate::authorization::ForbiddenResponse::Forbidden403
);
}
#[test]
fn env_override_forbidden_response_404() {
let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
let mut config = AutumnConfig::default();
config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.forbidden_response,
crate::authorization::ForbiddenResponse::NotFound404
);
}
#[test]
fn env_override_forbidden_response_invalid_keeps_existing() {
let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
let mut config = AutumnConfig::default();
config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.forbidden_response,
crate::authorization::ForbiddenResponse::Forbidden403
);
}
#[test]
fn env_override_allow_unauthorized_repository_api() {
let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
let mut config = AutumnConfig::default();
assert!(!config.security.allow_unauthorized_repository_api);
config.apply_env_overrides_with_env(&env);
assert!(config.security.allow_unauthorized_repository_api);
}
#[test]
fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
"false",
);
let mut config = AutumnConfig::default();
config.security.allow_unauthorized_repository_api = true;
config.apply_env_overrides_with_env(&env);
assert!(!config.security.allow_unauthorized_repository_api);
}
#[test]
fn env_override_csrf_token_scan_bytes() {
let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "8388608");
let mut config = AutumnConfig::default();
assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.csrf.token_scan_bytes, 8_388_608);
}
#[test]
fn env_override_csrf_token_scan_bytes_invalid_is_ignored() {
let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "not-a-number");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
}
#[test]
fn openapi_runtime_config_defaults_enabled() {
let config = AutumnConfig::default();
assert!(
config.openapi_runtime.enabled,
"[openapi] must default to enabled = true"
);
assert_eq!(
config.openapi_runtime.path, "/openapi.json",
"[openapi] must default to path = \"/openapi.json\""
);
}
#[test]
fn openapi_runtime_config_can_be_disabled_via_toml() {
let toml_str = "
[openapi]
enabled = false
";
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
assert!(
!config.openapi_runtime.enabled,
"[openapi] enabled = false must deserialize correctly"
);
}
#[test]
fn openapi_runtime_config_path_can_be_customized() {
let toml_str = r#"
[openapi]
path = "/api-spec.json"
"#;
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.openapi_runtime.path, "/api-spec.json",
"[openapi] path must deserialize correctly"
);
}
#[test]
fn cache_env_overrides_fields() {
let env = MockEnv::new()
.with("AUTUMN_CACHE__BACKEND", "redis")
.with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
.with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.cache.is_redis(), "backend should be redis");
assert_eq!(
config.cache.redis.url.as_deref(),
Some("redis://cache:6379/1")
);
assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
}
#[test]
fn cache_backend_from_env_value_invalid_is_none() {
assert!(CacheBackend::from_env_value("postgres").is_none());
assert!(CacheBackend::from_env_value("").is_none());
}
#[test]
fn scheduler_validate_rejects_zero_lease_ttl() {
let cfg = SchedulerConfig {
lease_ttl_secs: 0,
..SchedulerConfig::default()
};
assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
}
#[test]
fn scheduler_validate_rejects_empty_key_prefix() {
let cfg = SchedulerConfig {
key_prefix: " ".to_owned(),
..SchedulerConfig::default()
};
assert!(cfg.validate().is_err(), "blank key_prefix must fail");
}
#[test]
fn scheduler_validate_ok_with_defaults() {
assert!(SchedulerConfig::default().validate().is_ok());
}
#[test]
fn scheduler_resolved_replica_id_uses_explicit_value() {
let cfg = SchedulerConfig {
replica_id: Some("my-pod".to_owned()),
..SchedulerConfig::default()
};
assert_eq!(cfg.resolved_replica_id(), "my-pod");
}
#[test]
fn scheduler_resolved_replica_id_falls_back_to_pid() {
let cfg = SchedulerConfig {
replica_id: None,
..SchedulerConfig::default()
};
assert!(!cfg.resolved_replica_id().is_empty());
}
#[cfg(feature = "mail")]
#[test]
fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
let env = MockEnv::new()
.with(
"AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
"true",
)
.with("AUTUMN_MAIL__TRANSPORT", "smtp")
.with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
config.mail.allow_in_process_deliver_later_in_production,
"env var should set allow_in_process_deliver_later_in_production"
);
}
#[cfg(feature = "mail")]
#[test]
fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
!config.mail.allow_in_process_deliver_later_in_production,
"flag should default to false when env var is not set"
);
}
#[cfg(feature = "mail")]
#[test]
fn mail_inline_css_is_overridable_via_env() {
let env = MockEnv::new().with("AUTUMN_MAIL__INLINE_CSS", "true");
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
config.mail.inline_css,
"AUTUMN_MAIL__INLINE_CSS=true should enable inline_css"
);
}
#[cfg(feature = "mail")]
#[test]
fn mail_inline_css_defaults_false() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
!config.mail.inline_css,
"inline_css should default to false when env var is not set"
);
}
#[test]
fn config_credentials_empty_when_no_directory() {
let env = MockEnv::new();
let config = AutumnConfig::load_with_env(&env).unwrap();
assert!(
config.credentials().is_empty(),
"existing apps without config/credentials/ must boot with an empty credentials store"
);
}
#[test]
fn config_has_credentials_accessor() {
let config = AutumnConfig::default();
let _store = config.credentials();
}
#[test]
fn config_credentials_loaded_when_file_present() {
use crate::credentials::{MasterKey, encrypt};
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let key = MasterKey::generate();
let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
let env = MockEnv::new()
.with("AUTUMN_MASTER_KEY", &key.to_hex())
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
let val: Option<String> = config.credentials().get("stripe_key");
assert_eq!(val.as_deref(), Some("sk_test_xyz"));
}
#[cfg(feature = "oauth2")]
#[test]
fn config_resolves_oauth_credentials_by_convention() {
use crate::credentials::{MasterKey, encrypt};
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let key = MasterKey::generate();
let ct = encrypt(
&key,
b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
);
std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
let config_toml = r#"
[auth.oauth2.github]
client_id = ""
client_secret = ""
authorize_url = "https://github.com/login/oauth/authorize"
token_url = "https://github.com/login/oauth/access_token"
redirect_uri = "http://localhost:3000/auth/github/callback"
"#;
std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
let env = MockEnv::new()
.with("AUTUMN_MASTER_KEY", &key.to_hex())
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
let github = config.auth.oauth2.providers.get("github").unwrap();
assert_eq!(github.client_id, "git-id-123");
assert_eq!(github.client_secret, "git-secret-456");
}
#[test]
fn config_fails_with_credentials_error_when_key_is_invalid() {
use crate::credentials::encrypt;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let bogus_key = "zz".repeat(32); let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
let env = MockEnv::new()
.with("AUTUMN_MASTER_KEY", &bogus_key)
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
let err = AutumnConfig::load_with_env(&env).unwrap_err();
assert!(
matches!(err, ConfigError::Credentials(_)),
"bad master key should produce ConfigError::Credentials, got {err:?}"
);
}
#[test]
fn test_parse_duration_str() {
assert_eq!(
parse_duration_str("500ms").unwrap(),
std::time::Duration::from_millis(500)
);
assert_eq!(
parse_duration_str("5s").unwrap(),
std::time::Duration::from_secs(5)
);
assert_eq!(
parse_duration_str("2m").unwrap(),
std::time::Duration::from_secs(120)
);
assert_eq!(
parse_duration_str("1h").unwrap(),
std::time::Duration::from_secs(3600)
);
assert_eq!(
parse_duration_str("1000").unwrap(),
std::time::Duration::from_secs(1)
);
assert!(parse_duration_str("abc").is_err());
assert!(parse_duration_str("").is_err());
}
#[test]
fn test_database_config_duration_deserialization() {
#[derive(Debug, Deserialize)]
struct TestConfig {
#[serde(deserialize_with = "deserialize_option_duration", default)]
timeout: Option<std::time::Duration>,
#[serde(deserialize_with = "deserialize_duration")]
threshold: std::time::Duration,
}
let toml_str = r#"
timeout = "2s"
threshold = "100ms"
"#;
let parsed: TestConfig = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
let toml_str_null = r#"
threshold = "500"
"#;
let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
assert_eq!(parsed_null.timeout, None);
assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
}
#[test]
fn request_timeouts_config_defaults_to_none() {
let config = RequestTimeoutsConfig::default();
assert!(config.request_timeout_ms.is_none());
}
#[test]
fn server_config_timeouts_defaults_to_disabled() {
let config = ServerConfig::default();
assert!(config.timeouts.request_timeout_ms.is_none());
}
#[test]
fn request_timeouts_config_can_be_set_via_toml() {
let toml_str = "request_timeout_ms = 5000";
let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.request_timeout_ms, Some(5000));
}
#[test]
fn server_config_timeouts_deserialize_nested() {
let toml_str = r#"
port = 3000
host = "127.0.0.1"
shutdown_timeout_secs = 30
prestop_grace_secs = 5
[timeouts]
request_timeout_ms = 15000
"#;
let config: ServerConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
}
#[test]
fn autumn_config_server_timeouts_roundtrip() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(20_000);
assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
}
#[test]
fn server_timeouts_env_var_override() {
struct FakeEnv(std::collections::HashMap<String, String>);
impl Env for FakeEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.0
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
let mut config = AutumnConfig::default();
let env = FakeEnv(
[(
"AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
"8000".to_owned(),
)]
.into(),
);
config.apply_server_env_overrides_with_env(&env);
assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
}
#[test]
fn prod_profile_sets_request_timeout_30s() {
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.server.timeouts.request_timeout_ms,
Some(30_000),
"prod profile must enable the 30-second request timeout by default"
);
}
#[test]
fn dev_profile_leaves_request_timeout_disabled() {
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.server.timeouts.request_timeout_ms.is_none(),
"dev profile must not enable a request timeout by default"
);
}
#[test]
fn test_resilience_config_defaults() {
let config = AutumnConfig::default();
assert!(
config
.resilience
.circuit_breaker
.defaults
.failure_ratio_threshold
.is_none()
);
}
#[test]
fn test_resilience_config_parsing() {
let toml_str = r#"
[resilience.circuit_breaker.defaults]
failure_ratio_threshold = 0.6
sample_window_secs = 20
minimum_sample_count = 15
open_duration_secs = 30
half_open_trial_count = 5
[resilience.circuit_breaker.hosts."api.github.com"]
failure_ratio_threshold = 0.3
open_duration_secs = 10
"#;
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
let cb = &config.resilience.circuit_breaker;
assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
assert_eq!(cb.defaults.sample_window_secs, Some(20));
assert_eq!(cb.defaults.minimum_sample_count, Some(15));
assert_eq!(cb.defaults.open_duration_secs, Some(30));
assert_eq!(cb.defaults.half_open_trial_count, Some(5));
let host_cb = cb.hosts.get("api.github.com").unwrap();
assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
assert_eq!(host_cb.open_duration_secs, Some(10));
assert!(host_cb.sample_window_secs.is_none());
}
#[test]
fn test_resilience_config_env_overrides() {
struct FakeEnv(std::collections::HashMap<String, String>);
impl Env for FakeEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.0
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
let mut config = AutumnConfig::default();
let env = FakeEnv(
[(
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
"0.7".to_owned(),
)]
.into(),
);
config.apply_resilience_env_overrides_with_env(&env);
assert_eq!(
config
.resilience
.circuit_breaker
.defaults
.failure_ratio_threshold,
Some(0.7)
);
}
const TEST_REGISTRY: &[DeprecatedKey] = &[DeprecatedKey {
path: "a.b.c",
replacement: Some("a.b.d"),
since: "0.1.0",
remove_in: "1.0.0",
}];
fn merged_with_abc(value: toml::Value) -> toml::Table {
let mut root = toml::Table::new();
let mut b = toml::Table::new();
b.insert("c".to_owned(), value);
let mut a = toml::Table::new();
a.insert("b".to_owned(), toml::Value::Table(b));
root.insert("a".to_owned(), toml::Value::Table(a));
root
}
#[test]
fn red_detect_from_toml_present_emits_finding() {
let merged = merged_with_abc(toml::Value::Integer(1));
let env = MockEnv::new(); let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.path, "a.b.c");
assert_eq!(f.replacement.as_deref(), Some("a.b.d"));
assert_eq!(f.since, "0.1.0");
assert_eq!(f.remove_in, "1.0.0");
assert_eq!(f.source, DeprecationSource::Toml);
}
#[test]
fn red_detect_from_env_present_emits_finding() {
let merged = toml::Table::new(); let env = MockEnv::new().with("AUTUMN_A__B__C", "val");
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].source, DeprecationSource::Env);
}
#[test]
fn red_detect_dedupe_toml_and_env_single_finding() {
let merged = merged_with_abc(toml::Value::Boolean(true));
let env = MockEnv::new().with("AUTUMN_A__B__C", "true");
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert_eq!(findings.len(), 1, "TOML+env should collapse to one finding");
assert_eq!(findings[0].source, DeprecationSource::Both);
}
#[test]
fn red_detect_replacement_only_no_finding() {
let mut merged = toml::Table::new();
let mut b = toml::Table::new();
b.insert("d".to_owned(), toml::Value::Integer(1)); let mut a = toml::Table::new();
a.insert("b".to_owned(), toml::Value::Table(b));
merged.insert("a".to_owned(), toml::Value::Table(a));
let env = MockEnv::new();
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert!(
findings.is_empty(),
"only replacement key set — no deprecation warning"
);
}
#[test]
fn red_detect_absent_everywhere_no_finding() {
let merged = toml::Table::new();
let env = MockEnv::new();
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert!(findings.is_empty());
}
#[test]
fn red_env_var_name_mapping() {
assert_eq!(
deprecated_env_var_name("security.rate_limit.trusted_proxies"),
"AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
);
assert_eq!(deprecated_env_var_name("a.b.c"), "AUTUMN_A__B__C");
}
#[test]
fn red_toml_path_non_table_mid_segment_not_present() {
let mut root = toml::Table::new();
root.insert("a".to_owned(), toml::Value::Integer(42)); assert!(!toml_path_present(&root, "a.b.c"));
}
#[test]
fn red_schema_leaf_paths_includes_known_paths() {
let leaves = AutumnConfig::schema_leaf_paths();
assert!(
leaves.contains("server.port"),
"server.port must be a schema leaf"
);
assert!(
leaves.contains("server.host"),
"server.host must be a schema leaf"
);
assert!(
leaves.contains("database.url"),
"database.url must be a schema leaf"
);
assert!(
leaves.contains("security"),
"security must appear as a root-level leaf"
);
assert!(
leaves.contains("session"),
"session must appear as a root-level leaf"
);
}
#[test]
fn shards_auto_split_true_when_all_slots_none() {
let config = DatabaseConfig {
shards: vec![
shard("a", "postgres://a/app"),
shard("b", "postgres://b/app"),
],
..Default::default()
};
assert!(config.shards_auto_split());
}
#[test]
fn shards_auto_split_false_when_no_shards() {
assert!(!DatabaseConfig::default().shards_auto_split());
}
#[test]
fn shards_auto_split_false_when_any_shard_declares_slots() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-8191"]),
shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
],
..Default::default()
};
assert!(!config.shards_auto_split());
}
#[test]
fn resolved_shard_assignments_two_shards() {
let config = DatabaseConfig {
shards: vec![
shard("s0", "postgres://s0/app"),
shard("s1", "postgres://s1/app"),
],
..Default::default()
};
let assignments = config
.resolved_shard_assignments()
.expect("two-shard auto-split should resolve");
assert_eq!(assignments.len(), 2);
assert_eq!(assignments[0].name, "s0");
assert_eq!(assignments[0].ranges, "0-8191");
assert_eq!(assignments[1].name, "s1");
assert_eq!(assignments[1].ranges, "8192-16383");
}
#[test]
fn resolved_shard_assignments_three_shards() {
let config = DatabaseConfig {
shards: vec![
shard("s0", "postgres://s0/app"),
shard("s1", "postgres://s1/app"),
shard("s2", "postgres://s2/app"),
],
..Default::default()
};
let assignments = config
.resolved_shard_assignments()
.expect("three-shard auto-split should resolve");
assert_eq!(assignments.len(), 3);
assert_eq!(assignments[0].ranges, "0-5461");
assert_eq!(assignments[1].ranges, "5462-10922");
assert_eq!(assignments[2].ranges, "10923-16383");
}
fn assignment(name: &str, ranges: &str) -> ShardSlotAssignment {
ShardSlotAssignment {
name: name.to_owned(),
ranges: ranges.to_owned(),
}
}
#[test]
fn check_stored_slot_map_explicit_mode_always_ok() {
let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let stored = vec![
assignment("s0", "0-5460"),
assignment("s1", "5461-10922"),
assignment("s2", "10923-16383"),
];
assert!(check_stored_slot_map(false, &computed, Some(&stored)).is_ok());
}
#[test]
fn check_stored_slot_map_first_boot_no_stored_ok() {
let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
assert!(check_stored_slot_map(true, &computed, None).is_ok());
}
#[test]
fn check_stored_slot_map_matching_map_ok() {
let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let stored = vec![assignment("s1", "8192-16383"), assignment("s0", "0-8191")];
assert!(check_stored_slot_map(true, &computed, Some(&stored)).is_ok());
}
#[test]
fn check_stored_slot_map_mismatch_two_to_three_shards_returns_err() {
let computed = vec![
assignment("s0", "0-5460"),
assignment("s1", "5461-10922"),
assignment("s2", "10923-16383"),
];
let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let err = check_stored_slot_map(true, &computed, Some(&stored))
.expect_err("3-shard auto-split vs 2-shard stored map must fail");
assert!(err.contains("shard slot map mismatch"), "message: {err}");
assert!(err.contains("3 shards"), "message: {err}");
assert!(err.contains("2 shards"), "message: {err}");
}
#[test]
fn check_stored_slot_map_mismatch_shard_rename_returns_err() {
let computed = vec![
assignment("alpha", "0-8191"),
assignment("beta", "8192-16383"),
];
let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let err = check_stored_slot_map(true, &computed, Some(&stored))
.expect_err("renamed shards must be detected as mismatch");
assert!(err.contains("shard slot map mismatch"), "message: {err}");
assert!(
err.contains("alpha"),
"message must name computed shards: {err}"
);
assert!(err.contains("s0"), "message must name stored shards: {err}");
}
#[test]
fn process_role_default_is_combined() {
assert_eq!(ProcessRole::default(), ProcessRole::Combined);
assert_eq!(AutumnConfig::default().role, ProcessRole::Combined);
}
#[test]
fn process_role_from_env_value_accepts_aliases_case_insensitively() {
for v in [
"combined",
"COMBINED",
" all ",
"web_and_worker",
"server_and_worker",
] {
assert_eq!(
ProcessRole::from_env_value(v),
Some(ProcessRole::Combined),
"{v}"
);
}
for v in ["web", "Web", " SERVER ", "http"] {
assert_eq!(
ProcessRole::from_env_value(v),
Some(ProcessRole::Web),
"{v}"
);
}
for v in ["worker", "WORKER", " jobs ", "worker_only"] {
assert_eq!(
ProcessRole::from_env_value(v),
Some(ProcessRole::Worker),
"{v}"
);
}
for v in ["", "webby", "workers", "scheduler", "both"] {
assert_eq!(ProcessRole::from_env_value(v), None, "{v}");
}
}
#[test]
fn process_role_as_str_round_trips_through_from_env_value() {
for role in [ProcessRole::Combined, ProcessRole::Web, ProcessRole::Worker] {
assert_eq!(ProcessRole::from_env_value(role.as_str()), Some(role));
}
}
#[test]
fn process_role_serves_http_and_runs_workers_truth_table() {
assert!(ProcessRole::Combined.serves_http());
assert!(ProcessRole::Combined.runs_workers());
assert!(ProcessRole::Web.serves_http());
assert!(!ProcessRole::Web.runs_workers());
assert!(!ProcessRole::Worker.serves_http());
assert!(ProcessRole::Worker.runs_workers());
}
#[test]
fn process_role_deserializes_from_toml() {
let web: AutumnConfig = toml::from_str("role = \"web\"\n").expect("web role");
assert_eq!(web.role, ProcessRole::Web);
let worker: AutumnConfig = toml::from_str("role = \"worker\"\n").expect("worker role");
assert_eq!(worker.role, ProcessRole::Worker);
let combined: AutumnConfig =
toml::from_str("role = \"combined\"\n").expect("combined role");
assert_eq!(combined.role, ProcessRole::Combined);
let aliased: AutumnConfig = toml::from_str("role = \"all\"\n").expect("all alias");
assert_eq!(aliased.role, ProcessRole::Combined);
let absent: AutumnConfig = toml::from_str("").expect("empty config");
assert_eq!(absent.role, ProcessRole::Combined);
}
#[test]
fn split_role_requires_durable_backend_truth_table() {
assert!(!split_role_requires_durable_backend(
ProcessRole::Combined,
"local"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Combined,
"postgres"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"local"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Worker,
"local"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"postgresql"
));
assert!(split_role_requires_durable_backend(ProcessRole::Web, ""));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"unknown"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"LOCAL"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Web,
"postgres"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Worker,
"redis"
));
}
#[test]
fn autumn_role_env_override_sets_role() {
let env = MockEnv::new().with("AUTUMN_ROLE", "worker");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Worker);
let env = MockEnv::new().with("AUTUMN_ROLE", " WEB ");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Web);
}
#[test]
fn autumn_role_env_override_ignores_invalid_value_keeping_default() {
let env = MockEnv::new().with("AUTUMN_ROLE", "nonsense");
let mut config = AutumnConfig {
role: ProcessRole::Worker,
..Default::default()
};
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Worker);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Combined);
}
}