1use std::path::{Path, PathBuf};
130
131use serde::Deserialize;
132use thiserror::Error;
133
134use std::sync::OnceLock;
136
137static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
138static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
139
140#[doc(hidden)]
141pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
142 let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
143 let _ = MACRO_IS_DEBUG.set(is_debug);
144}
145
146pub trait Env {
152 fn var(&self, key: &str) -> Result<String, std::env::VarError>;
167}
168
169#[derive(Clone, Default)]
171pub struct OsEnv;
172
173impl Env for OsEnv {
174 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
175 if key == "AUTUMN_MANIFEST_DIR" {
176 if let Some(dir) = MACRO_MANIFEST_DIR.get() {
177 return Ok(dir.clone());
178 }
179 } else if key == "AUTUMN_IS_DEBUG"
180 && let Some(is_debug) = MACRO_IS_DEBUG.get()
181 {
182 return Ok(if *is_debug {
183 "1".to_string()
184 } else {
185 "0".to_string()
186 });
187 }
188 std::env::var(key)
189 }
190}
191
192#[derive(Clone, Default)]
194pub struct MockEnv {
195 vars: std::collections::HashMap<String, String>,
196}
197
198impl MockEnv {
199 #[must_use]
201 pub fn new() -> Self {
202 Self {
203 vars: std::collections::HashMap::new(),
204 }
205 }
206
207 #[must_use]
209 pub fn with(mut self, key: &str, value: &str) -> Self {
210 self.vars.insert(key.to_owned(), value.to_owned());
211 self
212 }
213
214 #[must_use]
216 pub fn without(mut self, key: &str) -> Self {
217 self.vars.remove(key);
218 self
219 }
220}
221
222impl Env for MockEnv {
223 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
224 self.vars
225 .get(key)
226 .cloned()
227 .ok_or(std::env::VarError::NotPresent)
228 }
229}
230
231fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
233 if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
234 let candidate = PathBuf::from(manifest_dir).join(filename);
235 if candidate.exists() {
236 return candidate;
237 }
238 }
239 PathBuf::from(filename)
240}
241
242fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
245 match std::fs::read_to_string(path) {
246 Ok(contents) => {
247 let table = toml::from_str::<toml::Table>(&contents)?;
248 Ok(Some(toml::Value::Table(table)))
249 }
250 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
251 Err(e) => Err(ConfigError::Io(e)),
252 }
253}
254
255pub(crate) fn resolve_profile(env: &dyn Env) -> String {
263 let selected_profile_input = resolve_profile_input(env);
264 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
265}
266
267fn resolve_profile_input(env: &dyn Env) -> String {
269 if let Ok(profile) = env.var("AUTUMN_ENV") {
271 let trimmed = profile.trim();
272 if !trimmed.is_empty() {
273 return trimmed.to_owned();
274 }
275 }
276
277 if let Ok(profile) = env.var("AUTUMN_PROFILE") {
279 let trimmed = profile.trim();
280 if !trimmed.is_empty() {
281 return trimmed.to_owned();
282 }
283 }
284
285 let args: Vec<String> = std::env::args().collect();
287 for (i, arg) in args.iter().enumerate() {
288 if arg == "--profile"
289 && let Some(profile) = args.get(i + 1)
290 {
291 let trimmed = profile.trim();
292 if !trimmed.is_empty() {
293 return trimmed.to_owned();
294 }
295 }
296 if let Some(profile) = arg.strip_prefix("--profile=") {
297 let trimmed = profile.trim();
298 if !trimmed.is_empty() {
299 return trimmed.to_owned();
300 }
301 }
302 }
303
304 if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
306 return "prod".to_owned();
307 }
308 "dev".to_owned()
309}
310
311fn normalize_profile_name(profile: &str) -> Option<String> {
319 let trimmed = profile.trim();
320 if trimmed.is_empty() {
321 return None;
322 }
323
324 if trimmed.eq_ignore_ascii_case("production") {
325 return Some("prod".to_owned());
326 }
327 if trimmed.eq_ignore_ascii_case("development") {
328 return Some("dev".to_owned());
329 }
330 if trimmed.eq_ignore_ascii_case("prod") {
331 return Some("prod".to_owned());
332 }
333 if trimmed.eq_ignore_ascii_case("dev") {
334 return Some("dev".to_owned());
335 }
336
337 Some(trimmed.to_owned())
339}
340
341fn profile_lookup_names(profile: &str) -> Vec<&str> {
346 match profile {
347 "prod" => vec!["production", "prod"],
348 "dev" => vec!["development", "dev"],
349 other => vec![other],
350 }
351}
352
353fn profile_override_file_lookup_names(profile: &str, selected_profile_input: &str) -> Vec<String> {
358 match profile {
359 "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
360 vec!["production".to_owned(), "prod".to_owned()]
361 }
362 "prod" => vec!["prod".to_owned(), "production".to_owned()],
363 "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
364 vec!["development".to_owned(), "dev".to_owned()]
365 }
366 "dev" => vec!["dev".to_owned(), "development".to_owned()],
367 other => vec![other.to_owned()],
368 }
369}
370
371fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
373 base.get("profile")
374 .and_then(toml::Value::as_table)
375 .and_then(|profiles| profiles.get(profile))
376 .and_then(toml::Value::as_table)
377 .map(|table| toml::Value::Table(table.clone()))
378}
379
380fn profile_defaults_as_toml(profile: &str) -> toml::Value {
386 let mut table = toml::map::Map::new();
387
388 match profile {
389 "dev" => {
390 let mut log = toml::map::Map::new();
391 log.insert("level".into(), "debug".into());
392 log.insert("format".into(), "Pretty".into());
393 table.insert("log".into(), toml::Value::Table(log));
394
395 let mut telemetry = toml::map::Map::new();
396 telemetry.insert("environment".into(), "development".into());
397 table.insert("telemetry".into(), toml::Value::Table(telemetry));
398
399 let mut server = toml::map::Map::new();
400 server.insert("host".into(), "127.0.0.1".into());
401 server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(1));
402 server.insert("prestop_grace_secs".into(), toml::Value::Integer(0));
406 table.insert("server".into(), toml::Value::Table(server));
407
408 let mut health = toml::map::Map::new();
409 health.insert("detailed".into(), toml::Value::Boolean(true));
410 table.insert("health".into(), toml::Value::Table(health));
411
412 let mut actuator = toml::map::Map::new();
413 actuator.insert("sensitive".into(), toml::Value::Boolean(true));
414 table.insert("actuator".into(), toml::Value::Table(actuator));
415
416 let mut cors = toml::map::Map::new();
417 cors.insert(
418 "allowed_origins".into(),
419 toml::Value::Array(vec![toml::Value::String("*".to_owned())]),
420 );
421 table.insert("cors".into(), toml::Value::Table(cors));
422
423 let mut storage = toml::map::Map::new();
429 storage.insert("backend".into(), "local".into());
430 table.insert("storage".into(), toml::Value::Table(storage));
431 let mut trusted_proxies = toml::map::Map::new();
434 trusted_proxies.insert("trust_forwarded_headers".into(), toml::Value::Boolean(true));
435 trusted_proxies.insert(
436 "ranges".into(),
437 toml::Value::Array(vec![
438 toml::Value::String("127.0.0.0/8".to_owned()),
439 toml::Value::String("::1/128".to_owned()),
440 ]),
441 );
442 let mut security = toml::map::Map::new();
443 security.insert(
444 "trusted_proxies".into(),
445 toml::Value::Table(trusted_proxies),
446 );
447 table.insert("security".into(), toml::Value::Table(security));
448 }
450 "prod" => {
451 let mut log = toml::map::Map::new();
452 log.insert("level".into(), "info".into());
453 log.insert("format".into(), "Json".into());
454 table.insert("log".into(), toml::Value::Table(log));
455
456 let mut telemetry = toml::map::Map::new();
457 telemetry.insert("environment".into(), "production".into());
458 table.insert("telemetry".into(), toml::Value::Table(telemetry));
459
460 let mut server = toml::map::Map::new();
461 server.insert("host".into(), "0.0.0.0".into());
462 server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(30));
463 let mut timeouts = toml::map::Map::new();
464 timeouts.insert("request_timeout_ms".into(), toml::Value::Integer(30_000));
465 server.insert("timeouts".into(), toml::Value::Table(timeouts));
466 table.insert("server".into(), toml::Value::Table(server));
467
468 let mut health = toml::map::Map::new();
469 health.insert("detailed".into(), toml::Value::Boolean(false));
470 table.insert("health".into(), toml::Value::Table(health));
471
472 let mut security = toml::map::Map::new();
474 let mut headers = toml::map::Map::new();
475 headers.insert(
476 "strict_transport_security".into(),
477 toml::Value::Boolean(true),
478 );
479 security.insert("headers".into(), toml::Value::Table(headers));
480 let mut csrf = toml::map::Map::new();
481 csrf.insert("enabled".into(), toml::Value::Boolean(true));
482 security.insert("csrf".into(), toml::Value::Table(csrf));
483 table.insert("security".into(), toml::Value::Table(security));
484
485 let mut session = toml::map::Map::new();
486 session.insert("secure".into(), toml::Value::Boolean(true));
487 table.insert("session".into(), toml::Value::Table(session));
488 }
489 _ => {} }
491
492 toml::Value::Table(table)
493}
494
495#[cfg(feature = "mail")]
496fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
497 merged
498 .get("mail")
499 .and_then(toml::Value::as_table)
500 .is_some_and(|mail| mail.contains_key("transport"))
501 || env
502 .var("AUTUMN_MAIL__TRANSPORT")
503 .ok()
504 .as_deref()
505 .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
506}
507
508const MAX_MERGE_DEPTH: usize = 16;
510
511fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
514 deep_merge_with_depth(base, overlay, 0);
515}
516
517fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
518 if depth > MAX_MERGE_DEPTH {
519 eprintln!(
520 "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
521 );
522 return;
523 }
524
525 let toml::Value::Table(overlay_table) = overlay else {
526 return;
527 };
528 let Some(base_table) = base.as_table_mut() else {
529 return;
530 };
531
532 for (key, overlay_val) in overlay_table {
533 let is_recursive_merge =
534 overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
535
536 if is_recursive_merge {
537 if let Some(base_val) = base_table.get_mut(&key) {
538 deep_merge_with_depth(base_val, overlay_val, depth + 1);
539 }
540 } else {
541 base_table.insert(key, overlay_val);
542 }
543 }
544}
545
546fn suggest_profile(profile: &str) -> Option<&'static str> {
550 let known = ["dev", "prod"];
551 let mut suggestions: Vec<(&str, usize)> = known
552 .iter()
553 .map(|k| (*k, levenshtein(profile, k)))
554 .filter(|(_, d)| *d <= 2)
555 .collect();
556 suggestions.sort_by_key(|(_, d)| *d);
557 suggestions.first().map(|(name, _)| *name)
558}
559
560fn warn_profile_typo(profile: &str) {
562 if let Some(suggestion) = suggest_profile(profile) {
563 eprintln!(
564 "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
565 and no smart defaults. Did you mean \"{suggestion}\"?"
566 );
567 }
568}
569
570fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
571 profile != "dev" && profile != "prod" && !has_inline_profile_section
572}
573
574fn levenshtein(a: &str, b: &str) -> usize {
580 let n = b.chars().count();
581 let mut prev: Vec<usize> = (0..=n).collect();
582 for (i, a_ch) in a.chars().enumerate() {
583 let mut prev_diag = prev[0];
584 prev[0] = i + 1;
585 for (j, b_ch) in b.chars().enumerate() {
586 let old_prev = prev[j + 1];
587 let cost = usize::from(a_ch != b_ch);
588 prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
589 prev_diag = old_prev;
590 }
591 }
592 prev[n]
593}
594
595#[derive(Debug, Error)]
611#[non_exhaustive]
612pub enum ConfigError {
613 #[error("failed to read autumn.toml: {0}")]
615 Io(#[from] std::io::Error),
616
617 #[error("invalid autumn.toml: {0}")]
619 Parse(#[from] toml::de::Error),
620
621 #[error("configuration error: {0}")]
624 Validation(String),
625
626 #[error("credentials error: {0}")]
628 Credentials(String),
629}
630
631#[derive(Debug, Clone, Default, Deserialize)]
659pub struct AutumnConfig {
660 #[serde(skip)]
663 pub profile: Option<String>,
664
665 #[serde(default)]
667 pub server: ServerConfig,
668
669 #[serde(default)]
671 pub database: DatabaseConfig,
672
673 #[serde(default)]
675 pub log: LogConfig,
676
677 #[serde(default)]
679 pub telemetry: TelemetryConfig,
680
681 #[serde(default)]
683 pub health: HealthConfig,
684
685 #[serde(default)]
687 pub actuator: ActuatorConfig,
688
689 #[serde(default)]
691 pub cors: CorsConfig,
692
693 #[serde(default)]
695 pub session: crate::session::SessionConfig,
696
697 #[serde(default)]
699 pub cache: CacheConfig,
700
701 #[serde(default)]
703 pub tenancy: TenancyConfig,
704
705 #[serde(default)]
707 pub idempotency: IdempotencyConfig,
708
709 #[serde(default)]
711 pub channels: ChannelConfig,
712
713 #[serde(default)]
715 pub jobs: JobConfig,
716
717 #[serde(default)]
719 pub scheduler: SchedulerConfig,
720
721 #[serde(default)]
723 pub auth: crate::auth::AuthConfig,
724
725 #[serde(default)]
727 pub security: crate::security::config::SecurityConfig,
728
729 #[cfg(feature = "i18n")]
733 #[serde(default)]
734 pub i18n: crate::i18n::I18nConfig,
735 #[cfg(feature = "storage")]
738 #[serde(default)]
739 pub storage: crate::storage::StorageConfig,
740 #[cfg(feature = "mail")]
742 #[serde(default)]
743 pub mail: crate::mail::MailConfig,
744 #[serde(default, rename = "openapi")]
750 pub openapi_runtime: OpenApiRuntimeConfig,
751
752 #[serde(skip)]
757 pub credentials: crate::credentials::CredentialsStore,
758
759 #[cfg(feature = "http-client")]
763 #[serde(default, rename = "http")]
764 pub http: HttpConfig,
765
766 #[serde(default)]
771 pub dev: DevConfig,
772
773 #[cfg(feature = "reporting")]
779 #[serde(default)]
780 pub reporting: ReportingConfig,
781
782 #[serde(default)]
791 pub compression: CompressionConfig,
792
793 #[serde(default)]
809 pub bot_protection: crate::security::captcha::BotProtectionConfig,
810
811 #[serde(default)]
813 pub resilience: ResilienceConfig,
814
815 #[serde(default)]
830 pub seo: SeoConfig,
831}
832
833#[derive(Debug, Clone, Default, Deserialize)]
845pub struct SeoConfig {
846 pub base_url: Option<String>,
851
852 #[serde(default)]
854 pub robots: RobotsConfig,
855}
856
857#[derive(Debug, Clone, Default, Deserialize)]
862pub struct RobotsConfig {
863 pub allow_all: Option<bool>,
868
869 #[serde(default)]
873 pub additional_rules: Vec<String>,
874
875 pub sitemap_url: Option<String>,
879}
880
881#[cfg(feature = "reporting")]
895#[derive(Debug, Clone, Deserialize)]
896pub struct ReportingConfig {
897 #[serde(default = "default_reporting_enabled")]
903 pub enabled: bool,
904 #[serde(default = "default_reporting_sample_rate")]
909 pub sample_rate: f64,
910}
911
912#[cfg(feature = "reporting")]
913impl Default for ReportingConfig {
914 fn default() -> Self {
915 Self {
916 enabled: default_reporting_enabled(),
917 sample_rate: default_reporting_sample_rate(),
918 }
919 }
920}
921
922#[cfg(feature = "reporting")]
923const fn default_reporting_enabled() -> bool {
924 true
925}
926
927#[cfg(feature = "reporting")]
928const fn default_reporting_sample_rate() -> f64 {
929 1.0
930}
931
932#[derive(Debug, Clone, Deserialize)]
945pub struct DevConfig {
946 #[serde(default = "default_inspector_path")]
951 pub inspector_path: String,
952
953 #[serde(default = "default_inspector_capacity")]
958 pub inspector_capacity: usize,
959
960 #[serde(default = "default_inspector_n_plus_one_threshold")]
965 pub inspector_n_plus_one_threshold: usize,
966}
967
968impl Default for DevConfig {
969 fn default() -> Self {
970 Self {
971 inspector_path: default_inspector_path(),
972 inspector_capacity: default_inspector_capacity(),
973 inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
974 }
975 }
976}
977
978fn default_inspector_path() -> String {
979 "/_autumn/inspect".to_owned()
980}
981
982const fn default_inspector_capacity() -> usize {
983 100
984}
985
986const fn default_inspector_n_plus_one_threshold() -> usize {
987 5
988}
989
990#[cfg(feature = "http-client")]
992#[derive(Debug, Clone, Default, Deserialize)]
993pub struct HttpConfig {
994 #[serde(default)]
996 pub client: HttpClientConfig,
997}
998
999#[cfg(feature = "http-client")]
1013#[derive(Debug, Clone, Deserialize)]
1014pub struct HttpClientConfig {
1015 #[serde(default = "default_http_timeout_secs")]
1017 pub timeout_secs: u64,
1018
1019 #[serde(default = "default_http_max_retries")]
1022 pub max_retries: u32,
1023
1024 #[serde(default = "default_http_max_retry_after_secs")]
1027 pub max_retry_after_secs: u64,
1028
1029 #[serde(default)]
1036 pub base_urls: std::collections::HashMap<String, String>,
1037}
1038
1039#[cfg(feature = "http-client")]
1040const fn default_http_timeout_secs() -> u64 {
1041 30
1042}
1043
1044#[cfg(feature = "http-client")]
1045const fn default_http_max_retries() -> u32 {
1046 3
1047}
1048
1049#[cfg(feature = "http-client")]
1050const fn default_http_max_retry_after_secs() -> u64 {
1051 10
1052}
1053
1054#[cfg(feature = "http-client")]
1055impl Default for HttpClientConfig {
1056 fn default() -> Self {
1057 Self {
1058 timeout_secs: default_http_timeout_secs(),
1059 max_retries: default_http_max_retries(),
1060 max_retry_after_secs: default_http_max_retry_after_secs(),
1061 base_urls: std::collections::HashMap::new(),
1062 }
1063 }
1064}
1065
1066impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
1067 type Rejection = crate::AutumnError;
1068
1069 async fn from_request_parts(
1070 _parts: &mut http::request::Parts,
1071 state: &crate::AppState,
1072 ) -> Result<Self, Self::Rejection> {
1073 state
1074 .extension::<Self>()
1075 .as_deref()
1076 .cloned()
1077 .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
1078 }
1079}
1080
1081#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1083#[serde(rename_all = "snake_case")]
1084pub enum ChannelBackend {
1085 #[serde(alias = "local", alias = "memory")]
1087 #[default]
1088 InProcess,
1089 Redis,
1091}
1092
1093impl ChannelBackend {
1094 #[must_use]
1096 pub fn from_env_value(value: &str) -> Option<Self> {
1097 match value.trim().to_ascii_lowercase().as_str() {
1098 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
1099 "redis" => Some(Self::Redis),
1100 _ => None,
1101 }
1102 }
1103}
1104
1105#[derive(Debug, Clone, Deserialize)]
1107pub struct ChannelConfig {
1108 #[serde(default)]
1110 pub backend: ChannelBackend,
1111 #[serde(default = "default_channel_capacity")]
1113 pub capacity: usize,
1114 #[serde(default)]
1116 pub redis: ChannelRedisConfig,
1117}
1118
1119impl Default for ChannelConfig {
1120 fn default() -> Self {
1121 Self {
1122 backend: ChannelBackend::default(),
1123 capacity: default_channel_capacity(),
1124 redis: ChannelRedisConfig::default(),
1125 }
1126 }
1127}
1128
1129#[derive(Debug, Clone, Deserialize)]
1131pub struct ChannelRedisConfig {
1132 #[serde(default)]
1134 pub url: Option<String>,
1135 #[serde(default = "default_channels_redis_prefix")]
1137 pub key_prefix: String,
1138}
1139
1140impl Default for ChannelRedisConfig {
1141 fn default() -> Self {
1142 Self {
1143 url: None,
1144 key_prefix: default_channels_redis_prefix(),
1145 }
1146 }
1147}
1148
1149const fn default_channel_capacity() -> usize {
1150 32
1151}
1152
1153fn default_channels_redis_prefix() -> String {
1154 "autumn:channels".to_owned()
1155}
1156
1157#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
1161#[serde(rename_all = "lowercase")]
1162#[non_exhaustive]
1163pub enum CacheBackend {
1164 #[default]
1166 Memory,
1167 Redis,
1169}
1170
1171impl CacheBackend {
1172 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
1173 match value.trim().to_ascii_lowercase().as_str() {
1174 "memory" => Some(Self::Memory),
1175 "redis" => Some(Self::Redis),
1176 _ => None,
1177 }
1178 }
1179}
1180
1181#[derive(Debug, Clone, Default, serde::Deserialize)]
1196pub struct CacheConfig {
1197 #[serde(default)]
1199 pub backend: CacheBackend,
1200
1201 #[serde(default)]
1203 pub redis: CacheRedisConfig,
1204}
1205
1206impl CacheConfig {
1207 #[must_use]
1209 pub fn is_memory(&self) -> bool {
1210 self.backend == CacheBackend::Memory
1211 }
1212
1213 #[must_use]
1215 pub fn is_redis(&self) -> bool {
1216 self.backend == CacheBackend::Redis
1217 }
1218}
1219
1220#[derive(Debug, Clone, serde::Deserialize)]
1222pub struct CacheRedisConfig {
1223 #[serde(default)]
1225 pub url: Option<String>,
1226
1227 #[serde(default = "default_cache_redis_key_prefix")]
1229 pub key_prefix: String,
1230}
1231
1232impl Default for CacheRedisConfig {
1233 fn default() -> Self {
1234 Self {
1235 url: None,
1236 key_prefix: default_cache_redis_key_prefix(),
1237 }
1238 }
1239}
1240
1241fn default_cache_redis_key_prefix() -> String {
1242 "autumn:cache".to_owned()
1243}
1244
1245#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1247#[serde(rename_all = "snake_case")]
1248pub enum SchedulerBackend {
1249 #[serde(alias = "local", alias = "memory")]
1251 #[default]
1252 InProcess,
1253 Postgres,
1255}
1256
1257impl SchedulerBackend {
1258 #[must_use]
1260 pub fn from_env_value(value: &str) -> Option<Self> {
1261 match value.trim().to_ascii_lowercase().as_str() {
1262 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
1263 "postgres" | "postgresql" => Some(Self::Postgres),
1264 _ => None,
1265 }
1266 }
1267}
1268
1269#[derive(Debug, Clone, Deserialize)]
1271pub struct SchedulerConfig {
1272 #[serde(default)]
1274 pub backend: SchedulerBackend,
1275 #[serde(default = "default_scheduler_lease_ttl_secs")]
1277 pub lease_ttl_secs: u64,
1278 #[serde(default)]
1280 pub replica_id: Option<String>,
1281 #[serde(default = "default_scheduler_key_prefix")]
1283 pub key_prefix: String,
1284}
1285
1286impl SchedulerConfig {
1287 #[must_use]
1289 pub fn resolved_replica_id(&self) -> String {
1290 self.replica_id
1291 .as_ref()
1292 .filter(|id| !id.trim().is_empty())
1293 .cloned()
1294 .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
1295 .or_else(|| std::env::var("HOSTNAME").ok())
1296 .unwrap_or_else(|| format!("pid-{}", std::process::id()))
1297 }
1298
1299 pub fn validate(&self) -> Result<(), ConfigError> {
1306 if self.lease_ttl_secs == 0 {
1307 return Err(ConfigError::Validation(
1308 "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
1309 ));
1310 }
1311 if self.key_prefix.trim().is_empty() {
1312 return Err(ConfigError::Validation(
1313 "scheduler.key_prefix must not be empty".to_owned(),
1314 ));
1315 }
1316 Ok(())
1317 }
1318}
1319
1320impl Default for SchedulerConfig {
1321 fn default() -> Self {
1322 Self {
1323 backend: SchedulerBackend::default(),
1324 lease_ttl_secs: default_scheduler_lease_ttl_secs(),
1325 replica_id: None,
1326 key_prefix: default_scheduler_key_prefix(),
1327 }
1328 }
1329}
1330
1331const fn default_scheduler_lease_ttl_secs() -> u64 {
1332 300
1333}
1334
1335fn default_scheduler_key_prefix() -> String {
1336 "autumn:scheduler".to_owned()
1337}
1338
1339#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1341#[serde(rename_all = "lowercase")]
1342#[non_exhaustive]
1343pub enum IdempotencyBackend {
1344 #[default]
1345 Memory,
1346 Redis,
1347}
1348
1349impl IdempotencyBackend {
1350 #[must_use]
1352 pub fn from_env_value(value: &str) -> Option<Self> {
1353 match value.trim().to_ascii_lowercase().as_str() {
1354 "memory" | "mem" => Some(Self::Memory),
1355 "redis" => Some(Self::Redis),
1356 _ => None,
1357 }
1358 }
1359}
1360
1361#[derive(Debug, Clone, Deserialize)]
1363pub struct IdempotencyRedisConfig {
1364 pub url: Option<String>,
1366 #[serde(default = "default_idempotency_redis_key_prefix")]
1368 pub key_prefix: String,
1369}
1370
1371impl Default for IdempotencyRedisConfig {
1372 fn default() -> Self {
1373 Self {
1374 url: None,
1375 key_prefix: default_idempotency_redis_key_prefix(),
1376 }
1377 }
1378}
1379
1380fn default_idempotency_redis_key_prefix() -> String {
1381 "autumn:idempotency".to_owned()
1382}
1383
1384#[derive(Debug, Clone, Deserialize)]
1386pub struct IdempotencyConfig {
1387 #[serde(default)]
1396 pub enabled: Option<bool>,
1397 #[serde(default)]
1399 pub backend: IdempotencyBackend,
1400 #[serde(default = "default_idempotency_ttl_secs")]
1402 pub ttl_secs: u64,
1403 #[serde(default = "default_idempotency_in_flight_ttl_secs")]
1409 pub in_flight_ttl_secs: u64,
1410 #[serde(default)]
1412 pub allow_memory_in_production: bool,
1413 #[serde(default)]
1415 pub redis: IdempotencyRedisConfig,
1416}
1417
1418impl Default for IdempotencyConfig {
1419 fn default() -> Self {
1420 Self {
1421 enabled: None,
1422 backend: IdempotencyBackend::default(),
1423 ttl_secs: default_idempotency_ttl_secs(),
1424 in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
1425 allow_memory_in_production: false,
1426 redis: IdempotencyRedisConfig::default(),
1427 }
1428 }
1429}
1430
1431const fn default_idempotency_ttl_secs() -> u64 {
1432 86_400
1433}
1434
1435const fn default_idempotency_in_flight_ttl_secs() -> u64 {
1436 86_400
1437}
1438
1439#[derive(Debug, Clone, Deserialize)]
1454pub struct OpenApiRuntimeConfig {
1455 #[serde(default = "default_openapi_enabled")]
1460 pub enabled: bool,
1461 #[serde(default = "default_openapi_path")]
1465 pub path: String,
1466}
1467
1468impl Default for OpenApiRuntimeConfig {
1469 fn default() -> Self {
1470 Self {
1471 enabled: default_openapi_enabled(),
1472 path: default_openapi_path(),
1473 }
1474 }
1475}
1476
1477const fn default_openapi_enabled() -> bool {
1478 true
1479}
1480
1481fn default_openapi_path() -> String {
1482 "/openapi.json".to_owned()
1483}
1484
1485#[derive(Debug, Clone, Deserialize)]
1487pub struct JobConfig {
1488 #[serde(default = "default_job_backend")]
1494 pub backend: String,
1495 #[serde(default = "default_job_workers")]
1497 pub workers: usize,
1498 #[serde(default = "default_job_max_attempts")]
1500 pub max_attempts: u32,
1501 #[serde(default = "default_job_backoff_ms")]
1503 pub initial_backoff_ms: u64,
1504 #[serde(default)]
1506 pub redis: JobRedisConfig,
1507 #[serde(default)]
1509 pub postgres: JobPostgresConfig,
1510}
1511
1512impl Default for JobConfig {
1513 fn default() -> Self {
1514 Self {
1515 backend: default_job_backend(),
1516 workers: default_job_workers(),
1517 max_attempts: default_job_max_attempts(),
1518 initial_backoff_ms: default_job_backoff_ms(),
1519 redis: JobRedisConfig::default(),
1520 postgres: JobPostgresConfig::default(),
1521 }
1522 }
1523}
1524
1525#[derive(Debug, Clone, Deserialize)]
1527pub struct JobRedisConfig {
1528 #[serde(default)]
1530 pub url: Option<String>,
1531 #[serde(default = "default_jobs_redis_prefix")]
1533 pub key_prefix: String,
1534 #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
1536 pub visibility_timeout_ms: u64,
1537}
1538
1539impl Default for JobRedisConfig {
1540 fn default() -> Self {
1541 Self {
1542 url: None,
1543 key_prefix: default_jobs_redis_prefix(),
1544 visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
1545 }
1546 }
1547}
1548
1549#[derive(Debug, Clone, Deserialize)]
1551pub struct JobPostgresConfig {
1552 #[serde(default = "default_jobs_pg_visibility_timeout_ms")]
1557 pub visibility_timeout_ms: u64,
1558}
1559
1560impl Default for JobPostgresConfig {
1561 fn default() -> Self {
1562 Self {
1563 visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
1564 }
1565 }
1566}
1567
1568const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
1569 30_000
1570}
1571
1572fn default_job_backend() -> String {
1573 "local".to_owned()
1574}
1575
1576const fn default_job_workers() -> usize {
1577 1
1578}
1579
1580const fn default_job_max_attempts() -> u32 {
1581 5
1582}
1583
1584const fn default_job_backoff_ms() -> u64 {
1585 250
1586}
1587
1588fn default_jobs_redis_prefix() -> String {
1589 "autumn:jobs".to_owned()
1590}
1591
1592const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
1593 30_000
1594}
1595
1596impl AutumnConfig {
1597 #[must_use]
1602 pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
1603 &self.credentials
1604 }
1605
1606 pub fn load() -> Result<Self, ConfigError> {
1627 Self::load_with_env(&OsEnv)
1628 }
1629
1630 pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
1641 let selected_profile_input = resolve_profile_input(env);
1642 let profile =
1643 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
1644 let mut has_inline_profile_section = false;
1645
1646 let mut merged = profile_defaults_as_toml(&profile);
1649
1650 if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
1652 deep_merge(&mut merged, base.clone());
1653
1654 for profile_name in profile_lookup_names(&profile) {
1656 if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
1657 deep_merge(&mut merged, inline_profile);
1658 has_inline_profile_section = true;
1659 }
1660 }
1661 }
1662
1663 let mut has_profile_file = false;
1665 for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
1666 let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
1667 if let Some(profile_toml) = load_raw_toml(&profile_path)? {
1668 deep_merge(&mut merged, profile_toml);
1669 has_profile_file = true;
1670 break;
1671 }
1672 }
1673 if !has_profile_file
1674 && should_warn_missing_profile_file(&profile, has_inline_profile_section)
1675 {
1676 warn_profile_typo(&profile);
1677 }
1678
1679 let toml_str =
1681 toml::to_string(&merged).expect("internal error: failed to serialize merged config");
1682 let mut config: Self = toml::from_str(&toml_str)?;
1683 config.profile = Some(profile);
1684
1685 config.apply_env_overrides_with_env(env);
1687
1688 #[cfg(feature = "mail")]
1689 if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
1690 config.mail.transport = crate::mail::Transport::Log;
1691 }
1692
1693 config.validate()?;
1694
1695 let base_dir: PathBuf = env
1696 .var("AUTUMN_MANIFEST_DIR")
1697 .map_or_else(|_| PathBuf::from("."), PathBuf::from);
1698 let cred_profile = config.profile.as_deref().unwrap_or("dev");
1699 let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
1700 config.credentials = crate::credentials::load_credentials_with_key_override(
1701 cred_profile,
1702 &base_dir,
1703 master_key_override.as_deref(),
1704 )
1705 .map_err(|e| ConfigError::Credentials(e.to_string()))?;
1706
1707 #[cfg(feature = "oauth2")]
1708 {
1709 config.expand_oauth2_providers();
1710 }
1711
1712 Ok(config)
1713 }
1714
1715 #[cfg(feature = "oauth2")]
1717 fn expand_oauth2_providers(&mut self) {
1718 let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
1719 for name in provider_names {
1720 if let (Some(preset), Some(p)) = (
1722 crate::auth::provider_preset(&name),
1723 self.auth.oauth2.providers.get_mut(&name),
1724 ) {
1725 if p.authorize_url.is_empty() {
1726 p.authorize_url = preset.authorize_url;
1727 }
1728 if p.token_url.is_empty() {
1729 p.token_url = preset.token_url;
1730 }
1731 if p.userinfo_url.is_none() {
1732 p.userinfo_url = preset.userinfo_url;
1733 }
1734 if p.scope.is_empty() || p.scope == "default" {
1735 p.scope = preset.scope;
1736 }
1737 if p.issuer.is_none() {
1738 p.issuer = preset.issuer;
1739 }
1740 if p.jwks_url.is_none() {
1741 p.jwks_url = preset.jwks_url;
1742 }
1743 if p.discovery_url.is_none() {
1744 p.discovery_url = preset.discovery_url;
1745 }
1746 }
1747
1748 if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
1750 let normalized_name = name
1751 .chars()
1752 .map(|c| if c.is_alphanumeric() { c } else { '_' })
1753 .collect::<String>()
1754 .to_lowercase();
1755
1756 let id_key = format!("oauth2_{normalized_name}_client_id");
1757 if p.client_id.is_empty() {
1758 if let Some(id) = self.credentials.get::<String>(&id_key) {
1759 p.client_id = id;
1760 } else if let Some(id) = self
1761 .credentials
1762 .get::<String>(&format!("oauth2_{name}_client_id"))
1763 {
1764 p.client_id = id;
1765 }
1766 }
1767 let secret_key = format!("oauth2_{normalized_name}_client_secret");
1768 if p.client_secret.is_empty() {
1769 if let Some(secret) = self.credentials.get::<String>(&secret_key) {
1770 p.client_secret = secret;
1771 } else if let Some(secret) = self
1772 .credentials
1773 .get::<String>(&format!("oauth2_{name}_client_secret"))
1774 {
1775 p.client_secret = secret;
1776 }
1777 }
1778 }
1779 }
1780 }
1781
1782 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1793 match std::fs::read_to_string(path) {
1794 Ok(contents) => {
1795 let config: Self = toml::from_str(&contents)?;
1796 config.validate()?;
1797 Ok(config)
1798 }
1799 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1800 Err(e) => Err(ConfigError::Io(e)),
1801 }
1802 }
1803
1804 pub fn validate(&self) -> Result<(), ConfigError> {
1810 self.database.validate()?;
1811 self.cors.validate()?;
1812 self.scheduler.validate()?;
1813 let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
1814 self.security
1815 .webhooks
1816 .validate(is_production)
1817 .map_err(|error| ConfigError::Validation(error.to_string()))?;
1818 #[cfg(feature = "mail")]
1819 self.mail.validate(self.profile.as_deref())?;
1820 Ok(())
1830 }
1831
1832 pub fn apply_env_overrides(&mut self) {
1890 self.apply_env_overrides_with_env(&OsEnv);
1891 }
1892
1893 pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
1895 self.apply_server_env_overrides_with_env(env);
1896 self.apply_database_env_overrides_with_env(env);
1897 self.apply_log_env_overrides_with_env(env);
1898 self.apply_telemetry_env_overrides_with_env(env);
1899 self.apply_health_env_overrides_with_env(env);
1900 self.apply_cors_env_overrides_with_env(env);
1901 self.apply_session_env_overrides_with_env(env);
1902 self.apply_cache_env_overrides_with_env(env);
1903 self.apply_channels_env_overrides_with_env(env);
1904 self.apply_jobs_env_overrides_with_env(env);
1905 self.apply_scheduler_env_overrides_with_env(env);
1906 self.apply_auth_env_overrides_with_env(env);
1907 self.apply_security_env_overrides_with_env(env);
1908 self.apply_bot_protection_env_overrides_with_env(env);
1909 self.apply_idempotency_env_overrides_with_env(env);
1910 self.apply_dev_env_overrides_with_env(env);
1911 self.apply_compression_env_overrides_with_env(env);
1912 self.apply_actuator_env_overrides_with_env(env);
1913 #[cfg(feature = "reporting")]
1914 self.apply_reporting_env_overrides_with_env(env);
1915 #[cfg(feature = "storage")]
1916 self.apply_storage_env_overrides_with_env(env);
1917 #[cfg(feature = "mail")]
1918 self.apply_mail_env_overrides_with_env(env);
1919 self.apply_resilience_env_overrides_with_env(env);
1920 }
1921
1922 #[cfg(feature = "reporting")]
1923 fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
1924 parse_env_bool(
1925 env,
1926 "AUTUMN_REPORTING__ENABLED",
1927 &mut self.reporting.enabled,
1928 );
1929 parse_env(
1930 env,
1931 "AUTUMN_REPORTING__SAMPLE_RATE",
1932 &mut self.reporting.sample_rate,
1933 );
1934 }
1935
1936 fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
1937 parse_env_string(
1938 env,
1939 "AUTUMN_DEV__INSPECTOR_PATH",
1940 &mut self.dev.inspector_path,
1941 );
1942 parse_env(
1943 env,
1944 "AUTUMN_DEV__INSPECTOR_CAPACITY",
1945 &mut self.dev.inspector_capacity,
1946 );
1947 parse_env(
1948 env,
1949 "AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
1950 &mut self.dev.inspector_n_plus_one_threshold,
1951 );
1952 }
1953
1954 fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
1955 parse_env_bool(
1956 env,
1957 "AUTUMN_COMPRESSION__ENABLED",
1958 &mut self.compression.enabled,
1959 );
1960 }
1961
1962 fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
1963 parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
1964 parse_env_bool(
1965 env,
1966 "AUTUMN_ACTUATOR__SENSITIVE",
1967 &mut self.actuator.sensitive,
1968 );
1969 parse_env_bool(
1973 env,
1974 "AUTUMN_ACTUATOR__PROMETHEUS",
1975 &mut self.actuator.prometheus,
1976 );
1977 }
1978
1979 fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
1980 parse_env_option_bool(
1981 env,
1982 "AUTUMN_IDEMPOTENCY__ENABLED",
1983 &mut self.idempotency.enabled,
1984 );
1985 if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
1986 match IdempotencyBackend::from_env_value(&val) {
1987 Some(backend) => self.idempotency.backend = backend,
1988 None => eprintln!(
1989 "Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
1990 ),
1991 }
1992 }
1993 parse_env(
1994 env,
1995 "AUTUMN_IDEMPOTENCY__TTL_SECS",
1996 &mut self.idempotency.ttl_secs,
1997 );
1998 parse_env(
1999 env,
2000 "AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
2001 &mut self.idempotency.in_flight_ttl_secs,
2002 );
2003 parse_env_bool(
2004 env,
2005 "AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
2006 &mut self.idempotency.allow_memory_in_production,
2007 );
2008 parse_env_string(
2009 env,
2010 "AUTUMN_IDEMPOTENCY__REDIS__URL",
2011 self.idempotency.redis.url.get_or_insert_with(String::new),
2012 );
2013 parse_env_string(
2014 env,
2015 "AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
2016 &mut self.idempotency.redis.key_prefix,
2017 );
2018 }
2019
2020 fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
2021 parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
2022 parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
2023 parse_env(
2024 env,
2025 "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
2026 &mut self.server.shutdown_timeout_secs,
2027 );
2028 parse_env(
2029 env,
2030 "AUTUMN_SERVER__PRESTOP_GRACE_SECS",
2031 &mut self.server.prestop_grace_secs,
2032 );
2033 parse_env_option(
2034 env,
2035 "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
2036 &mut self.server.timeouts.request_timeout_ms,
2037 );
2038 }
2039
2040 fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
2041 if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
2042 self.database.url = Some(val);
2043 self.database.primary_url = None;
2044 }
2045 parse_env_option_string(
2046 env,
2047 "AUTUMN_DATABASE__PRIMARY_URL",
2048 &mut self.database.primary_url,
2049 );
2050 parse_env_option_string(
2051 env,
2052 "AUTUMN_DATABASE__REPLICA_URL",
2053 &mut self.database.replica_url,
2054 );
2055 parse_env(
2056 env,
2057 "AUTUMN_DATABASE__POOL_SIZE",
2058 &mut self.database.pool_size,
2059 );
2060 parse_env_option(
2061 env,
2062 "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
2063 &mut self.database.primary_pool_size,
2064 );
2065 parse_env_option(
2066 env,
2067 "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
2068 &mut self.database.replica_pool_size,
2069 );
2070 parse_env(
2071 env,
2072 "AUTUMN_DATABASE__REPLICA_FALLBACK",
2073 &mut self.database.replica_fallback,
2074 );
2075 parse_env(
2076 env,
2077 "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
2078 &mut self.database.connect_timeout_secs,
2079 );
2080 parse_env_bool(
2081 env,
2082 "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
2083 &mut self.database.auto_migrate_in_production,
2084 );
2085 }
2086
2087 fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
2088 parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
2089 parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
2090 parse_env_csv(
2091 env,
2092 "AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
2093 &mut self.log.access_log_exclude,
2094 );
2095 if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
2096 match val.as_str() {
2097 "Auto" => self.log.format = LogFormat::Auto,
2098 "Pretty" => self.log.format = LogFormat::Pretty,
2099 "Json" => self.log.format = LogFormat::Json,
2100 _ => eprintln!(
2101 "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
2102 (expected Auto, Pretty, or Json), ignoring"
2103 ),
2104 }
2105 }
2106 }
2107
2108 fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
2109 parse_env_bool(
2111 env,
2112 "AUTUMN_TELEMETRY__ENABLED",
2113 &mut self.telemetry.enabled,
2114 );
2115 parse_env_string(
2116 env,
2117 "AUTUMN_TELEMETRY__SERVICE_NAME",
2118 &mut self.telemetry.service_name,
2119 );
2120 parse_env_option_string(
2121 env,
2122 "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
2123 &mut self.telemetry.service_namespace,
2124 );
2125 parse_env_string(
2126 env,
2127 "AUTUMN_TELEMETRY__SERVICE_VERSION",
2128 &mut self.telemetry.service_version,
2129 );
2130 parse_env_string(
2131 env,
2132 "AUTUMN_TELEMETRY__ENVIRONMENT",
2133 &mut self.telemetry.environment,
2134 );
2135 parse_env_option_string(
2136 env,
2137 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
2138 &mut self.telemetry.otlp_endpoint,
2139 );
2140 if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
2141 match TelemetryProtocol::from_env_value(&val) {
2142 Some(protocol) => self.telemetry.protocol = protocol,
2143 None => eprintln!(
2144 "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
2145 (expected Grpc or HttpProtobuf), ignoring"
2146 ),
2147 }
2148 }
2149 parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
2150 }
2151
2152 fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
2153 parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
2154 parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
2155 parse_env_string(
2156 env,
2157 "AUTUMN_HEALTH__READY_PATH",
2158 &mut self.health.ready_path,
2159 );
2160 parse_env_string(
2161 env,
2162 "AUTUMN_HEALTH__STARTUP_PATH",
2163 &mut self.health.startup_path,
2164 );
2165 parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
2166 }
2167
2168 fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
2169 parse_env_csv(
2170 env,
2171 "AUTUMN_CORS__ALLOWED_ORIGINS",
2172 &mut self.cors.allowed_origins,
2173 );
2174 parse_env_csv(
2175 env,
2176 "AUTUMN_CORS__ALLOWED_METHODS",
2177 &mut self.cors.allowed_methods,
2178 );
2179 parse_env_csv(
2180 env,
2181 "AUTUMN_CORS__ALLOWED_HEADERS",
2182 &mut self.cors.allowed_headers,
2183 );
2184 parse_env_bool(
2185 env,
2186 "AUTUMN_CORS__ALLOW_CREDENTIALS",
2187 &mut self.cors.allow_credentials,
2188 );
2189 parse_env(
2190 env,
2191 "AUTUMN_CORS__MAX_AGE_SECS",
2192 &mut self.cors.max_age_secs,
2193 );
2194 }
2195
2196 fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
2197 parse_env_string(
2198 env,
2199 "AUTUMN_SESSION__COOKIE_NAME",
2200 &mut self.session.cookie_name,
2201 );
2202 if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
2203 match crate::session::SessionBackend::from_env_value(&val) {
2204 Some(backend) => self.session.backend = backend,
2205 None => eprintln!(
2206 "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
2207 (expected memory or redis), ignoring"
2208 ),
2209 }
2210 }
2211 parse_env(
2212 env,
2213 "AUTUMN_SESSION__MAX_AGE_SECS",
2214 &mut self.session.max_age_secs,
2215 );
2216 parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
2217 parse_env_string(
2218 env,
2219 "AUTUMN_SESSION__SAME_SITE",
2220 &mut self.session.same_site,
2221 );
2222 parse_env_bool(
2223 env,
2224 "AUTUMN_SESSION__HTTP_ONLY",
2225 &mut self.session.http_only,
2226 );
2227 parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
2228 parse_env_bool(
2229 env,
2230 "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
2231 &mut self.session.allow_memory_in_production,
2232 );
2233 parse_env_option_string(
2234 env,
2235 "AUTUMN_SESSION__REDIS__URL",
2236 &mut self.session.redis.url,
2237 );
2238 parse_env_string(
2239 env,
2240 "AUTUMN_SESSION__REDIS__KEY_PREFIX",
2241 &mut self.session.redis.key_prefix,
2242 );
2243 }
2244
2245 fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
2246 if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
2247 match CacheBackend::from_env_value(&val) {
2248 Some(backend) => self.cache.backend = backend,
2249 None => eprintln!(
2250 "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
2251 (expected memory or redis), ignoring"
2252 ),
2253 }
2254 }
2255 parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
2256 parse_env_string(
2257 env,
2258 "AUTUMN_CACHE__REDIS__KEY_PREFIX",
2259 &mut self.cache.redis.key_prefix,
2260 );
2261 }
2262
2263 fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
2264 if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
2265 match ChannelBackend::from_env_value(&val) {
2266 Some(backend) => self.channels.backend = backend,
2267 None => eprintln!(
2268 "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
2269 (expected in_process or redis), ignoring"
2270 ),
2271 }
2272 }
2273 parse_env(
2274 env,
2275 "AUTUMN_CHANNELS__CAPACITY",
2276 &mut self.channels.capacity,
2277 );
2278 parse_env_option_string(
2279 env,
2280 "AUTUMN_CHANNELS__REDIS__URL",
2281 &mut self.channels.redis.url,
2282 );
2283 parse_env_string(
2284 env,
2285 "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
2286 &mut self.channels.redis.key_prefix,
2287 );
2288 }
2289
2290 fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
2291 parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
2292 parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
2293 parse_env(
2294 env,
2295 "AUTUMN_JOBS__MAX_ATTEMPTS",
2296 &mut self.jobs.max_attempts,
2297 );
2298 parse_env(
2299 env,
2300 "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
2301 &mut self.jobs.initial_backoff_ms,
2302 );
2303 parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
2304 parse_env_string(
2305 env,
2306 "AUTUMN_JOBS__REDIS__KEY_PREFIX",
2307 &mut self.jobs.redis.key_prefix,
2308 );
2309 parse_env(
2310 env,
2311 "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
2312 &mut self.jobs.redis.visibility_timeout_ms,
2313 );
2314 parse_env(
2315 env,
2316 "AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
2317 &mut self.jobs.postgres.visibility_timeout_ms,
2318 );
2319 }
2320
2321 fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
2322 if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
2323 match SchedulerBackend::from_env_value(&val) {
2324 Some(backend) => self.scheduler.backend = backend,
2325 None => eprintln!(
2326 "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
2327 (expected in_process or postgres), ignoring"
2328 ),
2329 }
2330 }
2331 parse_env(
2332 env,
2333 "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
2334 &mut self.scheduler.lease_ttl_secs,
2335 );
2336 parse_env_option_string(
2337 env,
2338 "AUTUMN_SCHEDULER__REPLICA_ID",
2339 &mut self.scheduler.replica_id,
2340 );
2341 parse_env_string(
2342 env,
2343 "AUTUMN_SCHEDULER__KEY_PREFIX",
2344 &mut self.scheduler.key_prefix,
2345 );
2346 }
2347
2348 fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
2349 parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
2350 parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
2351 parse_env(
2352 env,
2353 "AUTUMN_AUTH__LOCKOUT__ENABLED",
2354 &mut self.auth.lockout.enabled,
2355 );
2356 parse_env(
2357 env,
2358 "AUTUMN_AUTH__LOCKOUT__THRESHOLD",
2359 &mut self.auth.lockout.threshold,
2360 );
2361 parse_env(
2362 env,
2363 "AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
2364 &mut self.auth.lockout.window_secs,
2365 );
2366 parse_env(
2367 env,
2368 "AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
2369 &mut self.auth.lockout.cooloff_secs,
2370 );
2371 #[cfg(feature = "oauth2")]
2372 {
2373 let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
2374 for name in provider_names {
2375 let upper = name
2376 .chars()
2377 .map(|c| if c.is_alphanumeric() { c } else { '_' })
2378 .collect::<String>()
2379 .to_uppercase();
2380
2381 let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
2382 if let Ok(id) = env.var(&client_id_var)
2383 && !id.is_empty()
2384 && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
2385 {
2386 p.client_id = id;
2387 }
2388
2389 let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
2390 if let Ok(secret) = env.var(&client_secret_var)
2391 && !secret.is_empty()
2392 && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
2393 {
2394 p.client_secret = secret;
2395 }
2396 }
2397 }
2398 }
2399
2400 #[allow(clippy::too_many_lines)]
2402 fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
2403 parse_env_string(
2404 env,
2405 "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
2406 &mut self.security.headers.x_frame_options,
2407 );
2408 parse_env_bool(
2409 env,
2410 "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
2411 &mut self.security.headers.x_content_type_options,
2412 );
2413 parse_env_bool(
2414 env,
2415 "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
2416 &mut self.security.headers.strict_transport_security,
2417 );
2418 parse_env(
2419 env,
2420 "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
2421 &mut self.security.headers.hsts_max_age_secs,
2422 );
2423 parse_env_string(
2424 env,
2425 "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
2426 &mut self.security.headers.content_security_policy,
2427 );
2428 parse_env_string(
2429 env,
2430 "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
2431 &mut self.security.headers.referrer_policy,
2432 );
2433 parse_env_string(
2434 env,
2435 "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
2436 &mut self.security.headers.permissions_policy,
2437 );
2438
2439 parse_env_bool(
2441 env,
2442 "AUTUMN_SECURITY__CSRF__ENABLED",
2443 &mut self.security.csrf.enabled,
2444 );
2445 parse_env_string(
2446 env,
2447 "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
2448 &mut self.security.csrf.token_header,
2449 );
2450 parse_env_string(
2451 env,
2452 "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
2453 &mut self.security.csrf.cookie_name,
2454 );
2455
2456 self.apply_rate_limit_env_overrides_with_env(env);
2457
2458 parse_env(
2460 env,
2461 "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
2462 &mut self.security.upload.max_request_size_bytes,
2463 );
2464 parse_env(
2465 env,
2466 "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
2467 &mut self.security.upload.max_file_size_bytes,
2468 );
2469 parse_env_csv(
2470 env,
2471 "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
2472 &mut self.security.upload.allowed_mime_types,
2473 );
2474
2475 if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
2477 match value.parse::<crate::authorization::ForbiddenResponse>() {
2478 Ok(parsed) => self.security.forbidden_response = parsed,
2479 Err(err) => tracing::warn!(
2480 "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
2481 ),
2482 }
2483 }
2484 parse_env_bool(
2485 env,
2486 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
2487 &mut self.security.allow_unauthorized_repository_api,
2488 );
2489
2490 parse_env_option_string(
2492 env,
2493 "AUTUMN_SECURITY__SIGNING_SECRET",
2494 &mut self.security.signing_secret.secret,
2495 );
2496 parse_env_csv(
2497 env,
2498 "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
2499 &mut self.security.trusted_hosts.hosts,
2500 );
2501
2502 parse_env_csv(
2504 env,
2505 "AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
2506 &mut self.security.trusted_proxies.ranges,
2507 );
2508 parse_env_bool(
2509 env,
2510 "AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
2511 &mut self.security.trusted_proxies.trust_forwarded_headers,
2512 );
2513 if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
2514 if let Ok(hops) = val.trim().parse::<u32>() {
2515 self.security.trusted_proxies.trusted_hops = Some(hops);
2516 } else {
2517 tracing::warn!(
2518 "ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
2519 expected a non-negative integer"
2520 );
2521 }
2522 }
2523
2524 self.security.webhooks.apply_env_overrides_with_env(env);
2525 }
2526
2527 fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
2528 parse_env_bool(
2529 env,
2530 "AUTUMN_BOT_PROTECTION__ENABLED",
2531 &mut self.bot_protection.enabled,
2532 );
2533 parse_env_bool(
2534 env,
2535 "AUTUMN_BOT_PROTECTION__DEV_BYPASS",
2536 &mut self.bot_protection.dev_bypass,
2537 );
2538 if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
2539 match val.to_lowercase().as_str() {
2540 "turnstile" => {
2541 self.bot_protection.provider =
2542 crate::security::captcha::CaptchaProviderKind::Turnstile;
2543 }
2544 "hcaptcha" => {
2545 self.bot_protection.provider =
2546 crate::security::captcha::CaptchaProviderKind::HCaptcha;
2547 }
2548 _ => tracing::warn!(
2549 "ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
2550 expected \"turnstile\" or \"hcaptcha\""
2551 ),
2552 }
2553 }
2554 parse_env_option_string(
2555 env,
2556 "AUTUMN_BOT_PROTECTION__SITE_KEY",
2557 &mut self.bot_protection.site_key,
2558 );
2559 parse_env_option_string(
2560 env,
2561 "AUTUMN_BOT_PROTECTION__SECRET_KEY",
2562 &mut self.bot_protection.secret_key,
2563 );
2564 parse_env_option_string(
2565 env,
2566 "AUTUMN_BOT_PROTECTION__FORM_FIELD",
2567 &mut self.bot_protection.form_field,
2568 );
2569 }
2570
2571 fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
2572 parse_env_bool(
2573 env,
2574 "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
2575 &mut self.security.rate_limit.enabled,
2576 );
2577 parse_env(
2578 env,
2579 "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
2580 &mut self.security.rate_limit.requests_per_second,
2581 );
2582 parse_env(
2583 env,
2584 "AUTUMN_SECURITY__RATE_LIMIT__BURST",
2585 &mut self.security.rate_limit.burst,
2586 );
2587 parse_env_bool(
2588 env,
2589 "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
2590 &mut self.security.rate_limit.trust_forwarded_headers,
2591 );
2592 parse_env_csv(
2593 env,
2594 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
2595 &mut self.security.rate_limit.trusted_proxies,
2596 );
2597 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
2598 match crate::security::config::KeyStrategy::from_env_value(&val) {
2599 Some(strategy) => self.security.rate_limit.key_strategy = strategy,
2600 None => eprintln!(
2601 "Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
2602 (expected ip, api_token, or authenticated_principal), ignoring"
2603 ),
2604 }
2605 }
2606 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
2609 match crate::security::config::RateLimitBackend::from_env_value(&val) {
2610 Some(backend) => self.security.rate_limit.backend = backend,
2611 None => eprintln!(
2612 "Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
2613 (expected memory or redis), ignoring"
2614 ),
2615 }
2616 }
2617 #[cfg(feature = "redis")]
2618 {
2619 use crate::security::config::RateLimitBackendFailure;
2620 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
2621 match RateLimitBackendFailure::from_env_value(&val) {
2622 Some(mode) => self.security.rate_limit.on_backend_failure = mode,
2623 None => eprintln!(
2624 "Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
2625 valid (expected fail_open or fail_closed), ignoring"
2626 ),
2627 }
2628 }
2629 parse_env_option_string(
2630 env,
2631 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
2632 &mut self.security.rate_limit.redis.url,
2633 );
2634 parse_env_string(
2635 env,
2636 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
2637 &mut self.security.rate_limit.redis.key_prefix,
2638 );
2639 }
2640 }
2641
2642 #[cfg(feature = "storage")]
2643 fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
2644 if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
2645 match crate::storage::StorageBackend::from_env_value(&val) {
2646 Some(backend) => self.storage.backend = backend,
2647 None => eprintln!(
2648 "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
2649 (expected disabled, local, or s3), ignoring"
2650 ),
2651 }
2652 }
2653 parse_env_string(
2654 env,
2655 "AUTUMN_STORAGE__DEFAULT_PROVIDER",
2656 &mut self.storage.default_provider,
2657 );
2658 parse_env_bool(
2659 env,
2660 "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
2661 &mut self.storage.allow_local_in_production,
2662 );
2663 if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
2664 self.storage.local.root = PathBuf::from(val);
2665 }
2666 parse_env_string(
2667 env,
2668 "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
2669 &mut self.storage.local.mount_path,
2670 );
2671 parse_env(
2672 env,
2673 "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
2674 &mut self.storage.local.default_url_expiry_secs,
2675 );
2676 parse_env_option_string(
2677 env,
2678 "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
2679 &mut self.storage.local.signing_key,
2680 );
2681 parse_env_option_string(
2682 env,
2683 "AUTUMN_STORAGE__S3__BUCKET",
2684 &mut self.storage.s3.bucket,
2685 );
2686 parse_env_option_string(
2687 env,
2688 "AUTUMN_STORAGE__S3__REGION",
2689 &mut self.storage.s3.region,
2690 );
2691 parse_env_option_string(
2692 env,
2693 "AUTUMN_STORAGE__S3__ENDPOINT",
2694 &mut self.storage.s3.endpoint,
2695 );
2696 parse_env_option_string(
2697 env,
2698 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
2699 &mut self.storage.s3.public_base_url,
2700 );
2701 parse_env_option_string(
2702 env,
2703 "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
2704 &mut self.storage.s3.access_key_id_env,
2705 );
2706 parse_env_option_string(
2707 env,
2708 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
2709 &mut self.storage.s3.secret_access_key_env,
2710 );
2711 parse_env_bool(
2712 env,
2713 "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
2714 &mut self.storage.s3.force_path_style,
2715 );
2716 parse_env(
2717 env,
2718 "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
2719 &mut self.storage.s3.default_url_expiry_secs,
2720 );
2721 parse_env(
2722 env,
2723 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
2724 &mut self.storage.variants.max_source_bytes,
2725 );
2726 parse_env(
2727 env,
2728 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
2729 &mut self.storage.variants.max_source_width,
2730 );
2731 parse_env(
2732 env,
2733 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
2734 &mut self.storage.variants.max_source_height,
2735 );
2736 }
2737
2738 #[cfg(feature = "mail")]
2739 fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
2740 if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
2741 match crate::mail::Transport::from_env_value(&val) {
2742 Some(transport) => self.mail.transport = transport,
2743 None => eprintln!(
2744 "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
2745 (expected log, file, smtp, or disabled), ignoring"
2746 ),
2747 }
2748 }
2749 parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
2750 parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
2751 parse_env_bool(
2752 env,
2753 "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
2754 &mut self.mail.allow_log_in_production,
2755 );
2756 parse_env_bool(
2757 env,
2758 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
2759 &mut self.mail.allow_in_process_deliver_later_in_production,
2760 );
2761 parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
2762 if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
2763 self.mail.file_dir = PathBuf::from(val);
2764 }
2765 parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
2766 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
2767 match val.parse::<u16>() {
2768 Ok(port) => self.mail.smtp.port = Some(port),
2769 Err(_) => {
2770 eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
2771 }
2772 }
2773 }
2774 parse_env_option_string(
2775 env,
2776 "AUTUMN_MAIL__SMTP__USERNAME",
2777 &mut self.mail.smtp.username,
2778 );
2779 parse_env_option_string(
2780 env,
2781 "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
2782 &mut self.mail.smtp.password_env,
2783 );
2784 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
2785 match crate::mail::TlsMode::from_env_value(&val) {
2786 Some(tls) => self.mail.smtp.tls = tls,
2787 None => eprintln!(
2788 "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
2789 (expected disabled, starttls, or tls), ignoring"
2790 ),
2791 }
2792 }
2793 }
2794
2795 #[must_use]
2797 pub fn profile_name(&self) -> Option<&str> {
2798 self.profile.as_deref()
2799 }
2800}
2801
2802#[derive(Debug, Clone, Default, Deserialize)]
2837pub struct RequestTimeoutsConfig {
2838 #[serde(default)]
2844 pub request_timeout_ms: Option<u64>,
2845}
2846
2847#[derive(Debug, Clone, Deserialize)]
2848pub struct ServerConfig {
2849 #[serde(default = "default_port")]
2851 pub port: u16,
2852
2853 #[serde(default = "default_host")]
2858 pub host: String,
2859
2860 #[serde(default = "default_shutdown_timeout")]
2867 pub shutdown_timeout_secs: u64,
2868
2869 #[serde(default = "default_prestop_grace")]
2878 pub prestop_grace_secs: u64,
2879
2880 #[serde(default)]
2886 pub timeouts: RequestTimeoutsConfig,
2887}
2888
2889#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
2891#[serde(rename_all = "snake_case")]
2892#[non_exhaustive]
2893pub enum ReplicaFallback {
2894 #[default]
2896 FailReadiness,
2897 Primary,
2899}
2900
2901impl std::str::FromStr for ReplicaFallback {
2902 type Err = ();
2903
2904 fn from_str(value: &str) -> Result<Self, Self::Err> {
2905 match value.trim().to_ascii_lowercase().as_str() {
2906 "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
2907 "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
2908 _ => Err(()),
2909 }
2910 }
2911}
2912
2913#[derive(Debug, Clone, Deserialize)]
2944pub struct DatabaseConfig {
2945 #[serde(default)]
2952 pub url: Option<String>,
2953
2954 #[serde(default)]
2959 pub primary_url: Option<String>,
2960
2961 #[serde(default)]
2966 pub replica_url: Option<String>,
2967
2968 #[serde(default = "default_pool_size")]
2973 pub pool_size: usize,
2974
2975 #[serde(default)]
2977 pub primary_pool_size: Option<usize>,
2978
2979 #[serde(default)]
2981 pub replica_pool_size: Option<usize>,
2982
2983 #[serde(default)]
2986 pub replica_fallback: ReplicaFallback,
2987
2988 #[serde(default = "default_connect_timeout")]
2992 pub connect_timeout_secs: u64,
2993
2994 #[serde(default)]
3000 pub auto_migrate_in_production: bool,
3001
3002 #[serde(deserialize_with = "deserialize_option_duration", default)]
3004 pub statement_timeout: Option<std::time::Duration>,
3005
3006 #[serde(
3008 deserialize_with = "deserialize_duration",
3009 default = "default_slow_query_threshold"
3010 )]
3011 pub slow_query_threshold: std::time::Duration,
3012}
3013
3014impl DatabaseConfig {
3015 #[must_use]
3017 pub fn effective_primary_url(&self) -> Option<&str> {
3018 self.primary_url.as_deref().or(self.url.as_deref())
3019 }
3020
3021 #[must_use]
3023 pub fn effective_primary_pool_size(&self) -> usize {
3024 self.primary_pool_size.unwrap_or(self.pool_size)
3025 }
3026
3027 #[must_use]
3029 pub fn effective_replica_pool_size(&self) -> usize {
3030 self.replica_pool_size.unwrap_or(self.pool_size)
3031 }
3032
3033 pub fn validate(&self) -> Result<(), ConfigError> {
3039 for (field, url) in [
3040 ("database.url", self.url.as_deref()),
3041 ("database.primary_url", self.primary_url.as_deref()),
3042 ("database.replica_url", self.replica_url.as_deref()),
3043 ] {
3044 if let Some(url) = url
3045 && !url.starts_with("postgres://")
3046 && !url.starts_with("postgresql://")
3047 {
3048 let label = if field == "database.url" {
3049 "database URL"
3050 } else {
3051 field
3052 };
3053 return Err(ConfigError::Validation(format!(
3054 "Invalid {label}: must start with postgres:// or postgresql://, got {url:?}"
3055 )));
3056 }
3057 }
3058
3059 if self.replica_url.is_some() && self.effective_primary_url().is_none() {
3060 return Err(ConfigError::Validation(
3061 "database.replica_url requires database.primary_url or database.url".to_owned(),
3062 ));
3063 }
3064 Ok(())
3065 }
3066}
3067
3068#[derive(Debug, Clone, Deserialize)]
3084pub struct LogConfig {
3085 #[serde(default = "default_log_level")]
3090 pub level: String,
3091
3092 #[serde(default)]
3094 pub format: LogFormat,
3095
3096 #[serde(default)]
3098 pub filter_parameters: Vec<String>,
3099
3100 #[serde(default)]
3102 pub unfilter_parameters: Vec<String>,
3103
3104 #[serde(default = "default_access_log")]
3113 pub access_log: bool,
3114
3115 #[serde(default = "default_access_log_exclude")]
3125 pub access_log_exclude: Vec<String>,
3126
3127 #[serde(default)]
3134 pub capture: crate::log::capture::LogCaptureConfig,
3135}
3136
3137#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
3156#[non_exhaustive]
3157pub enum LogFormat {
3158 #[default]
3160 Auto,
3161 Pretty,
3163 Json,
3165}
3166
3167#[derive(Debug, Clone, Deserialize)]
3172pub struct TelemetryConfig {
3173 #[serde(default)]
3175 pub enabled: bool,
3176
3177 #[serde(default = "default_telemetry_service_name")]
3179 pub service_name: String,
3180
3181 #[serde(default)]
3183 pub service_namespace: Option<String>,
3184
3185 #[serde(default = "default_telemetry_service_version")]
3187 pub service_version: String,
3188
3189 #[serde(default = "default_telemetry_environment")]
3191 pub environment: String,
3192
3193 #[serde(default)]
3195 pub otlp_endpoint: Option<String>,
3196
3197 #[serde(default)]
3199 pub protocol: TelemetryProtocol,
3200
3201 #[serde(default)]
3203 pub strict: bool,
3204}
3205
3206#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
3208#[non_exhaustive]
3209pub enum TelemetryProtocol {
3210 #[serde(alias = "grpc", alias = "GRPC")]
3212 #[default]
3213 Grpc,
3214 #[serde(
3216 alias = "http-protobuf",
3217 alias = "http_protobuf",
3218 alias = "HTTP_PROTOBUF"
3219 )]
3220 HttpProtobuf,
3221}
3222
3223impl TelemetryProtocol {
3224 fn from_env_value(value: &str) -> Option<Self> {
3225 match value {
3226 "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
3227 "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
3228 | "httpprotobuf" => Some(Self::HttpProtobuf),
3229 _ => None,
3230 }
3231 }
3232}
3233
3234#[derive(Debug, Clone, Deserialize)]
3252pub struct HealthConfig {
3253 #[serde(default = "default_health_path")]
3257 pub path: String,
3258
3259 #[serde(default = "default_live_path")]
3261 pub live_path: String,
3262
3263 #[serde(default = "default_ready_path")]
3265 pub ready_path: String,
3266
3267 #[serde(default = "default_startup_path")]
3269 pub startup_path: String,
3270
3271 #[serde(default)]
3275 pub detailed: bool,
3276}
3277
3278#[derive(Debug, Clone, Deserialize)]
3284pub struct ActuatorConfig {
3285 #[serde(default = "default_actuator_prefix")]
3287 pub prefix: String,
3288
3289 #[serde(default)]
3292 pub sensitive: bool,
3293
3294 #[serde(default = "default_actuator_prometheus")]
3302 pub prometheus: bool,
3303}
3304
3305impl Default for ActuatorConfig {
3306 fn default() -> Self {
3307 Self {
3308 prefix: default_actuator_prefix(),
3309 sensitive: false,
3310 prometheus: default_actuator_prometheus(),
3311 }
3312 }
3313}
3314
3315fn default_actuator_prefix() -> String {
3316 "/actuator".to_owned()
3317}
3318
3319const fn default_actuator_prometheus() -> bool {
3320 true
3321}
3322
3323#[derive(Debug, Clone, Deserialize)]
3360pub struct CorsConfig {
3361 #[serde(default)]
3366 pub allowed_origins: Vec<String>,
3367
3368 #[serde(default = "default_cors_methods")]
3371 pub allowed_methods: Vec<String>,
3372
3373 #[serde(default = "default_cors_headers")]
3376 pub allowed_headers: Vec<String>,
3377
3378 #[serde(default)]
3381 pub allow_credentials: bool,
3382
3383 #[serde(default = "default_cors_max_age")]
3386 pub max_age_secs: u64,
3387}
3388
3389impl Default for CorsConfig {
3390 fn default() -> Self {
3391 Self {
3392 allowed_origins: Vec::new(),
3393 allowed_methods: default_cors_methods(),
3394 allowed_headers: default_cors_headers(),
3395 allow_credentials: false,
3396 max_age_secs: default_cors_max_age(),
3397 }
3398 }
3399}
3400
3401impl CorsConfig {
3402 pub fn validate(&self) -> Result<(), ConfigError> {
3411 if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
3412 return Err(ConfigError::Validation(
3413 "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
3414 list explicit origins instead (browsers reject the wildcard+credentials combo)"
3415 .to_owned(),
3416 ));
3417 }
3418 Ok(())
3419 }
3420}
3421
3422fn default_cors_methods() -> Vec<String> {
3423 vec![
3424 "GET".to_owned(),
3425 "POST".to_owned(),
3426 "PUT".to_owned(),
3427 "DELETE".to_owned(),
3428 "PATCH".to_owned(),
3429 "OPTIONS".to_owned(),
3430 ]
3431}
3432
3433fn default_cors_headers() -> Vec<String> {
3434 vec!["Content-Type".to_owned(), "Authorization".to_owned()]
3435}
3436
3437const fn default_cors_max_age() -> u64 {
3438 86400
3439}
3440
3441#[derive(Debug, Clone, Deserialize, Default)]
3485pub struct CompressionConfig {
3486 #[serde(default)]
3494 pub enabled: bool,
3495}
3496
3497fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
3499 if let Ok(val) = env.var(key) {
3500 match val.parse::<T>() {
3501 Ok(v) => *target = v,
3502 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
3503 }
3504 }
3505}
3506
3507fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
3508 if let Ok(val) = env.var(key) {
3509 *target = if val.is_empty() { None } else { Some(val) };
3510 }
3511}
3512
3513fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
3514 if let Ok(val) = env.var(key) {
3515 if val.is_empty() {
3516 *target = None;
3517 } else {
3518 match val.parse::<T>() {
3519 Ok(v) => *target = Some(v),
3520 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
3521 }
3522 }
3523 }
3524}
3525
3526fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
3527 if let Ok(val) = env.var(key) {
3528 *target = val;
3529 }
3530}
3531
3532fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
3533 if let Ok(val) = env.var(key) {
3534 match val.as_str() {
3535 "true" | "1" => *target = true,
3536 "false" | "0" => *target = false,
3537 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
3538 }
3539 }
3540}
3541
3542fn parse_env_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
3543 if let Ok(val) = env.var(key) {
3544 match val.as_str() {
3545 "true" | "1" => *target = Some(true),
3546 "false" | "0" => *target = Some(false),
3547 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
3548 }
3549 }
3550}
3551
3552fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
3553 if let Ok(val) = env.var(key) {
3554 *target = val.split(',').map(|s| s.trim().to_owned()).collect();
3555 }
3556}
3557
3558const fn default_port() -> u16 {
3561 3000
3562}
3563
3564fn default_host() -> String {
3565 "127.0.0.1".to_owned()
3566}
3567
3568const fn default_shutdown_timeout() -> u64 {
3569 30
3570}
3571
3572const fn default_prestop_grace() -> u64 {
3573 5
3574}
3575
3576const fn default_pool_size() -> usize {
3577 10
3578}
3579
3580const fn default_connect_timeout() -> u64 {
3581 5
3582}
3583
3584fn default_log_level() -> String {
3585 "info".to_owned()
3586}
3587
3588const fn default_access_log() -> bool {
3589 true
3590}
3591
3592fn default_access_log_exclude() -> Vec<String> {
3593 vec![
3594 "/health".to_owned(),
3595 "/live".to_owned(),
3596 "/ready".to_owned(),
3597 "/startup".to_owned(),
3598 "/actuator".to_owned(),
3599 "/static".to_owned(),
3600 ]
3601}
3602
3603fn default_telemetry_service_name() -> String {
3604 "autumn-app".to_owned()
3605}
3606
3607fn default_telemetry_service_version() -> String {
3608 "unknown".to_owned()
3609}
3610
3611fn default_telemetry_environment() -> String {
3612 "development".to_owned()
3613}
3614
3615fn default_health_path() -> String {
3616 "/health".to_owned()
3617}
3618
3619fn default_live_path() -> String {
3620 "/live".to_owned()
3621}
3622
3623fn default_ready_path() -> String {
3624 "/ready".to_owned()
3625}
3626
3627fn default_startup_path() -> String {
3628 "/startup".to_owned()
3629}
3630
3631impl Default for ServerConfig {
3634 fn default() -> Self {
3635 Self {
3636 port: default_port(),
3637 host: default_host(),
3638 shutdown_timeout_secs: default_shutdown_timeout(),
3639 prestop_grace_secs: default_prestop_grace(),
3640 timeouts: RequestTimeoutsConfig::default(),
3641 }
3642 }
3643}
3644
3645impl Default for DatabaseConfig {
3646 fn default() -> Self {
3647 Self {
3648 url: None,
3649 primary_url: None,
3650 replica_url: None,
3651 pool_size: default_pool_size(),
3652 primary_pool_size: None,
3653 replica_pool_size: None,
3654 replica_fallback: ReplicaFallback::default(),
3655 connect_timeout_secs: default_connect_timeout(),
3656 auto_migrate_in_production: false,
3657 statement_timeout: None,
3658 slow_query_threshold: default_slow_query_threshold(),
3659 }
3660 }
3661}
3662
3663impl Default for LogConfig {
3664 fn default() -> Self {
3665 Self {
3666 level: default_log_level(),
3667 format: LogFormat::default(),
3668 filter_parameters: Vec::new(),
3669 unfilter_parameters: Vec::new(),
3670 access_log: default_access_log(),
3671 access_log_exclude: default_access_log_exclude(),
3672 capture: crate::log::capture::LogCaptureConfig::default(),
3673 }
3674 }
3675}
3676
3677impl Default for TelemetryConfig {
3678 fn default() -> Self {
3679 Self {
3680 enabled: false,
3681 service_name: default_telemetry_service_name(),
3682 service_namespace: None,
3683 service_version: default_telemetry_service_version(),
3684 environment: default_telemetry_environment(),
3685 otlp_endpoint: None,
3686 protocol: TelemetryProtocol::default(),
3687 strict: false,
3688 }
3689 }
3690}
3691
3692impl Default for HealthConfig {
3693 fn default() -> Self {
3694 Self {
3695 path: default_health_path(),
3696 live_path: default_live_path(),
3697 ready_path: default_ready_path(),
3698 startup_path: default_startup_path(),
3699 detailed: false,
3700 }
3701 }
3702}
3703
3704pub trait ConfigLoader: Send + Sync + 'static {
3735 fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
3743}
3744
3745#[derive(Debug, Default, Clone, Copy)]
3751pub struct TomlEnvConfigLoader;
3752
3753impl TomlEnvConfigLoader {
3754 #[must_use]
3756 pub const fn new() -> Self {
3757 Self
3758 }
3759}
3760
3761impl ConfigLoader for TomlEnvConfigLoader {
3762 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
3763 AutumnConfig::load_with_env(&OsEnv)
3764 }
3765}
3766
3767const fn default_slow_query_threshold() -> std::time::Duration {
3768 std::time::Duration::from_millis(500)
3769}
3770
3771pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
3778 if s.is_empty() {
3779 return Err("duration string is empty".to_owned());
3780 }
3781
3782 if let Ok(ms) = s.parse::<u64>() {
3784 return Ok(std::time::Duration::from_millis(ms));
3785 }
3786
3787 if let Some(val_str) = s.strip_suffix("ms") {
3789 let val = val_str
3790 .parse::<u64>()
3791 .map_err(|e| format!("invalid duration integer: {e}"))?;
3792 return Ok(std::time::Duration::from_millis(val));
3793 }
3794
3795 if let Some(val_str) = s.strip_suffix('s') {
3796 let val = val_str
3797 .parse::<u64>()
3798 .map_err(|e| format!("invalid duration integer: {e}"))?;
3799 return Ok(std::time::Duration::from_secs(val));
3800 }
3801
3802 if let Some(val_str) = s.strip_suffix('m') {
3803 let val = val_str
3804 .parse::<u64>()
3805 .map_err(|e| format!("invalid duration integer: {e}"))?;
3806 let secs = val.checked_mul(60).ok_or_else(|| {
3807 format!("duration overflow: '{s}' exceeds maximum representable value")
3808 })?;
3809 return Ok(std::time::Duration::from_secs(secs));
3810 }
3811
3812 if let Some(val_str) = s.strip_suffix('h') {
3813 let val = val_str
3814 .parse::<u64>()
3815 .map_err(|e| format!("invalid duration integer: {e}"))?;
3816 let secs = val.checked_mul(3600).ok_or_else(|| {
3817 format!("duration overflow: '{s}' exceeds maximum representable value")
3818 })?;
3819 return Ok(std::time::Duration::from_secs(secs));
3820 }
3821
3822 Err(format!("invalid duration format: '{s}'"))
3823}
3824
3825pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
3833where
3834 D: serde::Deserializer<'de>,
3835{
3836 use serde::Deserialize;
3837
3838 #[derive(Deserialize)]
3839 #[serde(untagged)]
3840 enum DurationOrStr {
3841 String(String),
3842 Integer(u64),
3843 }
3844
3845 match DurationOrStr::deserialize(deserializer)? {
3846 DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
3847 DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
3848 }
3849}
3850
3851pub fn deserialize_option_duration<'de, D>(
3859 deserializer: D,
3860) -> Result<Option<std::time::Duration>, D::Error>
3861where
3862 D: serde::Deserializer<'de>,
3863{
3864 use serde::Deserialize;
3865
3866 #[derive(Deserialize)]
3867 struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
3868
3869 Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
3870}
3871
3872#[derive(Debug, Clone, Deserialize)]
3874pub struct TenancyConfig {
3875 #[serde(default)]
3877 pub enabled: bool,
3878
3879 #[serde(default = "default_tenancy_source")]
3882 pub source: String,
3883
3884 #[serde(default = "default_tenancy_header_name")]
3886 pub header_name: String,
3887
3888 #[serde(default = "default_tenancy_session_key")]
3890 pub session_key: String,
3891
3892 #[serde(default = "default_tenancy_jwt_claim")]
3894 pub jwt_claim: String,
3895
3896 #[serde(default)]
3898 pub jwt_secret: Option<String>,
3899
3900 #[serde(default)]
3902 pub jwt_issuer: Option<String>,
3903
3904 #[serde(default)]
3908 pub jwt_audience: Option<String>,
3909
3910 #[serde(default)]
3912 pub base_domain: Option<String>,
3913}
3914
3915fn default_tenancy_source() -> String {
3916 "header".to_string()
3917}
3918
3919fn default_tenancy_header_name() -> String {
3920 "x-tenant-id".to_string()
3921}
3922
3923fn default_tenancy_session_key() -> String {
3924 "tenant_id".to_string()
3925}
3926
3927fn default_tenancy_jwt_claim() -> String {
3928 "tenant_id".to_string()
3929}
3930
3931impl Default for TenancyConfig {
3932 fn default() -> Self {
3933 Self {
3934 enabled: false,
3935 source: default_tenancy_source(),
3936 header_name: default_tenancy_header_name(),
3937 session_key: default_tenancy_session_key(),
3938 jwt_claim: default_tenancy_jwt_claim(),
3939 jwt_secret: None,
3940 jwt_issuer: None,
3941 jwt_audience: None,
3942 base_domain: None,
3943 }
3944 }
3945}
3946
3947#[derive(Debug, Clone, Default, Deserialize)]
3951pub struct ResilienceConfig {
3952 #[serde(default)]
3954 pub circuit_breaker: CircuitBreakerConfig,
3955}
3956
3957#[derive(Debug, Clone, Default, Deserialize)]
3959pub struct CircuitBreakerConfig {
3960 #[serde(default)]
3962 pub defaults: CircuitBreakerPolicyConfig,
3963 #[serde(default)]
3965 pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
3966}
3967
3968#[derive(Debug, Clone, Default, Deserialize)]
3970pub struct CircuitBreakerPolicyConfig {
3971 pub failure_ratio_threshold: Option<f64>,
3973 pub sample_window_secs: Option<u64>,
3975 pub minimum_sample_count: Option<u64>,
3977 pub open_duration_secs: Option<u64>,
3979 pub half_open_trial_count: Option<u64>,
3981}
3982
3983impl AutumnConfig {
3984 fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
3985 parse_env_option(
3986 env,
3987 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
3988 &mut self
3989 .resilience
3990 .circuit_breaker
3991 .defaults
3992 .failure_ratio_threshold,
3993 );
3994 parse_env_option(
3995 env,
3996 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
3997 &mut self.resilience.circuit_breaker.defaults.sample_window_secs,
3998 );
3999 parse_env_option(
4000 env,
4001 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
4002 &mut self
4003 .resilience
4004 .circuit_breaker
4005 .defaults
4006 .minimum_sample_count,
4007 );
4008 parse_env_option(
4009 env,
4010 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
4011 &mut self.resilience.circuit_breaker.defaults.open_duration_secs,
4012 );
4013 parse_env_option(
4014 env,
4015 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
4016 &mut self
4017 .resilience
4018 .circuit_breaker
4019 .defaults
4020 .half_open_trial_count,
4021 );
4022 }
4023}
4024
4025#[cfg(test)]
4026mod tests {
4027
4028 use super::*;
4029
4030 struct MockConfigLoader {
4032 config: AutumnConfig,
4033 }
4034
4035 impl ConfigLoader for MockConfigLoader {
4036 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
4037 Ok(self.config.clone())
4038 }
4039 }
4040
4041 #[tokio::test]
4042 async fn config_loader_trait_returns_supplied_config() {
4043 let mut custom = AutumnConfig::default();
4044 custom.server.port = 9999;
4045 custom.profile = Some("integration-test".to_owned());
4046
4047 let loader = MockConfigLoader {
4048 config: custom.clone(),
4049 };
4050 let resolved = loader.load().await.expect("mock loader should succeed");
4051
4052 assert_eq!(resolved.server.port, 9999);
4053 assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
4054 }
4055
4056 #[test]
4057 fn validate_does_not_error_on_redis_backend_without_url() {
4058 let mut config = AutumnConfig::default();
4066 config.session.backend = crate::session::SessionBackend::Redis;
4067 config.session.redis.url = None;
4068
4069 config.validate().expect(
4070 "validate() must accept redis-backend-without-url so custom \
4071 session store overrides aren't blocked at boot",
4072 );
4073 }
4074
4075 #[tokio::test]
4076 async fn default_toml_env_loader_succeeds_without_files() {
4077 let loader = TomlEnvConfigLoader::new();
4080 let resolved = loader.load().await.expect("default loader should succeed");
4081 assert_eq!(resolved.server.port, 3000);
4083 }
4084
4085 #[test]
4086 fn database_config_validate_none() {
4087 let config = DatabaseConfig {
4088 url: None,
4089 ..Default::default()
4090 };
4091 assert!(config.validate().is_ok());
4092 }
4093
4094 #[test]
4095 fn database_config_validate_valid_postgres() {
4096 let config = DatabaseConfig {
4097 url: Some("postgres://user:pass@localhost:5432/db".to_string()),
4098 ..Default::default()
4099 };
4100 assert!(config.validate().is_ok());
4101 }
4102
4103 #[test]
4104 fn database_config_validate_valid_postgresql() {
4105 let config = DatabaseConfig {
4106 url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
4107 ..Default::default()
4108 };
4109 assert!(config.validate().is_ok());
4110 }
4111
4112 #[test]
4113 fn database_config_validate_invalid_scheme() {
4114 let config = DatabaseConfig {
4115 url: Some("mysql://user:pass@localhost:3306/db".to_string()),
4116 ..Default::default()
4117 };
4118 let result = config.validate();
4119 assert!(result.is_err());
4120 match result {
4121 Err(ConfigError::Validation(msg)) => {
4122 assert!(msg.contains("must start with postgres:// or postgresql://"));
4125 }
4126 _ => panic!("Expected ConfigError::Validation"),
4127 }
4128 }
4129
4130 #[test]
4131 fn server_defaults() {
4132 let config = ServerConfig::default();
4133 assert_eq!(config.port, 3000);
4134 assert_eq!(config.host, "127.0.0.1");
4135 assert_eq!(config.shutdown_timeout_secs, 30);
4136 }
4137
4138 #[test]
4139 fn database_defaults() {
4140 let config = DatabaseConfig::default();
4141 assert!(config.url.is_none());
4142 assert_eq!(config.pool_size, 10);
4143 assert_eq!(config.connect_timeout_secs, 5);
4144 }
4145
4146 #[test]
4147 fn database_validate_none_url_is_ok() {
4148 let config = DatabaseConfig {
4149 url: None,
4150 ..Default::default()
4151 };
4152 assert!(config.validate().is_ok());
4153 }
4154
4155 #[test]
4156 fn database_validate_postgres_url_is_ok() {
4157 let config = DatabaseConfig {
4158 url: Some("postgres://user:pass@localhost/db".to_string()),
4159 ..Default::default()
4160 };
4161 assert!(config.validate().is_ok());
4162 }
4163
4164 #[test]
4165 fn database_validate_postgresql_url_is_ok() {
4166 let config = DatabaseConfig {
4167 url: Some("postgresql://user:pass@localhost/db".to_string()),
4168 ..Default::default()
4169 };
4170 assert!(config.validate().is_ok());
4171 }
4172
4173 #[test]
4174 fn database_validate_invalid_url_is_err() {
4175 let config = DatabaseConfig {
4176 url: Some("mysql://user:pass@localhost/db".to_string()),
4177 ..Default::default()
4178 };
4179 let result = config.validate();
4180 assert!(result.is_err());
4181 if let Err(ConfigError::Validation(msg)) = result {
4182 assert!(msg.contains("Invalid database URL"));
4183 assert!(msg.contains("must start with postgres:// or postgresql://"));
4184 } else {
4185 panic!("Expected ConfigError::Validation");
4186 }
4187 }
4188
4189 #[test]
4190 fn database_topology_deserializes_primary_and_replica_urls() {
4191 let config: AutumnConfig = toml::from_str(
4192 r#"
4193[database]
4194primary_url = "postgres://primary.example/app"
4195replica_url = "postgres://replica.example/app"
4196primary_pool_size = 12
4197replica_pool_size = 4
4198replica_fallback = "primary"
4199"#,
4200 )
4201 .expect("database topology config should parse");
4202
4203 assert_eq!(
4204 config.database.primary_url.as_deref(),
4205 Some("postgres://primary.example/app")
4206 );
4207 assert_eq!(
4208 config.database.replica_url.as_deref(),
4209 Some("postgres://replica.example/app")
4210 );
4211 assert_eq!(config.database.primary_pool_size, Some(12));
4212 assert_eq!(config.database.replica_pool_size, Some(4));
4213 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
4214 assert_eq!(
4215 config.database.effective_primary_url(),
4216 Some("postgres://primary.example/app")
4217 );
4218 assert_eq!(config.database.effective_primary_pool_size(), 12);
4219 assert_eq!(config.database.effective_replica_pool_size(), 4);
4220 }
4221
4222 #[test]
4223 fn database_topology_keeps_url_as_single_primary_compatibility_path() {
4224 let config: AutumnConfig = toml::from_str(
4225 r#"
4226[database]
4227url = "postgres://single.example/app"
4228pool_size = 7
4229"#,
4230 )
4231 .expect("legacy database.url config should parse");
4232
4233 assert_eq!(
4234 config.database.effective_primary_url(),
4235 Some("postgres://single.example/app")
4236 );
4237 assert_eq!(config.database.effective_primary_pool_size(), 7);
4238 assert_eq!(config.database.effective_replica_pool_size(), 7);
4239 assert!(config.database.replica_url.is_none());
4240 }
4241
4242 #[test]
4243 fn database_topology_rejects_replica_without_primary() {
4244 let config = DatabaseConfig {
4245 replica_url: Some("postgres://replica.example/app".to_owned()),
4246 ..Default::default()
4247 };
4248
4249 let result = config.validate();
4250
4251 assert!(result.is_err());
4252 let Err(ConfigError::Validation(message)) = result else {
4253 panic!("expected database topology validation error");
4254 };
4255 assert!(message.contains("database.replica_url"));
4256 assert!(message.contains("database.primary_url"));
4257 }
4258
4259 #[test]
4260 fn database_topology_env_overrides_role_fields() {
4261 let env = MockEnv::new()
4262 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
4263 .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
4264 .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
4265 .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
4266 .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
4267 let mut config = AutumnConfig::default();
4268
4269 config.apply_env_overrides_with_env(&env);
4270
4271 assert_eq!(
4272 config.database.primary_url.as_deref(),
4273 Some("postgres://primary.env/app")
4274 );
4275 assert_eq!(
4276 config.database.replica_url.as_deref(),
4277 Some("postgres://replica.env/app")
4278 );
4279 assert_eq!(config.database.primary_pool_size, Some(9));
4280 assert_eq!(config.database.replica_pool_size, Some(3));
4281 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
4282 }
4283
4284 #[test]
4285 fn database_validate_url_edge_cases() {
4286 let invalid_urls = vec![
4287 "POSTGRES://localhost/db",
4288 "postgres:/localhost/db",
4289 "postgres:localhost/db",
4290 "http://postgres",
4291 " postgres://localhost/db",
4292 "",
4293 ];
4294
4295 for invalid_url in invalid_urls {
4296 let config = DatabaseConfig {
4297 url: Some(invalid_url.to_string()),
4298 ..Default::default()
4299 };
4300 assert!(
4301 config.validate().is_err(),
4302 "URL should be invalid: {invalid_url}"
4303 );
4304 }
4305 }
4306
4307 #[test]
4308 fn autumn_config_validate_ok() {
4309 let config = AutumnConfig::default();
4310 assert!(config.validate().is_ok());
4311 }
4312
4313 #[test]
4314 fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
4315 let mut config = AutumnConfig::default();
4325 config.session.backend = crate::session::SessionBackend::Redis;
4326 config.session.redis.url = None;
4327
4328 config
4329 .validate()
4330 .expect("validate() must accept invalid session backend so custom store can override");
4331 }
4332
4333 #[test]
4334 fn autumn_config_validate_database_err() {
4335 let mut config = AutumnConfig::default();
4336 config.database.url = Some("mysql://localhost/test".to_string());
4337 assert!(config.validate().is_err());
4338 }
4339
4340 #[test]
4341 fn log_defaults() {
4342 let config = LogConfig::default();
4343 assert_eq!(config.level, "info");
4344 assert_eq!(config.format, LogFormat::Auto);
4345 }
4346
4347 #[test]
4348 fn telemetry_defaults() {
4349 let config = TelemetryConfig::default();
4350 assert!(!config.enabled);
4351 assert_eq!(config.service_name, "autumn-app");
4352 assert!(config.service_namespace.is_none());
4353 assert_eq!(config.service_version, "unknown");
4354 assert_eq!(config.environment, "development");
4355 assert!(config.otlp_endpoint.is_none());
4356 assert_eq!(config.protocol, TelemetryProtocol::Grpc);
4357 assert!(!config.strict);
4358 }
4359
4360 #[test]
4361 fn health_defaults() {
4362 let config = HealthConfig::default();
4363 assert_eq!(config.path, "/health");
4364 assert_eq!(config.live_path, "/live");
4365 assert_eq!(config.ready_path, "/ready");
4366 assert_eq!(config.startup_path, "/startup");
4367 assert!(!config.detailed);
4368 }
4369
4370 #[test]
4371 fn top_level_default_populates_all_sections() {
4372 let config = AutumnConfig::default();
4373 assert_eq!(config.server.port, 3000);
4374 assert!(config.database.url.is_none());
4375 assert_eq!(config.log.level, "info");
4376 assert_eq!(config.health.path, "/health");
4377 }
4378
4379 #[test]
4380 fn deserialize_empty_object_uses_all_defaults() {
4381 let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
4382 assert_eq!(config.server.port, 3000);
4383 assert_eq!(config.server.host, "127.0.0.1");
4384 assert_eq!(config.server.shutdown_timeout_secs, 30);
4385 assert!(config.database.url.is_none());
4386 assert_eq!(config.database.pool_size, 10);
4387 assert_eq!(config.database.connect_timeout_secs, 5);
4388 assert!(!config.database.auto_migrate_in_production);
4389 assert_eq!(config.log.level, "info");
4390 assert_eq!(config.log.format, LogFormat::Auto);
4391 assert_eq!(config.health.path, "/health");
4392 }
4393
4394 #[test]
4395 fn deserialize_partial_config_merges_with_defaults() {
4396 let json = r#"{"server": {"port": 8080}}"#;
4397 let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
4398 assert_eq!(config.server.port, 8080);
4399 assert_eq!(config.server.host, "127.0.0.1");
4400 assert_eq!(config.database.pool_size, 10);
4401 assert_eq!(config.log.level, "info");
4402 }
4403
4404 #[test]
4405 fn log_format_variants_deserialize() {
4406 let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
4407 let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
4408 let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
4409 assert_eq!(auto, LogFormat::Auto);
4410 assert_eq!(pretty, LogFormat::Pretty);
4411 assert_eq!(json, LogFormat::Json);
4412 }
4413
4414 #[test]
4417 fn load_missing_file_returns_defaults() {
4418 let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
4419 assert_eq!(config.server.port, 3000);
4420 assert!(config.database.url.is_none());
4421 }
4422
4423 #[test]
4424 fn load_valid_full_config() {
4425 let dir = tempfile::tempdir().unwrap();
4426 let path = dir.path().join("autumn.toml");
4427 std::fs::write(
4428 &path,
4429 r#"
4430[server]
4431port = 8080
4432host = "0.0.0.0"
4433shutdown_timeout_secs = 60
4434
4435[database]
4436url = "postgres://user:pass@db:5432/myapp"
4437pool_size = 20
4438connect_timeout_secs = 10
4439auto_migrate_in_production = true
4440
4441[log]
4442level = "debug"
4443format = "Json"
4444
4445[health]
4446path = "/healthz"
4447"#,
4448 )
4449 .unwrap();
4450
4451 let config = AutumnConfig::load_from(&path).unwrap();
4452 assert_eq!(config.server.port, 8080);
4453 assert_eq!(config.server.host, "0.0.0.0");
4454 assert_eq!(config.server.shutdown_timeout_secs, 60);
4455 assert_eq!(
4456 config.database.url.as_deref(),
4457 Some("postgres://user:pass@db:5432/myapp")
4458 );
4459 assert_eq!(config.database.pool_size, 20);
4460 assert_eq!(config.database.connect_timeout_secs, 10);
4461 assert!(config.database.auto_migrate_in_production);
4462 assert_eq!(config.log.level, "debug");
4463 assert_eq!(config.log.format, LogFormat::Json);
4464 assert_eq!(config.health.path, "/healthz");
4465 }
4466
4467 #[test]
4468 fn load_partial_config_merges_with_defaults() {
4469 let dir = tempfile::tempdir().unwrap();
4470 let path = dir.path().join("autumn.toml");
4471 std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
4472
4473 let config = AutumnConfig::load_from(&path).unwrap();
4474 assert_eq!(config.server.port, 9090);
4475 assert_eq!(config.server.host, "127.0.0.1");
4476 assert_eq!(config.database.pool_size, 10);
4477 assert_eq!(config.log.level, "info");
4478 }
4479
4480 #[test]
4481 fn access_log_defaults_on_with_probe_and_asset_exclusions() {
4482 let log = LogConfig::default();
4483 assert!(log.access_log);
4484 assert_eq!(
4485 log.access_log_exclude,
4486 vec![
4487 "/health",
4488 "/live",
4489 "/ready",
4490 "/startup",
4491 "/actuator",
4492 "/static"
4493 ]
4494 );
4495 }
4496
4497 #[test]
4498 fn env_override_access_log_off() {
4499 let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
4500 let mut config = AutumnConfig::default();
4501 config.apply_env_overrides_with_env(&env);
4502 assert!(!config.log.access_log);
4503 }
4504
4505 #[test]
4506 fn env_override_access_log_exclude_csv() {
4507 let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
4508 let mut config = AutumnConfig::default();
4509 config.apply_env_overrides_with_env(&env);
4510 assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
4511 }
4512
4513 #[test]
4514 fn access_log_is_configurable_from_toml() {
4515 let dir = tempfile::tempdir().unwrap();
4516 let path = dir.path().join("autumn.toml");
4517 std::fs::write(
4518 &path,
4519 "[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
4520 )
4521 .unwrap();
4522
4523 let config = AutumnConfig::load_from(&path).unwrap();
4524 assert!(!config.log.access_log);
4525 assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
4526 }
4527
4528 #[test]
4529 fn load_invalid_toml_returns_error() {
4530 let dir = tempfile::tempdir().unwrap();
4531 let path = dir.path().join("autumn.toml");
4532 std::fs::write(&path, "not valid [[[toml").unwrap();
4533
4534 let result = AutumnConfig::load_from(&path);
4535 assert!(result.is_err());
4536 let err = result.unwrap_err();
4537 assert!(err.to_string().contains("invalid autumn.toml"));
4538 }
4539
4540 #[test]
4541 fn load_empty_file_returns_defaults() {
4542 let dir = tempfile::tempdir().unwrap();
4543 let path = dir.path().join("autumn.toml");
4544 std::fs::write(&path, "").unwrap();
4545
4546 let config = AutumnConfig::load_from(&path).unwrap();
4547 assert_eq!(config.server.port, 3000);
4548 }
4549
4550 #[test]
4553 fn env_override_database_url() {
4554 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
4555 let mut config = AutumnConfig::default();
4556 config.apply_env_overrides_with_env(&env);
4557 assert_eq!(
4558 config.database.url.as_deref(),
4559 Some("postgres://override:5432/test")
4560 );
4561 }
4562
4563 #[test]
4564 fn env_override_actuator_prometheus_disables() {
4565 let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
4568 let mut config = AutumnConfig::default();
4569 assert!(config.actuator.prometheus, "default should be enabled");
4570 config.apply_env_overrides_with_env(&env);
4571 assert!(
4572 !config.actuator.prometheus,
4573 "AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
4574 );
4575 }
4576
4577 #[test]
4578 fn env_override_actuator_sensitive() {
4579 let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
4580 let mut config = AutumnConfig::default();
4581 assert!(!config.actuator.sensitive);
4582 config.apply_env_overrides_with_env(&env);
4583 assert!(config.actuator.sensitive);
4584 }
4585
4586 #[test]
4587 fn env_override_actuator_prefix() {
4588 let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
4589 let mut config = AutumnConfig::default();
4590 config.apply_env_overrides_with_env(&env);
4591 assert_eq!(config.actuator.prefix, "/ops");
4592 }
4593
4594 #[test]
4595 fn env_override_database_url_wins_over_file_primary_url() {
4596 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
4597 let mut config = AutumnConfig::default();
4598 config.database.primary_url = Some("postgres://file.example/app".to_owned());
4599
4600 config.apply_env_overrides_with_env(&env);
4601
4602 assert_eq!(
4603 config.database.effective_primary_url(),
4604 Some("postgres://env.example/app")
4605 );
4606 assert!(config.database.primary_url.is_none());
4607 }
4608
4609 #[test]
4610 fn env_override_database_primary_url_wins_over_legacy_database_url() {
4611 let env = MockEnv::new()
4612 .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
4613 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
4614 let mut config = AutumnConfig::default();
4615 config.database.primary_url = Some("postgres://file.example/app".to_owned());
4616
4617 config.apply_env_overrides_with_env(&env);
4618
4619 assert_eq!(
4620 config.database.effective_primary_url(),
4621 Some("postgres://primary.env/app")
4622 );
4623 assert_eq!(
4624 config.database.url.as_deref(),
4625 Some("postgres://legacy.env/app")
4626 );
4627 }
4628
4629 #[test]
4630 fn env_override_pool_size() {
4631 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
4632 let mut config = AutumnConfig::default();
4633 config.apply_env_overrides_with_env(&env);
4634 assert_eq!(config.database.pool_size, 25);
4635 }
4636
4637 #[cfg(feature = "reporting")]
4638 #[test]
4639 fn env_override_reporting() {
4640 let env = MockEnv::new()
4641 .with("AUTUMN_REPORTING__ENABLED", "false")
4642 .with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
4643 let mut config = AutumnConfig::default();
4644 assert!(config.reporting.enabled);
4645 assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
4646 config.apply_env_overrides_with_env(&env);
4647 assert!(!config.reporting.enabled);
4648 assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
4649 }
4650
4651 #[test]
4652 fn env_override_connect_timeout() {
4653 let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
4654 let mut config = AutumnConfig::default();
4655 config.apply_env_overrides_with_env(&env);
4656 assert_eq!(config.database.connect_timeout_secs, 15);
4657 }
4658
4659 #[test]
4660 fn env_override_invalid_pool_size_ignored() {
4661 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
4662 let mut config = AutumnConfig::default();
4663 config.apply_env_overrides_with_env(&env);
4664 assert_eq!(config.database.pool_size, 10);
4665 }
4666
4667 #[cfg(feature = "storage")]
4668 #[test]
4669 fn env_override_storage_fields() {
4670 let env = MockEnv::new()
4671 .with("AUTUMN_STORAGE__BACKEND", "s3")
4672 .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
4673 .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
4674 .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
4675 .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
4676 .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
4677 .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
4678 .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
4679 .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
4680 .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
4681 .with(
4682 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
4683 "https://cdn.example.test",
4684 )
4685 .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
4686 .with(
4687 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
4688 "AWS_SECRET_ACCESS_KEY",
4689 )
4690 .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
4691 .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
4692 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
4693 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
4694 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
4695 let mut config = AutumnConfig::default();
4696
4697 config.apply_env_overrides_with_env(&env);
4698
4699 assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
4700 assert_eq!(config.storage.default_provider, "media");
4701 assert!(config.storage.allow_local_in_production);
4702 assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
4703 assert_eq!(config.storage.local.mount_path, "/files");
4704 assert_eq!(config.storage.local.default_url_expiry_secs, 42);
4705 assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
4706 assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
4707 assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
4708 assert_eq!(
4709 config.storage.s3.endpoint.as_deref(),
4710 Some("https://s3.example.test")
4711 );
4712 assert_eq!(
4713 config.storage.s3.public_base_url.as_deref(),
4714 Some("https://cdn.example.test")
4715 );
4716 assert_eq!(
4717 config.storage.s3.access_key_id_env.as_deref(),
4718 Some("AWS_ACCESS_KEY_ID")
4719 );
4720 assert_eq!(
4721 config.storage.s3.secret_access_key_env.as_deref(),
4722 Some("AWS_SECRET_ACCESS_KEY")
4723 );
4724 assert!(config.storage.s3.force_path_style);
4725 assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
4726 assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
4727 assert_eq!(config.storage.variants.max_source_width, 2_000);
4728 assert_eq!(config.storage.variants.max_source_height, 1_500);
4729 }
4730
4731 #[test]
4732 fn env_override_database_auto_migrate_in_production() {
4733 let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
4734 let mut config = AutumnConfig::default();
4735 config.apply_env_overrides_with_env(&env);
4736 assert!(config.database.auto_migrate_in_production);
4737 }
4738
4739 #[test]
4740 fn env_override_jobs_fields() {
4741 let env = MockEnv::new()
4742 .with("AUTUMN_JOBS__BACKEND", "redis")
4743 .with("AUTUMN_JOBS__WORKERS", "8")
4744 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
4745 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
4746 .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
4747 .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
4748 .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
4749 let mut config = AutumnConfig::default();
4750 config.apply_env_overrides_with_env(&env);
4751
4752 assert_eq!(config.jobs.backend, "redis");
4753 assert_eq!(config.jobs.workers, 8);
4754 assert_eq!(config.jobs.max_attempts, 12);
4755 assert_eq!(config.jobs.initial_backoff_ms, 750);
4756 assert_eq!(
4757 config.jobs.redis.url.as_deref(),
4758 Some("redis://jobs:6379/2")
4759 );
4760 assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
4761 assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
4762 }
4763
4764 #[test]
4765 fn jobs_toml_deserializes_redis_visibility_timeout() {
4766 let config: AutumnConfig = toml::from_str(
4767 r#"
4768 [jobs]
4769 backend = "redis"
4770
4771 [jobs.redis]
4772 url = "redis://localhost:6379/5"
4773 key_prefix = "demo:jobs"
4774 visibility_timeout_ms = 15000
4775 "#,
4776 )
4777 .unwrap();
4778
4779 assert_eq!(config.jobs.backend, "redis");
4780 assert_eq!(
4781 config.jobs.redis.url.as_deref(),
4782 Some("redis://localhost:6379/5")
4783 );
4784 assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
4785 assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
4786 }
4787
4788 #[test]
4789 fn channels_defaults_to_in_process_backend() {
4790 let config = AutumnConfig::default();
4791
4792 assert_eq!(config.channels.backend, ChannelBackend::InProcess);
4793 assert_eq!(config.channels.capacity, 32);
4794 assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
4795 assert!(config.channels.redis.url.is_none());
4796 }
4797
4798 #[test]
4799 fn channels_env_overrides_fields() {
4800 let env = MockEnv::new()
4801 .with("AUTUMN_CHANNELS__BACKEND", "redis")
4802 .with("AUTUMN_CHANNELS__CAPACITY", "128")
4803 .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
4804 .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
4805 let mut config = AutumnConfig::default();
4806
4807 config.apply_env_overrides_with_env(&env);
4808
4809 assert_eq!(config.channels.backend, ChannelBackend::Redis);
4810 assert_eq!(config.channels.capacity, 128);
4811 assert_eq!(
4812 config.channels.redis.url.as_deref(),
4813 Some("redis://channels:6379/4")
4814 );
4815 assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
4816 }
4817
4818 #[test]
4819 fn channels_toml_deserializes_redis_backend() {
4820 let config: AutumnConfig = toml::from_str(
4821 r#"
4822 [channels]
4823 backend = "redis"
4824 capacity = 64
4825
4826 [channels.redis]
4827 url = "redis://localhost:6379/5"
4828 key_prefix = "demo:channels"
4829 "#,
4830 )
4831 .unwrap();
4832
4833 assert_eq!(config.channels.backend, ChannelBackend::Redis);
4834 assert_eq!(config.channels.capacity, 64);
4835 assert_eq!(
4836 config.channels.redis.url.as_deref(),
4837 Some("redis://localhost:6379/5")
4838 );
4839 assert_eq!(config.channels.redis.key_prefix, "demo:channels");
4840 }
4841
4842 #[test]
4843 fn env_override_invalid_jobs_numeric_values_ignored() {
4844 let env = MockEnv::new()
4845 .with("AUTUMN_JOBS__WORKERS", "many")
4846 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
4847 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
4848 let mut config = AutumnConfig::default();
4849 config.apply_env_overrides_with_env(&env);
4850
4851 assert_eq!(config.jobs.workers, 1);
4852 assert_eq!(config.jobs.max_attempts, 5);
4853 assert_eq!(config.jobs.initial_backoff_ms, 250);
4854 }
4855
4856 #[test]
4859 fn env_override_server_port() {
4860 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
4861 let mut config = AutumnConfig::default();
4862 config.apply_env_overrides_with_env(&env);
4863 assert_eq!(config.server.port, 8080);
4864 }
4865
4866 #[test]
4867 fn parse_env_works() {
4868 let env = MockEnv::new().with("SOME_NUM", "123");
4869 let mut target: u32 = 0;
4870 parse_env(&env, "SOME_NUM", &mut target);
4871 assert_eq!(target, 123);
4872
4873 let env_err = MockEnv::new().with("SOME_NUM", "abc");
4874 let mut target_err: u32 = 0;
4875 parse_env(&env_err, "SOME_NUM", &mut target_err);
4876 assert_eq!(target_err, 0); }
4878
4879 #[test]
4880 fn parse_env_option_string_works() {
4881 let env = MockEnv::new().with("SOME_OPT", "val");
4882 let mut target = None;
4883 parse_env_option_string(&env, "SOME_OPT", &mut target);
4884 assert_eq!(target, Some("val".to_string()));
4885
4886 let env_empty = MockEnv::new().with("SOME_OPT", "");
4887 let mut target_empty = Some("old".to_string());
4888 parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
4889 assert_eq!(target_empty, None);
4890 }
4891
4892 #[test]
4893 fn parse_env_string_works() {
4894 let env = MockEnv::new().with("SOME_STR", "val");
4895 let mut target = "old".to_string();
4896 parse_env_string(&env, "SOME_STR", &mut target);
4897 assert_eq!(target, "val");
4898 }
4899
4900 #[test]
4901 fn parse_env_bool_works() {
4902 let env = MockEnv::new().with("SOME_BOOL", "true");
4903 let mut target = false;
4904 parse_env_bool(&env, "SOME_BOOL", &mut target);
4905 assert!(target);
4906
4907 let env2 = MockEnv::new().with("SOME_BOOL", "1");
4908 let mut target2 = false;
4909 parse_env_bool(&env2, "SOME_BOOL", &mut target2);
4910 assert!(target2);
4911
4912 let env3 = MockEnv::new().with("SOME_BOOL", "0");
4913 let mut target3 = true;
4914 parse_env_bool(&env3, "SOME_BOOL", &mut target3);
4915 assert!(!target3);
4916
4917 let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
4918 let mut target_err = true;
4919 parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
4920 assert!(target_err); }
4922
4923 #[test]
4924 fn parse_env_csv_works() {
4925 let env = MockEnv::new().with("SOME_CSV", "a, b,c");
4926 let mut target = vec![];
4927 parse_env_csv(&env, "SOME_CSV", &mut target);
4928 assert_eq!(target, vec!["a", "b", "c"]);
4929 }
4930
4931 #[test]
4932 fn env_override_rate_limit_trusted_proxies() {
4933 let env = MockEnv::new().with(
4934 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
4935 "10.0.0.10, 203.0.113.0/24",
4936 );
4937 let mut config = AutumnConfig::default();
4938 config.apply_env_overrides_with_env(&env);
4939 assert_eq!(
4940 config.security.rate_limit.trusted_proxies,
4941 vec!["10.0.0.10", "203.0.113.0/24"]
4942 );
4943 }
4944
4945 #[test]
4946 fn env_override_rate_limit_backend_redis() {
4947 use crate::security::config::RateLimitBackend;
4948 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
4949 let mut config = AutumnConfig::default();
4950 config.apply_env_overrides_with_env(&env);
4951 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
4952 }
4953
4954 #[test]
4955 fn env_override_rate_limit_backend_memory() {
4956 use crate::security::config::RateLimitBackend;
4957 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
4958 let mut config = AutumnConfig::default();
4959 config.apply_env_overrides_with_env(&env);
4960 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
4961 }
4962
4963 #[test]
4964 fn env_override_rate_limit_backend_invalid_ignored() {
4965 use crate::security::config::RateLimitBackend;
4966 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
4967 let mut config = AutumnConfig::default();
4968 config.apply_env_overrides_with_env(&env);
4969 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
4970 }
4971
4972 #[cfg(feature = "redis")]
4973 #[test]
4974 fn env_override_rate_limit_on_backend_failure_fail_closed() {
4975 use crate::security::config::RateLimitBackendFailure;
4976 let env = MockEnv::new().with(
4977 "AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
4978 "fail_closed",
4979 );
4980 let mut config = AutumnConfig::default();
4981 config.apply_env_overrides_with_env(&env);
4982 assert_eq!(
4983 config.security.rate_limit.on_backend_failure,
4984 RateLimitBackendFailure::FailClosed
4985 );
4986 }
4987
4988 #[cfg(feature = "redis")]
4989 #[test]
4990 fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
4991 use crate::security::config::RateLimitBackendFailure;
4992 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
4993 let mut config = AutumnConfig::default();
4994 config.apply_env_overrides_with_env(&env);
4995 assert_eq!(
4996 config.security.rate_limit.on_backend_failure,
4997 RateLimitBackendFailure::FailOpen
4998 );
4999 }
5000
5001 #[cfg(feature = "redis")]
5002 #[test]
5003 fn env_override_rate_limit_redis_url() {
5004 let env = MockEnv::new().with(
5005 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
5006 "redis://myhost:6379",
5007 );
5008 let mut config = AutumnConfig::default();
5009 config.apply_env_overrides_with_env(&env);
5010 assert_eq!(
5011 config.security.rate_limit.redis.url.as_deref(),
5012 Some("redis://myhost:6379")
5013 );
5014 }
5015
5016 #[cfg(feature = "redis")]
5017 #[test]
5018 fn env_override_rate_limit_redis_key_prefix() {
5019 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
5020 let mut config = AutumnConfig::default();
5021 config.apply_env_overrides_with_env(&env);
5022 assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
5023 }
5024
5025 #[test]
5026 fn env_override_server_host() {
5027 let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
5028 let mut config = AutumnConfig::default();
5029 config.apply_env_overrides_with_env(&env);
5030 assert_eq!(config.server.host, "0.0.0.0");
5031 }
5032
5033 #[test]
5034 fn env_override_server_shutdown_timeout() {
5035 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
5036 let mut config = AutumnConfig::default();
5037 config.apply_env_overrides_with_env(&env);
5038 assert_eq!(config.server.shutdown_timeout_secs, 60);
5039 }
5040
5041 #[test]
5042 fn env_override_invalid_server_port_ignored() {
5043 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
5044 let mut config = AutumnConfig::default();
5045 config.apply_env_overrides_with_env(&env);
5046 assert_eq!(config.server.port, 3000);
5047 }
5048
5049 #[test]
5050 fn env_override_invalid_shutdown_timeout_ignored() {
5051 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
5052 let mut config = AutumnConfig::default();
5053 config.apply_env_overrides_with_env(&env);
5054 assert_eq!(config.server.shutdown_timeout_secs, 30);
5055 }
5056
5057 #[test]
5060 fn env_override_log_level() {
5061 let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
5062 let mut config = AutumnConfig::default();
5063 config.apply_env_overrides_with_env(&env);
5064 assert_eq!(config.log.level, "debug");
5065 }
5066
5067 #[test]
5068 fn env_override_log_format_json() {
5069 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
5070 let mut config = AutumnConfig::default();
5071 config.apply_env_overrides_with_env(&env);
5072 assert_eq!(config.log.format, LogFormat::Json);
5073 }
5074
5075 #[test]
5076 fn env_override_log_format_pretty() {
5077 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
5078 let mut config = AutumnConfig::default();
5079 config.apply_env_overrides_with_env(&env);
5080 assert_eq!(config.log.format, LogFormat::Pretty);
5081 }
5082
5083 #[test]
5084 fn env_override_invalid_log_format_ignored() {
5085 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
5086 let mut config = AutumnConfig::default();
5087 config.apply_env_overrides_with_env(&env);
5088 assert_eq!(config.log.format, LogFormat::Auto);
5089 }
5090
5091 #[test]
5094 fn env_override_telemetry_fields() {
5095 let env = MockEnv::new()
5096 .with("AUTUMN_TELEMETRY__ENABLED", "true")
5097 .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
5098 .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
5099 .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
5100 .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
5101 .with(
5102 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
5103 "http://otel-collector:4317",
5104 )
5105 .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
5106 .with("AUTUMN_TELEMETRY__STRICT", "true");
5107 let mut config = AutumnConfig::default();
5108 config.apply_env_overrides_with_env(&env);
5109 assert!(config.telemetry.enabled);
5110 assert_eq!(config.telemetry.service_name, "orders-api");
5111 assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
5112 assert_eq!(config.telemetry.service_version, "1.2.3");
5113 assert_eq!(config.telemetry.environment, "production");
5114 assert_eq!(
5115 config.telemetry.otlp_endpoint.as_deref(),
5116 Some("http://otel-collector:4317")
5117 );
5118 assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
5119 assert!(config.telemetry.strict);
5120 }
5121
5122 #[test]
5123 fn env_override_invalid_telemetry_protocol_ignored() {
5124 let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
5125 let mut config = AutumnConfig::default();
5126 config.apply_env_overrides_with_env(&env);
5127 assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
5128 }
5129
5130 #[test]
5131 fn env_override_health_path() {
5132 let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
5133 let mut config = AutumnConfig::default();
5134 config.apply_env_overrides_with_env(&env);
5135 assert_eq!(config.health.path, "/healthz");
5136 }
5137
5138 #[test]
5139 fn env_override_probe_paths() {
5140 let env = MockEnv::new()
5141 .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
5142 .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
5143 .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
5144 let mut config = AutumnConfig::default();
5145 config.apply_env_overrides_with_env(&env);
5146 assert_eq!(config.health.live_path, "/livez");
5147 assert_eq!(config.health.ready_path, "/readyz");
5148 assert_eq!(config.health.startup_path, "/startupz");
5149 }
5150
5151 #[test]
5154 fn env_overrides_toml_values() {
5155 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
5156 let dir = tempfile::tempdir().unwrap();
5157 let path = dir.path().join("autumn.toml");
5158 std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
5159 let mut config = AutumnConfig::load_from(&path).unwrap();
5160 config.apply_env_overrides_with_env(&env);
5161 assert_eq!(config.server.port, 9999); }
5163
5164 #[test]
5167 fn validate_rejects_invalid_url_scheme() {
5168 let config = DatabaseConfig {
5169 url: Some("mysql://localhost/test".to_owned()),
5170 ..Default::default()
5171 };
5172 let result = config.validate();
5173 assert!(result.is_err());
5174 assert!(
5175 result
5176 .unwrap_err()
5177 .to_string()
5178 .contains("must start with postgres://")
5179 );
5180 }
5181
5182 #[test]
5183 fn validate_accepts_postgres_url() {
5184 let config = DatabaseConfig {
5185 url: Some("postgres://localhost/test".to_owned()),
5186 ..Default::default()
5187 };
5188 assert!(config.validate().is_ok());
5189 }
5190
5191 #[test]
5192 fn validate_accepts_postgresql_url() {
5193 let config = DatabaseConfig {
5194 url: Some("postgresql://localhost/test".to_owned()),
5195 ..Default::default()
5196 };
5197 assert!(config.validate().is_ok());
5198 }
5199
5200 #[test]
5201 fn validate_accepts_no_url() {
5202 let config = DatabaseConfig::default();
5203 assert!(config.validate().is_ok());
5204 }
5205
5206 #[test]
5209 fn resolve_profile_from_autumn_env() {
5210 let env = MockEnv::new().with("AUTUMN_ENV", "prod");
5211 let profile = resolve_profile(&env);
5212 assert_eq!(profile, "prod");
5213 }
5214
5215 #[test]
5216 fn resolve_profile_from_legacy_env() {
5217 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
5218 let profile = resolve_profile(&env);
5219 assert_eq!(profile, "staging");
5220 }
5221
5222 #[test]
5223 fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
5224 let env = MockEnv::new()
5225 .with("AUTUMN_ENV", "dev")
5226 .with("AUTUMN_PROFILE", "prod");
5227 let profile = resolve_profile(&env);
5228 assert_eq!(profile, "dev");
5229 }
5230
5231 #[test]
5232 fn resolve_profile_normalizes_production_alias() {
5233 let env = MockEnv::new().with("AUTUMN_ENV", "production");
5234 let profile = resolve_profile(&env);
5235 assert_eq!(profile, "prod");
5236 }
5237
5238 #[test]
5239 fn resolve_profile_normalizes_development_alias_with_whitespace() {
5240 let env = MockEnv::new().with("AUTUMN_ENV", " development ");
5241 let profile = resolve_profile(&env);
5242 assert_eq!(profile, "dev");
5243 }
5244
5245 #[test]
5246 fn resolve_profile_normalizes_uppercase_dev_and_prod() {
5247 let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
5248 let prod = resolve_profile(&prod_env);
5249 assert_eq!(prod, "prod");
5250
5251 let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
5252 let dev = resolve_profile(&dev_env);
5253 assert_eq!(dev, "dev");
5254 }
5255
5256 #[test]
5257 fn resolve_profile_preserves_case_for_custom_profiles() {
5258 let env = MockEnv::new().with("AUTUMN_ENV", "QA");
5259 let profile = resolve_profile(&env);
5260 assert_eq!(profile, "QA");
5261 }
5262
5263 #[test]
5264 fn resolve_profile_auto_detect_debug() {
5265 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
5266 let profile = resolve_profile(&env);
5267 assert_eq!(profile, "dev");
5268 }
5269
5270 #[test]
5271 fn resolve_profile_auto_detect_release() {
5272 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
5273 let profile = resolve_profile(&env);
5274 assert_eq!(profile, "prod");
5275 }
5276
5277 #[test]
5278 fn resolve_profile_defaults_to_dev_when_no_signal_present() {
5279 let env = MockEnv::new();
5280 let profile = resolve_profile(&env);
5281 assert_eq!(profile, "dev");
5282 }
5283
5284 #[test]
5285 fn dev_profile_smart_defaults() {
5286 let defaults = profile_defaults_as_toml("dev");
5287 let toml_str = toml::to_string(&defaults).unwrap();
5288 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5289
5290 assert_eq!(config.log.level, "debug");
5291 assert_eq!(config.log.format, LogFormat::Pretty);
5292 assert_eq!(config.server.host, "127.0.0.1");
5293 assert_eq!(config.server.shutdown_timeout_secs, 1);
5294 assert_eq!(
5295 config.server.prestop_grace_secs, 0,
5296 "dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
5297 );
5298 assert_eq!(config.telemetry.environment, "development");
5299 assert!(config.health.detailed);
5300 assert_eq!(config.cors.allowed_origins, vec!["*"]);
5301 assert!(
5302 config.security.trusted_proxies.trust_forwarded_headers,
5303 "dev profile must trust forwarded headers from loopback"
5304 );
5305 assert!(
5306 config
5307 .security
5308 .trusted_proxies
5309 .ranges
5310 .contains(&"127.0.0.0/8".to_owned()),
5311 "dev profile must include 127.0.0.0/8 as trusted proxy range"
5312 );
5313 assert!(
5314 config
5315 .security
5316 .trusted_proxies
5317 .ranges
5318 .contains(&"::1/128".to_owned()),
5319 "dev profile must include ::1/128 as trusted proxy range"
5320 );
5321 }
5322
5323 #[test]
5324 fn prod_profile_smart_defaults() {
5325 let defaults = profile_defaults_as_toml("prod");
5326 let toml_str = toml::to_string(&defaults).unwrap();
5327 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5328
5329 assert_eq!(config.log.level, "info");
5330 assert_eq!(config.log.format, LogFormat::Json);
5331 assert_eq!(config.server.host, "0.0.0.0");
5332 assert_eq!(config.server.shutdown_timeout_secs, 30);
5333 assert_eq!(config.telemetry.environment, "production");
5334 assert!(!config.health.detailed);
5335 assert!(
5337 config.security.headers.strict_transport_security,
5338 "prod profile must auto-enable Strict-Transport-Security"
5339 );
5340 assert_eq!(config.security.headers.x_frame_options, "DENY");
5342 assert!(config.security.headers.x_content_type_options);
5343 assert!(!config.security.headers.content_security_policy.is_empty());
5344 }
5345
5346 #[test]
5347 fn dev_profile_does_not_auto_enable_hsts() {
5348 let defaults = profile_defaults_as_toml("dev");
5349 let toml_str = toml::to_string(&defaults).unwrap();
5350 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5351
5352 assert!(
5353 !config.security.headers.strict_transport_security,
5354 "dev profile must not force HSTS on (local http development)"
5355 );
5356 }
5357
5358 #[test]
5359 fn custom_profile_no_smart_defaults() {
5360 let defaults = profile_defaults_as_toml("staging");
5361 assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
5362 }
5363
5364 #[test]
5365 fn deep_merge_tables() {
5366 let mut base: toml::Value = toml::from_str(
5367 r#"
5368 [server]
5369 port = 3000
5370 host = "127.0.0.1"
5371 [database]
5372 pool_size = 10
5373 "#,
5374 )
5375 .unwrap();
5376
5377 let overlay: toml::Value = toml::from_str(
5378 r#"
5379 [server]
5380 port = 8080
5381 [database]
5382 url = "postgres://localhost/test"
5383 "#,
5384 )
5385 .unwrap();
5386
5387 deep_merge(&mut base, overlay);
5388
5389 assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
5391 assert_eq!(
5393 base["server"]["host"],
5394 toml::Value::String("127.0.0.1".into())
5395 );
5396 assert_eq!(
5398 base["database"]["url"],
5399 toml::Value::String("postgres://localhost/test".into())
5400 );
5401 assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
5403 }
5404
5405 #[test]
5406 fn profile_toml_overrides_base_toml() {
5407 let dir = tempfile::tempdir().unwrap();
5408 let base_path = dir.path().join("autumn.toml");
5409 let dev_path = dir.path().join("autumn-dev.toml");
5410
5411 std::fs::write(
5412 &base_path,
5413 r"
5414 [server]
5415 port = 3000
5416 [database]
5417 pool_size = 10
5418 ",
5419 )
5420 .unwrap();
5421
5422 std::fs::write(
5423 &dev_path,
5424 r#"
5425 [database]
5426 url = "postgres://localhost/myapp_dev"
5427 "#,
5428 )
5429 .unwrap();
5430
5431 let mut merged = toml::Value::Table(toml::map::Map::new());
5433 let base = load_raw_toml(&base_path).unwrap().unwrap();
5434 deep_merge(&mut merged, base);
5435 let profile = load_raw_toml(&dev_path).unwrap().unwrap();
5436 deep_merge(&mut merged, profile);
5437
5438 let toml_str = toml::to_string(&merged).unwrap();
5439 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5440
5441 assert_eq!(config.server.port, 3000); assert_eq!(config.database.pool_size, 10); assert_eq!(
5444 config.database.url.as_deref(),
5445 Some("postgres://localhost/myapp_dev")
5446 ); }
5448
5449 #[test]
5450 fn inline_profile_section_overrides_base_toml() {
5451 let mut merged = toml::Value::Table(toml::map::Map::new());
5452 let base: toml::Value = toml::from_str(
5453 r#"
5454 [server]
5455 port = 3000
5456
5457 [log]
5458 level = "info"
5459
5460 [profile.dev.log]
5461 level = "debug"
5462 "#,
5463 )
5464 .unwrap();
5465
5466 deep_merge(&mut merged, base.clone());
5467 let inline = profile_section_from_base_toml(&base, "dev").unwrap();
5468 deep_merge(&mut merged, inline);
5469
5470 let toml_str = toml::to_string(&merged).unwrap();
5471 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5472 assert_eq!(config.server.port, 3000);
5473 assert_eq!(config.log.level, "debug");
5474 }
5475
5476 #[test]
5477 fn levenshtein_basic() {
5478 assert_eq!(levenshtein("dev", "dev"), 0);
5479 assert_eq!(levenshtein("dev", "dve"), 2); assert_eq!(levenshtein("prod", "prodd"), 1);
5481 assert_eq!(levenshtein("prod", "prd"), 1);
5482 assert_eq!(levenshtein("staging", "dev"), 7);
5483 }
5484
5485 #[test]
5486 fn env_override_health_detailed() {
5487 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
5488 let mut config = AutumnConfig::default();
5489 config.apply_env_overrides_with_env(&env);
5490 assert!(config.health.detailed);
5491 }
5492
5493 #[test]
5494 fn profile_name_accessor() {
5495 let mut config = AutumnConfig::default();
5496 assert!(config.profile_name().is_none());
5497
5498 config.profile = Some("dev".to_owned());
5499 assert_eq!(config.profile_name(), Some("dev"));
5500 }
5501
5502 #[test]
5505 fn find_config_file_falls_back_to_cwd() {
5506 let env = MockEnv::new();
5508 let path = find_config_file_named("autumn.toml", &env);
5509 assert_eq!(path, PathBuf::from("autumn.toml"));
5510 }
5511
5512 #[test]
5513 fn find_config_file_uses_manifest_dir_when_file_exists() {
5514 let dir = tempfile::tempdir().unwrap();
5515 let config_path = dir.path().join("autumn.toml");
5516 std::fs::write(&config_path, "").unwrap();
5517
5518 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5519 let path = find_config_file_named("autumn.toml", &env);
5520 assert_eq!(path, config_path);
5521 }
5522
5523 #[test]
5524 fn find_config_file_falls_back_when_manifest_dir_missing_file() {
5525 let dir = tempfile::tempdir().unwrap();
5526 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5528 let path = find_config_file_named("nonexistent.toml", &env);
5529 assert_eq!(path, PathBuf::from("nonexistent.toml"));
5530 }
5531
5532 #[test]
5533 fn resolve_profile_cli_flag_exact_match() {
5534 let env = MockEnv::new();
5539 let profile = resolve_profile(&env);
5541 drop(profile);
5545 }
5546
5547 #[test]
5548 fn deep_merge_non_table_overlay_replaces_base() {
5549 let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
5552 let overlay = toml::Value::String("not_a_table".into());
5553
5554 deep_merge(&mut base, overlay);
5557 assert!(base.is_table());
5559 assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
5560 }
5561
5562 #[test]
5563 fn deep_merge_when_base_not_table() {
5564 let mut base = toml::Value::String("original".into());
5566 let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
5567
5568 deep_merge(&mut base, overlay);
5569 assert_eq!(base, toml::Value::String("original".into()));
5571 }
5572
5573 #[test]
5574 fn suggest_profile_close_match() {
5575 assert_eq!(suggest_profile("dve"), Some("dev"));
5577 }
5578
5579 #[test]
5580 fn suggest_profile_no_match_when_distant() {
5581 assert_eq!(suggest_profile("xyz"), None);
5583 }
5584
5585 #[test]
5586 fn suggest_profile_exact_known_profile() {
5587 assert_eq!(suggest_profile("dev"), Some("dev"));
5589 assert_eq!(suggest_profile("prod"), Some("prod"));
5590 }
5591
5592 #[test]
5593 fn suggest_profile_prd() {
5594 assert_eq!(suggest_profile("prd"), Some("prod"));
5596 }
5597
5598 #[test]
5599 fn warn_profile_typo_runs_without_panic() {
5600 warn_profile_typo("dve");
5601 warn_profile_typo("xyz");
5602 }
5603
5604 #[test]
5605 fn should_warn_missing_profile_file_custom_without_inline() {
5606 assert!(should_warn_missing_profile_file("staging", false));
5607 }
5608
5609 #[test]
5610 fn should_not_warn_missing_profile_file_custom_with_inline() {
5611 assert!(!should_warn_missing_profile_file("staging", true));
5612 }
5613
5614 #[test]
5615 fn should_not_warn_missing_profile_file_dev_or_prod() {
5616 assert!(!should_warn_missing_profile_file("dev", false));
5617 assert!(!should_warn_missing_profile_file("prod", false));
5618 }
5619
5620 #[test]
5621 fn levenshtein_threshold_in_warn_profile_typo() {
5622 assert!(levenshtein("dve", "dev") <= 2);
5623 assert!(levenshtein("xyz", "dev") > 2);
5624 assert!(levenshtein("xyz", "prod") > 2);
5625 }
5626
5627 #[test]
5628 fn env_override_cors_allowed_origins() {
5629 let env = MockEnv::new().with(
5630 "AUTUMN_CORS__ALLOWED_ORIGINS",
5631 "https://a.com, https://b.com",
5632 );
5633 let mut config = AutumnConfig::default();
5634 config.apply_env_overrides_with_env(&env);
5635 assert_eq!(
5636 config.cors.allowed_origins,
5637 vec!["https://a.com", "https://b.com"]
5638 );
5639 }
5640
5641 #[test]
5642 fn env_override_cors_allow_credentials() {
5643 let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
5644 let mut config = AutumnConfig::default();
5645 config.apply_env_overrides_with_env(&env);
5646 assert!(config.cors.allow_credentials);
5647 }
5648
5649 #[test]
5650 fn env_override_cors_max_age() {
5651 let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
5652 let mut config = AutumnConfig::default();
5653 config.apply_env_overrides_with_env(&env);
5654 assert_eq!(config.cors.max_age_secs, 3600);
5655 }
5656
5657 #[test]
5658 fn cors_validate_rejects_wildcard_with_credentials() {
5659 let mut config = AutumnConfig::default();
5660 config.cors.allowed_origins = vec!["*".to_owned()];
5661 config.cors.allow_credentials = true;
5662
5663 let result = config.validate();
5664 match result {
5665 Err(ConfigError::Validation(msg)) => {
5666 assert!(
5667 msg.contains("allow_credentials") && msg.contains('*'),
5668 "message should mention credentials and wildcard, got: {msg}"
5669 );
5670 }
5671 other => panic!("expected ConfigError::Validation, got {other:?}"),
5672 }
5673 }
5674
5675 #[test]
5676 fn cors_validate_accepts_wildcard_without_credentials() {
5677 let mut config = AutumnConfig::default();
5678 config.cors.allowed_origins = vec!["*".to_owned()];
5679 config.cors.allow_credentials = false;
5680 assert!(config.validate().is_ok());
5681 }
5682
5683 #[test]
5684 fn cors_validate_accepts_explicit_origins_with_credentials() {
5685 let mut config = AutumnConfig::default();
5686 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
5687 config.cors.allow_credentials = true;
5688 assert!(config.validate().is_ok());
5689 }
5690
5691 #[test]
5692 fn load_uses_profile_layering() {
5693 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
5696
5697 let config = AutumnConfig::load_with_env(&env).unwrap();
5698 assert_eq!(config.profile.as_deref(), Some("dev"));
5700 assert_eq!(config.log.level, "debug"); assert_eq!(config.log.format, LogFormat::Pretty); assert!(config.health.detailed); }
5704
5705 #[test]
5706 fn load_custom_profile_without_toml_warns() {
5707 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
5711
5712 let config = AutumnConfig::load_with_env(&env).unwrap();
5713 assert_eq!(config.profile.as_deref(), Some("staging"));
5714 assert_eq!(config.server.port, 3000);
5716 assert_eq!(config.log.level, "info");
5717 }
5718
5719 #[test]
5720 fn load_dev_profile_no_profile_toml_no_warn() {
5721 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
5724
5725 let config = AutumnConfig::load_with_env(&env).unwrap();
5726 assert_eq!(config.profile.as_deref(), Some("dev"));
5727 }
5728
5729 #[test]
5730 fn load_custom_profile_uses_inline_profile_without_legacy_file() {
5731 let dir = tempfile::tempdir().unwrap();
5732 let base_path = dir.path().join("autumn.toml");
5733 std::fs::write(
5734 &base_path,
5735 r"
5736 [server]
5737 port = 3000
5738
5739 [profile.staging.server]
5740 port = 4100
5741 ",
5742 )
5743 .unwrap();
5744
5745 let env = MockEnv::new()
5746 .with("AUTUMN_ENV", "staging")
5747 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5748
5749 let config = AutumnConfig::load_with_env(&env).unwrap();
5750 assert_eq!(config.profile.as_deref(), Some("staging"));
5751 assert_eq!(config.server.port, 4100);
5752 }
5753
5754 #[test]
5755 fn load_production_profile_reads_inline_profile_production_section() {
5756 let dir = tempfile::tempdir().unwrap();
5757 let base_path = dir.path().join("autumn.toml");
5758 std::fs::write(
5759 &base_path,
5760 r"
5761 [profile.production.server]
5762 port = 4200
5763 ",
5764 )
5765 .unwrap();
5766
5767 let env = MockEnv::new()
5768 .with("AUTUMN_ENV", "production")
5769 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5770
5771 let config = AutumnConfig::load_with_env(&env).unwrap();
5772 assert_eq!(config.profile.as_deref(), Some("prod"));
5773 assert_eq!(config.server.port, 4200);
5774 }
5775
5776 #[test]
5777 fn load_production_profile_reads_legacy_autumn_production_toml() {
5778 let dir = tempfile::tempdir().unwrap();
5779 let production_path = dir.path().join("autumn-production.toml");
5780 std::fs::write(
5781 &production_path,
5782 r"
5783 [server]
5784 port = 4300
5785 ",
5786 )
5787 .unwrap();
5788
5789 let env = MockEnv::new()
5790 .with("AUTUMN_ENV", "production")
5791 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5792
5793 let config = AutumnConfig::load_with_env(&env).unwrap();
5794 assert_eq!(config.profile.as_deref(), Some("prod"));
5795 assert_eq!(config.server.port, 4300);
5796 }
5797
5798 #[test]
5799 fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
5800 let dir = tempfile::tempdir().unwrap();
5801 let prod_path = dir.path().join("autumn-prod.toml");
5802 let production_path = dir.path().join("autumn-production.toml");
5803
5804 std::fs::write(
5805 &prod_path,
5806 r"
5807 [server]
5808 port = 4400
5809 ",
5810 )
5811 .unwrap();
5812 std::fs::write(&production_path, "[server\nport = 4500").unwrap();
5814
5815 let env = MockEnv::new()
5816 .with("AUTUMN_ENV", "prod")
5817 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5818
5819 let config = AutumnConfig::load_with_env(&env).unwrap();
5820 assert_eq!(config.profile.as_deref(), Some("prod"));
5821 assert_eq!(config.server.port, 4400);
5822 }
5823
5824 #[test]
5825 fn load_production_prefers_autumn_production_toml_before_prod_alias() {
5826 let dir = tempfile::tempdir().unwrap();
5827 let prod_path = dir.path().join("autumn-prod.toml");
5828 let production_path = dir.path().join("autumn-production.toml");
5829
5830 std::fs::write(
5831 &production_path,
5832 r"
5833 [server]
5834 port = 4500
5835 ",
5836 )
5837 .unwrap();
5838 std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
5840
5841 let env = MockEnv::new()
5842 .with("AUTUMN_ENV", "production")
5843 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5844
5845 let config = AutumnConfig::load_with_env(&env).unwrap();
5846 assert_eq!(config.profile.as_deref(), Some("prod"));
5847 assert_eq!(config.server.port, 4500);
5848 }
5849
5850 #[test]
5851 fn load_from_io_error_is_not_swallowed() {
5852 let dir = tempfile::tempdir().unwrap();
5855 let result = AutumnConfig::load_from(dir.path());
5856 assert!(result.is_err());
5857 }
5858
5859 #[test]
5860 fn load_raw_toml_missing_file_returns_none() {
5861 let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
5862 assert!(result.is_none());
5863 }
5864
5865 #[test]
5866 fn load_raw_toml_directory_returns_io_error() {
5867 let dir = tempfile::tempdir().unwrap();
5871 let result = load_raw_toml(dir.path());
5872 assert!(result.is_err());
5873 }
5874
5875 #[test]
5876 fn load_raw_toml_valid_file_returns_some() {
5877 let dir = tempfile::tempdir().unwrap();
5878 let path = dir.path().join("test.toml");
5879 std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
5880 let result = load_raw_toml(&path).unwrap();
5881 assert!(result.is_some());
5882 assert_eq!(
5883 result.unwrap()["server"]["port"],
5884 toml::Value::Integer(3000)
5885 );
5886 }
5887
5888 #[test]
5889 fn env_override_log_format_auto() {
5890 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
5892 let mut config = AutumnConfig::default();
5893 config.log.format = LogFormat::Json;
5895 config.apply_env_overrides_with_env(&env);
5896 assert_eq!(config.log.format, LogFormat::Auto);
5897 }
5898
5899 #[test]
5900 fn env_override_health_detailed_false() {
5901 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
5903 let mut config = AutumnConfig::default();
5904 config.health.detailed = true; config.apply_env_overrides_with_env(&env);
5906 assert!(!config.health.detailed);
5907 }
5908
5909 #[test]
5910 fn env_override_health_detailed_zero() {
5911 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
5912 let mut config = AutumnConfig::default();
5913 config.health.detailed = true;
5914 config.apply_env_overrides_with_env(&env);
5915 assert!(!config.health.detailed);
5916 }
5917
5918 #[test]
5919 fn cors_defaults() {
5920 let cors = CorsConfig::default();
5921 assert!(cors.allowed_origins.is_empty());
5922 assert_eq!(cors.allowed_methods.len(), 6);
5923 assert!(cors.allowed_methods.contains(&"GET".to_owned()));
5924 assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
5925 assert!(!cors.allow_credentials);
5926 assert_eq!(cors.max_age_secs, 86400);
5927 }
5928
5929 #[test]
5930 fn cors_in_full_config_defaults() {
5931 let config = AutumnConfig::default();
5932 assert!(config.cors.allowed_origins.is_empty());
5933 }
5934
5935 #[test]
5936 fn actuator_defaults() {
5937 let config = ActuatorConfig::default();
5938 assert_eq!(config.prefix, "/actuator");
5939 assert!(!config.sensitive);
5940 assert!(config.prometheus);
5943 }
5944
5945 #[test]
5946 fn actuator_prometheus_can_be_disabled_via_toml() {
5947 let toml = r"
5948 sensitive = false
5949 prometheus = false
5950 ";
5951 let config: ActuatorConfig = toml::from_str(toml).unwrap();
5952 assert!(!config.sensitive);
5953 assert!(!config.prometheus);
5954 }
5955
5956 #[test]
5957 fn actuator_prefix_in_full_config() {
5958 let config = AutumnConfig::default();
5959 assert_eq!(config.actuator.prefix, "/actuator");
5960 }
5961
5962 #[test]
5963 fn deep_merge_handles_deep_nesting() {
5964 let mut base = toml::Value::Table(toml::map::Map::new());
5965 let mut overlay = toml::Value::Table(toml::map::Map::new());
5966
5967 let mut current_base = &mut base;
5969 let mut current_overlay = &mut overlay;
5970
5971 for _ in 0..10_000 {
5972 if let toml::Value::Table(t) = current_base {
5973 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
5974 current_base = t.get_mut("x").unwrap();
5975 }
5976 if let toml::Value::Table(t) = current_overlay {
5977 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
5978 current_overlay = t.get_mut("x").unwrap();
5979 }
5980 }
5981
5982 if let toml::Value::Table(t) = current_overlay {
5984 t.insert("y".to_owned(), toml::Value::Integer(42));
5985 }
5986
5987 std::thread::Builder::new()
5990 .stack_size(32 * 1024 * 1024)
5991 .spawn(move || {
5992 deep_merge(&mut base, overlay);
5993 std::mem::forget(base);
5995 })
5996 .unwrap()
5997 .join()
5998 .unwrap();
5999 }
6000
6001 #[test]
6002 fn deep_merge_stops_at_max_depth() {
6003 let mut base = toml::Value::Table(toml::map::Map::new());
6004 let mut overlay = toml::Value::Table(toml::map::Map::new());
6005
6006 let mut current_base = &mut base;
6008 let mut current_overlay = &mut overlay;
6009
6010 for _ in 0..=MAX_MERGE_DEPTH {
6011 if let toml::Value::Table(t) = current_base {
6012 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
6013 current_base = t.get_mut("x").unwrap();
6014 }
6015 if let toml::Value::Table(t) = current_overlay {
6016 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
6017 current_overlay = t.get_mut("x").unwrap();
6018 }
6019 }
6020
6021 if let toml::Value::Table(t) = current_overlay {
6023 t.insert("deep_value".to_owned(), toml::Value::Integer(123));
6024 }
6025
6026 deep_merge(&mut base, overlay);
6027
6028 let mut current_base_check = &base;
6030 for _ in 0..=MAX_MERGE_DEPTH {
6031 if let toml::Value::Table(t) = current_base_check {
6032 current_base_check = t.get("x").unwrap();
6033 }
6034 }
6035
6036 if let toml::Value::Table(t) = current_base_check {
6037 assert!(
6038 !t.contains_key("deep_value"),
6039 "Value beyond MAX_MERGE_DEPTH should not be merged"
6040 );
6041 } else {
6042 panic!("Expected a table");
6043 }
6044 }
6045
6046 #[test]
6049 fn env_override_forbidden_response_403() {
6050 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
6051 let mut config = AutumnConfig::default();
6052 config.apply_env_overrides_with_env(&env);
6053 assert_eq!(
6054 config.security.forbidden_response,
6055 crate::authorization::ForbiddenResponse::Forbidden403
6056 );
6057 }
6058
6059 #[test]
6060 fn env_override_forbidden_response_404() {
6061 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
6062 let mut config = AutumnConfig::default();
6063 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
6065 config.apply_env_overrides_with_env(&env);
6066 assert_eq!(
6067 config.security.forbidden_response,
6068 crate::authorization::ForbiddenResponse::NotFound404
6069 );
6070 }
6071
6072 #[test]
6073 fn env_override_forbidden_response_invalid_keeps_existing() {
6074 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
6075 let mut config = AutumnConfig::default();
6076 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
6077 config.apply_env_overrides_with_env(&env);
6078 assert_eq!(
6080 config.security.forbidden_response,
6081 crate::authorization::ForbiddenResponse::Forbidden403
6082 );
6083 }
6084
6085 #[test]
6086 fn env_override_allow_unauthorized_repository_api() {
6087 let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
6088 let mut config = AutumnConfig::default();
6089 assert!(!config.security.allow_unauthorized_repository_api);
6090 config.apply_env_overrides_with_env(&env);
6091 assert!(config.security.allow_unauthorized_repository_api);
6092 }
6093
6094 #[test]
6095 fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
6096 let env = MockEnv::new().with(
6097 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
6098 "false",
6099 );
6100 let mut config = AutumnConfig::default();
6101 config.security.allow_unauthorized_repository_api = true;
6102 config.apply_env_overrides_with_env(&env);
6103 assert!(!config.security.allow_unauthorized_repository_api);
6104 }
6105
6106 #[test]
6109 fn openapi_runtime_config_defaults_enabled() {
6110 let config = AutumnConfig::default();
6112 assert!(
6113 config.openapi_runtime.enabled,
6114 "[openapi] must default to enabled = true"
6115 );
6116 assert_eq!(
6117 config.openapi_runtime.path, "/openapi.json",
6118 "[openapi] must default to path = \"/openapi.json\""
6119 );
6120 }
6121
6122 #[test]
6123 fn openapi_runtime_config_can_be_disabled_via_toml() {
6124 let toml_str = "
6125[openapi]
6126enabled = false
6127";
6128 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
6129 assert!(
6130 !config.openapi_runtime.enabled,
6131 "[openapi] enabled = false must deserialize correctly"
6132 );
6133 }
6134
6135 #[test]
6136 fn openapi_runtime_config_path_can_be_customized() {
6137 let toml_str = r#"
6138[openapi]
6139path = "/api-spec.json"
6140"#;
6141 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
6142 assert_eq!(
6143 config.openapi_runtime.path, "/api-spec.json",
6144 "[openapi] path must deserialize correctly"
6145 );
6146 }
6147
6148 #[test]
6149 fn cache_env_overrides_fields() {
6150 let env = MockEnv::new()
6151 .with("AUTUMN_CACHE__BACKEND", "redis")
6152 .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
6153 .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
6154 let mut config = AutumnConfig::default();
6155
6156 config.apply_env_overrides_with_env(&env);
6157
6158 assert!(config.cache.is_redis(), "backend should be redis");
6159 assert_eq!(
6160 config.cache.redis.url.as_deref(),
6161 Some("redis://cache:6379/1")
6162 );
6163 assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
6164 }
6165
6166 #[test]
6167 fn cache_backend_from_env_value_invalid_is_none() {
6168 assert!(CacheBackend::from_env_value("postgres").is_none());
6169 assert!(CacheBackend::from_env_value("").is_none());
6170 }
6171
6172 #[test]
6173 fn scheduler_validate_rejects_zero_lease_ttl() {
6174 let cfg = SchedulerConfig {
6175 lease_ttl_secs: 0,
6176 ..SchedulerConfig::default()
6177 };
6178 assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
6179 }
6180
6181 #[test]
6182 fn scheduler_validate_rejects_empty_key_prefix() {
6183 let cfg = SchedulerConfig {
6184 key_prefix: " ".to_owned(),
6185 ..SchedulerConfig::default()
6186 };
6187 assert!(cfg.validate().is_err(), "blank key_prefix must fail");
6188 }
6189
6190 #[test]
6191 fn scheduler_validate_ok_with_defaults() {
6192 assert!(SchedulerConfig::default().validate().is_ok());
6193 }
6194
6195 #[test]
6196 fn scheduler_resolved_replica_id_uses_explicit_value() {
6197 let cfg = SchedulerConfig {
6198 replica_id: Some("my-pod".to_owned()),
6199 ..SchedulerConfig::default()
6200 };
6201 assert_eq!(cfg.resolved_replica_id(), "my-pod");
6202 }
6203
6204 #[test]
6205 fn scheduler_resolved_replica_id_falls_back_to_pid() {
6206 let cfg = SchedulerConfig {
6207 replica_id: None,
6208 ..SchedulerConfig::default()
6209 };
6210 assert!(!cfg.resolved_replica_id().is_empty());
6213 }
6214
6215 #[cfg(feature = "mail")]
6216 #[test]
6217 fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
6218 let env = MockEnv::new()
6219 .with(
6220 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
6221 "true",
6222 )
6223 .with("AUTUMN_MAIL__TRANSPORT", "smtp")
6224 .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
6225
6226 let mut config = AutumnConfig::default();
6227 config.apply_mail_env_overrides_with_env(&env);
6228
6229 assert!(
6230 config.mail.allow_in_process_deliver_later_in_production,
6231 "env var should set allow_in_process_deliver_later_in_production"
6232 );
6233 }
6234
6235 #[cfg(feature = "mail")]
6236 #[test]
6237 fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
6238 let env = MockEnv::new();
6239 let mut config = AutumnConfig::default();
6240 config.apply_mail_env_overrides_with_env(&env);
6241
6242 assert!(
6243 !config.mail.allow_in_process_deliver_later_in_production,
6244 "flag should default to false when env var is not set"
6245 );
6246 }
6247
6248 #[test]
6251 fn config_credentials_empty_when_no_directory() {
6252 let env = MockEnv::new();
6253 let config = AutumnConfig::load_with_env(&env).unwrap();
6254 assert!(
6255 config.credentials().is_empty(),
6256 "existing apps without config/credentials/ must boot with an empty credentials store"
6257 );
6258 }
6259
6260 #[test]
6261 fn config_has_credentials_accessor() {
6262 let config = AutumnConfig::default();
6263 let _store = config.credentials();
6264 }
6265
6266 #[test]
6267 fn config_credentials_loaded_when_file_present() {
6268 use crate::credentials::{MasterKey, encrypt};
6269 use tempfile::TempDir;
6270
6271 let tmp = TempDir::new().unwrap();
6272 let key = MasterKey::generate();
6273 let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
6274 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
6275 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
6276
6277 let env = MockEnv::new()
6278 .with("AUTUMN_MASTER_KEY", &key.to_hex())
6279 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
6280 let config = AutumnConfig::load_with_env(&env).unwrap();
6281 let val: Option<String> = config.credentials().get("stripe_key");
6282 assert_eq!(val.as_deref(), Some("sk_test_xyz"));
6283 }
6284
6285 #[cfg(feature = "oauth2")]
6286 #[test]
6287 fn config_resolves_oauth_credentials_by_convention() {
6288 use crate::credentials::{MasterKey, encrypt};
6289 use tempfile::TempDir;
6290
6291 let tmp = TempDir::new().unwrap();
6292 let key = MasterKey::generate();
6293 let ct = encrypt(
6294 &key,
6295 b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
6296 );
6297 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
6298 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
6299
6300 std::fs::create_dir_all(tmp.path().join("config")).unwrap();
6302 let config_toml = r#"
6303[auth.oauth2.github]
6304client_id = ""
6305client_secret = ""
6306authorize_url = "https://github.com/login/oauth/authorize"
6307token_url = "https://github.com/login/oauth/access_token"
6308redirect_uri = "http://localhost:3000/auth/github/callback"
6309"#;
6310 std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
6311
6312 let env = MockEnv::new()
6313 .with("AUTUMN_MASTER_KEY", &key.to_hex())
6314 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
6315 let config = AutumnConfig::load_with_env(&env).unwrap();
6316 let github = config.auth.oauth2.providers.get("github").unwrap();
6317 assert_eq!(github.client_id, "git-id-123");
6318 assert_eq!(github.client_secret, "git-secret-456");
6319 }
6320
6321 #[test]
6322 fn config_fails_with_credentials_error_when_key_is_invalid() {
6323 use crate::credentials::encrypt;
6324 use tempfile::TempDir;
6325
6326 let tmp = TempDir::new().unwrap();
6327 let bogus_key = "zz".repeat(32); let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
6330 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
6331 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
6332
6333 let env = MockEnv::new()
6334 .with("AUTUMN_MASTER_KEY", &bogus_key)
6335 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
6336 let err = AutumnConfig::load_with_env(&env).unwrap_err();
6337 assert!(
6338 matches!(err, ConfigError::Credentials(_)),
6339 "bad master key should produce ConfigError::Credentials, got {err:?}"
6340 );
6341 }
6342
6343 #[test]
6344 fn test_parse_duration_str() {
6345 assert_eq!(
6346 parse_duration_str("500ms").unwrap(),
6347 std::time::Duration::from_millis(500)
6348 );
6349 assert_eq!(
6350 parse_duration_str("5s").unwrap(),
6351 std::time::Duration::from_secs(5)
6352 );
6353 assert_eq!(
6354 parse_duration_str("2m").unwrap(),
6355 std::time::Duration::from_secs(120)
6356 );
6357 assert_eq!(
6358 parse_duration_str("1h").unwrap(),
6359 std::time::Duration::from_secs(3600)
6360 );
6361 assert_eq!(
6362 parse_duration_str("1000").unwrap(),
6363 std::time::Duration::from_secs(1)
6364 );
6365 assert!(parse_duration_str("abc").is_err());
6366 assert!(parse_duration_str("").is_err());
6367 }
6368
6369 #[test]
6370 fn test_database_config_duration_deserialization() {
6371 #[derive(Debug, Deserialize)]
6372 struct TestConfig {
6373 #[serde(deserialize_with = "deserialize_option_duration", default)]
6374 timeout: Option<std::time::Duration>,
6375 #[serde(deserialize_with = "deserialize_duration")]
6376 threshold: std::time::Duration,
6377 }
6378
6379 let toml_str = r#"
6380 timeout = "2s"
6381 threshold = "100ms"
6382 "#;
6383 let parsed: TestConfig = toml::from_str(toml_str).unwrap();
6384 assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
6385 assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
6386
6387 let toml_str_null = r#"
6388 threshold = "500"
6389 "#;
6390 let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
6391 assert_eq!(parsed_null.timeout, None);
6392 assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
6393 }
6394
6395 #[test]
6398 fn request_timeouts_config_defaults_to_none() {
6399 let config = RequestTimeoutsConfig::default();
6400 assert!(config.request_timeout_ms.is_none());
6401 }
6402
6403 #[test]
6404 fn server_config_timeouts_defaults_to_disabled() {
6405 let config = ServerConfig::default();
6406 assert!(config.timeouts.request_timeout_ms.is_none());
6407 }
6408
6409 #[test]
6410 fn request_timeouts_config_can_be_set_via_toml() {
6411 let toml_str = "request_timeout_ms = 5000";
6412 let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
6413 assert_eq!(config.request_timeout_ms, Some(5000));
6414 }
6415
6416 #[test]
6417 fn server_config_timeouts_deserialize_nested() {
6418 let toml_str = r#"
6419 port = 3000
6420 host = "127.0.0.1"
6421 shutdown_timeout_secs = 30
6422 prestop_grace_secs = 5
6423
6424 [timeouts]
6425 request_timeout_ms = 15000
6426 "#;
6427 let config: ServerConfig = toml::from_str(toml_str).unwrap();
6428 assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
6429 }
6430
6431 #[test]
6432 fn autumn_config_server_timeouts_roundtrip() {
6433 let mut config = AutumnConfig::default();
6434 config.server.timeouts.request_timeout_ms = Some(20_000);
6435 assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
6436 }
6437
6438 #[test]
6439 fn server_timeouts_env_var_override() {
6440 struct FakeEnv(std::collections::HashMap<String, String>);
6441 impl Env for FakeEnv {
6442 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
6443 self.0
6444 .get(key)
6445 .cloned()
6446 .ok_or(std::env::VarError::NotPresent)
6447 }
6448 }
6449
6450 let mut config = AutumnConfig::default();
6451 let env = FakeEnv(
6452 [(
6453 "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
6454 "8000".to_owned(),
6455 )]
6456 .into(),
6457 );
6458 config.apply_server_env_overrides_with_env(&env);
6459 assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
6460 }
6461
6462 #[test]
6463 fn prod_profile_sets_request_timeout_30s() {
6464 let defaults = profile_defaults_as_toml("prod");
6465 let toml_str = toml::to_string(&defaults).unwrap();
6466 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
6467 assert_eq!(
6468 config.server.timeouts.request_timeout_ms,
6469 Some(30_000),
6470 "prod profile must enable the 30-second request timeout by default"
6471 );
6472 }
6473
6474 #[test]
6475 fn dev_profile_leaves_request_timeout_disabled() {
6476 let defaults = profile_defaults_as_toml("dev");
6477 let toml_str = toml::to_string(&defaults).unwrap();
6478 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
6479 assert!(
6480 config.server.timeouts.request_timeout_ms.is_none(),
6481 "dev profile must not enable a request timeout by default"
6482 );
6483 }
6484
6485 #[test]
6486 fn test_resilience_config_defaults() {
6487 let config = AutumnConfig::default();
6488 assert!(
6489 config
6490 .resilience
6491 .circuit_breaker
6492 .defaults
6493 .failure_ratio_threshold
6494 .is_none()
6495 );
6496 }
6497
6498 #[test]
6499 fn test_resilience_config_parsing() {
6500 let toml_str = r#"
6501 [resilience.circuit_breaker.defaults]
6502 failure_ratio_threshold = 0.6
6503 sample_window_secs = 20
6504 minimum_sample_count = 15
6505 open_duration_secs = 30
6506 half_open_trial_count = 5
6507
6508 [resilience.circuit_breaker.hosts."api.github.com"]
6509 failure_ratio_threshold = 0.3
6510 open_duration_secs = 10
6511 "#;
6512 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
6513 let cb = &config.resilience.circuit_breaker;
6514 assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
6515 assert_eq!(cb.defaults.sample_window_secs, Some(20));
6516 assert_eq!(cb.defaults.minimum_sample_count, Some(15));
6517 assert_eq!(cb.defaults.open_duration_secs, Some(30));
6518 assert_eq!(cb.defaults.half_open_trial_count, Some(5));
6519
6520 let host_cb = cb.hosts.get("api.github.com").unwrap();
6521 assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
6522 assert_eq!(host_cb.open_duration_secs, Some(10));
6523 assert!(host_cb.sample_window_secs.is_none());
6524 }
6525
6526 #[test]
6527 fn test_resilience_config_env_overrides() {
6528 struct FakeEnv(std::collections::HashMap<String, String>);
6529 impl Env for FakeEnv {
6530 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
6531 self.0
6532 .get(key)
6533 .cloned()
6534 .ok_or(std::env::VarError::NotPresent)
6535 }
6536 }
6537
6538 let mut config = AutumnConfig::default();
6539 let env = FakeEnv(
6540 [(
6541 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
6542 "0.7".to_owned(),
6543 )]
6544 .into(),
6545 );
6546 config.apply_resilience_env_overrides_with_env(&env);
6547 assert_eq!(
6548 config
6549 .resilience
6550 .circuit_breaker
6551 .defaults
6552 .failure_ratio_threshold,
6553 Some(0.7)
6554 );
6555 }
6556}