1use std::path::{Path, PathBuf};
119
120use serde::Deserialize;
121use thiserror::Error;
122
123use std::sync::OnceLock;
125
126static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
127static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
128
129#[doc(hidden)]
130pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
131 let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
132 let _ = MACRO_IS_DEBUG.set(is_debug);
133}
134
135pub trait Env {
141 fn var(&self, key: &str) -> Result<String, std::env::VarError>;
156}
157
158#[derive(Clone, Default)]
160pub struct OsEnv;
161
162impl Env for OsEnv {
163 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
164 if key == "AUTUMN_MANIFEST_DIR" {
165 if let Some(dir) = MACRO_MANIFEST_DIR.get() {
166 return Ok(dir.clone());
167 }
168 } else if key == "AUTUMN_IS_DEBUG"
169 && let Some(is_debug) = MACRO_IS_DEBUG.get()
170 {
171 return Ok(if *is_debug {
172 "1".to_string()
173 } else {
174 "0".to_string()
175 });
176 }
177 std::env::var(key)
178 }
179}
180
181#[derive(Clone, Default)]
183pub struct MockEnv {
184 vars: std::collections::HashMap<String, String>,
185}
186
187impl MockEnv {
188 #[must_use]
190 pub fn new() -> Self {
191 Self {
192 vars: std::collections::HashMap::new(),
193 }
194 }
195
196 #[must_use]
198 pub fn with(mut self, key: &str, value: &str) -> Self {
199 self.vars.insert(key.to_owned(), value.to_owned());
200 self
201 }
202
203 #[must_use]
205 pub fn without(mut self, key: &str) -> Self {
206 self.vars.remove(key);
207 self
208 }
209}
210
211impl Env for MockEnv {
212 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
213 self.vars
214 .get(key)
215 .cloned()
216 .ok_or(std::env::VarError::NotPresent)
217 }
218}
219
220fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
222 if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
223 let candidate = PathBuf::from(manifest_dir).join(filename);
224 if candidate.exists() {
225 return candidate;
226 }
227 }
228 PathBuf::from(filename)
229}
230
231fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
234 match std::fs::read_to_string(path) {
235 Ok(contents) => {
236 let table = toml::from_str::<toml::Table>(&contents)?;
237 Ok(Some(toml::Value::Table(table)))
238 }
239 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
240 Err(e) => Err(ConfigError::Io(e)),
241 }
242}
243
244pub(crate) fn resolve_profile(env: &dyn Env) -> String {
252 let selected_profile_input = resolve_profile_input(env);
253 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
254}
255
256fn resolve_profile_input(env: &dyn Env) -> String {
258 if let Ok(profile) = env.var("AUTUMN_ENV") {
260 let trimmed = profile.trim();
261 if !trimmed.is_empty() {
262 return trimmed.to_owned();
263 }
264 }
265
266 if let Ok(profile) = env.var("AUTUMN_PROFILE") {
268 let trimmed = profile.trim();
269 if !trimmed.is_empty() {
270 return trimmed.to_owned();
271 }
272 }
273
274 let args: Vec<String> = std::env::args().collect();
276 for (i, arg) in args.iter().enumerate() {
277 if arg == "--profile"
278 && let Some(profile) = args.get(i + 1)
279 {
280 let trimmed = profile.trim();
281 if !trimmed.is_empty() {
282 return trimmed.to_owned();
283 }
284 }
285 if let Some(profile) = arg.strip_prefix("--profile=") {
286 let trimmed = profile.trim();
287 if !trimmed.is_empty() {
288 return trimmed.to_owned();
289 }
290 }
291 }
292
293 if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
295 return "prod".to_owned();
296 }
297 "dev".to_owned()
298}
299
300fn normalize_profile_name(profile: &str) -> Option<String> {
308 let trimmed = profile.trim();
309 if trimmed.is_empty() {
310 return None;
311 }
312
313 if trimmed.eq_ignore_ascii_case("production") {
314 return Some("prod".to_owned());
315 }
316 if trimmed.eq_ignore_ascii_case("development") {
317 return Some("dev".to_owned());
318 }
319 if trimmed.eq_ignore_ascii_case("prod") {
320 return Some("prod".to_owned());
321 }
322 if trimmed.eq_ignore_ascii_case("dev") {
323 return Some("dev".to_owned());
324 }
325
326 Some(trimmed.to_owned())
328}
329
330fn profile_lookup_names(profile: &str) -> Vec<&str> {
335 match profile {
336 "prod" => vec!["production", "prod"],
337 "dev" => vec!["development", "dev"],
338 other => vec![other],
339 }
340}
341
342fn profile_override_file_lookup_names(profile: &str, selected_profile_input: &str) -> Vec<String> {
347 match profile {
348 "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
349 vec!["production".to_owned(), "prod".to_owned()]
350 }
351 "prod" => vec!["prod".to_owned(), "production".to_owned()],
352 "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
353 vec!["development".to_owned(), "dev".to_owned()]
354 }
355 "dev" => vec!["dev".to_owned(), "development".to_owned()],
356 other => vec![other.to_owned()],
357 }
358}
359
360fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
362 base.get("profile")
363 .and_then(toml::Value::as_table)
364 .and_then(|profiles| profiles.get(profile))
365 .and_then(toml::Value::as_table)
366 .map(|table| toml::Value::Table(table.clone()))
367}
368
369fn profile_defaults_as_toml(profile: &str) -> toml::Value {
375 let mut table = toml::map::Map::new();
376
377 match profile {
378 "dev" => {
379 let mut log = toml::map::Map::new();
380 log.insert("level".into(), "debug".into());
381 log.insert("format".into(), "Pretty".into());
382 table.insert("log".into(), toml::Value::Table(log));
383
384 let mut telemetry = toml::map::Map::new();
385 telemetry.insert("environment".into(), "development".into());
386 table.insert("telemetry".into(), toml::Value::Table(telemetry));
387
388 let mut server = toml::map::Map::new();
389 server.insert("host".into(), "127.0.0.1".into());
390 server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(1));
391 table.insert("server".into(), toml::Value::Table(server));
392
393 let mut health = toml::map::Map::new();
394 health.insert("detailed".into(), toml::Value::Boolean(true));
395 table.insert("health".into(), toml::Value::Table(health));
396
397 let mut actuator = toml::map::Map::new();
398 actuator.insert("sensitive".into(), toml::Value::Boolean(true));
399 table.insert("actuator".into(), toml::Value::Table(actuator));
400
401 let mut cors = toml::map::Map::new();
402 cors.insert(
403 "allowed_origins".into(),
404 toml::Value::Array(vec![toml::Value::String("*".to_owned())]),
405 );
406 table.insert("cors".into(), toml::Value::Table(cors));
407
408 let mut storage = toml::map::Map::new();
414 storage.insert("backend".into(), "local".into());
415 table.insert("storage".into(), toml::Value::Table(storage));
416 }
418 "prod" => {
419 let mut log = toml::map::Map::new();
420 log.insert("level".into(), "info".into());
421 log.insert("format".into(), "Json".into());
422 table.insert("log".into(), toml::Value::Table(log));
423
424 let mut telemetry = toml::map::Map::new();
425 telemetry.insert("environment".into(), "production".into());
426 table.insert("telemetry".into(), toml::Value::Table(telemetry));
427
428 let mut server = toml::map::Map::new();
429 server.insert("host".into(), "0.0.0.0".into());
430 server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(30));
431 table.insert("server".into(), toml::Value::Table(server));
432
433 let mut health = toml::map::Map::new();
434 health.insert("detailed".into(), toml::Value::Boolean(false));
435 table.insert("health".into(), toml::Value::Table(health));
436
437 let mut security = toml::map::Map::new();
439 let mut headers = toml::map::Map::new();
440 headers.insert(
441 "strict_transport_security".into(),
442 toml::Value::Boolean(true),
443 );
444 security.insert("headers".into(), toml::Value::Table(headers));
445 let mut csrf = toml::map::Map::new();
446 csrf.insert("enabled".into(), toml::Value::Boolean(true));
447 security.insert("csrf".into(), toml::Value::Table(csrf));
448 table.insert("security".into(), toml::Value::Table(security));
449
450 let mut session = toml::map::Map::new();
451 session.insert("secure".into(), toml::Value::Boolean(true));
452 table.insert("session".into(), toml::Value::Table(session));
453 }
454 _ => {} }
456
457 toml::Value::Table(table)
458}
459
460#[cfg(feature = "mail")]
461fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
462 merged
463 .get("mail")
464 .and_then(toml::Value::as_table)
465 .is_some_and(|mail| mail.contains_key("transport"))
466 || env
467 .var("AUTUMN_MAIL__TRANSPORT")
468 .ok()
469 .as_deref()
470 .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
471}
472
473const MAX_MERGE_DEPTH: usize = 16;
475
476fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
479 deep_merge_with_depth(base, overlay, 0);
480}
481
482fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
483 if depth > MAX_MERGE_DEPTH {
484 eprintln!(
485 "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
486 );
487 return;
488 }
489
490 let toml::Value::Table(overlay_table) = overlay else {
491 return;
492 };
493 let Some(base_table) = base.as_table_mut() else {
494 return;
495 };
496
497 for (key, overlay_val) in overlay_table {
498 let is_recursive_merge =
499 overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
500
501 if is_recursive_merge {
502 if let Some(base_val) = base_table.get_mut(&key) {
503 deep_merge_with_depth(base_val, overlay_val, depth + 1);
504 }
505 } else {
506 base_table.insert(key, overlay_val);
507 }
508 }
509}
510
511fn suggest_profile(profile: &str) -> Option<&'static str> {
515 let known = ["dev", "prod"];
516 let mut suggestions: Vec<(&str, usize)> = known
517 .iter()
518 .map(|k| (*k, levenshtein(profile, k)))
519 .filter(|(_, d)| *d <= 2)
520 .collect();
521 suggestions.sort_by_key(|(_, d)| *d);
522 suggestions.first().map(|(name, _)| *name)
523}
524
525fn warn_profile_typo(profile: &str) {
527 if let Some(suggestion) = suggest_profile(profile) {
528 eprintln!(
529 "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
530 and no smart defaults. Did you mean \"{suggestion}\"?"
531 );
532 }
533}
534
535fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
536 profile != "dev" && profile != "prod" && !has_inline_profile_section
537}
538
539fn levenshtein(a: &str, b: &str) -> usize {
545 let n = b.chars().count();
546 let mut prev: Vec<usize> = (0..=n).collect();
547 for (i, a_ch) in a.chars().enumerate() {
548 let mut prev_diag = prev[0];
549 prev[0] = i + 1;
550 for (j, b_ch) in b.chars().enumerate() {
551 let old_prev = prev[j + 1];
552 let cost = usize::from(a_ch != b_ch);
553 prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
554 prev_diag = old_prev;
555 }
556 }
557 prev[n]
558}
559
560#[derive(Debug, Error)]
576#[non_exhaustive]
577pub enum ConfigError {
578 #[error("failed to read autumn.toml: {0}")]
580 Io(#[from] std::io::Error),
581
582 #[error("invalid autumn.toml: {0}")]
584 Parse(#[from] toml::de::Error),
585
586 #[error("configuration error: {0}")]
589 Validation(String),
590}
591
592#[derive(Debug, Clone, Default, Deserialize)]
620pub struct AutumnConfig {
621 #[serde(skip)]
624 pub profile: Option<String>,
625
626 #[serde(default)]
628 pub server: ServerConfig,
629
630 #[serde(default)]
632 pub database: DatabaseConfig,
633
634 #[serde(default)]
636 pub log: LogConfig,
637
638 #[serde(default)]
640 pub telemetry: TelemetryConfig,
641
642 #[serde(default)]
644 pub health: HealthConfig,
645
646 #[serde(default)]
648 pub actuator: ActuatorConfig,
649
650 #[serde(default)]
652 pub cors: CorsConfig,
653
654 #[serde(default)]
656 pub session: crate::session::SessionConfig,
657
658 #[serde(default)]
660 pub cache: CacheConfig,
661
662 #[serde(default)]
664 pub channels: ChannelConfig,
665
666 #[serde(default)]
668 pub jobs: JobConfig,
669
670 #[serde(default)]
672 pub scheduler: SchedulerConfig,
673
674 #[serde(default)]
676 pub auth: crate::auth::AuthConfig,
677
678 #[serde(default)]
680 pub security: crate::security::config::SecurityConfig,
681
682 #[cfg(feature = "i18n")]
686 #[serde(default)]
687 pub i18n: crate::i18n::I18nConfig,
688 #[cfg(feature = "storage")]
691 #[serde(default)]
692 pub storage: crate::storage::StorageConfig,
693 #[cfg(feature = "mail")]
695 #[serde(default)]
696 pub mail: crate::mail::MailConfig,
697 #[serde(default, rename = "openapi")]
703 pub openapi_runtime: OpenApiRuntimeConfig,
704}
705
706impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
707 type Rejection = crate::AutumnError;
708
709 async fn from_request_parts(
710 _parts: &mut http::request::Parts,
711 state: &crate::AppState,
712 ) -> Result<Self, Self::Rejection> {
713 state
714 .extension::<Self>()
715 .as_deref()
716 .cloned()
717 .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
718 }
719}
720
721#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
723#[serde(rename_all = "snake_case")]
724pub enum ChannelBackend {
725 #[serde(alias = "local", alias = "memory")]
727 #[default]
728 InProcess,
729 Redis,
731}
732
733impl ChannelBackend {
734 #[must_use]
736 pub fn from_env_value(value: &str) -> Option<Self> {
737 match value.trim().to_ascii_lowercase().as_str() {
738 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
739 "redis" => Some(Self::Redis),
740 _ => None,
741 }
742 }
743}
744
745#[derive(Debug, Clone, Deserialize)]
747pub struct ChannelConfig {
748 #[serde(default)]
750 pub backend: ChannelBackend,
751 #[serde(default = "default_channel_capacity")]
753 pub capacity: usize,
754 #[serde(default)]
756 pub redis: ChannelRedisConfig,
757}
758
759impl Default for ChannelConfig {
760 fn default() -> Self {
761 Self {
762 backend: ChannelBackend::default(),
763 capacity: default_channel_capacity(),
764 redis: ChannelRedisConfig::default(),
765 }
766 }
767}
768
769#[derive(Debug, Clone, Deserialize)]
771pub struct ChannelRedisConfig {
772 #[serde(default)]
774 pub url: Option<String>,
775 #[serde(default = "default_channels_redis_prefix")]
777 pub key_prefix: String,
778}
779
780impl Default for ChannelRedisConfig {
781 fn default() -> Self {
782 Self {
783 url: None,
784 key_prefix: default_channels_redis_prefix(),
785 }
786 }
787}
788
789const fn default_channel_capacity() -> usize {
790 32
791}
792
793fn default_channels_redis_prefix() -> String {
794 "autumn:channels".to_owned()
795}
796
797#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
801#[serde(rename_all = "lowercase")]
802#[non_exhaustive]
803pub enum CacheBackend {
804 #[default]
806 Memory,
807 Redis,
809}
810
811impl CacheBackend {
812 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
813 match value.trim().to_ascii_lowercase().as_str() {
814 "memory" => Some(Self::Memory),
815 "redis" => Some(Self::Redis),
816 _ => None,
817 }
818 }
819}
820
821#[derive(Debug, Clone, Default, serde::Deserialize)]
836pub struct CacheConfig {
837 #[serde(default)]
839 pub backend: CacheBackend,
840
841 #[serde(default)]
843 pub redis: CacheRedisConfig,
844}
845
846impl CacheConfig {
847 #[must_use]
849 pub fn is_memory(&self) -> bool {
850 self.backend == CacheBackend::Memory
851 }
852
853 #[must_use]
855 pub fn is_redis(&self) -> bool {
856 self.backend == CacheBackend::Redis
857 }
858}
859
860#[derive(Debug, Clone, serde::Deserialize)]
862pub struct CacheRedisConfig {
863 #[serde(default)]
865 pub url: Option<String>,
866
867 #[serde(default = "default_cache_redis_key_prefix")]
869 pub key_prefix: String,
870}
871
872impl Default for CacheRedisConfig {
873 fn default() -> Self {
874 Self {
875 url: None,
876 key_prefix: default_cache_redis_key_prefix(),
877 }
878 }
879}
880
881fn default_cache_redis_key_prefix() -> String {
882 "autumn:cache".to_owned()
883}
884
885#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
887#[serde(rename_all = "snake_case")]
888pub enum SchedulerBackend {
889 #[serde(alias = "local", alias = "memory")]
891 #[default]
892 InProcess,
893 Postgres,
895}
896
897impl SchedulerBackend {
898 #[must_use]
900 pub fn from_env_value(value: &str) -> Option<Self> {
901 match value.trim().to_ascii_lowercase().as_str() {
902 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
903 "postgres" | "postgresql" => Some(Self::Postgres),
904 _ => None,
905 }
906 }
907}
908
909#[derive(Debug, Clone, Deserialize)]
911pub struct SchedulerConfig {
912 #[serde(default)]
914 pub backend: SchedulerBackend,
915 #[serde(default = "default_scheduler_lease_ttl_secs")]
917 pub lease_ttl_secs: u64,
918 #[serde(default)]
920 pub replica_id: Option<String>,
921 #[serde(default = "default_scheduler_key_prefix")]
923 pub key_prefix: String,
924}
925
926impl SchedulerConfig {
927 #[must_use]
929 pub fn resolved_replica_id(&self) -> String {
930 self.replica_id
931 .as_ref()
932 .filter(|id| !id.trim().is_empty())
933 .cloned()
934 .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
935 .or_else(|| std::env::var("HOSTNAME").ok())
936 .unwrap_or_else(|| format!("pid-{}", std::process::id()))
937 }
938
939 pub fn validate(&self) -> Result<(), ConfigError> {
946 if self.lease_ttl_secs == 0 {
947 return Err(ConfigError::Validation(
948 "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
949 ));
950 }
951 if self.key_prefix.trim().is_empty() {
952 return Err(ConfigError::Validation(
953 "scheduler.key_prefix must not be empty".to_owned(),
954 ));
955 }
956 Ok(())
957 }
958}
959
960impl Default for SchedulerConfig {
961 fn default() -> Self {
962 Self {
963 backend: SchedulerBackend::default(),
964 lease_ttl_secs: default_scheduler_lease_ttl_secs(),
965 replica_id: None,
966 key_prefix: default_scheduler_key_prefix(),
967 }
968 }
969}
970
971const fn default_scheduler_lease_ttl_secs() -> u64 {
972 300
973}
974
975fn default_scheduler_key_prefix() -> String {
976 "autumn:scheduler".to_owned()
977}
978
979#[derive(Debug, Clone, Deserialize)]
994pub struct OpenApiRuntimeConfig {
995 #[serde(default = "default_openapi_enabled")]
1000 pub enabled: bool,
1001 #[serde(default = "default_openapi_path")]
1005 pub path: String,
1006}
1007
1008impl Default for OpenApiRuntimeConfig {
1009 fn default() -> Self {
1010 Self {
1011 enabled: default_openapi_enabled(),
1012 path: default_openapi_path(),
1013 }
1014 }
1015}
1016
1017const fn default_openapi_enabled() -> bool {
1018 true
1019}
1020
1021fn default_openapi_path() -> String {
1022 "/openapi.json".to_owned()
1023}
1024
1025#[derive(Debug, Clone, Deserialize)]
1027pub struct JobConfig {
1028 #[serde(default = "default_job_backend")]
1033 pub backend: String,
1034 #[serde(default = "default_job_workers")]
1036 pub workers: usize,
1037 #[serde(default = "default_job_max_attempts")]
1039 pub max_attempts: u32,
1040 #[serde(default = "default_job_backoff_ms")]
1042 pub initial_backoff_ms: u64,
1043 #[serde(default)]
1045 pub redis: JobRedisConfig,
1046}
1047
1048impl Default for JobConfig {
1049 fn default() -> Self {
1050 Self {
1051 backend: default_job_backend(),
1052 workers: default_job_workers(),
1053 max_attempts: default_job_max_attempts(),
1054 initial_backoff_ms: default_job_backoff_ms(),
1055 redis: JobRedisConfig::default(),
1056 }
1057 }
1058}
1059
1060#[derive(Debug, Clone, Deserialize)]
1062pub struct JobRedisConfig {
1063 #[serde(default)]
1065 pub url: Option<String>,
1066 #[serde(default = "default_jobs_redis_prefix")]
1068 pub key_prefix: String,
1069 #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
1071 pub visibility_timeout_ms: u64,
1072}
1073
1074impl Default for JobRedisConfig {
1075 fn default() -> Self {
1076 Self {
1077 url: None,
1078 key_prefix: default_jobs_redis_prefix(),
1079 visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
1080 }
1081 }
1082}
1083
1084fn default_job_backend() -> String {
1085 "local".to_owned()
1086}
1087
1088const fn default_job_workers() -> usize {
1089 1
1090}
1091
1092const fn default_job_max_attempts() -> u32 {
1093 5
1094}
1095
1096const fn default_job_backoff_ms() -> u64 {
1097 250
1098}
1099
1100fn default_jobs_redis_prefix() -> String {
1101 "autumn:jobs".to_owned()
1102}
1103
1104const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
1105 30_000
1106}
1107
1108impl AutumnConfig {
1109 pub fn load() -> Result<Self, ConfigError> {
1130 Self::load_with_env(&OsEnv)
1131 }
1132
1133 pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
1144 let selected_profile_input = resolve_profile_input(env);
1145 let profile =
1146 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
1147 let mut has_inline_profile_section = false;
1148
1149 let mut merged = profile_defaults_as_toml(&profile);
1152
1153 if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
1155 deep_merge(&mut merged, base.clone());
1156
1157 for profile_name in profile_lookup_names(&profile) {
1159 if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
1160 deep_merge(&mut merged, inline_profile);
1161 has_inline_profile_section = true;
1162 }
1163 }
1164 }
1165
1166 let mut has_profile_file = false;
1168 for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
1169 let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
1170 if let Some(profile_toml) = load_raw_toml(&profile_path)? {
1171 deep_merge(&mut merged, profile_toml);
1172 has_profile_file = true;
1173 break;
1174 }
1175 }
1176 if !has_profile_file
1177 && should_warn_missing_profile_file(&profile, has_inline_profile_section)
1178 {
1179 warn_profile_typo(&profile);
1180 }
1181
1182 let toml_str =
1184 toml::to_string(&merged).expect("internal error: failed to serialize merged config");
1185 let mut config: Self = toml::from_str(&toml_str)?;
1186 config.profile = Some(profile);
1187
1188 config.apply_env_overrides_with_env(env);
1190
1191 #[cfg(feature = "mail")]
1192 if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
1193 config.mail.transport = crate::mail::Transport::Log;
1194 }
1195
1196 config.validate()?;
1197 Ok(config)
1198 }
1199
1200 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1211 match std::fs::read_to_string(path) {
1212 Ok(contents) => {
1213 let config: Self = toml::from_str(&contents)?;
1214 config.validate()?;
1215 Ok(config)
1216 }
1217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1218 Err(e) => Err(ConfigError::Io(e)),
1219 }
1220 }
1221
1222 pub fn validate(&self) -> Result<(), ConfigError> {
1228 self.database.validate()?;
1229 self.cors.validate()?;
1230 self.scheduler.validate()?;
1231 let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
1232 self.security
1233 .webhooks
1234 .validate(is_production)
1235 .map_err(|error| ConfigError::Validation(error.to_string()))?;
1236 #[cfg(feature = "mail")]
1237 self.mail.validate(self.profile.as_deref())?;
1238 Ok(())
1248 }
1249
1250 pub fn apply_env_overrides(&mut self) {
1307 self.apply_env_overrides_with_env(&OsEnv);
1308 }
1309
1310 pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
1312 self.apply_server_env_overrides_with_env(env);
1313 self.apply_database_env_overrides_with_env(env);
1314 self.apply_log_env_overrides_with_env(env);
1315 self.apply_telemetry_env_overrides_with_env(env);
1316 self.apply_health_env_overrides_with_env(env);
1317 self.apply_cors_env_overrides_with_env(env);
1318 self.apply_session_env_overrides_with_env(env);
1319 self.apply_cache_env_overrides_with_env(env);
1320 self.apply_channels_env_overrides_with_env(env);
1321 self.apply_jobs_env_overrides_with_env(env);
1322 self.apply_scheduler_env_overrides_with_env(env);
1323 self.apply_auth_env_overrides_with_env(env);
1324 self.apply_security_env_overrides_with_env(env);
1325 #[cfg(feature = "storage")]
1326 self.apply_storage_env_overrides_with_env(env);
1327 #[cfg(feature = "mail")]
1328 self.apply_mail_env_overrides_with_env(env);
1329 }
1330
1331 fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
1332 parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
1333 parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
1334 parse_env(
1335 env,
1336 "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
1337 &mut self.server.shutdown_timeout_secs,
1338 );
1339 }
1340
1341 fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
1342 if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
1343 self.database.url = Some(val);
1344 self.database.primary_url = None;
1345 }
1346 parse_env_option_string(
1347 env,
1348 "AUTUMN_DATABASE__PRIMARY_URL",
1349 &mut self.database.primary_url,
1350 );
1351 parse_env_option_string(
1352 env,
1353 "AUTUMN_DATABASE__REPLICA_URL",
1354 &mut self.database.replica_url,
1355 );
1356 parse_env(
1357 env,
1358 "AUTUMN_DATABASE__POOL_SIZE",
1359 &mut self.database.pool_size,
1360 );
1361 parse_env_option(
1362 env,
1363 "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
1364 &mut self.database.primary_pool_size,
1365 );
1366 parse_env_option(
1367 env,
1368 "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
1369 &mut self.database.replica_pool_size,
1370 );
1371 parse_env(
1372 env,
1373 "AUTUMN_DATABASE__REPLICA_FALLBACK",
1374 &mut self.database.replica_fallback,
1375 );
1376 parse_env(
1377 env,
1378 "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
1379 &mut self.database.connect_timeout_secs,
1380 );
1381 parse_env_bool(
1382 env,
1383 "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
1384 &mut self.database.auto_migrate_in_production,
1385 );
1386 }
1387
1388 fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
1389 parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
1390 if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
1391 match val.as_str() {
1392 "Auto" => self.log.format = LogFormat::Auto,
1393 "Pretty" => self.log.format = LogFormat::Pretty,
1394 "Json" => self.log.format = LogFormat::Json,
1395 _ => eprintln!(
1396 "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
1397 (expected Auto, Pretty, or Json), ignoring"
1398 ),
1399 }
1400 }
1401 }
1402
1403 fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
1404 parse_env_bool(
1406 env,
1407 "AUTUMN_TELEMETRY__ENABLED",
1408 &mut self.telemetry.enabled,
1409 );
1410 parse_env_string(
1411 env,
1412 "AUTUMN_TELEMETRY__SERVICE_NAME",
1413 &mut self.telemetry.service_name,
1414 );
1415 parse_env_option_string(
1416 env,
1417 "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
1418 &mut self.telemetry.service_namespace,
1419 );
1420 parse_env_string(
1421 env,
1422 "AUTUMN_TELEMETRY__SERVICE_VERSION",
1423 &mut self.telemetry.service_version,
1424 );
1425 parse_env_string(
1426 env,
1427 "AUTUMN_TELEMETRY__ENVIRONMENT",
1428 &mut self.telemetry.environment,
1429 );
1430 parse_env_option_string(
1431 env,
1432 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
1433 &mut self.telemetry.otlp_endpoint,
1434 );
1435 if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
1436 match TelemetryProtocol::from_env_value(&val) {
1437 Some(protocol) => self.telemetry.protocol = protocol,
1438 None => eprintln!(
1439 "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
1440 (expected Grpc or HttpProtobuf), ignoring"
1441 ),
1442 }
1443 }
1444 parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
1445 }
1446
1447 fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
1448 parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
1449 parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
1450 parse_env_string(
1451 env,
1452 "AUTUMN_HEALTH__READY_PATH",
1453 &mut self.health.ready_path,
1454 );
1455 parse_env_string(
1456 env,
1457 "AUTUMN_HEALTH__STARTUP_PATH",
1458 &mut self.health.startup_path,
1459 );
1460 parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
1461 }
1462
1463 fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
1464 parse_env_csv(
1465 env,
1466 "AUTUMN_CORS__ALLOWED_ORIGINS",
1467 &mut self.cors.allowed_origins,
1468 );
1469 parse_env_csv(
1470 env,
1471 "AUTUMN_CORS__ALLOWED_METHODS",
1472 &mut self.cors.allowed_methods,
1473 );
1474 parse_env_csv(
1475 env,
1476 "AUTUMN_CORS__ALLOWED_HEADERS",
1477 &mut self.cors.allowed_headers,
1478 );
1479 parse_env_bool(
1480 env,
1481 "AUTUMN_CORS__ALLOW_CREDENTIALS",
1482 &mut self.cors.allow_credentials,
1483 );
1484 parse_env(
1485 env,
1486 "AUTUMN_CORS__MAX_AGE_SECS",
1487 &mut self.cors.max_age_secs,
1488 );
1489 }
1490
1491 fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
1492 parse_env_string(
1493 env,
1494 "AUTUMN_SESSION__COOKIE_NAME",
1495 &mut self.session.cookie_name,
1496 );
1497 if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
1498 match crate::session::SessionBackend::from_env_value(&val) {
1499 Some(backend) => self.session.backend = backend,
1500 None => eprintln!(
1501 "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
1502 (expected memory or redis), ignoring"
1503 ),
1504 }
1505 }
1506 parse_env(
1507 env,
1508 "AUTUMN_SESSION__MAX_AGE_SECS",
1509 &mut self.session.max_age_secs,
1510 );
1511 parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
1512 parse_env_string(
1513 env,
1514 "AUTUMN_SESSION__SAME_SITE",
1515 &mut self.session.same_site,
1516 );
1517 parse_env_bool(
1518 env,
1519 "AUTUMN_SESSION__HTTP_ONLY",
1520 &mut self.session.http_only,
1521 );
1522 parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
1523 parse_env_bool(
1524 env,
1525 "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
1526 &mut self.session.allow_memory_in_production,
1527 );
1528 parse_env_option_string(
1529 env,
1530 "AUTUMN_SESSION__REDIS__URL",
1531 &mut self.session.redis.url,
1532 );
1533 parse_env_string(
1534 env,
1535 "AUTUMN_SESSION__REDIS__KEY_PREFIX",
1536 &mut self.session.redis.key_prefix,
1537 );
1538 }
1539
1540 fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
1541 if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
1542 match CacheBackend::from_env_value(&val) {
1543 Some(backend) => self.cache.backend = backend,
1544 None => eprintln!(
1545 "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
1546 (expected memory or redis), ignoring"
1547 ),
1548 }
1549 }
1550 parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
1551 parse_env_string(
1552 env,
1553 "AUTUMN_CACHE__REDIS__KEY_PREFIX",
1554 &mut self.cache.redis.key_prefix,
1555 );
1556 }
1557
1558 fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
1559 if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
1560 match ChannelBackend::from_env_value(&val) {
1561 Some(backend) => self.channels.backend = backend,
1562 None => eprintln!(
1563 "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
1564 (expected in_process or redis), ignoring"
1565 ),
1566 }
1567 }
1568 parse_env(
1569 env,
1570 "AUTUMN_CHANNELS__CAPACITY",
1571 &mut self.channels.capacity,
1572 );
1573 parse_env_option_string(
1574 env,
1575 "AUTUMN_CHANNELS__REDIS__URL",
1576 &mut self.channels.redis.url,
1577 );
1578 parse_env_string(
1579 env,
1580 "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
1581 &mut self.channels.redis.key_prefix,
1582 );
1583 }
1584
1585 fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
1586 parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
1587 parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
1588 parse_env(
1589 env,
1590 "AUTUMN_JOBS__MAX_ATTEMPTS",
1591 &mut self.jobs.max_attempts,
1592 );
1593 parse_env(
1594 env,
1595 "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
1596 &mut self.jobs.initial_backoff_ms,
1597 );
1598 parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
1599 parse_env_string(
1600 env,
1601 "AUTUMN_JOBS__REDIS__KEY_PREFIX",
1602 &mut self.jobs.redis.key_prefix,
1603 );
1604 parse_env(
1605 env,
1606 "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
1607 &mut self.jobs.redis.visibility_timeout_ms,
1608 );
1609 }
1610
1611 fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
1612 if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
1613 match SchedulerBackend::from_env_value(&val) {
1614 Some(backend) => self.scheduler.backend = backend,
1615 None => eprintln!(
1616 "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
1617 (expected in_process or postgres), ignoring"
1618 ),
1619 }
1620 }
1621 parse_env(
1622 env,
1623 "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
1624 &mut self.scheduler.lease_ttl_secs,
1625 );
1626 parse_env_option_string(
1627 env,
1628 "AUTUMN_SCHEDULER__REPLICA_ID",
1629 &mut self.scheduler.replica_id,
1630 );
1631 parse_env_string(
1632 env,
1633 "AUTUMN_SCHEDULER__KEY_PREFIX",
1634 &mut self.scheduler.key_prefix,
1635 );
1636 }
1637
1638 fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
1639 parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
1640 parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
1641 }
1642
1643 #[allow(clippy::too_many_lines)]
1645 fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
1646 parse_env_string(
1647 env,
1648 "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
1649 &mut self.security.headers.x_frame_options,
1650 );
1651 parse_env_bool(
1652 env,
1653 "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
1654 &mut self.security.headers.x_content_type_options,
1655 );
1656 parse_env_bool(
1657 env,
1658 "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
1659 &mut self.security.headers.strict_transport_security,
1660 );
1661 parse_env(
1662 env,
1663 "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
1664 &mut self.security.headers.hsts_max_age_secs,
1665 );
1666 parse_env_string(
1667 env,
1668 "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
1669 &mut self.security.headers.content_security_policy,
1670 );
1671 parse_env_string(
1672 env,
1673 "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
1674 &mut self.security.headers.referrer_policy,
1675 );
1676 parse_env_string(
1677 env,
1678 "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
1679 &mut self.security.headers.permissions_policy,
1680 );
1681
1682 parse_env_bool(
1684 env,
1685 "AUTUMN_SECURITY__CSRF__ENABLED",
1686 &mut self.security.csrf.enabled,
1687 );
1688 parse_env_string(
1689 env,
1690 "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
1691 &mut self.security.csrf.token_header,
1692 );
1693 parse_env_string(
1694 env,
1695 "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
1696 &mut self.security.csrf.cookie_name,
1697 );
1698
1699 self.apply_rate_limit_env_overrides_with_env(env);
1700
1701 parse_env(
1703 env,
1704 "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
1705 &mut self.security.upload.max_request_size_bytes,
1706 );
1707 parse_env(
1708 env,
1709 "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
1710 &mut self.security.upload.max_file_size_bytes,
1711 );
1712 parse_env_csv(
1713 env,
1714 "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
1715 &mut self.security.upload.allowed_mime_types,
1716 );
1717
1718 if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
1720 match value.parse::<crate::authorization::ForbiddenResponse>() {
1721 Ok(parsed) => self.security.forbidden_response = parsed,
1722 Err(err) => tracing::warn!(
1723 "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
1724 ),
1725 }
1726 }
1727 parse_env_bool(
1728 env,
1729 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
1730 &mut self.security.allow_unauthorized_repository_api,
1731 );
1732
1733 parse_env_option_string(
1735 env,
1736 "AUTUMN_SECURITY__SIGNING_SECRET",
1737 &mut self.security.signing_secret.secret,
1738 );
1739
1740 self.security.webhooks.apply_env_overrides_with_env(env);
1741 }
1742
1743 fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
1744 parse_env_bool(
1745 env,
1746 "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
1747 &mut self.security.rate_limit.enabled,
1748 );
1749 parse_env(
1750 env,
1751 "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
1752 &mut self.security.rate_limit.requests_per_second,
1753 );
1754 parse_env(
1755 env,
1756 "AUTUMN_SECURITY__RATE_LIMIT__BURST",
1757 &mut self.security.rate_limit.burst,
1758 );
1759 parse_env_bool(
1760 env,
1761 "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
1762 &mut self.security.rate_limit.trust_forwarded_headers,
1763 );
1764 parse_env_csv(
1765 env,
1766 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
1767 &mut self.security.rate_limit.trusted_proxies,
1768 );
1769 }
1770
1771 #[cfg(feature = "storage")]
1772 fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
1773 if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
1774 match crate::storage::StorageBackend::from_env_value(&val) {
1775 Some(backend) => self.storage.backend = backend,
1776 None => eprintln!(
1777 "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
1778 (expected disabled, local, or s3), ignoring"
1779 ),
1780 }
1781 }
1782 parse_env_string(
1783 env,
1784 "AUTUMN_STORAGE__DEFAULT_PROVIDER",
1785 &mut self.storage.default_provider,
1786 );
1787 parse_env_bool(
1788 env,
1789 "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
1790 &mut self.storage.allow_local_in_production,
1791 );
1792 if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
1793 self.storage.local.root = PathBuf::from(val);
1794 }
1795 parse_env_string(
1796 env,
1797 "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
1798 &mut self.storage.local.mount_path,
1799 );
1800 parse_env(
1801 env,
1802 "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
1803 &mut self.storage.local.default_url_expiry_secs,
1804 );
1805 parse_env_option_string(
1806 env,
1807 "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
1808 &mut self.storage.local.signing_key,
1809 );
1810 parse_env_option_string(
1811 env,
1812 "AUTUMN_STORAGE__S3__BUCKET",
1813 &mut self.storage.s3.bucket,
1814 );
1815 parse_env_option_string(
1816 env,
1817 "AUTUMN_STORAGE__S3__REGION",
1818 &mut self.storage.s3.region,
1819 );
1820 parse_env_option_string(
1821 env,
1822 "AUTUMN_STORAGE__S3__ENDPOINT",
1823 &mut self.storage.s3.endpoint,
1824 );
1825 parse_env_option_string(
1826 env,
1827 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
1828 &mut self.storage.s3.public_base_url,
1829 );
1830 parse_env_option_string(
1831 env,
1832 "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
1833 &mut self.storage.s3.access_key_id_env,
1834 );
1835 parse_env_option_string(
1836 env,
1837 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
1838 &mut self.storage.s3.secret_access_key_env,
1839 );
1840 parse_env_bool(
1841 env,
1842 "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
1843 &mut self.storage.s3.force_path_style,
1844 );
1845 parse_env(
1846 env,
1847 "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
1848 &mut self.storage.s3.default_url_expiry_secs,
1849 );
1850 }
1851
1852 #[cfg(feature = "mail")]
1853 fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
1854 if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
1855 match crate::mail::Transport::from_env_value(&val) {
1856 Some(transport) => self.mail.transport = transport,
1857 None => eprintln!(
1858 "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
1859 (expected log, file, smtp, or disabled), ignoring"
1860 ),
1861 }
1862 }
1863 parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
1864 parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
1865 parse_env_bool(
1866 env,
1867 "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
1868 &mut self.mail.allow_log_in_production,
1869 );
1870 parse_env_bool(
1871 env,
1872 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
1873 &mut self.mail.allow_in_process_deliver_later_in_production,
1874 );
1875 parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
1876 if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
1877 self.mail.file_dir = PathBuf::from(val);
1878 }
1879 parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
1880 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
1881 match val.parse::<u16>() {
1882 Ok(port) => self.mail.smtp.port = Some(port),
1883 Err(_) => {
1884 eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
1885 }
1886 }
1887 }
1888 parse_env_option_string(
1889 env,
1890 "AUTUMN_MAIL__SMTP__USERNAME",
1891 &mut self.mail.smtp.username,
1892 );
1893 parse_env_option_string(
1894 env,
1895 "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
1896 &mut self.mail.smtp.password_env,
1897 );
1898 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
1899 match crate::mail::TlsMode::from_env_value(&val) {
1900 Some(tls) => self.mail.smtp.tls = tls,
1901 None => eprintln!(
1902 "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
1903 (expected disabled, starttls, or tls), ignoring"
1904 ),
1905 }
1906 }
1907 }
1908
1909 #[must_use]
1911 pub fn profile_name(&self) -> Option<&str> {
1912 self.profile.as_deref()
1913 }
1914}
1915
1916#[derive(Debug, Clone, Deserialize)]
1939pub struct ServerConfig {
1940 #[serde(default = "default_port")]
1942 pub port: u16,
1943
1944 #[serde(default = "default_host")]
1949 pub host: String,
1950
1951 #[serde(default = "default_shutdown_timeout")]
1958 pub shutdown_timeout_secs: u64,
1959}
1960
1961#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
1963#[serde(rename_all = "snake_case")]
1964#[non_exhaustive]
1965pub enum ReplicaFallback {
1966 #[default]
1968 FailReadiness,
1969 Primary,
1971}
1972
1973impl std::str::FromStr for ReplicaFallback {
1974 type Err = ();
1975
1976 fn from_str(value: &str) -> Result<Self, Self::Err> {
1977 match value.trim().to_ascii_lowercase().as_str() {
1978 "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
1979 "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
1980 _ => Err(()),
1981 }
1982 }
1983}
1984
1985#[derive(Debug, Clone, Deserialize)]
2016pub struct DatabaseConfig {
2017 #[serde(default)]
2024 pub url: Option<String>,
2025
2026 #[serde(default)]
2031 pub primary_url: Option<String>,
2032
2033 #[serde(default)]
2038 pub replica_url: Option<String>,
2039
2040 #[serde(default = "default_pool_size")]
2045 pub pool_size: usize,
2046
2047 #[serde(default)]
2049 pub primary_pool_size: Option<usize>,
2050
2051 #[serde(default)]
2053 pub replica_pool_size: Option<usize>,
2054
2055 #[serde(default)]
2058 pub replica_fallback: ReplicaFallback,
2059
2060 #[serde(default = "default_connect_timeout")]
2064 pub connect_timeout_secs: u64,
2065
2066 #[serde(default)]
2072 pub auto_migrate_in_production: bool,
2073}
2074
2075impl DatabaseConfig {
2076 #[must_use]
2078 pub fn effective_primary_url(&self) -> Option<&str> {
2079 self.primary_url.as_deref().or(self.url.as_deref())
2080 }
2081
2082 #[must_use]
2084 pub fn effective_primary_pool_size(&self) -> usize {
2085 self.primary_pool_size.unwrap_or(self.pool_size)
2086 }
2087
2088 #[must_use]
2090 pub fn effective_replica_pool_size(&self) -> usize {
2091 self.replica_pool_size.unwrap_or(self.pool_size)
2092 }
2093
2094 pub fn validate(&self) -> Result<(), ConfigError> {
2100 for (field, url) in [
2101 ("database.url", self.url.as_deref()),
2102 ("database.primary_url", self.primary_url.as_deref()),
2103 ("database.replica_url", self.replica_url.as_deref()),
2104 ] {
2105 if let Some(url) = url
2106 && !url.starts_with("postgres://")
2107 && !url.starts_with("postgresql://")
2108 {
2109 let label = if field == "database.url" {
2110 "database URL"
2111 } else {
2112 field
2113 };
2114 return Err(ConfigError::Validation(format!(
2115 "Invalid {label}: must start with postgres:// or postgresql://, got {url:?}"
2116 )));
2117 }
2118 }
2119
2120 if self.replica_url.is_some() && self.effective_primary_url().is_none() {
2121 return Err(ConfigError::Validation(
2122 "database.replica_url requires database.primary_url or database.url".to_owned(),
2123 ));
2124 }
2125 Ok(())
2126 }
2127}
2128
2129#[derive(Debug, Clone, Deserialize)]
2144pub struct LogConfig {
2145 #[serde(default = "default_log_level")]
2150 pub level: String,
2151
2152 #[serde(default)]
2154 pub format: LogFormat,
2155}
2156
2157#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
2176#[non_exhaustive]
2177pub enum LogFormat {
2178 #[default]
2180 Auto,
2181 Pretty,
2183 Json,
2185}
2186
2187#[derive(Debug, Clone, Deserialize)]
2192pub struct TelemetryConfig {
2193 #[serde(default)]
2195 pub enabled: bool,
2196
2197 #[serde(default = "default_telemetry_service_name")]
2199 pub service_name: String,
2200
2201 #[serde(default)]
2203 pub service_namespace: Option<String>,
2204
2205 #[serde(default = "default_telemetry_service_version")]
2207 pub service_version: String,
2208
2209 #[serde(default = "default_telemetry_environment")]
2211 pub environment: String,
2212
2213 #[serde(default)]
2215 pub otlp_endpoint: Option<String>,
2216
2217 #[serde(default)]
2219 pub protocol: TelemetryProtocol,
2220
2221 #[serde(default)]
2223 pub strict: bool,
2224}
2225
2226#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
2228#[non_exhaustive]
2229pub enum TelemetryProtocol {
2230 #[serde(alias = "grpc", alias = "GRPC")]
2232 #[default]
2233 Grpc,
2234 #[serde(
2236 alias = "http-protobuf",
2237 alias = "http_protobuf",
2238 alias = "HTTP_PROTOBUF"
2239 )]
2240 HttpProtobuf,
2241}
2242
2243impl TelemetryProtocol {
2244 fn from_env_value(value: &str) -> Option<Self> {
2245 match value {
2246 "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
2247 "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
2248 | "httpprotobuf" => Some(Self::HttpProtobuf),
2249 _ => None,
2250 }
2251 }
2252}
2253
2254#[derive(Debug, Clone, Deserialize)]
2272pub struct HealthConfig {
2273 #[serde(default = "default_health_path")]
2277 pub path: String,
2278
2279 #[serde(default = "default_live_path")]
2281 pub live_path: String,
2282
2283 #[serde(default = "default_ready_path")]
2285 pub ready_path: String,
2286
2287 #[serde(default = "default_startup_path")]
2289 pub startup_path: String,
2290
2291 #[serde(default)]
2295 pub detailed: bool,
2296}
2297
2298#[derive(Debug, Clone, Deserialize)]
2304pub struct ActuatorConfig {
2305 #[serde(default = "default_actuator_prefix")]
2307 pub prefix: String,
2308
2309 #[serde(default)]
2312 pub sensitive: bool,
2313}
2314
2315impl Default for ActuatorConfig {
2316 fn default() -> Self {
2317 Self {
2318 prefix: default_actuator_prefix(),
2319 sensitive: false,
2320 }
2321 }
2322}
2323
2324fn default_actuator_prefix() -> String {
2325 "/actuator".to_owned()
2326}
2327
2328#[derive(Debug, Clone, Deserialize)]
2365pub struct CorsConfig {
2366 #[serde(default)]
2371 pub allowed_origins: Vec<String>,
2372
2373 #[serde(default = "default_cors_methods")]
2376 pub allowed_methods: Vec<String>,
2377
2378 #[serde(default = "default_cors_headers")]
2381 pub allowed_headers: Vec<String>,
2382
2383 #[serde(default)]
2386 pub allow_credentials: bool,
2387
2388 #[serde(default = "default_cors_max_age")]
2391 pub max_age_secs: u64,
2392}
2393
2394impl Default for CorsConfig {
2395 fn default() -> Self {
2396 Self {
2397 allowed_origins: Vec::new(),
2398 allowed_methods: default_cors_methods(),
2399 allowed_headers: default_cors_headers(),
2400 allow_credentials: false,
2401 max_age_secs: default_cors_max_age(),
2402 }
2403 }
2404}
2405
2406impl CorsConfig {
2407 pub fn validate(&self) -> Result<(), ConfigError> {
2416 if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
2417 return Err(ConfigError::Validation(
2418 "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
2419 list explicit origins instead (browsers reject the wildcard+credentials combo)"
2420 .to_owned(),
2421 ));
2422 }
2423 Ok(())
2424 }
2425}
2426
2427fn default_cors_methods() -> Vec<String> {
2428 vec![
2429 "GET".to_owned(),
2430 "POST".to_owned(),
2431 "PUT".to_owned(),
2432 "DELETE".to_owned(),
2433 "PATCH".to_owned(),
2434 "OPTIONS".to_owned(),
2435 ]
2436}
2437
2438fn default_cors_headers() -> Vec<String> {
2439 vec!["Content-Type".to_owned(), "Authorization".to_owned()]
2440}
2441
2442const fn default_cors_max_age() -> u64 {
2443 86400
2444}
2445
2446fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
2448 if let Ok(val) = env.var(key) {
2449 match val.parse::<T>() {
2450 Ok(v) => *target = v,
2451 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
2452 }
2453 }
2454}
2455
2456fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
2457 if let Ok(val) = env.var(key) {
2458 *target = if val.is_empty() { None } else { Some(val) };
2459 }
2460}
2461
2462fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
2463 if let Ok(val) = env.var(key) {
2464 if val.is_empty() {
2465 *target = None;
2466 } else {
2467 match val.parse::<T>() {
2468 Ok(v) => *target = Some(v),
2469 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
2470 }
2471 }
2472 }
2473}
2474
2475fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
2476 if let Ok(val) = env.var(key) {
2477 *target = val;
2478 }
2479}
2480
2481fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
2482 if let Ok(val) = env.var(key) {
2483 match val.as_str() {
2484 "true" | "1" => *target = true,
2485 "false" | "0" => *target = false,
2486 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
2487 }
2488 }
2489}
2490
2491fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
2492 if let Ok(val) = env.var(key) {
2493 *target = val.split(',').map(|s| s.trim().to_owned()).collect();
2494 }
2495}
2496
2497const fn default_port() -> u16 {
2500 3000
2501}
2502
2503fn default_host() -> String {
2504 "127.0.0.1".to_owned()
2505}
2506
2507const fn default_shutdown_timeout() -> u64 {
2508 30
2509}
2510
2511const fn default_pool_size() -> usize {
2512 10
2513}
2514
2515const fn default_connect_timeout() -> u64 {
2516 5
2517}
2518
2519fn default_log_level() -> String {
2520 "info".to_owned()
2521}
2522
2523fn default_telemetry_service_name() -> String {
2524 "autumn-app".to_owned()
2525}
2526
2527fn default_telemetry_service_version() -> String {
2528 "unknown".to_owned()
2529}
2530
2531fn default_telemetry_environment() -> String {
2532 "development".to_owned()
2533}
2534
2535fn default_health_path() -> String {
2536 "/health".to_owned()
2537}
2538
2539fn default_live_path() -> String {
2540 "/live".to_owned()
2541}
2542
2543fn default_ready_path() -> String {
2544 "/ready".to_owned()
2545}
2546
2547fn default_startup_path() -> String {
2548 "/startup".to_owned()
2549}
2550
2551impl Default for ServerConfig {
2554 fn default() -> Self {
2555 Self {
2556 port: default_port(),
2557 host: default_host(),
2558 shutdown_timeout_secs: default_shutdown_timeout(),
2559 }
2560 }
2561}
2562
2563impl Default for DatabaseConfig {
2564 fn default() -> Self {
2565 Self {
2566 url: None,
2567 primary_url: None,
2568 replica_url: None,
2569 pool_size: default_pool_size(),
2570 primary_pool_size: None,
2571 replica_pool_size: None,
2572 replica_fallback: ReplicaFallback::default(),
2573 connect_timeout_secs: default_connect_timeout(),
2574 auto_migrate_in_production: false,
2575 }
2576 }
2577}
2578
2579impl Default for LogConfig {
2580 fn default() -> Self {
2581 Self {
2582 level: default_log_level(),
2583 format: LogFormat::default(),
2584 }
2585 }
2586}
2587
2588impl Default for TelemetryConfig {
2589 fn default() -> Self {
2590 Self {
2591 enabled: false,
2592 service_name: default_telemetry_service_name(),
2593 service_namespace: None,
2594 service_version: default_telemetry_service_version(),
2595 environment: default_telemetry_environment(),
2596 otlp_endpoint: None,
2597 protocol: TelemetryProtocol::default(),
2598 strict: false,
2599 }
2600 }
2601}
2602
2603impl Default for HealthConfig {
2604 fn default() -> Self {
2605 Self {
2606 path: default_health_path(),
2607 live_path: default_live_path(),
2608 ready_path: default_ready_path(),
2609 startup_path: default_startup_path(),
2610 detailed: false,
2611 }
2612 }
2613}
2614
2615pub trait ConfigLoader: Send + Sync + 'static {
2646 fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
2654}
2655
2656#[derive(Debug, Default, Clone, Copy)]
2662pub struct TomlEnvConfigLoader;
2663
2664impl TomlEnvConfigLoader {
2665 #[must_use]
2667 pub const fn new() -> Self {
2668 Self
2669 }
2670}
2671
2672impl ConfigLoader for TomlEnvConfigLoader {
2673 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
2674 AutumnConfig::load_with_env(&OsEnv)
2675 }
2676}
2677
2678#[cfg(test)]
2679mod tests {
2680
2681 use super::*;
2682
2683 struct MockConfigLoader {
2685 config: AutumnConfig,
2686 }
2687
2688 impl ConfigLoader for MockConfigLoader {
2689 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
2690 Ok(self.config.clone())
2691 }
2692 }
2693
2694 #[tokio::test]
2695 async fn config_loader_trait_returns_supplied_config() {
2696 let mut custom = AutumnConfig::default();
2697 custom.server.port = 9999;
2698 custom.profile = Some("integration-test".to_owned());
2699
2700 let loader = MockConfigLoader {
2701 config: custom.clone(),
2702 };
2703 let resolved = loader.load().await.expect("mock loader should succeed");
2704
2705 assert_eq!(resolved.server.port, 9999);
2706 assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
2707 }
2708
2709 #[test]
2710 fn validate_does_not_error_on_redis_backend_without_url() {
2711 let mut config = AutumnConfig::default();
2719 config.session.backend = crate::session::SessionBackend::Redis;
2720 config.session.redis.url = None;
2721
2722 config.validate().expect(
2723 "validate() must accept redis-backend-without-url so custom \
2724 session store overrides aren't blocked at boot",
2725 );
2726 }
2727
2728 #[tokio::test]
2729 async fn default_toml_env_loader_succeeds_without_files() {
2730 let loader = TomlEnvConfigLoader::new();
2733 let resolved = loader.load().await.expect("default loader should succeed");
2734 assert_eq!(resolved.server.port, 3000);
2736 }
2737
2738 #[test]
2739 fn database_config_validate_none() {
2740 let config = DatabaseConfig {
2741 url: None,
2742 ..Default::default()
2743 };
2744 assert!(config.validate().is_ok());
2745 }
2746
2747 #[test]
2748 fn database_config_validate_valid_postgres() {
2749 let config = DatabaseConfig {
2750 url: Some("postgres://user:pass@localhost:5432/db".to_string()),
2751 ..Default::default()
2752 };
2753 assert!(config.validate().is_ok());
2754 }
2755
2756 #[test]
2757 fn database_config_validate_valid_postgresql() {
2758 let config = DatabaseConfig {
2759 url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
2760 ..Default::default()
2761 };
2762 assert!(config.validate().is_ok());
2763 }
2764
2765 #[test]
2766 fn database_config_validate_invalid_scheme() {
2767 let config = DatabaseConfig {
2768 url: Some("mysql://user:pass@localhost:3306/db".to_string()),
2769 ..Default::default()
2770 };
2771 let result = config.validate();
2772 assert!(result.is_err());
2773 match result {
2774 Err(ConfigError::Validation(msg)) => {
2775 assert!(msg.contains("must start with postgres:// or postgresql://"));
2778 }
2779 _ => panic!("Expected ConfigError::Validation"),
2780 }
2781 }
2782
2783 #[test]
2784 fn server_defaults() {
2785 let config = ServerConfig::default();
2786 assert_eq!(config.port, 3000);
2787 assert_eq!(config.host, "127.0.0.1");
2788 assert_eq!(config.shutdown_timeout_secs, 30);
2789 }
2790
2791 #[test]
2792 fn database_defaults() {
2793 let config = DatabaseConfig::default();
2794 assert!(config.url.is_none());
2795 assert_eq!(config.pool_size, 10);
2796 assert_eq!(config.connect_timeout_secs, 5);
2797 }
2798
2799 #[test]
2800 fn database_validate_none_url_is_ok() {
2801 let config = DatabaseConfig {
2802 url: None,
2803 ..Default::default()
2804 };
2805 assert!(config.validate().is_ok());
2806 }
2807
2808 #[test]
2809 fn database_validate_postgres_url_is_ok() {
2810 let config = DatabaseConfig {
2811 url: Some("postgres://user:pass@localhost/db".to_string()),
2812 ..Default::default()
2813 };
2814 assert!(config.validate().is_ok());
2815 }
2816
2817 #[test]
2818 fn database_validate_postgresql_url_is_ok() {
2819 let config = DatabaseConfig {
2820 url: Some("postgresql://user:pass@localhost/db".to_string()),
2821 ..Default::default()
2822 };
2823 assert!(config.validate().is_ok());
2824 }
2825
2826 #[test]
2827 fn database_validate_invalid_url_is_err() {
2828 let config = DatabaseConfig {
2829 url: Some("mysql://user:pass@localhost/db".to_string()),
2830 ..Default::default()
2831 };
2832 let result = config.validate();
2833 assert!(result.is_err());
2834 if let Err(ConfigError::Validation(msg)) = result {
2835 assert!(msg.contains("Invalid database URL"));
2836 assert!(msg.contains("must start with postgres:// or postgresql://"));
2837 } else {
2838 panic!("Expected ConfigError::Validation");
2839 }
2840 }
2841
2842 #[test]
2843 fn database_topology_deserializes_primary_and_replica_urls() {
2844 let config: AutumnConfig = toml::from_str(
2845 r#"
2846[database]
2847primary_url = "postgres://primary.example/app"
2848replica_url = "postgres://replica.example/app"
2849primary_pool_size = 12
2850replica_pool_size = 4
2851replica_fallback = "primary"
2852"#,
2853 )
2854 .expect("database topology config should parse");
2855
2856 assert_eq!(
2857 config.database.primary_url.as_deref(),
2858 Some("postgres://primary.example/app")
2859 );
2860 assert_eq!(
2861 config.database.replica_url.as_deref(),
2862 Some("postgres://replica.example/app")
2863 );
2864 assert_eq!(config.database.primary_pool_size, Some(12));
2865 assert_eq!(config.database.replica_pool_size, Some(4));
2866 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
2867 assert_eq!(
2868 config.database.effective_primary_url(),
2869 Some("postgres://primary.example/app")
2870 );
2871 assert_eq!(config.database.effective_primary_pool_size(), 12);
2872 assert_eq!(config.database.effective_replica_pool_size(), 4);
2873 }
2874
2875 #[test]
2876 fn database_topology_keeps_url_as_single_primary_compatibility_path() {
2877 let config: AutumnConfig = toml::from_str(
2878 r#"
2879[database]
2880url = "postgres://single.example/app"
2881pool_size = 7
2882"#,
2883 )
2884 .expect("legacy database.url config should parse");
2885
2886 assert_eq!(
2887 config.database.effective_primary_url(),
2888 Some("postgres://single.example/app")
2889 );
2890 assert_eq!(config.database.effective_primary_pool_size(), 7);
2891 assert_eq!(config.database.effective_replica_pool_size(), 7);
2892 assert!(config.database.replica_url.is_none());
2893 }
2894
2895 #[test]
2896 fn database_topology_rejects_replica_without_primary() {
2897 let config = DatabaseConfig {
2898 replica_url: Some("postgres://replica.example/app".to_owned()),
2899 ..Default::default()
2900 };
2901
2902 let result = config.validate();
2903
2904 assert!(result.is_err());
2905 let Err(ConfigError::Validation(message)) = result else {
2906 panic!("expected database topology validation error");
2907 };
2908 assert!(message.contains("database.replica_url"));
2909 assert!(message.contains("database.primary_url"));
2910 }
2911
2912 #[test]
2913 fn database_topology_env_overrides_role_fields() {
2914 let env = MockEnv::new()
2915 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
2916 .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
2917 .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
2918 .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
2919 .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
2920 let mut config = AutumnConfig::default();
2921
2922 config.apply_env_overrides_with_env(&env);
2923
2924 assert_eq!(
2925 config.database.primary_url.as_deref(),
2926 Some("postgres://primary.env/app")
2927 );
2928 assert_eq!(
2929 config.database.replica_url.as_deref(),
2930 Some("postgres://replica.env/app")
2931 );
2932 assert_eq!(config.database.primary_pool_size, Some(9));
2933 assert_eq!(config.database.replica_pool_size, Some(3));
2934 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
2935 }
2936
2937 #[test]
2938 fn database_validate_url_edge_cases() {
2939 let invalid_urls = vec![
2940 "POSTGRES://localhost/db",
2941 "postgres:/localhost/db",
2942 "postgres:localhost/db",
2943 "http://postgres",
2944 " postgres://localhost/db",
2945 "",
2946 ];
2947
2948 for invalid_url in invalid_urls {
2949 let config = DatabaseConfig {
2950 url: Some(invalid_url.to_string()),
2951 ..Default::default()
2952 };
2953 assert!(
2954 config.validate().is_err(),
2955 "URL should be invalid: {invalid_url}"
2956 );
2957 }
2958 }
2959
2960 #[test]
2961 fn autumn_config_validate_ok() {
2962 let config = AutumnConfig::default();
2963 assert!(config.validate().is_ok());
2964 }
2965
2966 #[test]
2967 fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
2968 let mut config = AutumnConfig::default();
2978 config.session.backend = crate::session::SessionBackend::Redis;
2979 config.session.redis.url = None;
2980
2981 config
2982 .validate()
2983 .expect("validate() must accept invalid session backend so custom store can override");
2984 }
2985
2986 #[test]
2987 fn autumn_config_validate_database_err() {
2988 let mut config = AutumnConfig::default();
2989 config.database.url = Some("mysql://localhost/test".to_string());
2990 assert!(config.validate().is_err());
2991 }
2992
2993 #[test]
2994 fn log_defaults() {
2995 let config = LogConfig::default();
2996 assert_eq!(config.level, "info");
2997 assert_eq!(config.format, LogFormat::Auto);
2998 }
2999
3000 #[test]
3001 fn telemetry_defaults() {
3002 let config = TelemetryConfig::default();
3003 assert!(!config.enabled);
3004 assert_eq!(config.service_name, "autumn-app");
3005 assert!(config.service_namespace.is_none());
3006 assert_eq!(config.service_version, "unknown");
3007 assert_eq!(config.environment, "development");
3008 assert!(config.otlp_endpoint.is_none());
3009 assert_eq!(config.protocol, TelemetryProtocol::Grpc);
3010 assert!(!config.strict);
3011 }
3012
3013 #[test]
3014 fn health_defaults() {
3015 let config = HealthConfig::default();
3016 assert_eq!(config.path, "/health");
3017 assert_eq!(config.live_path, "/live");
3018 assert_eq!(config.ready_path, "/ready");
3019 assert_eq!(config.startup_path, "/startup");
3020 assert!(!config.detailed);
3021 }
3022
3023 #[test]
3024 fn top_level_default_populates_all_sections() {
3025 let config = AutumnConfig::default();
3026 assert_eq!(config.server.port, 3000);
3027 assert!(config.database.url.is_none());
3028 assert_eq!(config.log.level, "info");
3029 assert_eq!(config.health.path, "/health");
3030 }
3031
3032 #[test]
3033 fn deserialize_empty_object_uses_all_defaults() {
3034 let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
3035 assert_eq!(config.server.port, 3000);
3036 assert_eq!(config.server.host, "127.0.0.1");
3037 assert_eq!(config.server.shutdown_timeout_secs, 30);
3038 assert!(config.database.url.is_none());
3039 assert_eq!(config.database.pool_size, 10);
3040 assert_eq!(config.database.connect_timeout_secs, 5);
3041 assert!(!config.database.auto_migrate_in_production);
3042 assert_eq!(config.log.level, "info");
3043 assert_eq!(config.log.format, LogFormat::Auto);
3044 assert_eq!(config.health.path, "/health");
3045 }
3046
3047 #[test]
3048 fn deserialize_partial_config_merges_with_defaults() {
3049 let json = r#"{"server": {"port": 8080}}"#;
3050 let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
3051 assert_eq!(config.server.port, 8080);
3052 assert_eq!(config.server.host, "127.0.0.1");
3053 assert_eq!(config.database.pool_size, 10);
3054 assert_eq!(config.log.level, "info");
3055 }
3056
3057 #[test]
3058 fn log_format_variants_deserialize() {
3059 let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
3060 let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
3061 let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
3062 assert_eq!(auto, LogFormat::Auto);
3063 assert_eq!(pretty, LogFormat::Pretty);
3064 assert_eq!(json, LogFormat::Json);
3065 }
3066
3067 #[test]
3070 fn load_missing_file_returns_defaults() {
3071 let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
3072 assert_eq!(config.server.port, 3000);
3073 assert!(config.database.url.is_none());
3074 }
3075
3076 #[test]
3077 fn load_valid_full_config() {
3078 let dir = tempfile::tempdir().unwrap();
3079 let path = dir.path().join("autumn.toml");
3080 std::fs::write(
3081 &path,
3082 r#"
3083[server]
3084port = 8080
3085host = "0.0.0.0"
3086shutdown_timeout_secs = 60
3087
3088[database]
3089url = "postgres://user:pass@db:5432/myapp"
3090pool_size = 20
3091connect_timeout_secs = 10
3092auto_migrate_in_production = true
3093
3094[log]
3095level = "debug"
3096format = "Json"
3097
3098[health]
3099path = "/healthz"
3100"#,
3101 )
3102 .unwrap();
3103
3104 let config = AutumnConfig::load_from(&path).unwrap();
3105 assert_eq!(config.server.port, 8080);
3106 assert_eq!(config.server.host, "0.0.0.0");
3107 assert_eq!(config.server.shutdown_timeout_secs, 60);
3108 assert_eq!(
3109 config.database.url.as_deref(),
3110 Some("postgres://user:pass@db:5432/myapp")
3111 );
3112 assert_eq!(config.database.pool_size, 20);
3113 assert_eq!(config.database.connect_timeout_secs, 10);
3114 assert!(config.database.auto_migrate_in_production);
3115 assert_eq!(config.log.level, "debug");
3116 assert_eq!(config.log.format, LogFormat::Json);
3117 assert_eq!(config.health.path, "/healthz");
3118 }
3119
3120 #[test]
3121 fn load_partial_config_merges_with_defaults() {
3122 let dir = tempfile::tempdir().unwrap();
3123 let path = dir.path().join("autumn.toml");
3124 std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
3125
3126 let config = AutumnConfig::load_from(&path).unwrap();
3127 assert_eq!(config.server.port, 9090);
3128 assert_eq!(config.server.host, "127.0.0.1");
3129 assert_eq!(config.database.pool_size, 10);
3130 assert_eq!(config.log.level, "info");
3131 }
3132
3133 #[test]
3134 fn load_invalid_toml_returns_error() {
3135 let dir = tempfile::tempdir().unwrap();
3136 let path = dir.path().join("autumn.toml");
3137 std::fs::write(&path, "not valid [[[toml").unwrap();
3138
3139 let result = AutumnConfig::load_from(&path);
3140 assert!(result.is_err());
3141 let err = result.unwrap_err();
3142 assert!(err.to_string().contains("invalid autumn.toml"));
3143 }
3144
3145 #[test]
3146 fn load_empty_file_returns_defaults() {
3147 let dir = tempfile::tempdir().unwrap();
3148 let path = dir.path().join("autumn.toml");
3149 std::fs::write(&path, "").unwrap();
3150
3151 let config = AutumnConfig::load_from(&path).unwrap();
3152 assert_eq!(config.server.port, 3000);
3153 }
3154
3155 #[test]
3158 fn env_override_database_url() {
3159 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
3160 let mut config = AutumnConfig::default();
3161 config.apply_env_overrides_with_env(&env);
3162 assert_eq!(
3163 config.database.url.as_deref(),
3164 Some("postgres://override:5432/test")
3165 );
3166 }
3167
3168 #[test]
3169 fn env_override_database_url_wins_over_file_primary_url() {
3170 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
3171 let mut config = AutumnConfig::default();
3172 config.database.primary_url = Some("postgres://file.example/app".to_owned());
3173
3174 config.apply_env_overrides_with_env(&env);
3175
3176 assert_eq!(
3177 config.database.effective_primary_url(),
3178 Some("postgres://env.example/app")
3179 );
3180 assert!(config.database.primary_url.is_none());
3181 }
3182
3183 #[test]
3184 fn env_override_database_primary_url_wins_over_legacy_database_url() {
3185 let env = MockEnv::new()
3186 .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
3187 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
3188 let mut config = AutumnConfig::default();
3189 config.database.primary_url = Some("postgres://file.example/app".to_owned());
3190
3191 config.apply_env_overrides_with_env(&env);
3192
3193 assert_eq!(
3194 config.database.effective_primary_url(),
3195 Some("postgres://primary.env/app")
3196 );
3197 assert_eq!(
3198 config.database.url.as_deref(),
3199 Some("postgres://legacy.env/app")
3200 );
3201 }
3202
3203 #[test]
3204 fn env_override_pool_size() {
3205 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
3206 let mut config = AutumnConfig::default();
3207 config.apply_env_overrides_with_env(&env);
3208 assert_eq!(config.database.pool_size, 25);
3209 }
3210
3211 #[test]
3212 fn env_override_connect_timeout() {
3213 let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
3214 let mut config = AutumnConfig::default();
3215 config.apply_env_overrides_with_env(&env);
3216 assert_eq!(config.database.connect_timeout_secs, 15);
3217 }
3218
3219 #[test]
3220 fn env_override_invalid_pool_size_ignored() {
3221 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
3222 let mut config = AutumnConfig::default();
3223 config.apply_env_overrides_with_env(&env);
3224 assert_eq!(config.database.pool_size, 10);
3225 }
3226
3227 #[cfg(feature = "storage")]
3228 #[test]
3229 fn env_override_storage_fields() {
3230 let env = MockEnv::new()
3231 .with("AUTUMN_STORAGE__BACKEND", "s3")
3232 .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
3233 .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
3234 .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
3235 .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
3236 .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
3237 .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
3238 .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
3239 .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
3240 .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
3241 .with(
3242 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
3243 "https://cdn.example.test",
3244 )
3245 .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
3246 .with(
3247 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
3248 "AWS_SECRET_ACCESS_KEY",
3249 )
3250 .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
3251 .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99");
3252 let mut config = AutumnConfig::default();
3253
3254 config.apply_env_overrides_with_env(&env);
3255
3256 assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
3257 assert_eq!(config.storage.default_provider, "media");
3258 assert!(config.storage.allow_local_in_production);
3259 assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
3260 assert_eq!(config.storage.local.mount_path, "/files");
3261 assert_eq!(config.storage.local.default_url_expiry_secs, 42);
3262 assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
3263 assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
3264 assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
3265 assert_eq!(
3266 config.storage.s3.endpoint.as_deref(),
3267 Some("https://s3.example.test")
3268 );
3269 assert_eq!(
3270 config.storage.s3.public_base_url.as_deref(),
3271 Some("https://cdn.example.test")
3272 );
3273 assert_eq!(
3274 config.storage.s3.access_key_id_env.as_deref(),
3275 Some("AWS_ACCESS_KEY_ID")
3276 );
3277 assert_eq!(
3278 config.storage.s3.secret_access_key_env.as_deref(),
3279 Some("AWS_SECRET_ACCESS_KEY")
3280 );
3281 assert!(config.storage.s3.force_path_style);
3282 assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
3283 }
3284
3285 #[test]
3286 fn env_override_database_auto_migrate_in_production() {
3287 let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
3288 let mut config = AutumnConfig::default();
3289 config.apply_env_overrides_with_env(&env);
3290 assert!(config.database.auto_migrate_in_production);
3291 }
3292
3293 #[test]
3294 fn env_override_jobs_fields() {
3295 let env = MockEnv::new()
3296 .with("AUTUMN_JOBS__BACKEND", "redis")
3297 .with("AUTUMN_JOBS__WORKERS", "8")
3298 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
3299 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
3300 .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
3301 .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
3302 .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
3303 let mut config = AutumnConfig::default();
3304 config.apply_env_overrides_with_env(&env);
3305
3306 assert_eq!(config.jobs.backend, "redis");
3307 assert_eq!(config.jobs.workers, 8);
3308 assert_eq!(config.jobs.max_attempts, 12);
3309 assert_eq!(config.jobs.initial_backoff_ms, 750);
3310 assert_eq!(
3311 config.jobs.redis.url.as_deref(),
3312 Some("redis://jobs:6379/2")
3313 );
3314 assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
3315 assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
3316 }
3317
3318 #[test]
3319 fn jobs_toml_deserializes_redis_visibility_timeout() {
3320 let config: AutumnConfig = toml::from_str(
3321 r#"
3322 [jobs]
3323 backend = "redis"
3324
3325 [jobs.redis]
3326 url = "redis://localhost:6379/5"
3327 key_prefix = "demo:jobs"
3328 visibility_timeout_ms = 15000
3329 "#,
3330 )
3331 .unwrap();
3332
3333 assert_eq!(config.jobs.backend, "redis");
3334 assert_eq!(
3335 config.jobs.redis.url.as_deref(),
3336 Some("redis://localhost:6379/5")
3337 );
3338 assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
3339 assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
3340 }
3341
3342 #[test]
3343 fn channels_defaults_to_in_process_backend() {
3344 let config = AutumnConfig::default();
3345
3346 assert_eq!(config.channels.backend, ChannelBackend::InProcess);
3347 assert_eq!(config.channels.capacity, 32);
3348 assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
3349 assert!(config.channels.redis.url.is_none());
3350 }
3351
3352 #[test]
3353 fn channels_env_overrides_fields() {
3354 let env = MockEnv::new()
3355 .with("AUTUMN_CHANNELS__BACKEND", "redis")
3356 .with("AUTUMN_CHANNELS__CAPACITY", "128")
3357 .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
3358 .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
3359 let mut config = AutumnConfig::default();
3360
3361 config.apply_env_overrides_with_env(&env);
3362
3363 assert_eq!(config.channels.backend, ChannelBackend::Redis);
3364 assert_eq!(config.channels.capacity, 128);
3365 assert_eq!(
3366 config.channels.redis.url.as_deref(),
3367 Some("redis://channels:6379/4")
3368 );
3369 assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
3370 }
3371
3372 #[test]
3373 fn channels_toml_deserializes_redis_backend() {
3374 let config: AutumnConfig = toml::from_str(
3375 r#"
3376 [channels]
3377 backend = "redis"
3378 capacity = 64
3379
3380 [channels.redis]
3381 url = "redis://localhost:6379/5"
3382 key_prefix = "demo:channels"
3383 "#,
3384 )
3385 .unwrap();
3386
3387 assert_eq!(config.channels.backend, ChannelBackend::Redis);
3388 assert_eq!(config.channels.capacity, 64);
3389 assert_eq!(
3390 config.channels.redis.url.as_deref(),
3391 Some("redis://localhost:6379/5")
3392 );
3393 assert_eq!(config.channels.redis.key_prefix, "demo:channels");
3394 }
3395
3396 #[test]
3397 fn env_override_invalid_jobs_numeric_values_ignored() {
3398 let env = MockEnv::new()
3399 .with("AUTUMN_JOBS__WORKERS", "many")
3400 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
3401 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
3402 let mut config = AutumnConfig::default();
3403 config.apply_env_overrides_with_env(&env);
3404
3405 assert_eq!(config.jobs.workers, 1);
3406 assert_eq!(config.jobs.max_attempts, 5);
3407 assert_eq!(config.jobs.initial_backoff_ms, 250);
3408 }
3409
3410 #[test]
3413 fn env_override_server_port() {
3414 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
3415 let mut config = AutumnConfig::default();
3416 config.apply_env_overrides_with_env(&env);
3417 assert_eq!(config.server.port, 8080);
3418 }
3419
3420 #[test]
3421 fn parse_env_works() {
3422 let env = MockEnv::new().with("SOME_NUM", "123");
3423 let mut target: u32 = 0;
3424 parse_env(&env, "SOME_NUM", &mut target);
3425 assert_eq!(target, 123);
3426
3427 let env_err = MockEnv::new().with("SOME_NUM", "abc");
3428 let mut target_err: u32 = 0;
3429 parse_env(&env_err, "SOME_NUM", &mut target_err);
3430 assert_eq!(target_err, 0); }
3432
3433 #[test]
3434 fn parse_env_option_string_works() {
3435 let env = MockEnv::new().with("SOME_OPT", "val");
3436 let mut target = None;
3437 parse_env_option_string(&env, "SOME_OPT", &mut target);
3438 assert_eq!(target, Some("val".to_string()));
3439
3440 let env_empty = MockEnv::new().with("SOME_OPT", "");
3441 let mut target_empty = Some("old".to_string());
3442 parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
3443 assert_eq!(target_empty, None);
3444 }
3445
3446 #[test]
3447 fn parse_env_string_works() {
3448 let env = MockEnv::new().with("SOME_STR", "val");
3449 let mut target = "old".to_string();
3450 parse_env_string(&env, "SOME_STR", &mut target);
3451 assert_eq!(target, "val");
3452 }
3453
3454 #[test]
3455 fn parse_env_bool_works() {
3456 let env = MockEnv::new().with("SOME_BOOL", "true");
3457 let mut target = false;
3458 parse_env_bool(&env, "SOME_BOOL", &mut target);
3459 assert!(target);
3460
3461 let env2 = MockEnv::new().with("SOME_BOOL", "1");
3462 let mut target2 = false;
3463 parse_env_bool(&env2, "SOME_BOOL", &mut target2);
3464 assert!(target2);
3465
3466 let env3 = MockEnv::new().with("SOME_BOOL", "0");
3467 let mut target3 = true;
3468 parse_env_bool(&env3, "SOME_BOOL", &mut target3);
3469 assert!(!target3);
3470
3471 let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
3472 let mut target_err = true;
3473 parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
3474 assert!(target_err); }
3476
3477 #[test]
3478 fn parse_env_csv_works() {
3479 let env = MockEnv::new().with("SOME_CSV", "a, b,c");
3480 let mut target = vec![];
3481 parse_env_csv(&env, "SOME_CSV", &mut target);
3482 assert_eq!(target, vec!["a", "b", "c"]);
3483 }
3484
3485 #[test]
3486 fn env_override_rate_limit_trusted_proxies() {
3487 let env = MockEnv::new().with(
3488 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
3489 "10.0.0.10, 203.0.113.0/24",
3490 );
3491 let mut config = AutumnConfig::default();
3492 config.apply_env_overrides_with_env(&env);
3493 assert_eq!(
3494 config.security.rate_limit.trusted_proxies,
3495 vec!["10.0.0.10", "203.0.113.0/24"]
3496 );
3497 }
3498
3499 #[test]
3500 fn env_override_server_host() {
3501 let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
3502 let mut config = AutumnConfig::default();
3503 config.apply_env_overrides_with_env(&env);
3504 assert_eq!(config.server.host, "0.0.0.0");
3505 }
3506
3507 #[test]
3508 fn env_override_server_shutdown_timeout() {
3509 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
3510 let mut config = AutumnConfig::default();
3511 config.apply_env_overrides_with_env(&env);
3512 assert_eq!(config.server.shutdown_timeout_secs, 60);
3513 }
3514
3515 #[test]
3516 fn env_override_invalid_server_port_ignored() {
3517 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
3518 let mut config = AutumnConfig::default();
3519 config.apply_env_overrides_with_env(&env);
3520 assert_eq!(config.server.port, 3000);
3521 }
3522
3523 #[test]
3524 fn env_override_invalid_shutdown_timeout_ignored() {
3525 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
3526 let mut config = AutumnConfig::default();
3527 config.apply_env_overrides_with_env(&env);
3528 assert_eq!(config.server.shutdown_timeout_secs, 30);
3529 }
3530
3531 #[test]
3534 fn env_override_log_level() {
3535 let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
3536 let mut config = AutumnConfig::default();
3537 config.apply_env_overrides_with_env(&env);
3538 assert_eq!(config.log.level, "debug");
3539 }
3540
3541 #[test]
3542 fn env_override_log_format_json() {
3543 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
3544 let mut config = AutumnConfig::default();
3545 config.apply_env_overrides_with_env(&env);
3546 assert_eq!(config.log.format, LogFormat::Json);
3547 }
3548
3549 #[test]
3550 fn env_override_log_format_pretty() {
3551 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
3552 let mut config = AutumnConfig::default();
3553 config.apply_env_overrides_with_env(&env);
3554 assert_eq!(config.log.format, LogFormat::Pretty);
3555 }
3556
3557 #[test]
3558 fn env_override_invalid_log_format_ignored() {
3559 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
3560 let mut config = AutumnConfig::default();
3561 config.apply_env_overrides_with_env(&env);
3562 assert_eq!(config.log.format, LogFormat::Auto);
3563 }
3564
3565 #[test]
3568 fn env_override_telemetry_fields() {
3569 let env = MockEnv::new()
3570 .with("AUTUMN_TELEMETRY__ENABLED", "true")
3571 .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
3572 .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
3573 .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
3574 .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
3575 .with(
3576 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
3577 "http://otel-collector:4317",
3578 )
3579 .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
3580 .with("AUTUMN_TELEMETRY__STRICT", "true");
3581 let mut config = AutumnConfig::default();
3582 config.apply_env_overrides_with_env(&env);
3583 assert!(config.telemetry.enabled);
3584 assert_eq!(config.telemetry.service_name, "orders-api");
3585 assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
3586 assert_eq!(config.telemetry.service_version, "1.2.3");
3587 assert_eq!(config.telemetry.environment, "production");
3588 assert_eq!(
3589 config.telemetry.otlp_endpoint.as_deref(),
3590 Some("http://otel-collector:4317")
3591 );
3592 assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
3593 assert!(config.telemetry.strict);
3594 }
3595
3596 #[test]
3597 fn env_override_invalid_telemetry_protocol_ignored() {
3598 let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
3599 let mut config = AutumnConfig::default();
3600 config.apply_env_overrides_with_env(&env);
3601 assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
3602 }
3603
3604 #[test]
3605 fn env_override_health_path() {
3606 let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
3607 let mut config = AutumnConfig::default();
3608 config.apply_env_overrides_with_env(&env);
3609 assert_eq!(config.health.path, "/healthz");
3610 }
3611
3612 #[test]
3613 fn env_override_probe_paths() {
3614 let env = MockEnv::new()
3615 .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
3616 .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
3617 .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
3618 let mut config = AutumnConfig::default();
3619 config.apply_env_overrides_with_env(&env);
3620 assert_eq!(config.health.live_path, "/livez");
3621 assert_eq!(config.health.ready_path, "/readyz");
3622 assert_eq!(config.health.startup_path, "/startupz");
3623 }
3624
3625 #[test]
3628 fn env_overrides_toml_values() {
3629 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
3630 let dir = tempfile::tempdir().unwrap();
3631 let path = dir.path().join("autumn.toml");
3632 std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
3633 let mut config = AutumnConfig::load_from(&path).unwrap();
3634 config.apply_env_overrides_with_env(&env);
3635 assert_eq!(config.server.port, 9999); }
3637
3638 #[test]
3641 fn validate_rejects_invalid_url_scheme() {
3642 let config = DatabaseConfig {
3643 url: Some("mysql://localhost/test".to_owned()),
3644 ..Default::default()
3645 };
3646 let result = config.validate();
3647 assert!(result.is_err());
3648 assert!(
3649 result
3650 .unwrap_err()
3651 .to_string()
3652 .contains("must start with postgres://")
3653 );
3654 }
3655
3656 #[test]
3657 fn validate_accepts_postgres_url() {
3658 let config = DatabaseConfig {
3659 url: Some("postgres://localhost/test".to_owned()),
3660 ..Default::default()
3661 };
3662 assert!(config.validate().is_ok());
3663 }
3664
3665 #[test]
3666 fn validate_accepts_postgresql_url() {
3667 let config = DatabaseConfig {
3668 url: Some("postgresql://localhost/test".to_owned()),
3669 ..Default::default()
3670 };
3671 assert!(config.validate().is_ok());
3672 }
3673
3674 #[test]
3675 fn validate_accepts_no_url() {
3676 let config = DatabaseConfig::default();
3677 assert!(config.validate().is_ok());
3678 }
3679
3680 #[test]
3683 fn resolve_profile_from_autumn_env() {
3684 let env = MockEnv::new().with("AUTUMN_ENV", "prod");
3685 let profile = resolve_profile(&env);
3686 assert_eq!(profile, "prod");
3687 }
3688
3689 #[test]
3690 fn resolve_profile_from_legacy_env() {
3691 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
3692 let profile = resolve_profile(&env);
3693 assert_eq!(profile, "staging");
3694 }
3695
3696 #[test]
3697 fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
3698 let env = MockEnv::new()
3699 .with("AUTUMN_ENV", "dev")
3700 .with("AUTUMN_PROFILE", "prod");
3701 let profile = resolve_profile(&env);
3702 assert_eq!(profile, "dev");
3703 }
3704
3705 #[test]
3706 fn resolve_profile_normalizes_production_alias() {
3707 let env = MockEnv::new().with("AUTUMN_ENV", "production");
3708 let profile = resolve_profile(&env);
3709 assert_eq!(profile, "prod");
3710 }
3711
3712 #[test]
3713 fn resolve_profile_normalizes_development_alias_with_whitespace() {
3714 let env = MockEnv::new().with("AUTUMN_ENV", " development ");
3715 let profile = resolve_profile(&env);
3716 assert_eq!(profile, "dev");
3717 }
3718
3719 #[test]
3720 fn resolve_profile_normalizes_uppercase_dev_and_prod() {
3721 let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
3722 let prod = resolve_profile(&prod_env);
3723 assert_eq!(prod, "prod");
3724
3725 let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
3726 let dev = resolve_profile(&dev_env);
3727 assert_eq!(dev, "dev");
3728 }
3729
3730 #[test]
3731 fn resolve_profile_preserves_case_for_custom_profiles() {
3732 let env = MockEnv::new().with("AUTUMN_ENV", "QA");
3733 let profile = resolve_profile(&env);
3734 assert_eq!(profile, "QA");
3735 }
3736
3737 #[test]
3738 fn resolve_profile_auto_detect_debug() {
3739 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
3740 let profile = resolve_profile(&env);
3741 assert_eq!(profile, "dev");
3742 }
3743
3744 #[test]
3745 fn resolve_profile_auto_detect_release() {
3746 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
3747 let profile = resolve_profile(&env);
3748 assert_eq!(profile, "prod");
3749 }
3750
3751 #[test]
3752 fn resolve_profile_defaults_to_dev_when_no_signal_present() {
3753 let env = MockEnv::new();
3754 let profile = resolve_profile(&env);
3755 assert_eq!(profile, "dev");
3756 }
3757
3758 #[test]
3759 fn dev_profile_smart_defaults() {
3760 let defaults = profile_defaults_as_toml("dev");
3761 let toml_str = toml::to_string(&defaults).unwrap();
3762 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3763
3764 assert_eq!(config.log.level, "debug");
3765 assert_eq!(config.log.format, LogFormat::Pretty);
3766 assert_eq!(config.server.host, "127.0.0.1");
3767 assert_eq!(config.server.shutdown_timeout_secs, 1);
3768 assert_eq!(config.telemetry.environment, "development");
3769 assert!(config.health.detailed);
3770 assert_eq!(config.cors.allowed_origins, vec!["*"]);
3771 }
3772
3773 #[test]
3774 fn prod_profile_smart_defaults() {
3775 let defaults = profile_defaults_as_toml("prod");
3776 let toml_str = toml::to_string(&defaults).unwrap();
3777 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3778
3779 assert_eq!(config.log.level, "info");
3780 assert_eq!(config.log.format, LogFormat::Json);
3781 assert_eq!(config.server.host, "0.0.0.0");
3782 assert_eq!(config.server.shutdown_timeout_secs, 30);
3783 assert_eq!(config.telemetry.environment, "production");
3784 assert!(!config.health.detailed);
3785 assert!(
3787 config.security.headers.strict_transport_security,
3788 "prod profile must auto-enable Strict-Transport-Security"
3789 );
3790 assert_eq!(config.security.headers.x_frame_options, "DENY");
3792 assert!(config.security.headers.x_content_type_options);
3793 assert!(!config.security.headers.content_security_policy.is_empty());
3794 }
3795
3796 #[test]
3797 fn dev_profile_does_not_auto_enable_hsts() {
3798 let defaults = profile_defaults_as_toml("dev");
3799 let toml_str = toml::to_string(&defaults).unwrap();
3800 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3801
3802 assert!(
3803 !config.security.headers.strict_transport_security,
3804 "dev profile must not force HSTS on (local http development)"
3805 );
3806 }
3807
3808 #[test]
3809 fn custom_profile_no_smart_defaults() {
3810 let defaults = profile_defaults_as_toml("staging");
3811 assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
3812 }
3813
3814 #[test]
3815 fn deep_merge_tables() {
3816 let mut base: toml::Value = toml::from_str(
3817 r#"
3818 [server]
3819 port = 3000
3820 host = "127.0.0.1"
3821 [database]
3822 pool_size = 10
3823 "#,
3824 )
3825 .unwrap();
3826
3827 let overlay: toml::Value = toml::from_str(
3828 r#"
3829 [server]
3830 port = 8080
3831 [database]
3832 url = "postgres://localhost/test"
3833 "#,
3834 )
3835 .unwrap();
3836
3837 deep_merge(&mut base, overlay);
3838
3839 assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
3841 assert_eq!(
3843 base["server"]["host"],
3844 toml::Value::String("127.0.0.1".into())
3845 );
3846 assert_eq!(
3848 base["database"]["url"],
3849 toml::Value::String("postgres://localhost/test".into())
3850 );
3851 assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
3853 }
3854
3855 #[test]
3856 fn profile_toml_overrides_base_toml() {
3857 let dir = tempfile::tempdir().unwrap();
3858 let base_path = dir.path().join("autumn.toml");
3859 let dev_path = dir.path().join("autumn-dev.toml");
3860
3861 std::fs::write(
3862 &base_path,
3863 r"
3864 [server]
3865 port = 3000
3866 [database]
3867 pool_size = 10
3868 ",
3869 )
3870 .unwrap();
3871
3872 std::fs::write(
3873 &dev_path,
3874 r#"
3875 [database]
3876 url = "postgres://localhost/myapp_dev"
3877 "#,
3878 )
3879 .unwrap();
3880
3881 let mut merged = toml::Value::Table(toml::map::Map::new());
3883 let base = load_raw_toml(&base_path).unwrap().unwrap();
3884 deep_merge(&mut merged, base);
3885 let profile = load_raw_toml(&dev_path).unwrap().unwrap();
3886 deep_merge(&mut merged, profile);
3887
3888 let toml_str = toml::to_string(&merged).unwrap();
3889 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3890
3891 assert_eq!(config.server.port, 3000); assert_eq!(config.database.pool_size, 10); assert_eq!(
3894 config.database.url.as_deref(),
3895 Some("postgres://localhost/myapp_dev")
3896 ); }
3898
3899 #[test]
3900 fn inline_profile_section_overrides_base_toml() {
3901 let mut merged = toml::Value::Table(toml::map::Map::new());
3902 let base: toml::Value = toml::from_str(
3903 r#"
3904 [server]
3905 port = 3000
3906
3907 [log]
3908 level = "info"
3909
3910 [profile.dev.log]
3911 level = "debug"
3912 "#,
3913 )
3914 .unwrap();
3915
3916 deep_merge(&mut merged, base.clone());
3917 let inline = profile_section_from_base_toml(&base, "dev").unwrap();
3918 deep_merge(&mut merged, inline);
3919
3920 let toml_str = toml::to_string(&merged).unwrap();
3921 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3922 assert_eq!(config.server.port, 3000);
3923 assert_eq!(config.log.level, "debug");
3924 }
3925
3926 #[test]
3927 fn levenshtein_basic() {
3928 assert_eq!(levenshtein("dev", "dev"), 0);
3929 assert_eq!(levenshtein("dev", "dve"), 2); assert_eq!(levenshtein("prod", "prodd"), 1);
3931 assert_eq!(levenshtein("prod", "prd"), 1);
3932 assert_eq!(levenshtein("staging", "dev"), 7);
3933 }
3934
3935 #[test]
3936 fn env_override_health_detailed() {
3937 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
3938 let mut config = AutumnConfig::default();
3939 config.apply_env_overrides_with_env(&env);
3940 assert!(config.health.detailed);
3941 }
3942
3943 #[test]
3944 fn profile_name_accessor() {
3945 let mut config = AutumnConfig::default();
3946 assert!(config.profile_name().is_none());
3947
3948 config.profile = Some("dev".to_owned());
3949 assert_eq!(config.profile_name(), Some("dev"));
3950 }
3951
3952 #[test]
3955 fn find_config_file_falls_back_to_cwd() {
3956 let env = MockEnv::new();
3958 let path = find_config_file_named("autumn.toml", &env);
3959 assert_eq!(path, PathBuf::from("autumn.toml"));
3960 }
3961
3962 #[test]
3963 fn find_config_file_uses_manifest_dir_when_file_exists() {
3964 let dir = tempfile::tempdir().unwrap();
3965 let config_path = dir.path().join("autumn.toml");
3966 std::fs::write(&config_path, "").unwrap();
3967
3968 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
3969 let path = find_config_file_named("autumn.toml", &env);
3970 assert_eq!(path, config_path);
3971 }
3972
3973 #[test]
3974 fn find_config_file_falls_back_when_manifest_dir_missing_file() {
3975 let dir = tempfile::tempdir().unwrap();
3976 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
3978 let path = find_config_file_named("nonexistent.toml", &env);
3979 assert_eq!(path, PathBuf::from("nonexistent.toml"));
3980 }
3981
3982 #[test]
3983 fn resolve_profile_cli_flag_exact_match() {
3984 let env = MockEnv::new();
3989 let profile = resolve_profile(&env);
3991 drop(profile);
3995 }
3996
3997 #[test]
3998 fn deep_merge_non_table_overlay_replaces_base() {
3999 let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
4002 let overlay = toml::Value::String("not_a_table".into());
4003
4004 deep_merge(&mut base, overlay);
4007 assert!(base.is_table());
4009 assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
4010 }
4011
4012 #[test]
4013 fn deep_merge_when_base_not_table() {
4014 let mut base = toml::Value::String("original".into());
4016 let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
4017
4018 deep_merge(&mut base, overlay);
4019 assert_eq!(base, toml::Value::String("original".into()));
4021 }
4022
4023 #[test]
4024 fn suggest_profile_close_match() {
4025 assert_eq!(suggest_profile("dve"), Some("dev"));
4027 }
4028
4029 #[test]
4030 fn suggest_profile_no_match_when_distant() {
4031 assert_eq!(suggest_profile("xyz"), None);
4033 }
4034
4035 #[test]
4036 fn suggest_profile_exact_known_profile() {
4037 assert_eq!(suggest_profile("dev"), Some("dev"));
4039 assert_eq!(suggest_profile("prod"), Some("prod"));
4040 }
4041
4042 #[test]
4043 fn suggest_profile_prd() {
4044 assert_eq!(suggest_profile("prd"), Some("prod"));
4046 }
4047
4048 #[test]
4049 fn warn_profile_typo_runs_without_panic() {
4050 warn_profile_typo("dve");
4051 warn_profile_typo("xyz");
4052 }
4053
4054 #[test]
4055 fn should_warn_missing_profile_file_custom_without_inline() {
4056 assert!(should_warn_missing_profile_file("staging", false));
4057 }
4058
4059 #[test]
4060 fn should_not_warn_missing_profile_file_custom_with_inline() {
4061 assert!(!should_warn_missing_profile_file("staging", true));
4062 }
4063
4064 #[test]
4065 fn should_not_warn_missing_profile_file_dev_or_prod() {
4066 assert!(!should_warn_missing_profile_file("dev", false));
4067 assert!(!should_warn_missing_profile_file("prod", false));
4068 }
4069
4070 #[test]
4071 fn levenshtein_threshold_in_warn_profile_typo() {
4072 assert!(levenshtein("dve", "dev") <= 2);
4073 assert!(levenshtein("xyz", "dev") > 2);
4074 assert!(levenshtein("xyz", "prod") > 2);
4075 }
4076
4077 #[test]
4078 fn env_override_cors_allowed_origins() {
4079 let env = MockEnv::new().with(
4080 "AUTUMN_CORS__ALLOWED_ORIGINS",
4081 "https://a.com, https://b.com",
4082 );
4083 let mut config = AutumnConfig::default();
4084 config.apply_env_overrides_with_env(&env);
4085 assert_eq!(
4086 config.cors.allowed_origins,
4087 vec!["https://a.com", "https://b.com"]
4088 );
4089 }
4090
4091 #[test]
4092 fn env_override_cors_allow_credentials() {
4093 let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
4094 let mut config = AutumnConfig::default();
4095 config.apply_env_overrides_with_env(&env);
4096 assert!(config.cors.allow_credentials);
4097 }
4098
4099 #[test]
4100 fn env_override_cors_max_age() {
4101 let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
4102 let mut config = AutumnConfig::default();
4103 config.apply_env_overrides_with_env(&env);
4104 assert_eq!(config.cors.max_age_secs, 3600);
4105 }
4106
4107 #[test]
4108 fn cors_validate_rejects_wildcard_with_credentials() {
4109 let mut config = AutumnConfig::default();
4110 config.cors.allowed_origins = vec!["*".to_owned()];
4111 config.cors.allow_credentials = true;
4112
4113 let result = config.validate();
4114 match result {
4115 Err(ConfigError::Validation(msg)) => {
4116 assert!(
4117 msg.contains("allow_credentials") && msg.contains('*'),
4118 "message should mention credentials and wildcard, got: {msg}"
4119 );
4120 }
4121 other => panic!("expected ConfigError::Validation, got {other:?}"),
4122 }
4123 }
4124
4125 #[test]
4126 fn cors_validate_accepts_wildcard_without_credentials() {
4127 let mut config = AutumnConfig::default();
4128 config.cors.allowed_origins = vec!["*".to_owned()];
4129 config.cors.allow_credentials = false;
4130 assert!(config.validate().is_ok());
4131 }
4132
4133 #[test]
4134 fn cors_validate_accepts_explicit_origins_with_credentials() {
4135 let mut config = AutumnConfig::default();
4136 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
4137 config.cors.allow_credentials = true;
4138 assert!(config.validate().is_ok());
4139 }
4140
4141 #[test]
4142 fn load_uses_profile_layering() {
4143 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
4146
4147 let config = AutumnConfig::load_with_env(&env).unwrap();
4148 assert_eq!(config.profile.as_deref(), Some("dev"));
4150 assert_eq!(config.log.level, "debug"); assert_eq!(config.log.format, LogFormat::Pretty); assert!(config.health.detailed); }
4154
4155 #[test]
4156 fn load_custom_profile_without_toml_warns() {
4157 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
4161
4162 let config = AutumnConfig::load_with_env(&env).unwrap();
4163 assert_eq!(config.profile.as_deref(), Some("staging"));
4164 assert_eq!(config.server.port, 3000);
4166 assert_eq!(config.log.level, "info");
4167 }
4168
4169 #[test]
4170 fn load_dev_profile_no_profile_toml_no_warn() {
4171 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
4174
4175 let config = AutumnConfig::load_with_env(&env).unwrap();
4176 assert_eq!(config.profile.as_deref(), Some("dev"));
4177 }
4178
4179 #[test]
4180 fn load_custom_profile_uses_inline_profile_without_legacy_file() {
4181 let dir = tempfile::tempdir().unwrap();
4182 let base_path = dir.path().join("autumn.toml");
4183 std::fs::write(
4184 &base_path,
4185 r"
4186 [server]
4187 port = 3000
4188
4189 [profile.staging.server]
4190 port = 4100
4191 ",
4192 )
4193 .unwrap();
4194
4195 let env = MockEnv::new()
4196 .with("AUTUMN_ENV", "staging")
4197 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4198
4199 let config = AutumnConfig::load_with_env(&env).unwrap();
4200 assert_eq!(config.profile.as_deref(), Some("staging"));
4201 assert_eq!(config.server.port, 4100);
4202 }
4203
4204 #[test]
4205 fn load_production_profile_reads_inline_profile_production_section() {
4206 let dir = tempfile::tempdir().unwrap();
4207 let base_path = dir.path().join("autumn.toml");
4208 std::fs::write(
4209 &base_path,
4210 r"
4211 [profile.production.server]
4212 port = 4200
4213 ",
4214 )
4215 .unwrap();
4216
4217 let env = MockEnv::new()
4218 .with("AUTUMN_ENV", "production")
4219 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4220
4221 let config = AutumnConfig::load_with_env(&env).unwrap();
4222 assert_eq!(config.profile.as_deref(), Some("prod"));
4223 assert_eq!(config.server.port, 4200);
4224 }
4225
4226 #[test]
4227 fn load_production_profile_reads_legacy_autumn_production_toml() {
4228 let dir = tempfile::tempdir().unwrap();
4229 let production_path = dir.path().join("autumn-production.toml");
4230 std::fs::write(
4231 &production_path,
4232 r"
4233 [server]
4234 port = 4300
4235 ",
4236 )
4237 .unwrap();
4238
4239 let env = MockEnv::new()
4240 .with("AUTUMN_ENV", "production")
4241 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4242
4243 let config = AutumnConfig::load_with_env(&env).unwrap();
4244 assert_eq!(config.profile.as_deref(), Some("prod"));
4245 assert_eq!(config.server.port, 4300);
4246 }
4247
4248 #[test]
4249 fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
4250 let dir = tempfile::tempdir().unwrap();
4251 let prod_path = dir.path().join("autumn-prod.toml");
4252 let production_path = dir.path().join("autumn-production.toml");
4253
4254 std::fs::write(
4255 &prod_path,
4256 r"
4257 [server]
4258 port = 4400
4259 ",
4260 )
4261 .unwrap();
4262 std::fs::write(&production_path, "[server\nport = 4500").unwrap();
4264
4265 let env = MockEnv::new()
4266 .with("AUTUMN_ENV", "prod")
4267 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4268
4269 let config = AutumnConfig::load_with_env(&env).unwrap();
4270 assert_eq!(config.profile.as_deref(), Some("prod"));
4271 assert_eq!(config.server.port, 4400);
4272 }
4273
4274 #[test]
4275 fn load_production_prefers_autumn_production_toml_before_prod_alias() {
4276 let dir = tempfile::tempdir().unwrap();
4277 let prod_path = dir.path().join("autumn-prod.toml");
4278 let production_path = dir.path().join("autumn-production.toml");
4279
4280 std::fs::write(
4281 &production_path,
4282 r"
4283 [server]
4284 port = 4500
4285 ",
4286 )
4287 .unwrap();
4288 std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
4290
4291 let env = MockEnv::new()
4292 .with("AUTUMN_ENV", "production")
4293 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4294
4295 let config = AutumnConfig::load_with_env(&env).unwrap();
4296 assert_eq!(config.profile.as_deref(), Some("prod"));
4297 assert_eq!(config.server.port, 4500);
4298 }
4299
4300 #[test]
4301 fn load_from_io_error_is_not_swallowed() {
4302 let dir = tempfile::tempdir().unwrap();
4305 let result = AutumnConfig::load_from(dir.path());
4306 assert!(result.is_err());
4307 }
4308
4309 #[test]
4310 fn load_raw_toml_missing_file_returns_none() {
4311 let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
4312 assert!(result.is_none());
4313 }
4314
4315 #[test]
4316 fn load_raw_toml_directory_returns_io_error() {
4317 let dir = tempfile::tempdir().unwrap();
4321 let result = load_raw_toml(dir.path());
4322 assert!(result.is_err());
4323 }
4324
4325 #[test]
4326 fn load_raw_toml_valid_file_returns_some() {
4327 let dir = tempfile::tempdir().unwrap();
4328 let path = dir.path().join("test.toml");
4329 std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
4330 let result = load_raw_toml(&path).unwrap();
4331 assert!(result.is_some());
4332 assert_eq!(
4333 result.unwrap()["server"]["port"],
4334 toml::Value::Integer(3000)
4335 );
4336 }
4337
4338 #[test]
4339 fn env_override_log_format_auto() {
4340 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
4342 let mut config = AutumnConfig::default();
4343 config.log.format = LogFormat::Json;
4345 config.apply_env_overrides_with_env(&env);
4346 assert_eq!(config.log.format, LogFormat::Auto);
4347 }
4348
4349 #[test]
4350 fn env_override_health_detailed_false() {
4351 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
4353 let mut config = AutumnConfig::default();
4354 config.health.detailed = true; config.apply_env_overrides_with_env(&env);
4356 assert!(!config.health.detailed);
4357 }
4358
4359 #[test]
4360 fn env_override_health_detailed_zero() {
4361 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
4362 let mut config = AutumnConfig::default();
4363 config.health.detailed = true;
4364 config.apply_env_overrides_with_env(&env);
4365 assert!(!config.health.detailed);
4366 }
4367
4368 #[test]
4369 fn cors_defaults() {
4370 let cors = CorsConfig::default();
4371 assert!(cors.allowed_origins.is_empty());
4372 assert_eq!(cors.allowed_methods.len(), 6);
4373 assert!(cors.allowed_methods.contains(&"GET".to_owned()));
4374 assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
4375 assert!(!cors.allow_credentials);
4376 assert_eq!(cors.max_age_secs, 86400);
4377 }
4378
4379 #[test]
4380 fn cors_in_full_config_defaults() {
4381 let config = AutumnConfig::default();
4382 assert!(config.cors.allowed_origins.is_empty());
4383 }
4384
4385 #[test]
4386 fn actuator_defaults() {
4387 let config = ActuatorConfig::default();
4388 assert_eq!(config.prefix, "/actuator");
4389 assert!(!config.sensitive);
4390 }
4391
4392 #[test]
4393 fn actuator_prefix_in_full_config() {
4394 let config = AutumnConfig::default();
4395 assert_eq!(config.actuator.prefix, "/actuator");
4396 }
4397
4398 #[test]
4399 fn deep_merge_handles_deep_nesting() {
4400 let mut base = toml::Value::Table(toml::map::Map::new());
4401 let mut overlay = toml::Value::Table(toml::map::Map::new());
4402
4403 let mut current_base = &mut base;
4405 let mut current_overlay = &mut overlay;
4406
4407 for _ in 0..10_000 {
4408 if let toml::Value::Table(t) = current_base {
4409 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4410 current_base = t.get_mut("x").unwrap();
4411 }
4412 if let toml::Value::Table(t) = current_overlay {
4413 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4414 current_overlay = t.get_mut("x").unwrap();
4415 }
4416 }
4417
4418 if let toml::Value::Table(t) = current_overlay {
4420 t.insert("y".to_owned(), toml::Value::Integer(42));
4421 }
4422
4423 std::thread::Builder::new()
4426 .stack_size(32 * 1024 * 1024)
4427 .spawn(move || {
4428 deep_merge(&mut base, overlay);
4429 std::mem::forget(base);
4431 })
4432 .unwrap()
4433 .join()
4434 .unwrap();
4435 }
4436
4437 #[test]
4438 fn deep_merge_stops_at_max_depth() {
4439 let mut base = toml::Value::Table(toml::map::Map::new());
4440 let mut overlay = toml::Value::Table(toml::map::Map::new());
4441
4442 let mut current_base = &mut base;
4444 let mut current_overlay = &mut overlay;
4445
4446 for _ in 0..=MAX_MERGE_DEPTH {
4447 if let toml::Value::Table(t) = current_base {
4448 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4449 current_base = t.get_mut("x").unwrap();
4450 }
4451 if let toml::Value::Table(t) = current_overlay {
4452 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4453 current_overlay = t.get_mut("x").unwrap();
4454 }
4455 }
4456
4457 if let toml::Value::Table(t) = current_overlay {
4459 t.insert("deep_value".to_owned(), toml::Value::Integer(123));
4460 }
4461
4462 deep_merge(&mut base, overlay);
4463
4464 let mut current_base_check = &base;
4466 for _ in 0..=MAX_MERGE_DEPTH {
4467 if let toml::Value::Table(t) = current_base_check {
4468 current_base_check = t.get("x").unwrap();
4469 }
4470 }
4471
4472 if let toml::Value::Table(t) = current_base_check {
4473 assert!(
4474 !t.contains_key("deep_value"),
4475 "Value beyond MAX_MERGE_DEPTH should not be merged"
4476 );
4477 } else {
4478 panic!("Expected a table");
4479 }
4480 }
4481
4482 #[test]
4485 fn env_override_forbidden_response_403() {
4486 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
4487 let mut config = AutumnConfig::default();
4488 config.apply_env_overrides_with_env(&env);
4489 assert_eq!(
4490 config.security.forbidden_response,
4491 crate::authorization::ForbiddenResponse::Forbidden403
4492 );
4493 }
4494
4495 #[test]
4496 fn env_override_forbidden_response_404() {
4497 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
4498 let mut config = AutumnConfig::default();
4499 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
4501 config.apply_env_overrides_with_env(&env);
4502 assert_eq!(
4503 config.security.forbidden_response,
4504 crate::authorization::ForbiddenResponse::NotFound404
4505 );
4506 }
4507
4508 #[test]
4509 fn env_override_forbidden_response_invalid_keeps_existing() {
4510 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
4511 let mut config = AutumnConfig::default();
4512 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
4513 config.apply_env_overrides_with_env(&env);
4514 assert_eq!(
4516 config.security.forbidden_response,
4517 crate::authorization::ForbiddenResponse::Forbidden403
4518 );
4519 }
4520
4521 #[test]
4522 fn env_override_allow_unauthorized_repository_api() {
4523 let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
4524 let mut config = AutumnConfig::default();
4525 assert!(!config.security.allow_unauthorized_repository_api);
4526 config.apply_env_overrides_with_env(&env);
4527 assert!(config.security.allow_unauthorized_repository_api);
4528 }
4529
4530 #[test]
4531 fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
4532 let env = MockEnv::new().with(
4533 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
4534 "false",
4535 );
4536 let mut config = AutumnConfig::default();
4537 config.security.allow_unauthorized_repository_api = true;
4538 config.apply_env_overrides_with_env(&env);
4539 assert!(!config.security.allow_unauthorized_repository_api);
4540 }
4541
4542 #[test]
4545 fn openapi_runtime_config_defaults_enabled() {
4546 let config = AutumnConfig::default();
4548 assert!(
4549 config.openapi_runtime.enabled,
4550 "[openapi] must default to enabled = true"
4551 );
4552 assert_eq!(
4553 config.openapi_runtime.path, "/openapi.json",
4554 "[openapi] must default to path = \"/openapi.json\""
4555 );
4556 }
4557
4558 #[test]
4559 fn openapi_runtime_config_can_be_disabled_via_toml() {
4560 let toml_str = "
4561[openapi]
4562enabled = false
4563";
4564 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
4565 assert!(
4566 !config.openapi_runtime.enabled,
4567 "[openapi] enabled = false must deserialize correctly"
4568 );
4569 }
4570
4571 #[test]
4572 fn openapi_runtime_config_path_can_be_customized() {
4573 let toml_str = r#"
4574[openapi]
4575path = "/api-spec.json"
4576"#;
4577 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
4578 assert_eq!(
4579 config.openapi_runtime.path, "/api-spec.json",
4580 "[openapi] path must deserialize correctly"
4581 );
4582 }
4583
4584 #[test]
4585 fn cache_env_overrides_fields() {
4586 let env = MockEnv::new()
4587 .with("AUTUMN_CACHE__BACKEND", "redis")
4588 .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
4589 .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
4590 let mut config = AutumnConfig::default();
4591
4592 config.apply_env_overrides_with_env(&env);
4593
4594 assert!(config.cache.is_redis(), "backend should be redis");
4595 assert_eq!(
4596 config.cache.redis.url.as_deref(),
4597 Some("redis://cache:6379/1")
4598 );
4599 assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
4600 }
4601
4602 #[test]
4603 fn cache_backend_from_env_value_invalid_is_none() {
4604 assert!(CacheBackend::from_env_value("postgres").is_none());
4605 assert!(CacheBackend::from_env_value("").is_none());
4606 }
4607
4608 #[test]
4609 fn scheduler_validate_rejects_zero_lease_ttl() {
4610 let cfg = SchedulerConfig {
4611 lease_ttl_secs: 0,
4612 ..SchedulerConfig::default()
4613 };
4614 assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
4615 }
4616
4617 #[test]
4618 fn scheduler_validate_rejects_empty_key_prefix() {
4619 let cfg = SchedulerConfig {
4620 key_prefix: " ".to_owned(),
4621 ..SchedulerConfig::default()
4622 };
4623 assert!(cfg.validate().is_err(), "blank key_prefix must fail");
4624 }
4625
4626 #[test]
4627 fn scheduler_validate_ok_with_defaults() {
4628 assert!(SchedulerConfig::default().validate().is_ok());
4629 }
4630
4631 #[test]
4632 fn scheduler_resolved_replica_id_uses_explicit_value() {
4633 let cfg = SchedulerConfig {
4634 replica_id: Some("my-pod".to_owned()),
4635 ..SchedulerConfig::default()
4636 };
4637 assert_eq!(cfg.resolved_replica_id(), "my-pod");
4638 }
4639
4640 #[test]
4641 fn scheduler_resolved_replica_id_falls_back_to_pid() {
4642 let cfg = SchedulerConfig {
4643 replica_id: None,
4644 ..SchedulerConfig::default()
4645 };
4646 assert!(!cfg.resolved_replica_id().is_empty());
4649 }
4650
4651 #[cfg(feature = "mail")]
4652 #[test]
4653 fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
4654 let env = MockEnv::new()
4655 .with(
4656 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
4657 "true",
4658 )
4659 .with("AUTUMN_MAIL__TRANSPORT", "smtp")
4660 .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
4661
4662 let mut config = AutumnConfig::default();
4663 config.apply_mail_env_overrides_with_env(&env);
4664
4665 assert!(
4666 config.mail.allow_in_process_deliver_later_in_production,
4667 "env var should set allow_in_process_deliver_later_in_production"
4668 );
4669 }
4670
4671 #[cfg(feature = "mail")]
4672 #[test]
4673 fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
4674 let env = MockEnv::new();
4675 let mut config = AutumnConfig::default();
4676 config.apply_mail_env_overrides_with_env(&env);
4677
4678 assert!(
4679 !config.mail.allow_in_process_deliver_later_in_production,
4680 "flag should default to false when env var is not set"
4681 );
4682 }
4683}