1use std::path::{Path, PathBuf};
160
161use serde::Deserialize;
162use thiserror::Error;
163
164use std::sync::OnceLock;
166
167static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
168static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
169
170#[doc(hidden)]
171pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
172 let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
173 let _ = MACRO_IS_DEBUG.set(is_debug);
174}
175
176pub trait Env {
182 fn var(&self, key: &str) -> Result<String, std::env::VarError>;
197}
198
199#[derive(Clone, Default)]
201pub struct OsEnv;
202
203impl Env for OsEnv {
204 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
205 if key == "AUTUMN_MANIFEST_DIR" {
206 if let Ok(override_val) = std::env::var(key) {
210 return Ok(override_val);
211 }
212 if let Some(dir) = MACRO_MANIFEST_DIR.get() {
213 return Ok(dir.clone());
214 }
215 } else if key == "AUTUMN_IS_DEBUG"
216 && let Some(is_debug) = MACRO_IS_DEBUG.get()
217 {
218 return Ok(if *is_debug {
219 "1".to_string()
220 } else {
221 "0".to_string()
222 });
223 }
224 std::env::var(key)
225 }
226}
227
228#[derive(Clone, Default)]
230pub struct MockEnv {
231 vars: std::collections::HashMap<String, String>,
232}
233
234impl MockEnv {
235 #[must_use]
237 pub fn new() -> Self {
238 Self {
239 vars: std::collections::HashMap::new(),
240 }
241 }
242
243 #[must_use]
245 pub fn with(mut self, key: &str, value: &str) -> Self {
246 self.vars.insert(key.to_owned(), value.to_owned());
247 self
248 }
249
250 #[must_use]
252 pub fn without(mut self, key: &str) -> Self {
253 self.vars.remove(key);
254 self
255 }
256}
257
258impl Env for MockEnv {
259 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
260 self.vars
261 .get(key)
262 .cloned()
263 .ok_or(std::env::VarError::NotPresent)
264 }
265}
266
267fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
269 if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
270 let candidate = PathBuf::from(manifest_dir).join(filename);
271 if candidate.exists() {
272 return candidate;
273 }
274 }
275 PathBuf::from(filename)
276}
277
278fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
281 match std::fs::read_to_string(path) {
282 Ok(contents) => {
283 let table = toml::from_str::<toml::Table>(&contents)?;
284 Ok(Some(toml::Value::Table(table)))
285 }
286 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
287 Err(e) => Err(ConfigError::Io(e)),
288 }
289}
290
291pub(crate) fn resolve_profile(env: &dyn Env) -> String {
299 let selected_profile_input = resolve_profile_input(env);
300 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
301}
302
303fn resolve_profile_input(env: &dyn Env) -> String {
311 if let Ok(profile) = env.var("AUTUMN_ENV") {
313 let trimmed = profile.trim();
314 if !trimmed.is_empty() {
315 return trimmed.to_owned();
316 }
317 }
318
319 if let Ok(profile) = env.var("AUTUMN_PROFILE") {
321 let trimmed = profile.trim();
322 if !trimmed.is_empty() {
323 return trimmed.to_owned();
324 }
325 }
326
327 let args: Vec<String> = std::env::args().collect();
329 for (i, arg) in args.iter().enumerate() {
330 if arg == "--profile"
331 && let Some(profile) = args.get(i + 1)
332 {
333 let trimmed = profile.trim();
334 if !trimmed.is_empty() {
335 return trimmed.to_owned();
336 }
337 }
338 if let Some(profile) = arg.strip_prefix("--profile=") {
339 let trimmed = profile.trim();
340 if !trimmed.is_empty() {
341 return trimmed.to_owned();
342 }
343 }
344 }
345
346 if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
348 return "prod".to_owned();
349 }
350 "dev".to_owned()
351}
352
353#[must_use]
365pub fn normalize_profile_name(profile: &str) -> Option<String> {
366 let trimmed = profile.trim();
367 if trimmed.is_empty() {
368 return None;
369 }
370
371 if trimmed.eq_ignore_ascii_case("production") {
372 return Some("prod".to_owned());
373 }
374 if trimmed.eq_ignore_ascii_case("development") {
375 return Some("dev".to_owned());
376 }
377 if trimmed.eq_ignore_ascii_case("prod") {
378 return Some("prod".to_owned());
379 }
380 if trimmed.eq_ignore_ascii_case("dev") {
381 return Some("dev".to_owned());
382 }
383
384 Some(trimmed.to_owned())
386}
387
388fn profile_lookup_names(profile: &str) -> Vec<&str> {
393 match profile {
394 "prod" => vec!["production", "prod"],
395 "dev" => vec!["development", "dev"],
396 other => vec![other],
397 }
398}
399
400#[must_use]
409pub fn profile_override_file_lookup_names(
410 profile: &str,
411 selected_profile_input: &str,
412) -> Vec<String> {
413 match profile {
414 "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
415 vec!["production".to_owned(), "prod".to_owned()]
416 }
417 "prod" => vec!["prod".to_owned(), "production".to_owned()],
418 "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
419 vec!["development".to_owned(), "dev".to_owned()]
420 }
421 "dev" => vec!["dev".to_owned(), "development".to_owned()],
422 other => vec![other.to_owned()],
423 }
424}
425
426fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
428 base.get("profile")
429 .and_then(toml::Value::as_table)
430 .and_then(|profiles| profiles.get(profile))
431 .and_then(toml::Value::as_table)
432 .map(|table| toml::Value::Table(table.clone()))
433}
434
435fn profile_defaults_as_toml(profile: &str) -> toml::Value {
441 let mut table = toml::map::Map::new();
442
443 match profile {
444 "dev" => {
445 let mut log = toml::map::Map::new();
446 log.insert("level".into(), "debug".into());
447 log.insert("format".into(), "Pretty".into());
448 table.insert("log".into(), toml::Value::Table(log));
449
450 let mut telemetry = toml::map::Map::new();
451 telemetry.insert("environment".into(), "development".into());
452 table.insert("telemetry".into(), toml::Value::Table(telemetry));
453
454 let mut server = toml::map::Map::new();
455 server.insert("host".into(), "127.0.0.1".into());
456 server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(1));
457 server.insert("prestop_grace_secs".into(), toml::Value::Integer(0));
461 table.insert("server".into(), toml::Value::Table(server));
462
463 let mut health = toml::map::Map::new();
464 health.insert("detailed".into(), toml::Value::Boolean(true));
465 table.insert("health".into(), toml::Value::Table(health));
466
467 let mut actuator = toml::map::Map::new();
468 actuator.insert("sensitive".into(), toml::Value::Boolean(true));
469 table.insert("actuator".into(), toml::Value::Table(actuator));
470
471 let mut cors = toml::map::Map::new();
472 cors.insert(
473 "allowed_origins".into(),
474 toml::Value::Array(vec![toml::Value::String("*".to_owned())]),
475 );
476 table.insert("cors".into(), toml::Value::Table(cors));
477
478 let mut storage = toml::map::Map::new();
484 storage.insert("backend".into(), "local".into());
485 table.insert("storage".into(), toml::Value::Table(storage));
486 let mut trusted_proxies = toml::map::Map::new();
489 trusted_proxies.insert("trust_forwarded_headers".into(), toml::Value::Boolean(true));
490 trusted_proxies.insert(
491 "ranges".into(),
492 toml::Value::Array(vec![
493 toml::Value::String("127.0.0.0/8".to_owned()),
494 toml::Value::String("::1/128".to_owned()),
495 ]),
496 );
497 let mut security = toml::map::Map::new();
498 security.insert(
499 "trusted_proxies".into(),
500 toml::Value::Table(trusted_proxies),
501 );
502 table.insert("security".into(), toml::Value::Table(security));
503 }
505 "prod" => {
506 let mut log = toml::map::Map::new();
507 log.insert("level".into(), "info".into());
508 log.insert("format".into(), "Json".into());
509 table.insert("log".into(), toml::Value::Table(log));
510
511 let mut telemetry = toml::map::Map::new();
512 telemetry.insert("environment".into(), "production".into());
513 table.insert("telemetry".into(), toml::Value::Table(telemetry));
514
515 let mut server = toml::map::Map::new();
516 server.insert("host".into(), "0.0.0.0".into());
517 server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(30));
518 let mut timeouts = toml::map::Map::new();
519 timeouts.insert("request_timeout_ms".into(), toml::Value::Integer(30_000));
520 server.insert("timeouts".into(), toml::Value::Table(timeouts));
521 table.insert("server".into(), toml::Value::Table(server));
522
523 let mut health = toml::map::Map::new();
524 health.insert("detailed".into(), toml::Value::Boolean(false));
525 table.insert("health".into(), toml::Value::Table(health));
526
527 let mut security = toml::map::Map::new();
529 let mut headers = toml::map::Map::new();
530 headers.insert(
531 "strict_transport_security".into(),
532 toml::Value::Boolean(true),
533 );
534 security.insert("headers".into(), toml::Value::Table(headers));
535 let mut csrf = toml::map::Map::new();
536 csrf.insert("enabled".into(), toml::Value::Boolean(true));
537 security.insert("csrf".into(), toml::Value::Table(csrf));
538 table.insert("security".into(), toml::Value::Table(security));
539
540 let mut session = toml::map::Map::new();
541 session.insert("secure".into(), toml::Value::Boolean(true));
542 table.insert("session".into(), toml::Value::Table(session));
543 }
544 _ => {} }
546
547 toml::Value::Table(table)
548}
549
550#[cfg(feature = "mail")]
551fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
552 merged
553 .get("mail")
554 .and_then(toml::Value::as_table)
555 .is_some_and(|mail| mail.contains_key("transport"))
556 || env
557 .var("AUTUMN_MAIL__TRANSPORT")
558 .ok()
559 .as_deref()
560 .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
561}
562
563const MAX_MERGE_DEPTH: usize = 16;
565
566fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
569 deep_merge_with_depth(base, overlay, 0);
570}
571
572fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
573 if depth > MAX_MERGE_DEPTH {
574 eprintln!(
575 "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
576 );
577 return;
578 }
579
580 let toml::Value::Table(overlay_table) = overlay else {
581 return;
582 };
583 let Some(base_table) = base.as_table_mut() else {
584 return;
585 };
586
587 for (key, overlay_val) in overlay_table {
588 let is_recursive_merge =
589 overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
590
591 if is_recursive_merge {
592 if let Some(base_val) = base_table.get_mut(&key) {
593 deep_merge_with_depth(base_val, overlay_val, depth + 1);
594 }
595 } else {
596 base_table.insert(key, overlay_val);
597 }
598 }
599}
600
601fn suggest_profile(profile: &str) -> Option<&'static str> {
605 let known = ["dev", "prod"];
606 let mut suggestions: Vec<(&str, usize)> = known
607 .iter()
608 .map(|k| (*k, levenshtein(profile, k)))
609 .filter(|(_, d)| *d <= 2)
610 .collect();
611 suggestions.sort_by_key(|(_, d)| *d);
612 suggestions.first().map(|(name, _)| *name)
613}
614
615fn warn_profile_typo(profile: &str) {
617 if let Some(suggestion) = suggest_profile(profile) {
618 eprintln!(
619 "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
620 and no smart defaults. Did you mean \"{suggestion}\"?"
621 );
622 }
623}
624
625fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
626 profile != "dev" && profile != "prod" && !has_inline_profile_section
627}
628
629#[must_use]
635pub fn levenshtein(a: &str, b: &str) -> usize {
636 let n = b.chars().count();
637 let mut prev: Vec<usize> = (0..=n).collect();
638 for (i, a_ch) in a.chars().enumerate() {
639 let mut prev_diag = prev[0];
640 prev[0] = i + 1;
641 for (j, b_ch) in b.chars().enumerate() {
642 let old_prev = prev[j + 1];
643 let cost = usize::from(a_ch != b_ch);
644 prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
645 prev_diag = old_prev;
646 }
647 }
648 prev[n]
649}
650
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671pub struct DeprecatedKey {
672 pub path: &'static str,
674 pub replacement: Option<&'static str>,
676 pub since: &'static str,
678 pub remove_in: &'static str,
680}
681
682pub static DEPRECATED_CONFIG_KEYS: &[DeprecatedKey] = &[
688 DeprecatedKey {
689 path: "security.rate_limit.trusted_proxies",
690 replacement: Some("security.trusted_proxies.ranges"),
691 since: "0.5.0",
692 remove_in: "1.0.0",
693 },
694 DeprecatedKey {
695 path: "security.rate_limit.trust_forwarded_headers",
696 replacement: Some("security.trusted_proxies.trust_forwarded_headers"),
697 since: "0.5.0",
698 remove_in: "1.0.0",
699 },
700];
701
702#[must_use]
704pub fn deprecated_config_keys() -> &'static [DeprecatedKey] {
705 DEPRECATED_CONFIG_KEYS
706}
707
708#[must_use]
719pub fn deprecated_env_var_name(path: &str) -> String {
720 format!("AUTUMN_{}", path.to_uppercase().replace('.', "__"))
721}
722
723#[derive(Debug, Clone, Copy, PartialEq, Eq)]
725pub enum DeprecationSource {
726 Toml,
727 Env,
728 Both,
729}
730
731#[derive(Debug, Clone, PartialEq, Eq)]
733pub struct DeprecationFinding {
734 pub path: String,
735 pub replacement: Option<String>,
736 pub since: String,
737 pub remove_in: String,
738 pub source: DeprecationSource,
739}
740
741fn toml_path_present(table: &toml::Table, path: &str) -> bool {
745 let mut current_table = table;
746 let mut segments = path.split('.').peekable();
747
748 while let Some(segment) = segments.next() {
749 if segments.peek().is_none() {
750 return current_table.contains_key(segment);
751 }
752 match current_table.get(segment) {
753 Some(toml::Value::Table(next)) => current_table = next,
754 _ => return false,
755 }
756 }
757 false
758}
759
760#[must_use]
766pub fn detect_deprecated_keys(
767 merged: &toml::Table,
768 env: &dyn Env,
769 registry: &[DeprecatedKey],
770) -> Vec<DeprecationFinding> {
771 let mut findings = Vec::new();
772 for entry in registry {
773 let in_toml = toml_path_present(merged, entry.path);
774 let env_name = deprecated_env_var_name(entry.path);
775 let in_env = env.var(&env_name).is_ok();
776
777 let source = match (in_toml, in_env) {
778 (false, false) => continue,
779 (true, false) => DeprecationSource::Toml,
780 (false, true) => DeprecationSource::Env,
781 (true, true) => DeprecationSource::Both,
782 };
783
784 findings.push(DeprecationFinding {
785 path: entry.path.to_owned(),
786 replacement: entry.replacement.map(str::to_owned),
787 since: entry.since.to_owned(),
788 remove_in: entry.remove_in.to_owned(),
789 source,
790 });
791 }
792 findings
793}
794
795#[must_use]
803pub fn detect_deprecated_keys_for(
804 profile: &str,
805 file_table: &toml::Table,
806 env: &dyn Env,
807 registry: &[DeprecatedKey],
808) -> Vec<DeprecationFinding> {
809 let mut merged = profile_defaults_as_toml(profile);
810 deep_merge(&mut merged, toml::Value::Table(file_table.clone()));
811 let empty_table = toml::Table::new();
812 let merged_table = merged.as_table().unwrap_or(&empty_table);
813 detect_deprecated_keys(merged_table, env, registry)
814}
815
816#[derive(Debug, Error)]
832#[non_exhaustive]
833pub enum ConfigError {
834 #[error("failed to read autumn.toml: {0}")]
836 Io(#[from] std::io::Error),
837
838 #[error("invalid autumn.toml: {0}")]
840 Parse(#[from] toml::de::Error),
841
842 #[error("configuration error: {0}")]
845 Validation(String),
846
847 #[error("credentials error: {0}")]
849 Credentials(String),
850
851 #[error("dotenv error: {0}")]
853 Dotenv(String),
854}
855
856#[derive(Debug, Clone, Default, Deserialize)]
892pub struct BackupConfig {
893 #[serde(default)]
901 pub offsite: Option<Box<OffsiteBackupConfig>>,
902}
903
904#[derive(Debug, Clone, Default, Deserialize)]
911pub struct OffsiteBackupConfig {
912 #[serde(default)]
915 pub s3: OffsiteS3Config,
916
917 #[serde(default)]
920 pub prefix: Option<String>,
921
922 #[serde(default)]
926 pub keep: Option<usize>,
927
928 #[serde(default)]
931 pub auto_upload: bool,
932
933 #[serde(default)]
937 pub allow_shared_bucket: bool,
938}
939
940#[derive(Debug, Clone, Default, Deserialize)]
947pub struct OffsiteS3Config {
948 #[serde(default)]
950 pub bucket: Option<String>,
951
952 #[serde(default)]
955 pub region: Option<String>,
956
957 #[serde(default)]
960 pub endpoint: Option<String>,
961
962 #[serde(default)]
964 pub access_key_id_env: Option<String>,
965
966 #[serde(default)]
968 pub secret_access_key_env: Option<String>,
969
970 #[serde(default)]
972 pub force_path_style: bool,
973}
974
975#[derive(Debug, Clone, Default, Deserialize)]
976pub struct AutumnConfig {
977 #[serde(skip)]
980 pub profile: Option<String>,
981
982 #[serde(default)]
984 pub server: ServerConfig,
985
986 #[serde(default)]
1014 pub deploy: Option<DeployConfig>,
1015
1016 #[serde(default)]
1018 pub database: DatabaseConfig,
1019
1020 #[serde(default)]
1022 pub log: LogConfig,
1023
1024 #[serde(default)]
1026 pub telemetry: TelemetryConfig,
1027
1028 #[serde(default)]
1030 pub health: HealthConfig,
1031
1032 #[serde(default)]
1034 pub actuator: ActuatorConfig,
1035
1036 #[serde(default)]
1038 pub cors: CorsConfig,
1039
1040 #[serde(default)]
1042 pub session: crate::session::SessionConfig,
1043
1044 #[serde(default)]
1046 pub cache: CacheConfig,
1047
1048 #[serde(default)]
1050 pub tenancy: TenancyConfig,
1051
1052 #[serde(default)]
1054 pub idempotency: IdempotencyConfig,
1055
1056 #[serde(default)]
1058 pub channels: ChannelConfig,
1059
1060 #[serde(default)]
1062 pub jobs: JobConfig,
1063
1064 #[serde(default)]
1066 pub scheduler: SchedulerConfig,
1067
1068 #[serde(default)]
1073 pub role: ProcessRole,
1074
1075 #[serde(default)]
1077 pub auth: crate::auth::AuthConfig,
1078
1079 #[serde(default)]
1081 pub security: crate::security::config::SecurityConfig,
1082
1083 #[cfg(feature = "i18n")]
1087 #[serde(default)]
1088 pub i18n: crate::i18n::I18nConfig,
1089
1090 #[serde(default)]
1102 pub time_zone: crate::time_zone::TimeZoneConfig,
1103 #[cfg(feature = "storage")]
1106 #[serde(default)]
1107 pub storage: crate::storage::StorageConfig,
1108
1109 #[serde(default)]
1118 pub backup: BackupConfig,
1119 #[cfg(feature = "mail")]
1121 #[serde(default)]
1122 pub mail: crate::mail::MailConfig,
1123 #[serde(default, rename = "openapi")]
1129 pub openapi_runtime: OpenApiRuntimeConfig,
1130
1131 #[serde(skip)]
1136 pub credentials: crate::credentials::CredentialsStore,
1137
1138 #[cfg(feature = "http-client")]
1142 #[serde(default, rename = "http")]
1143 pub http: HttpConfig,
1144
1145 #[serde(default)]
1150 pub dev: DevConfig,
1151
1152 #[cfg(feature = "maud")]
1158 #[serde(default)]
1159 pub stories: crate::stories::StoriesConfig,
1160
1161 #[cfg(feature = "reporting")]
1167 #[serde(default)]
1168 pub reporting: ReportingConfig,
1169
1170 #[serde(default)]
1179 pub compression: CompressionConfig,
1180
1181 #[serde(default)]
1197 pub bot_protection: crate::security::captcha::BotProtectionConfig,
1198
1199 #[serde(default)]
1201 pub resilience: ResilienceConfig,
1202
1203 #[serde(default)]
1218 pub seo: SeoConfig,
1219
1220 #[serde(default)]
1226 pub observability: ObservabilityConfig,
1227
1228 #[serde(default)]
1243 pub alerts: Box<crate::alerts::AlertConfig>,
1244}
1245
1246#[derive(Debug, Clone, Default, Deserialize)]
1266pub struct DeployTlsConfig {
1267 #[serde(default)]
1270 pub enabled: bool,
1271
1272 #[serde(default)]
1276 pub host: Option<String>,
1277}
1278
1279#[derive(Debug, Clone, Deserialize)]
1302pub struct DeployConfig {
1303 #[serde(default)]
1309 pub host: Option<String>,
1310
1311 #[serde(default = "default_deploy_user")]
1313 pub user: String,
1314
1315 #[serde(default = "default_deploy_ssh_port")]
1317 pub ssh_port: u16,
1318
1319 #[serde(default)]
1323 pub app_name: Option<String>,
1324
1325 #[serde(default)]
1328 pub app_dir: Option<String>,
1329
1330 #[serde(default)]
1332 pub service_name: Option<String>,
1333
1334 #[serde(default = "default_deploy_readiness_timeout_secs")]
1337 pub readiness_timeout_secs: u64,
1338
1339 #[serde(default = "default_deploy_keep_releases")]
1341 pub keep_releases: u32,
1342
1343 #[serde(default = "default_deploy_profile")]
1348 pub profile: String,
1349
1350 #[serde(default)]
1354 pub tls: DeployTlsConfig,
1355}
1356
1357impl Default for DeployConfig {
1358 fn default() -> Self {
1359 Self {
1360 host: None,
1361 user: default_deploy_user(),
1362 ssh_port: default_deploy_ssh_port(),
1363 app_name: None,
1364 app_dir: None,
1365 service_name: None,
1366 readiness_timeout_secs: default_deploy_readiness_timeout_secs(),
1367 keep_releases: default_deploy_keep_releases(),
1368 profile: default_deploy_profile(),
1369 tls: DeployTlsConfig::default(),
1370 }
1371 }
1372}
1373
1374impl DeployConfig {
1375 pub fn validate(&self) -> Result<(), String> {
1385 match self.host.as_deref() {
1386 Some(host) if !host.trim().is_empty() => Ok(()),
1387 _ => Err(
1388 "[deploy] requires a target host: set `[deploy] host = \"<address>\"` in \
1389 autumn.toml to the SSH-reachable hostname or IP of your server"
1390 .to_owned(),
1391 ),
1392 }
1393 }
1394}
1395
1396#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
1428pub struct ObservabilityConfig {
1429 #[serde(default)]
1435 pub server_timing: Option<bool>,
1436}
1437
1438pub(crate) fn server_timing_enabled(cfg: &AutumnConfig) -> bool {
1447 if let Some(explicit) = cfg.observability.server_timing {
1448 return explicit;
1449 }
1450 matches!(cfg.profile.as_deref(), Some("dev" | "development"))
1451}
1452
1453#[derive(Debug, Clone, Default, Deserialize)]
1465pub struct SeoConfig {
1466 pub base_url: Option<String>,
1471
1472 #[serde(default)]
1474 pub robots: RobotsConfig,
1475}
1476
1477#[derive(Debug, Clone, Default, Deserialize)]
1482pub struct RobotsConfig {
1483 pub allow_all: Option<bool>,
1488
1489 #[serde(default)]
1493 pub additional_rules: Vec<String>,
1494
1495 pub sitemap_url: Option<String>,
1499}
1500
1501#[cfg(feature = "reporting")]
1515#[derive(Debug, Clone, Deserialize)]
1516pub struct ReportingConfig {
1517 #[serde(default = "default_reporting_enabled")]
1523 pub enabled: bool,
1524 #[serde(default = "default_reporting_sample_rate")]
1529 pub sample_rate: f64,
1530}
1531
1532#[cfg(feature = "reporting")]
1533impl Default for ReportingConfig {
1534 fn default() -> Self {
1535 Self {
1536 enabled: default_reporting_enabled(),
1537 sample_rate: default_reporting_sample_rate(),
1538 }
1539 }
1540}
1541
1542#[cfg(feature = "reporting")]
1543const fn default_reporting_enabled() -> bool {
1544 true
1545}
1546
1547#[cfg(feature = "reporting")]
1548const fn default_reporting_sample_rate() -> f64 {
1549 1.0
1550}
1551
1552#[derive(Debug, Clone, Deserialize)]
1565pub struct DevConfig {
1566 #[serde(default = "default_inspector_path")]
1571 pub inspector_path: String,
1572
1573 #[serde(default = "default_inspector_capacity")]
1578 pub inspector_capacity: usize,
1579
1580 #[serde(default = "default_inspector_n_plus_one_threshold")]
1585 pub inspector_n_plus_one_threshold: usize,
1586}
1587
1588impl Default for DevConfig {
1589 fn default() -> Self {
1590 Self {
1591 inspector_path: default_inspector_path(),
1592 inspector_capacity: default_inspector_capacity(),
1593 inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
1594 }
1595 }
1596}
1597
1598fn default_inspector_path() -> String {
1599 "/_autumn/inspect".to_owned()
1600}
1601
1602const fn default_inspector_capacity() -> usize {
1603 100
1604}
1605
1606const fn default_inspector_n_plus_one_threshold() -> usize {
1607 crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD
1608}
1609
1610#[cfg(feature = "http-client")]
1612#[derive(Debug, Clone, Default, Deserialize)]
1613pub struct HttpConfig {
1614 #[serde(default)]
1616 pub client: HttpClientConfig,
1617}
1618
1619#[cfg(feature = "http-client")]
1633#[derive(Debug, Clone, Deserialize)]
1634pub struct HttpClientConfig {
1635 #[serde(default = "default_http_timeout_secs")]
1637 pub timeout_secs: u64,
1638
1639 #[serde(default = "default_http_max_retries")]
1642 pub max_retries: u32,
1643
1644 #[serde(default = "default_http_max_retry_after_secs")]
1647 pub max_retry_after_secs: u64,
1648
1649 #[serde(default)]
1656 pub base_urls: std::collections::HashMap<String, String>,
1657}
1658
1659#[cfg(feature = "http-client")]
1660const fn default_http_timeout_secs() -> u64 {
1661 30
1662}
1663
1664#[cfg(feature = "http-client")]
1665const fn default_http_max_retries() -> u32 {
1666 3
1667}
1668
1669#[cfg(feature = "http-client")]
1670const fn default_http_max_retry_after_secs() -> u64 {
1671 10
1672}
1673
1674#[cfg(feature = "http-client")]
1675impl Default for HttpClientConfig {
1676 fn default() -> Self {
1677 Self {
1678 timeout_secs: default_http_timeout_secs(),
1679 max_retries: default_http_max_retries(),
1680 max_retry_after_secs: default_http_max_retry_after_secs(),
1681 base_urls: std::collections::HashMap::new(),
1682 }
1683 }
1684}
1685
1686impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
1687 type Rejection = crate::AutumnError;
1688
1689 async fn from_request_parts(
1690 _parts: &mut http::request::Parts,
1691 state: &crate::AppState,
1692 ) -> Result<Self, Self::Rejection> {
1693 state
1694 .extension::<Self>()
1695 .as_deref()
1696 .cloned()
1697 .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
1698 }
1699}
1700
1701#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1703#[serde(rename_all = "snake_case")]
1704pub enum ChannelBackend {
1705 #[serde(alias = "local", alias = "memory")]
1707 #[default]
1708 InProcess,
1709 Redis,
1711}
1712
1713impl ChannelBackend {
1714 #[must_use]
1716 pub fn from_env_value(value: &str) -> Option<Self> {
1717 match value.trim().to_ascii_lowercase().as_str() {
1718 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
1719 "redis" => Some(Self::Redis),
1720 _ => None,
1721 }
1722 }
1723}
1724
1725#[derive(Debug, Clone, Deserialize)]
1727pub struct ChannelConfig {
1728 #[serde(default)]
1730 pub backend: ChannelBackend,
1731 #[serde(default = "default_channel_capacity")]
1733 pub capacity: usize,
1734 #[serde(default = "default_channel_replay_buffer")]
1740 pub replay_buffer: usize,
1741 #[serde(default)]
1743 pub redis: ChannelRedisConfig,
1744}
1745
1746impl Default for ChannelConfig {
1747 fn default() -> Self {
1748 Self {
1749 backend: ChannelBackend::default(),
1750 capacity: default_channel_capacity(),
1751 replay_buffer: default_channel_replay_buffer(),
1752 redis: ChannelRedisConfig::default(),
1753 }
1754 }
1755}
1756
1757#[derive(Debug, Clone, Deserialize)]
1759pub struct ChannelRedisConfig {
1760 #[serde(default)]
1762 pub url: Option<String>,
1763 #[serde(default = "default_channels_redis_prefix")]
1765 pub key_prefix: String,
1766}
1767
1768impl Default for ChannelRedisConfig {
1769 fn default() -> Self {
1770 Self {
1771 url: None,
1772 key_prefix: default_channels_redis_prefix(),
1773 }
1774 }
1775}
1776
1777const fn default_channel_capacity() -> usize {
1778 32
1779}
1780
1781const fn default_channel_replay_buffer() -> usize {
1782 256
1783}
1784
1785fn default_channels_redis_prefix() -> String {
1786 "autumn:channels".to_owned()
1787}
1788
1789#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
1793#[serde(rename_all = "lowercase")]
1794#[non_exhaustive]
1795pub enum CacheBackend {
1796 #[default]
1798 Memory,
1799 Redis,
1801}
1802
1803impl CacheBackend {
1804 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
1805 match value.trim().to_ascii_lowercase().as_str() {
1806 "memory" => Some(Self::Memory),
1807 "redis" => Some(Self::Redis),
1808 _ => None,
1809 }
1810 }
1811}
1812
1813#[derive(Debug, Clone, Default, serde::Deserialize)]
1828pub struct CacheConfig {
1829 #[serde(default)]
1831 pub backend: CacheBackend,
1832
1833 #[serde(default)]
1835 pub redis: CacheRedisConfig,
1836}
1837
1838impl CacheConfig {
1839 #[must_use]
1841 pub fn is_memory(&self) -> bool {
1842 self.backend == CacheBackend::Memory
1843 }
1844
1845 #[must_use]
1847 pub fn is_redis(&self) -> bool {
1848 self.backend == CacheBackend::Redis
1849 }
1850}
1851
1852#[derive(Debug, Clone, serde::Deserialize)]
1854pub struct CacheRedisConfig {
1855 #[serde(default)]
1857 pub url: Option<String>,
1858
1859 #[serde(default = "default_cache_redis_key_prefix")]
1861 pub key_prefix: String,
1862}
1863
1864impl Default for CacheRedisConfig {
1865 fn default() -> Self {
1866 Self {
1867 url: None,
1868 key_prefix: default_cache_redis_key_prefix(),
1869 }
1870 }
1871}
1872
1873fn default_cache_redis_key_prefix() -> String {
1874 "autumn:cache".to_owned()
1875}
1876
1877#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1879#[serde(rename_all = "snake_case")]
1880pub enum SchedulerBackend {
1881 #[serde(alias = "local", alias = "memory")]
1883 #[default]
1884 InProcess,
1885 Postgres,
1887}
1888
1889impl SchedulerBackend {
1890 #[must_use]
1892 pub fn from_env_value(value: &str) -> Option<Self> {
1893 match value.trim().to_ascii_lowercase().as_str() {
1894 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
1895 "postgres" | "postgresql" => Some(Self::Postgres),
1896 _ => None,
1897 }
1898 }
1899}
1900
1901#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1918#[serde(rename_all = "snake_case")]
1919pub enum ProcessRole {
1920 #[serde(
1923 alias = "all",
1924 alias = "combined",
1925 alias = "web_and_worker",
1926 alias = "server_and_worker"
1927 )]
1928 #[default]
1929 Combined,
1930 #[serde(alias = "server", alias = "http")]
1932 Web,
1933 #[serde(alias = "jobs", alias = "worker_only")]
1935 Worker,
1936}
1937
1938impl ProcessRole {
1939 #[must_use]
1945 pub fn from_env_value(value: &str) -> Option<Self> {
1946 match value.trim().to_ascii_lowercase().as_str() {
1947 "combined" | "all" | "web_and_worker" | "server_and_worker" => Some(Self::Combined),
1948 "web" | "server" | "http" => Some(Self::Web),
1949 "worker" | "jobs" | "worker_only" => Some(Self::Worker),
1950 _ => None,
1951 }
1952 }
1953
1954 #[must_use]
1956 pub const fn as_str(self) -> &'static str {
1957 match self {
1958 Self::Combined => "combined",
1959 Self::Web => "web",
1960 Self::Worker => "worker",
1961 }
1962 }
1963
1964 #[must_use]
1967 pub const fn serves_http(self) -> bool {
1968 matches!(self, Self::Combined | Self::Web)
1969 }
1970
1971 #[must_use]
1974 pub const fn runs_workers(self) -> bool {
1975 matches!(self, Self::Combined | Self::Worker)
1976 }
1977}
1978
1979#[must_use]
1996pub fn split_role_requires_durable_backend(role: ProcessRole, jobs_backend: &str) -> bool {
1997 role != ProcessRole::Combined && !matches!(jobs_backend, "postgres" | "redis")
1998}
1999
2000#[derive(Debug, Clone, Deserialize)]
2002pub struct SchedulerConfig {
2003 #[serde(default)]
2005 pub backend: SchedulerBackend,
2006 #[serde(default = "default_scheduler_lease_ttl_secs")]
2008 pub lease_ttl_secs: u64,
2009 #[serde(default)]
2011 pub replica_id: Option<String>,
2012 #[serde(default = "default_scheduler_key_prefix")]
2014 pub key_prefix: String,
2015}
2016
2017impl SchedulerConfig {
2018 #[must_use]
2020 pub fn resolved_replica_id(&self) -> String {
2021 self.replica_id
2022 .as_ref()
2023 .filter(|id| !id.trim().is_empty())
2024 .cloned()
2025 .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
2026 .or_else(|| std::env::var("HOSTNAME").ok())
2027 .unwrap_or_else(|| format!("pid-{}", std::process::id()))
2028 }
2029
2030 pub fn validate(&self) -> Result<(), ConfigError> {
2037 if self.lease_ttl_secs == 0 {
2038 return Err(ConfigError::Validation(
2039 "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
2040 ));
2041 }
2042 if self.key_prefix.trim().is_empty() {
2043 return Err(ConfigError::Validation(
2044 "scheduler.key_prefix must not be empty".to_owned(),
2045 ));
2046 }
2047 Ok(())
2048 }
2049}
2050
2051impl Default for SchedulerConfig {
2052 fn default() -> Self {
2053 Self {
2054 backend: SchedulerBackend::default(),
2055 lease_ttl_secs: default_scheduler_lease_ttl_secs(),
2056 replica_id: None,
2057 key_prefix: default_scheduler_key_prefix(),
2058 }
2059 }
2060}
2061
2062const fn default_scheduler_lease_ttl_secs() -> u64 {
2063 300
2064}
2065
2066fn default_scheduler_key_prefix() -> String {
2067 "autumn:scheduler".to_owned()
2068}
2069
2070#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2072#[serde(rename_all = "lowercase")]
2073#[non_exhaustive]
2074pub enum IdempotencyBackend {
2075 #[default]
2076 Memory,
2077 Redis,
2078}
2079
2080impl IdempotencyBackend {
2081 #[must_use]
2083 pub fn from_env_value(value: &str) -> Option<Self> {
2084 match value.trim().to_ascii_lowercase().as_str() {
2085 "memory" | "mem" => Some(Self::Memory),
2086 "redis" => Some(Self::Redis),
2087 _ => None,
2088 }
2089 }
2090}
2091
2092#[derive(Debug, Clone, Deserialize)]
2094pub struct IdempotencyRedisConfig {
2095 pub url: Option<String>,
2097 #[serde(default = "default_idempotency_redis_key_prefix")]
2099 pub key_prefix: String,
2100}
2101
2102impl Default for IdempotencyRedisConfig {
2103 fn default() -> Self {
2104 Self {
2105 url: None,
2106 key_prefix: default_idempotency_redis_key_prefix(),
2107 }
2108 }
2109}
2110
2111fn default_idempotency_redis_key_prefix() -> String {
2112 "autumn:idempotency".to_owned()
2113}
2114
2115#[derive(Debug, Clone, Deserialize)]
2117pub struct IdempotencyConfig {
2118 #[serde(default)]
2127 pub enabled: Option<bool>,
2128 #[serde(default)]
2130 pub backend: IdempotencyBackend,
2131 #[serde(default = "default_idempotency_ttl_secs")]
2133 pub ttl_secs: u64,
2134 #[serde(default = "default_idempotency_in_flight_ttl_secs")]
2140 pub in_flight_ttl_secs: u64,
2141 #[serde(default)]
2143 pub allow_memory_in_production: bool,
2144 #[serde(default)]
2146 pub redis: IdempotencyRedisConfig,
2147}
2148
2149impl Default for IdempotencyConfig {
2150 fn default() -> Self {
2151 Self {
2152 enabled: None,
2153 backend: IdempotencyBackend::default(),
2154 ttl_secs: default_idempotency_ttl_secs(),
2155 in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
2156 allow_memory_in_production: false,
2157 redis: IdempotencyRedisConfig::default(),
2158 }
2159 }
2160}
2161
2162const fn default_idempotency_ttl_secs() -> u64 {
2163 86_400
2164}
2165
2166const fn default_idempotency_in_flight_ttl_secs() -> u64 {
2167 86_400
2168}
2169
2170#[derive(Debug, Clone, Deserialize)]
2185pub struct OpenApiRuntimeConfig {
2186 #[serde(default = "default_openapi_enabled")]
2191 pub enabled: bool,
2192 #[serde(default = "default_openapi_path")]
2196 pub path: String,
2197}
2198
2199impl Default for OpenApiRuntimeConfig {
2200 fn default() -> Self {
2201 Self {
2202 enabled: default_openapi_enabled(),
2203 path: default_openapi_path(),
2204 }
2205 }
2206}
2207
2208const fn default_openapi_enabled() -> bool {
2209 true
2210}
2211
2212fn default_openapi_path() -> String {
2213 "/openapi.json".to_owned()
2214}
2215
2216#[derive(Debug, Clone, Deserialize)]
2218pub struct JobConfig {
2219 #[serde(default = "default_job_backend")]
2225 pub backend: String,
2226 #[serde(default = "default_job_workers")]
2228 pub workers: usize,
2229 #[serde(default = "default_job_max_attempts")]
2231 pub max_attempts: u32,
2232 #[serde(default = "default_job_backoff_ms")]
2234 pub initial_backoff_ms: u64,
2235 #[serde(default)]
2242 pub queues: JobQueuesConfig,
2243 #[serde(default)]
2250 pub pin: Vec<String>,
2251 #[serde(default)]
2253 pub redis: JobRedisConfig,
2254 #[serde(default)]
2256 pub postgres: JobPostgresConfig,
2257 #[serde(default)]
2260 pub tracking: JobTrackingConfig,
2261}
2262
2263impl Default for JobConfig {
2264 fn default() -> Self {
2265 Self {
2266 backend: default_job_backend(),
2267 workers: default_job_workers(),
2268 max_attempts: default_job_max_attempts(),
2269 initial_backoff_ms: default_job_backoff_ms(),
2270 queues: JobQueuesConfig::default(),
2271 pin: Vec::new(),
2272 redis: JobRedisConfig::default(),
2273 postgres: JobPostgresConfig::default(),
2274 tracking: JobTrackingConfig::default(),
2275 }
2276 }
2277}
2278
2279#[derive(Debug, Clone, PartialEq, Eq)]
2282pub struct JobQueue {
2283 pub name: String,
2285 pub weight: u32,
2288 pub concurrency: Option<usize>,
2292 pub reserved: Option<usize>,
2296}
2297
2298impl JobQueue {
2299 #[must_use]
2301 pub fn new(name: impl Into<String>, weight: u32) -> Self {
2302 Self {
2303 name: name.into(),
2304 weight,
2305 concurrency: None,
2306 reserved: None,
2307 }
2308 }
2309}
2310
2311#[derive(Debug, Clone, PartialEq, Eq)]
2317pub struct JobQueuesConfig {
2318 pub queues: Vec<JobQueue>,
2320 pub strict: bool,
2323}
2324
2325impl Default for JobQueuesConfig {
2326 fn default() -> Self {
2327 Self::single_default()
2328 }
2329}
2330
2331impl JobQueuesConfig {
2332 #[must_use]
2334 pub fn single_default() -> Self {
2335 Self {
2336 queues: vec![JobQueue::new("default", 1)],
2337 strict: true,
2338 }
2339 }
2340
2341 #[must_use]
2343 pub fn strict_list<I, S>(names: I) -> Self
2344 where
2345 I: IntoIterator<Item = S>,
2346 S: Into<String>,
2347 {
2348 let queues: Vec<JobQueue> = names
2349 .into_iter()
2350 .map(|name| JobQueue::new(name, 1))
2351 .collect();
2352 if queues.is_empty() {
2353 Self::single_default()
2354 } else {
2355 Self {
2356 queues,
2357 strict: true,
2358 }
2359 }
2360 }
2361
2362 #[must_use]
2365 pub fn weighted<I, S>(entries: I) -> Self
2366 where
2367 I: IntoIterator<Item = (S, u32)>,
2368 S: Into<String>,
2369 {
2370 let queues: Vec<JobQueue> = entries
2371 .into_iter()
2372 .map(|(name, weight)| JobQueue::new(name, weight.max(1)))
2373 .collect();
2374 if queues.is_empty() {
2375 Self::single_default()
2376 } else {
2377 Self {
2378 queues,
2379 strict: false,
2380 }
2381 }
2382 }
2383
2384 #[must_use]
2389 pub fn weighted_specs(queues: Vec<JobQueue>) -> Self {
2390 if queues.is_empty() {
2391 Self::single_default()
2392 } else {
2393 Self {
2394 queues: queues
2395 .into_iter()
2396 .map(|mut q| {
2397 q.weight = q.weight.max(1);
2398 q
2399 })
2400 .collect(),
2401 strict: false,
2402 }
2403 }
2404 }
2405}
2406
2407#[derive(Debug, Clone)]
2411enum JobQueueValue {
2412 Weight(u32),
2413 Spec {
2414 weight: Option<u32>,
2415 concurrency: Option<usize>,
2416 reserved: Option<usize>,
2417 },
2418}
2419
2420impl<'de> serde::Deserialize<'de> for JobQueueValue {
2421 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2422 use serde::de::{MapAccess, Visitor};
2423 use std::fmt;
2424
2425 struct ValueVisitor;
2426
2427 impl<'de> Visitor<'de> for ValueVisitor {
2428 type Value = JobQueueValue;
2429
2430 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2431 f.write_str(
2432 "a queue weight (e.g. critical = 4) or a queue table \
2433 (e.g. critical = { weight = 4, concurrency = 8, reserved = 2 })",
2434 )
2435 }
2436
2437 fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
2438 Ok(JobQueueValue::Weight(
2439 u32::try_from(v).map_err(|_| E::custom("queue weight is too large"))?,
2440 ))
2441 }
2442
2443 fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
2444 if v < 0 {
2445 return Err(E::custom("queue weight must not be negative"));
2446 }
2447 self.visit_u64(u64::try_from(v).unwrap_or(0))
2448 }
2449
2450 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
2451 let mut weight = None;
2452 let mut concurrency = None;
2453 let mut reserved = None;
2454 while let Some(key) = map.next_key::<String>()? {
2455 match key.as_str() {
2456 "weight" => weight = Some(map.next_value::<u32>()?),
2457 "concurrency" => concurrency = Some(map.next_value::<usize>()?),
2458 "reserved" => reserved = Some(map.next_value::<usize>()?),
2459 other => {
2460 return Err(serde::de::Error::custom(format!(
2461 "unknown queue setting '{other}' (expected weight, concurrency, \
2462 or reserved)"
2463 )));
2464 }
2465 }
2466 }
2467 Ok(JobQueueValue::Spec {
2468 weight,
2469 concurrency,
2470 reserved,
2471 })
2472 }
2473 }
2474
2475 d.deserialize_any(ValueVisitor)
2476 }
2477}
2478
2479impl<'de> serde::Deserialize<'de> for JobQueuesConfig {
2480 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2481 use serde::de::{MapAccess, SeqAccess, Visitor};
2482 use std::fmt;
2483
2484 struct JobQueuesVisitor;
2485
2486 impl<'de> Visitor<'de> for JobQueuesVisitor {
2487 type Value = JobQueuesConfig;
2488
2489 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
2490 f.write_str(
2491 "an ordered list of queue names (e.g. queues = [\"critical\", \"default\"]) \
2492 or a weight table (e.g. [jobs.queues] critical = 4, default = 1)",
2493 )
2494 }
2495
2496 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
2497 let mut names = Vec::new();
2498 let mut seen = std::collections::HashSet::new();
2499 while let Some(name) = seq.next_element::<String>()? {
2500 if !seen.insert(name.clone()) {
2501 return Err(serde::de::Error::custom(format!(
2502 "duplicate queue name '{name}' in queues list"
2503 )));
2504 }
2505 names.push(name);
2506 }
2507 Ok(JobQueuesConfig::strict_list(names))
2508 }
2509
2510 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
2511 let mut queues: Vec<JobQueue> = Vec::new();
2512 while let Some((k, value)) = map.next_entry::<String, JobQueueValue>()? {
2513 let (weight, concurrency, reserved) = match value {
2514 JobQueueValue::Weight(w) => (w, None, None),
2515 JobQueueValue::Spec {
2516 weight,
2517 concurrency,
2518 reserved,
2519 } => (weight.unwrap_or(1), concurrency, reserved),
2520 };
2521 if weight == 0 {
2522 return Err(serde::de::Error::custom(format!(
2523 "queue '{k}' weight must be at least 1 (got 0); \
2524 to disable a queue remove it from the list"
2525 )));
2526 }
2527 queues.push(JobQueue {
2528 name: k,
2529 weight,
2530 concurrency,
2531 reserved,
2532 });
2533 }
2534 Ok(JobQueuesConfig::weighted_specs(queues))
2535 }
2536 }
2537
2538 d.deserialize_any(JobQueuesVisitor)
2539 }
2540}
2541
2542#[derive(Debug, Clone, Deserialize)]
2544pub struct JobRedisConfig {
2545 #[serde(default)]
2547 pub url: Option<String>,
2548 #[serde(default = "default_jobs_redis_prefix")]
2550 pub key_prefix: String,
2551 #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
2553 pub visibility_timeout_ms: u64,
2554}
2555
2556impl Default for JobRedisConfig {
2557 fn default() -> Self {
2558 Self {
2559 url: None,
2560 key_prefix: default_jobs_redis_prefix(),
2561 visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
2562 }
2563 }
2564}
2565
2566#[derive(Debug, Clone, Deserialize)]
2568pub struct JobPostgresConfig {
2569 #[serde(default = "default_jobs_pg_visibility_timeout_ms")]
2574 pub visibility_timeout_ms: u64,
2575}
2576
2577impl Default for JobPostgresConfig {
2578 fn default() -> Self {
2579 Self {
2580 visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
2581 }
2582 }
2583}
2584
2585#[derive(Debug, Clone, Deserialize)]
2587pub struct JobTrackingConfig {
2588 #[serde(default = "default_jobs_tracking_ttl_secs")]
2591 pub ttl_secs: u64,
2592 #[serde(default = "default_jobs_tracking_route_enabled")]
2595 pub route_enabled: bool,
2596}
2597
2598impl Default for JobTrackingConfig {
2599 fn default() -> Self {
2600 Self {
2601 ttl_secs: default_jobs_tracking_ttl_secs(),
2602 route_enabled: default_jobs_tracking_route_enabled(),
2603 }
2604 }
2605}
2606
2607const fn default_jobs_tracking_ttl_secs() -> u64 {
2608 86_400
2609}
2610
2611const fn default_jobs_tracking_route_enabled() -> bool {
2612 true
2613}
2614
2615const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
2616 30_000
2617}
2618
2619fn default_job_backend() -> String {
2620 "local".to_owned()
2621}
2622
2623const fn default_job_workers() -> usize {
2624 1
2625}
2626
2627const fn default_job_max_attempts() -> u32 {
2628 5
2629}
2630
2631const fn default_job_backoff_ms() -> u64 {
2632 250
2633}
2634
2635fn default_jobs_redis_prefix() -> String {
2636 "autumn:jobs".to_owned()
2637}
2638
2639const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
2640 30_000
2641}
2642
2643const PRE_1890_STRICT_PARENTS: &[&str] = &[
2652 "",
2653 "database",
2654 "deploy",
2655 "server",
2656 "server.timeouts",
2657 "server.tls",
2658 "server.tls.acme",
2659];
2660
2661fn unknown_key_was_previously_strict(schema_parent: &str) -> bool {
2666 schema_parent == "profile" || PRE_1890_STRICT_PARENTS.contains(&schema_parent)
2667}
2668
2669#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2688pub(crate) enum UnknownRootPolicy {
2689 Strict,
2691 LenientWarn,
2694}
2695
2696const MANUAL_SCHEMA_SECTIONS: &[(&str, &[&str])] = &[
2711 ("time_zone", &["identifier", "sources"]),
2713];
2714
2715impl AutumnConfig {
2716 #[must_use]
2718 #[allow(clippy::significant_drop_tightening)]
2719 pub fn get_schema_keys() -> HashMap<String, HashSet<String>> {
2720 const MAX_PASSES: usize = 8;
2728 let de = SchemaDeserializer::new();
2729 let mut prev_rejected: Vec<String> = Vec::new();
2730 for _ in 0..MAX_PASSES {
2731 de.rejected
2732 .lock()
2733 .unwrap_or_else(std::sync::PoisonError::into_inner)
2734 .clear();
2735 let _ = Self::deserialize(de.clone());
2736 let mut rejected: Vec<String> = std::mem::take(
2737 &mut de
2738 .rejected
2739 .lock()
2740 .unwrap_or_else(std::sync::PoisonError::into_inner),
2741 );
2742 rejected.sort();
2743 rejected.dedup();
2744 if rejected.is_empty() {
2745 break;
2746 }
2747 let mut advanced = false;
2748 {
2749 let mut probes = de
2750 .any_probe
2751 .lock()
2752 .unwrap_or_else(std::sync::PoisonError::into_inner);
2753 for p in &rejected {
2754 let cur = probes.get(p).copied().unwrap_or(AnyProbe::Str);
2755 let next = match cur {
2756 AnyProbe::Str => AnyProbe::Map,
2757 AnyProbe::Map | AnyProbe::Seq => AnyProbe::Seq,
2758 };
2759 if next != cur {
2760 advanced = true;
2761 }
2762 probes.insert(p.clone(), next);
2763 }
2764 }
2765 if !advanced && rejected == prev_rejected {
2768 break;
2769 }
2770 prev_rejected = rejected;
2771 }
2772 {
2776 let mut schema = de
2777 .schema
2778 .lock()
2779 .unwrap_or_else(std::sync::PoisonError::into_inner);
2780 for (section, keys) in MANUAL_SCHEMA_SECTIONS {
2781 let entry = schema.entry((*section).to_owned()).or_default();
2782 for k in *keys {
2783 entry.insert((*k).to_owned());
2784 }
2785 }
2786 }
2787 de.into_schema()
2788 }
2789
2790 #[must_use]
2802 pub fn schema_leaf_paths() -> std::collections::BTreeSet<String> {
2803 let schema = Self::get_schema_keys();
2804 let mut leaves = std::collections::BTreeSet::new();
2805 for (parent, fields) in &schema {
2806 for field in fields {
2807 let leaf = if parent.is_empty() {
2808 field.clone()
2809 } else {
2810 format!("{parent}.{field}")
2811 };
2812 leaves.insert(leaf);
2813 }
2814 }
2815 leaves
2816 }
2817
2818 #[must_use]
2821 pub fn validate_toml(
2822 content: &str,
2823 schema: &HashMap<String, HashSet<String>>,
2824 ) -> Vec<(String, Option<String>)> {
2825 Self::validate_toml_detailed(content, schema, &BTreeSet::new())
2826 .into_iter()
2827 .map(|(path, sug, _parent, _is_table, _is_top_level)| (path, sug))
2828 .collect()
2829 }
2830
2831 #[must_use]
2855 pub(crate) fn validate_toml_detailed(
2856 content: &str,
2857 schema: &HashMap<String, HashSet<String>>,
2858 plugin_config_roots: &BTreeSet<String>,
2859 ) -> Vec<(String, Option<String>, String, bool, bool)> {
2860 let Ok(table) = toml::from_str::<toml::Table>(content) else {
2861 return Vec::new();
2862 };
2863
2864 let mut errors = Vec::new();
2865 let mut path = Vec::new();
2866 Self::validate_toml_table(&table, &mut path, schema, plugin_config_roots, &mut errors);
2867 errors
2868 }
2869
2870 #[allow(clippy::too_many_lines)]
2871 fn validate_toml_table(
2872 table: &toml::Table,
2873 path: &mut Vec<String>,
2874 schema: &HashMap<String, HashSet<String>>,
2875 plugin_config_roots: &BTreeSet<String>,
2876 errors: &mut Vec<(String, Option<String>, String, bool, bool)>,
2877 ) {
2878 let mut schema_path_parts = Vec::new();
2879 if path.len() >= 2 && path[0] == "profile" {
2880 schema_path_parts.extend(path[2..].iter().cloned());
2881 } else {
2882 schema_path_parts.extend(path.iter().cloned());
2883 }
2884 let schema_path = schema_path_parts.join(".");
2885
2886 if let Some(valid_keys) = schema.get(&schema_path) {
2887 for (k, val) in table {
2888 if path.is_empty() && k == "profile" {
2889 path.push(k.clone());
2890 match val {
2891 toml::Value::Table(t) => {
2892 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
2893 }
2894 toml::Value::Array(arr) => {
2895 for item in arr {
2896 if let toml::Value::Table(t) = item {
2897 Self::validate_toml_table(
2898 t,
2899 path,
2900 schema,
2901 plugin_config_roots,
2902 errors,
2903 );
2904 }
2905 }
2906 }
2907 _ => {}
2908 }
2909 path.pop();
2910 continue;
2911 }
2912
2913 if valid_keys.contains(k) {
2914 path.push(k.clone());
2915 match val {
2916 toml::Value::Table(t) => {
2917 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
2918 }
2919 toml::Value::Array(arr) => {
2920 for item in arr {
2921 if let toml::Value::Table(t) = item {
2922 Self::validate_toml_table(
2923 t,
2924 path,
2925 schema,
2926 plugin_config_roots,
2927 errors,
2928 );
2929 }
2930 }
2931 }
2932 _ => {}
2933 }
2934 path.pop();
2935 } else if path.is_empty() && plugin_config_roots.contains(k) && val.is_table() {
2936 } else {
2967 let mut full_path_parts = path.clone();
2968 full_path_parts.push(k.clone());
2969 let full_path = full_path_parts.join(".");
2970
2971 let mut closest: Option<&str> = None;
2972 let mut min_dist = usize::MAX;
2973 for valid_key in valid_keys {
2974 let dist = levenshtein(k, valid_key);
2975 if dist <= 2 && dist < min_dist {
2976 min_dist = dist;
2977 closest = Some(valid_key);
2978 }
2979 }
2980
2981 let suggestion = closest.map(|c| {
2982 let mut sug_parts = path.clone();
2983 sug_parts.push(c.to_string());
2984 sug_parts.join(".")
2985 });
2986
2987 errors.push((
2988 full_path,
2989 suggestion,
2990 schema_path.clone(),
2991 val.is_table(),
2992 path.is_empty(),
2993 ));
2994 }
2995 }
2996 } else if path.len() == 1 && path[0] == "profile" {
2997 for (k, val) in table {
2998 if let toml::Value::Table(t) = val {
2999 path.push(k.clone());
3000 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
3001 path.pop();
3002 } else {
3003 let mut full_path_parts = path.clone();
3004 full_path_parts.push(k.clone());
3005 errors.push((
3006 full_path_parts.join("."),
3007 None,
3008 schema_path.clone(),
3009 val.is_table(),
3010 path.is_empty(),
3011 ));
3012 }
3013 }
3014 } else if path.is_empty() {
3015 let root_keys = schema.get("").cloned().unwrap_or_default();
3016 for (k, val) in table {
3017 if k == "profile" || root_keys.contains(k) {
3018 path.push(k.clone());
3019 match val {
3020 toml::Value::Table(t) => {
3021 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
3022 }
3023 toml::Value::Array(arr) => {
3024 for item in arr {
3025 if let toml::Value::Table(t) = item {
3026 Self::validate_toml_table(
3027 t,
3028 path,
3029 schema,
3030 plugin_config_roots,
3031 errors,
3032 );
3033 }
3034 }
3035 }
3036 _ => {}
3037 }
3038 path.pop();
3039 } else if plugin_config_roots.contains(k) && val.is_table() {
3040 } else {
3053 let mut closest: Option<&str> = None;
3054 let mut min_dist = usize::MAX;
3055 for valid_key in &root_keys {
3056 let dist = levenshtein(k, valid_key);
3057 if dist <= 2 && dist < min_dist {
3058 min_dist = dist;
3059 closest = Some(valid_key);
3060 }
3061 }
3062 errors.push((
3063 k.clone(),
3064 closest.map(String::from),
3065 schema_path.clone(),
3066 val.is_table(),
3067 path.is_empty(),
3068 ));
3069 }
3070 }
3071 }
3072 }
3073
3074 #[must_use]
3079 pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
3080 &self.credentials
3081 }
3082
3083 pub fn load() -> Result<Self, ConfigError> {
3104 Self::load_policy(UnknownRootPolicy::Strict)
3105 }
3106
3107 pub fn load_lenient_unknown_roots() -> Result<Self, ConfigError> {
3130 Self::load_policy(UnknownRootPolicy::LenientWarn)
3131 }
3132
3133 fn load_policy(root_policy: UnknownRootPolicy) -> Result<Self, ConfigError> {
3134 let base = OsEnv;
3141 let profile = resolve_profile(&base);
3142 let dir = crate::dotenv::dotenv_base_dir(&base);
3147 let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
3148 .map_err(|e| ConfigError::Dotenv(e.to_string()))?;
3149 let env = crate::dotenv::DotenvEnv::new(&base, vars);
3150 Self::load_with_env_and_plugin_roots_policy(&env, &BTreeSet::new(), root_policy)
3155 }
3156
3157 pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
3168 Self::load_with_env_and_plugin_roots_policy(
3169 env,
3170 &BTreeSet::new(),
3171 UnknownRootPolicy::Strict,
3172 )
3173 }
3174
3175 pub fn load_with_env_and_plugin_roots(
3202 env: &dyn Env,
3203 plugin_config_roots: &BTreeSet<String>,
3204 ) -> Result<Self, ConfigError> {
3205 Self::load_with_env_and_plugin_roots_policy(
3206 env,
3207 plugin_config_roots,
3208 UnknownRootPolicy::Strict,
3209 )
3210 }
3211
3212 pub fn load_with_env_lenient_unknown_roots(env: &dyn Env) -> Result<Self, ConfigError> {
3232 Self::load_with_env_and_plugin_roots_policy(
3233 env,
3234 &BTreeSet::new(),
3235 UnknownRootPolicy::LenientWarn,
3236 )
3237 }
3238
3239 fn load_with_env_and_plugin_roots_policy(
3249 env: &dyn Env,
3250 plugin_config_roots: &BTreeSet<String>,
3251 root_policy: UnknownRootPolicy,
3252 ) -> Result<Self, ConfigError> {
3253 let selected_profile_input = resolve_profile_input(env);
3254 let profile =
3255 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
3256 let mut has_inline_profile_section = false;
3257
3258 let mut merged = profile_defaults_as_toml(&profile);
3261
3262 if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
3264 deep_merge(&mut merged, base.clone());
3265
3266 for profile_name in profile_lookup_names(&profile) {
3268 if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
3269 deep_merge(&mut merged, inline_profile);
3270 has_inline_profile_section = true;
3271 }
3272 }
3273 }
3274
3275 let mut has_profile_file = false;
3277 for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
3278 let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
3279 if let Some(profile_toml) = load_raw_toml(&profile_path)? {
3280 deep_merge(&mut merged, profile_toml);
3281 has_profile_file = true;
3282 break;
3283 }
3284 }
3285 if !has_profile_file
3286 && should_warn_missing_profile_file(&profile, has_inline_profile_section)
3287 {
3288 warn_profile_typo(&profile);
3289 }
3290
3291 let toml_str =
3293 toml::to_string(&merged).expect("internal error: failed to serialize merged config");
3294 let mut config: Self = toml::from_str(&toml_str)?;
3295 config.profile = Some(profile);
3296
3297 config.apply_env_overrides_with_env(env);
3299
3300 let is_strict_env = env
3301 .var("AUTUMN_SERVER__STRICT_CONFIG")
3302 .is_ok_and(|v| v == "true" || v == "1");
3303 if config.server.strict_config || is_strict_env {
3304 let enforce_all = config.server.strict_config_enforce_all
3305 || env
3306 .var("AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL")
3307 .is_ok_and(|v| v == "true" || v == "1");
3308 Self::run_strict_unknown_key_check(
3309 &toml_str,
3310 enforce_all,
3311 plugin_config_roots,
3312 root_policy,
3313 )?;
3314 }
3315
3316 let empty_table = toml::Table::new();
3321 let merged_table = merged.as_table().unwrap_or(&empty_table);
3322 for f in detect_deprecated_keys(merged_table, env, DEPRECATED_CONFIG_KEYS) {
3323 eprintln!(
3328 "Warning: deprecated configuration key `{}` is still honored but will be removed \
3329 in {}; deprecated since {} (replacement: {}; source: {:?})",
3330 f.path,
3331 f.remove_in,
3332 f.since,
3333 f.replacement.as_deref().unwrap_or("none — remove this key"),
3334 f.source,
3335 );
3336 tracing::warn!(
3337 deprecated_key = f.path.as_str(),
3338 replacement = f.replacement.as_deref().unwrap_or("none; remove this key"),
3339 since = f.since.as_str(),
3340 remove_in = f.remove_in.as_str(),
3341 source = ?f.source,
3342 "deprecated configuration key in use; it is still honored but scheduled for removal"
3343 );
3344 }
3345
3346 #[cfg(feature = "mail")]
3347 if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
3348 config.mail.transport = crate::mail::Transport::Log;
3349 }
3350
3351 config.validate()?;
3352
3353 let base_dir: PathBuf = env
3354 .var("AUTUMN_MANIFEST_DIR")
3355 .map_or_else(|_| PathBuf::from("."), PathBuf::from);
3356 let cred_profile = config.profile.as_deref().unwrap_or("dev");
3357 let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
3358 config.credentials = crate::credentials::load_credentials_with_key_override(
3359 cred_profile,
3360 &base_dir,
3361 master_key_override.as_deref(),
3362 )
3363 .map_err(|e| ConfigError::Credentials(e.to_string()))?;
3364
3365 #[cfg(feature = "oauth2")]
3366 {
3367 config.expand_oauth2_providers();
3368 }
3369
3370 Ok(config)
3371 }
3372
3373 fn run_strict_unknown_key_check(
3381 toml_str: &str,
3382 enforce_all: bool,
3383 plugin_config_roots: &BTreeSet<String>,
3384 root_policy: UnknownRootPolicy,
3385 ) -> Result<(), ConfigError> {
3386 let schema = Self::get_schema_keys();
3387 let errors = Self::validate_toml_detailed(toml_str, &schema, plugin_config_roots);
3388
3389 let mut hard_errors = Vec::new();
3390 let mut warn_only = Vec::new();
3391 let mut opaque_roots = Vec::new();
3392 for (path, sug, schema_parent, is_table, is_top_level) in errors {
3393 if root_policy == UnknownRootPolicy::LenientWarn
3448 && schema_parent.is_empty()
3449 && is_top_level
3450 && is_table
3451 {
3452 opaque_roots.push(path);
3453 continue;
3454 }
3455 if enforce_all || unknown_key_was_previously_strict(&schema_parent) {
3456 hard_errors.push((path, sug));
3457 } else {
3458 warn_only.push((path, sug));
3459 }
3460 }
3461
3462 if !opaque_roots.is_empty() {
3469 let roots = opaque_roots.join(", ");
3470 let count = opaque_roots.len();
3471 eprintln!(
3472 "deploy config: accepting {count} unknown top-level config section(s) as \
3473 opaque — the deployed app runs the authoritative strict check, so each must \
3474 be a section the app declares (e.g. a plugin config table) or the app will \
3475 reject it at boot: {roots}. A typo here will make the app fail to start."
3476 );
3477 tracing::warn!(
3478 unknown_top_level_roots = roots.as_str(),
3479 count,
3480 "deploy config: accepting unknown top-level config section(s) as opaque; the \
3481 deployed app runs the authoritative strict check, so each must be a section \
3482 the app declares (e.g. a plugin config table) or it will reject it at boot — \
3483 a typo here will make the app fail to start (#2063)"
3484 );
3485 }
3486
3487 for (path, sug) in &warn_only {
3497 let hint = sug
3498 .as_deref()
3499 .map_or_else(String::new, |s| format!(" — did you mean \"{s}\"?"));
3500 eprintln!(
3501 "Warning: unknown configuration key \"{path}\"{hint}. It is ignored and \
3502 falls back to defaults. This will become a hard error in a future \
3503 release; set server.strict_config_enforce_all = true to enforce now."
3504 );
3505 tracing::warn!(
3506 unknown_key = path.as_str(),
3507 suggestion = sug.as_deref().unwrap_or(""),
3508 "unknown configuration key in a section newly covered by strict \
3509 validation; ignored for now (warn-first rollout, #1890), will hard-fail \
3510 once enforcement is promoted"
3511 );
3512 }
3513
3514 if !hard_errors.is_empty() {
3515 let err_messages: Vec<String> = hard_errors
3516 .into_iter()
3517 .map(|(path, sug)| {
3518 sug.map_or_else(
3519 || format!("unknown key \"{path}\""),
3520 |s| format!("unknown key \"{path}\" — did you mean \"{s}\"?"),
3521 )
3522 })
3523 .collect();
3524 return Err(ConfigError::Validation(format!(
3525 "Strict config check failed. Unknown keys in configuration: {}",
3526 err_messages.join(", ")
3527 )));
3528 }
3529 Ok(())
3530 }
3531
3532 #[cfg(feature = "oauth2")]
3534 fn expand_oauth2_providers(&mut self) {
3535 let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
3536 for name in provider_names {
3537 if let (Some(preset), Some(p)) = (
3539 crate::auth::provider_preset(&name),
3540 self.auth.oauth2.providers.get_mut(&name),
3541 ) {
3542 if p.authorize_url.is_empty() {
3543 p.authorize_url = preset.authorize_url;
3544 }
3545 if p.token_url.is_empty() {
3546 p.token_url = preset.token_url;
3547 }
3548 if p.userinfo_url.is_none() {
3549 p.userinfo_url = preset.userinfo_url;
3550 }
3551 if p.scope.is_empty() || p.scope == "default" {
3552 p.scope = preset.scope;
3553 }
3554 if p.issuer.is_none() {
3555 p.issuer = preset.issuer;
3556 }
3557 if p.jwks_url.is_none() {
3558 p.jwks_url = preset.jwks_url;
3559 }
3560 if p.discovery_url.is_none() {
3561 p.discovery_url = preset.discovery_url;
3562 }
3563 }
3564
3565 if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
3567 let normalized_name = name
3568 .chars()
3569 .map(|c| if c.is_alphanumeric() { c } else { '_' })
3570 .collect::<String>()
3571 .to_lowercase();
3572
3573 let id_key = format!("oauth2_{normalized_name}_client_id");
3574 if p.client_id.is_empty() {
3575 if let Some(id) = self.credentials.get::<String>(&id_key) {
3576 p.client_id = id;
3577 } else if let Some(id) = self
3578 .credentials
3579 .get::<String>(&format!("oauth2_{name}_client_id"))
3580 {
3581 p.client_id = id;
3582 }
3583 }
3584 let secret_key = format!("oauth2_{normalized_name}_client_secret");
3585 if p.client_secret.is_empty() {
3586 if let Some(secret) = self.credentials.get::<String>(&secret_key) {
3587 p.client_secret = secret;
3588 } else if let Some(secret) = self
3589 .credentials
3590 .get::<String>(&format!("oauth2_{name}_client_secret"))
3591 {
3592 p.client_secret = secret;
3593 }
3594 }
3595 }
3596 }
3597 }
3598
3599 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
3610 match std::fs::read_to_string(path) {
3611 Ok(contents) => {
3612 let config: Self = toml::from_str(&contents)?;
3613 config.validate()?;
3614 Ok(config)
3615 }
3616 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
3617 Err(e) => Err(ConfigError::Io(e)),
3618 }
3619 }
3620
3621 pub fn validate(&self) -> Result<(), ConfigError> {
3627 self.database.validate()?;
3628 self.cors.validate()?;
3629 self.scheduler.validate()?;
3630 if self.database.has_shards()
3635 && self.database.effective_primary_url().is_none()
3636 && (self.scheduler.backend == SchedulerBackend::Postgres
3637 || self.jobs.backend == "postgres")
3638 {
3639 return Err(ConfigError::Validation(
3640 "jobs/scheduler require a control database: set database.primary_url (or \
3641 database.url) alongside [[database.shards]] — framework state such as \
3642 autumn_jobs and scheduler locks is not sharded (see docs/guide/sharding.md)"
3643 .to_owned(),
3644 ));
3645 }
3646 let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
3647 self.security
3648 .webhooks
3649 .validate(is_production)
3650 .map_err(|error| ConfigError::Validation(error.to_string()))?;
3651 #[cfg(feature = "mail")]
3652 self.mail.validate(self.profile.as_deref())?;
3653 self.time_zone.validate()?;
3654 Ok(())
3664 }
3665
3666 pub fn apply_env_overrides(&mut self) {
3728 self.apply_env_overrides_with_env(&OsEnv);
3729 }
3730
3731 pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
3733 self.apply_server_env_overrides_with_env(env);
3734 self.apply_deploy_env_overrides_with_env(env);
3735 self.apply_database_env_overrides_with_env(env);
3736 self.apply_log_env_overrides_with_env(env);
3737 self.apply_telemetry_env_overrides_with_env(env);
3738 self.apply_health_env_overrides_with_env(env);
3739 self.apply_cors_env_overrides_with_env(env);
3740 self.apply_session_env_overrides_with_env(env);
3741 self.apply_cache_env_overrides_with_env(env);
3742 self.apply_channels_env_overrides_with_env(env);
3743 self.apply_jobs_env_overrides_with_env(env);
3744 self.apply_scheduler_env_overrides_with_env(env);
3745 self.apply_role_env_overrides_with_env(env);
3746 self.apply_auth_env_overrides_with_env(env);
3747 self.apply_security_env_overrides_with_env(env);
3748 self.apply_bot_protection_env_overrides_with_env(env);
3749 self.apply_idempotency_env_overrides_with_env(env);
3750 self.apply_dev_env_overrides_with_env(env);
3751 self.apply_observability_env_overrides_with_env(env);
3752 self.apply_compression_env_overrides_with_env(env);
3753 self.apply_actuator_env_overrides_with_env(env);
3754 #[cfg(feature = "reporting")]
3755 self.apply_reporting_env_overrides_with_env(env);
3756 #[cfg(feature = "storage")]
3757 self.apply_storage_env_overrides_with_env(env);
3758 self.apply_backup_env_overrides_with_env(env);
3759 #[cfg(feature = "mail")]
3760 self.apply_mail_env_overrides_with_env(env);
3761 #[cfg(feature = "maud")]
3762 self.apply_stories_env_overrides_with_env(env);
3763 self.apply_resilience_env_overrides_with_env(env);
3764 self.apply_time_zone_env_overrides_with_env(env);
3765 self.apply_alerts_env_overrides_with_env(env);
3766 self.apply_tenancy_env_overrides_with_env(env);
3767 }
3768
3769 fn apply_tenancy_env_overrides_with_env(&mut self, env: &dyn Env) {
3770 parse_env_bool(env, "AUTUMN_TENANCY__ENABLED", &mut self.tenancy.enabled);
3771 parse_env_string(env, "AUTUMN_TENANCY__SOURCE", &mut self.tenancy.source);
3772 parse_env_string(
3773 env,
3774 "AUTUMN_TENANCY__HEADER_NAME",
3775 &mut self.tenancy.header_name,
3776 );
3777 parse_env_string(
3778 env,
3779 "AUTUMN_TENANCY__SESSION_KEY",
3780 &mut self.tenancy.session_key,
3781 );
3782 parse_env_string(
3783 env,
3784 "AUTUMN_TENANCY__JWT_CLAIM",
3785 &mut self.tenancy.jwt_claim,
3786 );
3787 parse_env_option_secret(
3788 env,
3789 "AUTUMN_TENANCY__JWT_SECRET",
3790 &mut self.tenancy.jwt_secret,
3791 );
3792 parse_env_option_string(
3793 env,
3794 "AUTUMN_TENANCY__JWT_ISSUER",
3795 &mut self.tenancy.jwt_issuer,
3796 );
3797 parse_env_option_string(
3798 env,
3799 "AUTUMN_TENANCY__JWT_AUDIENCE",
3800 &mut self.tenancy.jwt_audience,
3801 );
3802 parse_env_option_string(
3803 env,
3804 "AUTUMN_TENANCY__BASE_DOMAIN",
3805 &mut self.tenancy.base_domain,
3806 );
3807 parse_env_option_string(
3808 env,
3809 "AUTUMN_TENANCY__LOGIN_REDIRECT",
3810 &mut self.tenancy.login_redirect,
3811 );
3812 parse_env_csv(
3813 env,
3814 "AUTUMN_TENANCY__PUBLIC_PATHS",
3815 &mut self.tenancy.public_paths,
3816 );
3817 parse_env(
3818 env,
3819 "AUTUMN_TENANCY__QUOTA_BYTES",
3820 &mut self.tenancy.quota_bytes,
3821 );
3822 parse_env(
3823 env,
3824 "AUTUMN_TENANCY__MAX_CELLS",
3825 &mut self.tenancy.max_cells,
3826 );
3827 parse_env(
3828 env,
3829 "AUTUMN_TENANCY__IDLE_TTL_SECS",
3830 &mut self.tenancy.idle_ttl_secs,
3831 );
3832 }
3833
3834 fn apply_alerts_env_overrides_with_env(&mut self, env: &dyn Env) {
3835 parse_env_bool(env, "AUTUMN_ALERTS__ENABLED", &mut self.alerts.enabled);
3836 parse_env_option_string(env, "AUTUMN_ALERTS__EMAIL", &mut self.alerts.email);
3837 parse_env_option_string(
3838 env,
3839 "AUTUMN_ALERTS__WEBHOOK_URL",
3840 &mut self.alerts.webhook_url,
3841 );
3842 parse_env_option_string(
3843 env,
3844 "AUTUMN_ALERTS__WEBHOOK_SECRET",
3845 &mut self.alerts.webhook_secret,
3846 );
3847 parse_env_option_string(
3848 env,
3849 "AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY",
3850 &mut self.alerts.pagerduty_routing_key,
3851 );
3852 parse_env_option_string(
3853 env,
3854 "AUTUMN_ALERTS__PAGERDUTY_URL",
3855 &mut self.alerts.pagerduty_url,
3856 );
3857 parse_env_option_string(
3858 env,
3859 "AUTUMN_ALERTS__SLACK_WEBHOOK_URL",
3860 &mut self.alerts.slack_webhook_url,
3861 );
3862 parse_env_option_string(
3863 env,
3864 "AUTUMN_ALERTS__DISCORD_WEBHOOK_URL",
3865 &mut self.alerts.discord_webhook_url,
3866 );
3867 parse_env(
3871 env,
3872 "AUTUMN_ALERTS__PAGERDUTY_SEVERITIES",
3873 &mut self.alerts.pagerduty_severities,
3874 );
3875 parse_env(
3876 env,
3877 "AUTUMN_ALERTS__SLACK_SEVERITIES",
3878 &mut self.alerts.slack_severities,
3879 );
3880 parse_env(
3881 env,
3882 "AUTUMN_ALERTS__DISCORD_SEVERITIES",
3883 &mut self.alerts.discord_severities,
3884 );
3885 parse_env_bool(
3886 env,
3887 "AUTUMN_ALERTS__CUSTOM_CHANNEL",
3888 &mut self.alerts.custom_channel,
3889 );
3890 parse_env(
3891 env,
3892 "AUTUMN_ALERTS__DEDUP_WINDOW_SECS",
3893 &mut self.alerts.dedup_window_secs,
3894 );
3895 parse_env(
3896 env,
3897 "AUTUMN_ALERTS__HEALTH_GRACE_SECS",
3898 &mut self.alerts.health_grace_secs,
3899 );
3900 parse_env(
3901 env,
3902 "AUTUMN_ALERTS__ERROR_RATE_THRESHOLD",
3903 &mut self.alerts.error_rate_threshold,
3904 );
3905 parse_env(
3906 env,
3907 "AUTUMN_ALERTS__ERROR_RATE_MIN_REQUESTS",
3908 &mut self.alerts.error_rate_min_requests,
3909 );
3910 parse_env(
3911 env,
3912 "AUTUMN_ALERTS__EVAL_INTERVAL_SECS",
3913 &mut self.alerts.eval_interval_secs,
3914 );
3915 }
3916
3917 fn apply_time_zone_env_overrides_with_env(&mut self, env: &dyn Env) {
3918 parse_env_string(
3919 env,
3920 "AUTUMN_TIME_ZONE__IDENTIFIER",
3921 &mut self.time_zone.identifier,
3922 );
3923 }
3924
3925 #[cfg(feature = "reporting")]
3926 fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
3927 parse_env_bool(
3928 env,
3929 "AUTUMN_REPORTING__ENABLED",
3930 &mut self.reporting.enabled,
3931 );
3932 parse_env(
3933 env,
3934 "AUTUMN_REPORTING__SAMPLE_RATE",
3935 &mut self.reporting.sample_rate,
3936 );
3937 }
3938
3939 fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
3940 parse_env_string(
3941 env,
3942 "AUTUMN_DEV__INSPECTOR_PATH",
3943 &mut self.dev.inspector_path,
3944 );
3945 parse_env(
3946 env,
3947 "AUTUMN_DEV__INSPECTOR_CAPACITY",
3948 &mut self.dev.inspector_capacity,
3949 );
3950 parse_env(
3951 env,
3952 "AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
3953 &mut self.dev.inspector_n_plus_one_threshold,
3954 );
3955 }
3956
3957 fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
3958 parse_env_bool(
3959 env,
3960 "AUTUMN_COMPRESSION__ENABLED",
3961 &mut self.compression.enabled,
3962 );
3963 }
3964
3965 fn apply_observability_env_overrides_with_env(&mut self, env: &dyn Env) {
3966 parse_env_option_bool(
3967 env,
3968 "AUTUMN_OBSERVABILITY__SERVER_TIMING",
3969 &mut self.observability.server_timing,
3970 );
3971 }
3972
3973 #[cfg(feature = "maud")]
3974 fn apply_stories_env_overrides_with_env(&mut self, env: &dyn Env) {
3975 parse_env_bool(env, "AUTUMN_STORIES__ENABLED", &mut self.stories.enabled);
3976 }
3977
3978 fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
3979 parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
3980 parse_env_bool(
3981 env,
3982 "AUTUMN_ACTUATOR__SENSITIVE",
3983 &mut self.actuator.sensitive,
3984 );
3985 parse_env_bool(
3989 env,
3990 "AUTUMN_ACTUATOR__PROMETHEUS",
3991 &mut self.actuator.prometheus,
3992 );
3993 }
3994
3995 fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
3996 parse_env_option_bool(
3997 env,
3998 "AUTUMN_IDEMPOTENCY__ENABLED",
3999 &mut self.idempotency.enabled,
4000 );
4001 if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
4002 match IdempotencyBackend::from_env_value(&val) {
4003 Some(backend) => self.idempotency.backend = backend,
4004 None => eprintln!(
4005 "Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
4006 ),
4007 }
4008 }
4009 parse_env(
4010 env,
4011 "AUTUMN_IDEMPOTENCY__TTL_SECS",
4012 &mut self.idempotency.ttl_secs,
4013 );
4014 parse_env(
4015 env,
4016 "AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
4017 &mut self.idempotency.in_flight_ttl_secs,
4018 );
4019 parse_env_bool(
4020 env,
4021 "AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
4022 &mut self.idempotency.allow_memory_in_production,
4023 );
4024 parse_env_string(
4025 env,
4026 "AUTUMN_IDEMPOTENCY__REDIS__URL",
4027 self.idempotency.redis.url.get_or_insert_with(String::new),
4028 );
4029 parse_env_string(
4030 env,
4031 "AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
4032 &mut self.idempotency.redis.key_prefix,
4033 );
4034 }
4035
4036 fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
4037 parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
4038 parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
4039 parse_env(
4040 env,
4041 "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
4042 &mut self.server.shutdown_timeout_secs,
4043 );
4044 parse_env(
4045 env,
4046 "AUTUMN_SERVER__PRESTOP_GRACE_SECS",
4047 &mut self.server.prestop_grace_secs,
4048 );
4049 parse_env_option(
4050 env,
4051 "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
4052 &mut self.server.timeouts.request_timeout_ms,
4053 );
4054 parse_env_option_string(
4055 env,
4056 "AUTUMN_SERVER__UNIX_SOCKET",
4057 &mut self.server.unix_socket,
4058 );
4059 parse_env_option(
4060 env,
4061 "AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS",
4062 &mut self.server.max_concurrent_requests,
4063 );
4064
4065 let tls_cert = env.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
4072 let tls_key = env.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
4073 let tls_reload = env.var("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS").ok();
4074 let tls_handshake = env.var("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS").ok();
4075 if tls_cert.is_some()
4076 || tls_key.is_some()
4077 || tls_reload.is_some()
4078 || tls_handshake.is_some()
4079 {
4080 let tls = self.server.tls.get_or_insert_with(TlsConfig::empty_for_env);
4081 if let Some(cert) = tls_cert {
4082 tls.cert_path = Some(PathBuf::from(cert));
4083 }
4084 if let Some(key) = tls_key {
4085 tls.key_path = Some(PathBuf::from(key));
4086 }
4087 if let Some(reload) = tls_reload.and_then(|v| v.trim().parse::<u64>().ok()) {
4088 tls.reload_interval_secs = reload;
4089 }
4090 if let Some(handshake) = tls_handshake.and_then(|v| v.trim().parse::<u64>().ok()) {
4091 tls.handshake_timeout_secs = handshake;
4092 }
4093 }
4094 }
4095
4096 fn apply_deploy_env_overrides_with_env(&mut self, env: &dyn Env) {
4097 apply_deploy_env_overrides(&mut self.deploy, env);
4098 }
4099
4100 fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
4101 if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
4102 self.database.url = Some(val);
4103 self.database.primary_url = None;
4104 }
4105 parse_env_option_string(
4106 env,
4107 "AUTUMN_DATABASE__PRIMARY_URL",
4108 &mut self.database.primary_url,
4109 );
4110 parse_env_option_string(
4111 env,
4112 "AUTUMN_DATABASE__REPLICA_URL",
4113 &mut self.database.replica_url,
4114 );
4115 parse_env(
4116 env,
4117 "AUTUMN_DATABASE__POOL_SIZE",
4118 &mut self.database.pool_size,
4119 );
4120 parse_env_option(
4121 env,
4122 "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
4123 &mut self.database.primary_pool_size,
4124 );
4125 parse_env_option(
4126 env,
4127 "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
4128 &mut self.database.replica_pool_size,
4129 );
4130 parse_env(
4131 env,
4132 "AUTUMN_DATABASE__REPLICA_FALLBACK",
4133 &mut self.database.replica_fallback,
4134 );
4135 parse_env(
4136 env,
4137 "AUTUMN_DATABASE__READ_YOUR_WRITES",
4138 &mut self.database.read_your_writes,
4139 );
4140 parse_env(
4141 env,
4142 "AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS",
4143 &mut self.database.pin_after_write_secs,
4144 );
4145 parse_env(
4146 env,
4147 "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
4148 &mut self.database.connect_timeout_secs,
4149 );
4150 parse_env(
4151 env,
4152 "AUTUMN_DATABASE__STARTUP_WAIT_SECS",
4153 &mut self.database.startup_wait_secs,
4154 );
4155 parse_env_bool(
4156 env,
4157 "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
4158 &mut self.database.auto_migrate_in_production,
4159 );
4160 parse_env_bool(
4161 env,
4162 "AUTUMN_DATABASE__DIRECTORY_SHARD_ROUTER",
4163 &mut self.database.directory_shard_router,
4164 );
4165 self.apply_shard_env_overrides(env);
4166 }
4167
4168 fn apply_shard_env_overrides(&mut self, env: &dyn Env) {
4178 const MAX_ENV_SHARDS: usize = 64;
4179 for i in 0..MAX_ENV_SHARDS {
4180 let key = |field: &str| format!("AUTUMN_DATABASE__SHARDS__{i}__{field}");
4181 if i >= self.database.shards.len() {
4182 let (Ok(name), Ok(primary_url)) =
4183 (env.var(&key("NAME")), env.var(&key("PRIMARY_URL")))
4184 else {
4185 break;
4186 };
4187 self.database.shards.push(ShardConfig {
4188 name,
4189 primary_url,
4190 slots: None,
4191 replica_url: None,
4192 primary_pool_size: None,
4193 replica_pool_size: None,
4194 replica_fallback: None,
4195 });
4196 }
4197 let shard = &mut self.database.shards[i];
4198 parse_env_string(env, &key("NAME"), &mut shard.name);
4199 parse_env_string(env, &key("PRIMARY_URL"), &mut shard.primary_url);
4200 if let Ok(val) = env.var(&key("SLOTS")) {
4202 shard.slots = Some(
4203 val.split(',')
4204 .map(|token| SlotSpec::Range(token.trim().to_owned()))
4205 .collect(),
4206 );
4207 }
4208 parse_env_option_string(env, &key("REPLICA_URL"), &mut shard.replica_url);
4209 parse_env_option(env, &key("PRIMARY_POOL_SIZE"), &mut shard.primary_pool_size);
4210 parse_env_option(env, &key("REPLICA_POOL_SIZE"), &mut shard.replica_pool_size);
4211 parse_env_option(env, &key("REPLICA_FALLBACK"), &mut shard.replica_fallback);
4212 }
4213 }
4214
4215 fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
4216 parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
4217 parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
4218 parse_env_csv(
4219 env,
4220 "AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
4221 &mut self.log.access_log_exclude,
4222 );
4223 if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
4224 match val.as_str() {
4225 "Auto" => self.log.format = LogFormat::Auto,
4226 "Pretty" => self.log.format = LogFormat::Pretty,
4227 "Json" => self.log.format = LogFormat::Json,
4228 _ => eprintln!(
4229 "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
4230 (expected Auto, Pretty, or Json), ignoring"
4231 ),
4232 }
4233 }
4234 }
4235
4236 fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
4237 parse_env_bool(
4239 env,
4240 "AUTUMN_TELEMETRY__ENABLED",
4241 &mut self.telemetry.enabled,
4242 );
4243 parse_env_string(
4244 env,
4245 "AUTUMN_TELEMETRY__SERVICE_NAME",
4246 &mut self.telemetry.service_name,
4247 );
4248 parse_env_option_string(
4249 env,
4250 "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
4251 &mut self.telemetry.service_namespace,
4252 );
4253 parse_env_string(
4254 env,
4255 "AUTUMN_TELEMETRY__SERVICE_VERSION",
4256 &mut self.telemetry.service_version,
4257 );
4258 parse_env_string(
4259 env,
4260 "AUTUMN_TELEMETRY__ENVIRONMENT",
4261 &mut self.telemetry.environment,
4262 );
4263 parse_env_option_string(
4264 env,
4265 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
4266 &mut self.telemetry.otlp_endpoint,
4267 );
4268 if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
4269 match TelemetryProtocol::from_env_value(&val) {
4270 Some(protocol) => self.telemetry.protocol = protocol,
4271 None => eprintln!(
4272 "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
4273 (expected Grpc or HttpProtobuf), ignoring"
4274 ),
4275 }
4276 }
4277 parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
4278 }
4279
4280 fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
4281 parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
4282 parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
4283 parse_env_string(
4284 env,
4285 "AUTUMN_HEALTH__READY_PATH",
4286 &mut self.health.ready_path,
4287 );
4288 parse_env_string(
4289 env,
4290 "AUTUMN_HEALTH__STARTUP_PATH",
4291 &mut self.health.startup_path,
4292 );
4293 parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
4294 }
4295
4296 fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
4297 parse_env_csv(
4298 env,
4299 "AUTUMN_CORS__ALLOWED_ORIGINS",
4300 &mut self.cors.allowed_origins,
4301 );
4302 parse_env_csv(
4303 env,
4304 "AUTUMN_CORS__ALLOWED_METHODS",
4305 &mut self.cors.allowed_methods,
4306 );
4307 parse_env_csv(
4308 env,
4309 "AUTUMN_CORS__ALLOWED_HEADERS",
4310 &mut self.cors.allowed_headers,
4311 );
4312 parse_env_bool(
4313 env,
4314 "AUTUMN_CORS__ALLOW_CREDENTIALS",
4315 &mut self.cors.allow_credentials,
4316 );
4317 parse_env(
4318 env,
4319 "AUTUMN_CORS__MAX_AGE_SECS",
4320 &mut self.cors.max_age_secs,
4321 );
4322 }
4323
4324 fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
4325 parse_env_string(
4326 env,
4327 "AUTUMN_SESSION__COOKIE_NAME",
4328 &mut self.session.cookie_name,
4329 );
4330 if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
4331 match crate::session::SessionBackend::from_env_value(&val) {
4332 Some(backend) => self.session.backend = backend,
4333 None => eprintln!(
4334 "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
4335 (expected memory or redis), ignoring"
4336 ),
4337 }
4338 }
4339 parse_env(
4340 env,
4341 "AUTUMN_SESSION__MAX_AGE_SECS",
4342 &mut self.session.max_age_secs,
4343 );
4344 parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
4345 parse_env_string(
4346 env,
4347 "AUTUMN_SESSION__SAME_SITE",
4348 &mut self.session.same_site,
4349 );
4350 parse_env_bool(
4351 env,
4352 "AUTUMN_SESSION__HTTP_ONLY",
4353 &mut self.session.http_only,
4354 );
4355 parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
4356 parse_env_bool(
4357 env,
4358 "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
4359 &mut self.session.allow_memory_in_production,
4360 );
4361 parse_env_option_string(
4362 env,
4363 "AUTUMN_SESSION__REDIS__URL",
4364 &mut self.session.redis.url,
4365 );
4366 parse_env_string(
4367 env,
4368 "AUTUMN_SESSION__REDIS__KEY_PREFIX",
4369 &mut self.session.redis.key_prefix,
4370 );
4371 }
4372
4373 fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
4374 if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
4375 match CacheBackend::from_env_value(&val) {
4376 Some(backend) => self.cache.backend = backend,
4377 None => eprintln!(
4378 "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
4379 (expected memory or redis), ignoring"
4380 ),
4381 }
4382 }
4383 parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
4384 parse_env_string(
4385 env,
4386 "AUTUMN_CACHE__REDIS__KEY_PREFIX",
4387 &mut self.cache.redis.key_prefix,
4388 );
4389 }
4390
4391 fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
4392 if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
4393 match ChannelBackend::from_env_value(&val) {
4394 Some(backend) => self.channels.backend = backend,
4395 None => eprintln!(
4396 "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
4397 (expected in_process or redis), ignoring"
4398 ),
4399 }
4400 }
4401 parse_env(
4402 env,
4403 "AUTUMN_CHANNELS__CAPACITY",
4404 &mut self.channels.capacity,
4405 );
4406 parse_env(
4407 env,
4408 "AUTUMN_CHANNELS__REPLAY_BUFFER",
4409 &mut self.channels.replay_buffer,
4410 );
4411 parse_env_option_string(
4412 env,
4413 "AUTUMN_CHANNELS__REDIS__URL",
4414 &mut self.channels.redis.url,
4415 );
4416 parse_env_string(
4417 env,
4418 "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
4419 &mut self.channels.redis.key_prefix,
4420 );
4421 }
4422
4423 fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
4424 parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
4425 parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
4426 if let Ok(val) = env.var("AUTUMN_JOBS__PIN") {
4427 self.jobs.pin = val
4428 .split(',')
4429 .map(str::trim)
4430 .filter(|s| !s.is_empty())
4431 .map(str::to_owned)
4432 .collect();
4433 }
4434 parse_env(
4435 env,
4436 "AUTUMN_JOBS__MAX_ATTEMPTS",
4437 &mut self.jobs.max_attempts,
4438 );
4439 parse_env(
4440 env,
4441 "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
4442 &mut self.jobs.initial_backoff_ms,
4443 );
4444 parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
4445 parse_env_string(
4446 env,
4447 "AUTUMN_JOBS__REDIS__KEY_PREFIX",
4448 &mut self.jobs.redis.key_prefix,
4449 );
4450 parse_env(
4451 env,
4452 "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
4453 &mut self.jobs.redis.visibility_timeout_ms,
4454 );
4455 parse_env(
4456 env,
4457 "AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
4458 &mut self.jobs.postgres.visibility_timeout_ms,
4459 );
4460 parse_env(
4461 env,
4462 "AUTUMN_JOBS__TRACKING__TTL_SECS",
4463 &mut self.jobs.tracking.ttl_secs,
4464 );
4465 parse_env_bool(
4466 env,
4467 "AUTUMN_JOBS__TRACKING__ROUTE_ENABLED",
4468 &mut self.jobs.tracking.route_enabled,
4469 );
4470 }
4471
4472 fn apply_role_env_overrides_with_env(&mut self, env: &dyn Env) {
4473 if let Ok(val) = env.var("AUTUMN_ROLE") {
4474 match ProcessRole::from_env_value(&val) {
4475 Some(role) => self.role = role,
4476 None => eprintln!(
4477 "Warning: AUTUMN_ROLE={val:?} is not valid \
4478 (expected combined, web, or worker), ignoring"
4479 ),
4480 }
4481 }
4482 }
4483
4484 fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
4485 if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
4486 match SchedulerBackend::from_env_value(&val) {
4487 Some(backend) => self.scheduler.backend = backend,
4488 None => eprintln!(
4489 "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
4490 (expected in_process or postgres), ignoring"
4491 ),
4492 }
4493 }
4494 parse_env(
4495 env,
4496 "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
4497 &mut self.scheduler.lease_ttl_secs,
4498 );
4499 parse_env_option_string(
4500 env,
4501 "AUTUMN_SCHEDULER__REPLICA_ID",
4502 &mut self.scheduler.replica_id,
4503 );
4504 parse_env_string(
4505 env,
4506 "AUTUMN_SCHEDULER__KEY_PREFIX",
4507 &mut self.scheduler.key_prefix,
4508 );
4509 }
4510
4511 fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
4512 parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
4513 parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
4514 parse_env(
4515 env,
4516 "AUTUMN_AUTH__LOCKOUT__ENABLED",
4517 &mut self.auth.lockout.enabled,
4518 );
4519 parse_env(
4520 env,
4521 "AUTUMN_AUTH__LOCKOUT__THRESHOLD",
4522 &mut self.auth.lockout.threshold,
4523 );
4524 parse_env(
4525 env,
4526 "AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
4527 &mut self.auth.lockout.window_secs,
4528 );
4529 parse_env(
4530 env,
4531 "AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
4532 &mut self.auth.lockout.cooloff_secs,
4533 );
4534 parse_env(
4535 env,
4536 "AUTUMN_AUTH__PASSWORD__MIN_LENGTH",
4537 &mut self.auth.password.min_length,
4538 );
4539 parse_env_bool(
4540 env,
4541 "AUTUMN_AUTH__PASSWORD__REJECT_COMMON",
4542 &mut self.auth.password.reject_common,
4543 );
4544 if let Ok(val) = env.var("AUTUMN_AUTH__PASSWORD__BREACH_CHECK") {
4545 match val.as_str() {
4546 "off" => self.auth.password.breach_check = crate::auth::BreachCheck::Off,
4547 "fail_open" => self.auth.password.breach_check = crate::auth::BreachCheck::FailOpen,
4548 "fail_closed" => {
4549 self.auth.password.breach_check = crate::auth::BreachCheck::FailClosed;
4550 }
4551 other => eprintln!(
4552 "Warning: AUTUMN_AUTH__PASSWORD__BREACH_CHECK={other:?} is not valid \
4553 (expected off, fail_open, or fail_closed), ignoring"
4554 ),
4555 }
4556 }
4557 parse_env_bool(
4558 env,
4559 "AUTUMN_AUTH__REMEMBER__ENABLED",
4560 &mut self.auth.remember.enabled,
4561 );
4562 parse_env(
4563 env,
4564 "AUTUMN_AUTH__REMEMBER__DURATION_SECS",
4565 &mut self.auth.remember.duration_secs,
4566 );
4567 parse_env_string(
4568 env,
4569 "AUTUMN_AUTH__REMEMBER__COOKIE_NAME",
4570 &mut self.auth.remember.cookie_name,
4571 );
4572 parse_env(
4573 env,
4574 "AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES",
4575 &mut self.auth.magic_link.ttl_minutes,
4576 );
4577 parse_env(
4578 env,
4579 "AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS",
4580 &mut self.auth.magic_link.email_cooldown_secs,
4581 );
4582 #[cfg(feature = "oauth2")]
4583 {
4584 let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
4585 for name in provider_names {
4586 let upper = name
4587 .chars()
4588 .map(|c| if c.is_alphanumeric() { c } else { '_' })
4589 .collect::<String>()
4590 .to_uppercase();
4591
4592 let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
4593 if let Ok(id) = env.var(&client_id_var)
4594 && !id.is_empty()
4595 && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
4596 {
4597 p.client_id = id;
4598 }
4599
4600 let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
4601 if let Ok(secret) = env.var(&client_secret_var)
4602 && !secret.is_empty()
4603 && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
4604 {
4605 p.client_secret = secret;
4606 }
4607 }
4608 }
4609 }
4610
4611 #[allow(clippy::too_many_lines)]
4613 fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
4614 parse_env_string(
4615 env,
4616 "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
4617 &mut self.security.headers.x_frame_options,
4618 );
4619 parse_env_bool(
4620 env,
4621 "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
4622 &mut self.security.headers.x_content_type_options,
4623 );
4624 parse_env_bool(
4625 env,
4626 "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
4627 &mut self.security.headers.strict_transport_security,
4628 );
4629 parse_env(
4630 env,
4631 "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
4632 &mut self.security.headers.hsts_max_age_secs,
4633 );
4634 parse_env_string(
4635 env,
4636 "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
4637 &mut self.security.headers.content_security_policy,
4638 );
4639 parse_env_string(
4640 env,
4641 "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
4642 &mut self.security.headers.referrer_policy,
4643 );
4644 parse_env_string(
4645 env,
4646 "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
4647 &mut self.security.headers.permissions_policy,
4648 );
4649
4650 parse_env_bool(
4652 env,
4653 "AUTUMN_SECURITY__CSRF__ENABLED",
4654 &mut self.security.csrf.enabled,
4655 );
4656 parse_env_string(
4657 env,
4658 "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
4659 &mut self.security.csrf.token_header,
4660 );
4661 parse_env_string(
4662 env,
4663 "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
4664 &mut self.security.csrf.cookie_name,
4665 );
4666 parse_env(
4667 env,
4668 "AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES",
4669 &mut self.security.csrf.token_scan_bytes,
4670 );
4671
4672 self.apply_rate_limit_env_overrides_with_env(env);
4673
4674 parse_env(
4676 env,
4677 "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
4678 &mut self.security.upload.max_request_size_bytes,
4679 );
4680 parse_env(
4681 env,
4682 "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
4683 &mut self.security.upload.max_file_size_bytes,
4684 );
4685 parse_env_csv(
4686 env,
4687 "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
4688 &mut self.security.upload.allowed_mime_types,
4689 );
4690 parse_env_bool(
4691 env,
4692 "AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
4693 &mut self.security.upload.reject_on_content_type_mismatch,
4694 );
4695
4696 if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
4698 match value.parse::<crate::authorization::ForbiddenResponse>() {
4699 Ok(parsed) => self.security.forbidden_response = parsed,
4700 Err(err) => tracing::warn!(
4701 "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
4702 ),
4703 }
4704 }
4705 parse_env_bool(
4706 env,
4707 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
4708 &mut self.security.allow_unauthorized_repository_api,
4709 );
4710
4711 parse_env_option_string(
4713 env,
4714 "AUTUMN_SECURITY__SIGNING_SECRET",
4715 &mut self.security.signing_secret.secret,
4716 );
4717 parse_env_csv(
4718 env,
4719 "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
4720 &mut self.security.trusted_hosts.hosts,
4721 );
4722
4723 parse_env_csv(
4725 env,
4726 "AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
4727 &mut self.security.trusted_proxies.ranges,
4728 );
4729 parse_env_bool(
4730 env,
4731 "AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
4732 &mut self.security.trusted_proxies.trust_forwarded_headers,
4733 );
4734 if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
4735 if let Ok(hops) = val.trim().parse::<u32>() {
4736 self.security.trusted_proxies.trusted_hops = Some(hops);
4737 } else {
4738 tracing::warn!(
4739 "ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
4740 expected a non-negative integer"
4741 );
4742 }
4743 }
4744
4745 self.security.webhooks.apply_env_overrides_with_env(env);
4746 }
4747
4748 fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
4749 parse_env_bool(
4750 env,
4751 "AUTUMN_BOT_PROTECTION__ENABLED",
4752 &mut self.bot_protection.enabled,
4753 );
4754 parse_env_bool(
4755 env,
4756 "AUTUMN_BOT_PROTECTION__DEV_BYPASS",
4757 &mut self.bot_protection.dev_bypass,
4758 );
4759 if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
4760 match val.to_lowercase().as_str() {
4761 "turnstile" => {
4762 self.bot_protection.provider =
4763 crate::security::captcha::CaptchaProviderKind::Turnstile;
4764 }
4765 "hcaptcha" => {
4766 self.bot_protection.provider =
4767 crate::security::captcha::CaptchaProviderKind::HCaptcha;
4768 }
4769 _ => tracing::warn!(
4770 "ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
4771 expected \"turnstile\" or \"hcaptcha\""
4772 ),
4773 }
4774 }
4775 parse_env_option_string(
4776 env,
4777 "AUTUMN_BOT_PROTECTION__SITE_KEY",
4778 &mut self.bot_protection.site_key,
4779 );
4780 parse_env_option_string(
4781 env,
4782 "AUTUMN_BOT_PROTECTION__SECRET_KEY",
4783 &mut self.bot_protection.secret_key,
4784 );
4785 parse_env_option_string(
4786 env,
4787 "AUTUMN_BOT_PROTECTION__FORM_FIELD",
4788 &mut self.bot_protection.form_field,
4789 );
4790 }
4791
4792 fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
4793 parse_env_bool(
4794 env,
4795 "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
4796 &mut self.security.rate_limit.enabled,
4797 );
4798 parse_env(
4799 env,
4800 "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
4801 &mut self.security.rate_limit.requests_per_second,
4802 );
4803 parse_env(
4804 env,
4805 "AUTUMN_SECURITY__RATE_LIMIT__BURST",
4806 &mut self.security.rate_limit.burst,
4807 );
4808 parse_env_bool(
4809 env,
4810 "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
4811 &mut self.security.rate_limit.trust_forwarded_headers,
4812 );
4813 parse_env_csv(
4814 env,
4815 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
4816 &mut self.security.rate_limit.trusted_proxies,
4817 );
4818 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
4819 match crate::security::config::KeyStrategy::from_env_value(&val) {
4820 Some(strategy) => self.security.rate_limit.key_strategy = strategy,
4821 None => eprintln!(
4822 "Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
4823 (expected ip, api_token, or authenticated_principal), ignoring"
4824 ),
4825 }
4826 }
4827 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
4830 match crate::security::config::RateLimitBackend::from_env_value(&val) {
4831 Some(backend) => self.security.rate_limit.backend = backend,
4832 None => eprintln!(
4833 "Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
4834 (expected memory or redis), ignoring"
4835 ),
4836 }
4837 }
4838 #[cfg(feature = "redis")]
4839 {
4840 use crate::security::config::RateLimitBackendFailure;
4841 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
4842 match RateLimitBackendFailure::from_env_value(&val) {
4843 Some(mode) => self.security.rate_limit.on_backend_failure = mode,
4844 None => eprintln!(
4845 "Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
4846 valid (expected fail_open or fail_closed), ignoring"
4847 ),
4848 }
4849 }
4850 parse_env_option_string(
4851 env,
4852 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
4853 &mut self.security.rate_limit.redis.url,
4854 );
4855 parse_env_string(
4856 env,
4857 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
4858 &mut self.security.rate_limit.redis.key_prefix,
4859 );
4860 }
4861 }
4862
4863 #[cfg(feature = "storage")]
4864 fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
4865 if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
4866 match crate::storage::StorageBackend::from_env_value(&val) {
4867 Some(backend) => self.storage.backend = backend,
4868 None => eprintln!(
4869 "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
4870 (expected disabled, local, or s3), ignoring"
4871 ),
4872 }
4873 }
4874 parse_env_string(
4875 env,
4876 "AUTUMN_STORAGE__DEFAULT_PROVIDER",
4877 &mut self.storage.default_provider,
4878 );
4879 parse_env_bool(
4880 env,
4881 "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
4882 &mut self.storage.allow_local_in_production,
4883 );
4884 if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
4885 self.storage.local.root = PathBuf::from(val);
4886 }
4887 parse_env_string(
4888 env,
4889 "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
4890 &mut self.storage.local.mount_path,
4891 );
4892 parse_env(
4893 env,
4894 "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
4895 &mut self.storage.local.default_url_expiry_secs,
4896 );
4897 parse_env_option_string(
4898 env,
4899 "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
4900 &mut self.storage.local.signing_key,
4901 );
4902 parse_env_option_string(
4903 env,
4904 "AUTUMN_STORAGE__S3__BUCKET",
4905 &mut self.storage.s3.bucket,
4906 );
4907 parse_env_option_string(
4908 env,
4909 "AUTUMN_STORAGE__S3__REGION",
4910 &mut self.storage.s3.region,
4911 );
4912 parse_env_option_string(
4913 env,
4914 "AUTUMN_STORAGE__S3__ENDPOINT",
4915 &mut self.storage.s3.endpoint,
4916 );
4917 parse_env_option_string(
4918 env,
4919 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
4920 &mut self.storage.s3.public_base_url,
4921 );
4922 parse_env_option_string(
4923 env,
4924 "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
4925 &mut self.storage.s3.access_key_id_env,
4926 );
4927 parse_env_option_string(
4928 env,
4929 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
4930 &mut self.storage.s3.secret_access_key_env,
4931 );
4932 parse_env_bool(
4933 env,
4934 "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
4935 &mut self.storage.s3.force_path_style,
4936 );
4937 parse_env(
4938 env,
4939 "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
4940 &mut self.storage.s3.default_url_expiry_secs,
4941 );
4942 parse_env(
4943 env,
4944 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
4945 &mut self.storage.variants.max_source_bytes,
4946 );
4947 parse_env(
4948 env,
4949 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
4950 &mut self.storage.variants.max_source_width,
4951 );
4952 parse_env(
4953 env,
4954 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
4955 &mut self.storage.variants.max_source_height,
4956 );
4957 }
4958
4959 fn apply_backup_env_overrides_with_env(&mut self, env: &dyn Env) {
4965 const OFFSITE_DEST_KEYS: &[&str] = &[
4982 "AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
4983 "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
4984 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
4985 ];
4986 let has_dest_key = OFFSITE_DEST_KEYS.iter().any(|k| env.var(k).is_ok());
4987 let auto_upload_truthy = env
4988 .var("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD")
4989 .ok()
4990 .is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
4991 if self.backup.offsite.is_none() && !has_dest_key && !auto_upload_truthy {
4992 return;
4993 }
4994 let offsite = self
4995 .backup
4996 .offsite
4997 .get_or_insert_with(|| Box::new(OffsiteBackupConfig::default()));
4998 parse_env_option_string(
4999 env,
5000 "AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
5001 &mut offsite.s3.bucket,
5002 );
5003 parse_env_option_string(
5004 env,
5005 "AUTUMN_BACKUP__OFFSITE__S3__REGION",
5006 &mut offsite.s3.region,
5007 );
5008 parse_env_option_string(
5009 env,
5010 "AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
5011 &mut offsite.s3.endpoint,
5012 );
5013 parse_env_option_string(
5014 env,
5015 "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
5016 &mut offsite.s3.access_key_id_env,
5017 );
5018 parse_env_option_string(
5019 env,
5020 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
5021 &mut offsite.s3.secret_access_key_env,
5022 );
5023 parse_env_bool(
5024 env,
5025 "AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE",
5026 &mut offsite.s3.force_path_style,
5027 );
5028 parse_env_option_string(env, "AUTUMN_BACKUP__OFFSITE__PREFIX", &mut offsite.prefix);
5029 parse_env_option(env, "AUTUMN_BACKUP__OFFSITE__KEEP", &mut offsite.keep);
5030 parse_env_bool(
5031 env,
5032 "AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
5033 &mut offsite.auto_upload,
5034 );
5035 parse_env_bool(
5036 env,
5037 "AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
5038 &mut offsite.allow_shared_bucket,
5039 );
5040 }
5041
5042 #[cfg(feature = "mail")]
5043 fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
5044 if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
5045 match crate::mail::Transport::from_env_value(&val) {
5046 Some(transport) => self.mail.transport = transport,
5047 None => eprintln!(
5048 "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
5049 (expected log, file, smtp, or disabled), ignoring"
5050 ),
5051 }
5052 }
5053 parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
5054 parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
5055 parse_env_bool(
5056 env,
5057 "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
5058 &mut self.mail.allow_log_in_production,
5059 );
5060 parse_env_bool(
5061 env,
5062 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
5063 &mut self.mail.allow_in_process_deliver_later_in_production,
5064 );
5065 parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
5066 parse_env_option_string(
5067 env,
5068 "AUTUMN_MAIL__UNSUBSCRIBE_BASE_URL",
5069 &mut self.mail.unsubscribe_base_url,
5070 );
5071 parse_env_option_string(
5072 env,
5073 "AUTUMN_MAIL__UNSUBSCRIBE_MAILTO",
5074 &mut self.mail.unsubscribe_mailto,
5075 );
5076 if let Ok(val) = env.var("AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS") {
5077 match val.parse::<i64>() {
5078 Ok(days) => self.mail.unsubscribe_token_ttl_days = days,
5079 Err(_) => eprintln!(
5080 "Warning: AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS={val:?} is not a valid integer, ignoring"
5081 ),
5082 }
5083 }
5084 parse_env_bool(
5085 env,
5086 "AUTUMN_MAIL__MOUNT_UNSUBSCRIBE_ENDPOINT",
5087 &mut self.mail.mount_unsubscribe_endpoint,
5088 );
5089 parse_env_bool(env, "AUTUMN_MAIL__INLINE_CSS", &mut self.mail.inline_css);
5090 if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
5091 self.mail.file_dir = PathBuf::from(val);
5092 }
5093 parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
5094 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
5095 match val.parse::<u16>() {
5096 Ok(port) => self.mail.smtp.port = Some(port),
5097 Err(_) => {
5098 eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
5099 }
5100 }
5101 }
5102 parse_env_option_string(
5103 env,
5104 "AUTUMN_MAIL__SMTP__USERNAME",
5105 &mut self.mail.smtp.username,
5106 );
5107 parse_env_option_string(
5108 env,
5109 "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
5110 &mut self.mail.smtp.password_env,
5111 );
5112 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
5113 match crate::mail::TlsMode::from_env_value(&val) {
5114 Some(tls) => self.mail.smtp.tls = tls,
5115 None => eprintln!(
5116 "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
5117 (expected disabled, starttls, or tls), ignoring"
5118 ),
5119 }
5120 }
5121 }
5122
5123 #[must_use]
5125 pub fn profile_name(&self) -> Option<&str> {
5126 self.profile.as_deref()
5127 }
5128}
5129
5130#[derive(Debug, Clone, Default, Deserialize)]
5165pub struct RequestTimeoutsConfig {
5166 #[serde(default)]
5193 pub request_timeout_ms: Option<u64>,
5194}
5195
5196#[derive(Debug, Clone, Deserialize)]
5197pub struct ServerConfig {
5198 #[serde(default = "default_port")]
5200 pub port: u16,
5201
5202 #[serde(default = "default_host")]
5207 pub host: String,
5208
5209 #[serde(default)]
5211 pub strict_config: bool,
5212
5213 #[serde(default)]
5222 pub strict_config_enforce_all: bool,
5223
5224 #[serde(default = "default_shutdown_timeout")]
5231 pub shutdown_timeout_secs: u64,
5232
5233 #[serde(default = "default_prestop_grace")]
5242 pub prestop_grace_secs: u64,
5243
5244 #[serde(default)]
5250 pub timeouts: RequestTimeoutsConfig,
5251
5252 #[serde(default)]
5262 pub unix_socket: Option<String>,
5263
5264 #[serde(default)]
5288 pub max_concurrent_requests: Option<usize>,
5289
5290 #[serde(default)]
5308 pub tls: Option<TlsConfig>,
5309}
5310
5311#[derive(Debug, Clone, Deserialize)]
5330pub struct TlsConfig {
5331 #[serde(default)]
5339 pub cert_path: Option<PathBuf>,
5340
5341 #[serde(default)]
5344 pub key_path: Option<PathBuf>,
5345
5346 #[serde(default = "default_tls_reload_interval_secs")]
5350 pub reload_interval_secs: u64,
5351
5352 #[serde(default = "default_tls_handshake_timeout_secs")]
5358 pub handshake_timeout_secs: u64,
5359
5360 #[serde(default)]
5368 pub acme: Option<AcmeConfig>,
5369}
5370
5371impl TlsConfig {
5372 const fn empty_for_env() -> Self {
5377 Self {
5378 cert_path: None,
5379 key_path: None,
5380 reload_interval_secs: default_tls_reload_interval_secs(),
5381 handshake_timeout_secs: default_tls_handshake_timeout_secs(),
5382 acme: None,
5383 }
5384 }
5385
5386 pub fn validate(&self) -> Result<(), String> {
5400 let has_cert = self.cert_path.is_some();
5401 let has_key = self.key_path.is_some();
5402 let static_configured = has_cert || has_key;
5403 let acme_configured = self.acme.is_some();
5404
5405 match (static_configured, acme_configured) {
5406 (true, true) => {
5407 return Err(
5408 "[server.tls] sets a static cert_path/key_path AND [server.tls.acme]; \
5409 choose exactly one — remove the static cert to use ACME, or remove \
5410 [server.tls.acme] to serve the static certificate"
5411 .to_owned(),
5412 );
5413 }
5414 (false, false) => {
5415 return Err(
5416 "[server.tls] must configure exactly one of: a static certificate \
5417 (cert_path AND key_path) or automatic provisioning ([server.tls.acme] \
5418 with domains + contact_email)"
5419 .to_owned(),
5420 );
5421 }
5422 (true, false) => {
5423 if !(has_cert && has_key) {
5424 return Err("[server.tls] cert_path and key_path must be set together; \
5425 set both, or configure [server.tls.acme] instead"
5426 .to_owned());
5427 }
5428 }
5429 (false, true) => {}
5430 }
5431
5432 if let Some(acme) = &self.acme {
5433 acme.validate()?;
5434 }
5435
5436 Ok(())
5437 }
5438}
5439
5440#[derive(Debug, Clone, Deserialize)]
5463pub struct AcmeConfig {
5464 pub domains: Vec<String>,
5468
5469 pub contact_email: String,
5472
5473 #[serde(default)]
5477 pub directory: AcmeDirectory,
5478
5479 #[serde(default = "default_acme_cache_dir")]
5482 pub cache_dir: PathBuf,
5483
5484 #[serde(default = "default_acme_http_challenge_port")]
5488 pub http_challenge_port: u16,
5489
5490 #[serde(default = "default_acme_renew_before_days")]
5493 pub renew_before_days: u32,
5494}
5495
5496impl AcmeConfig {
5497 pub fn validate(&self) -> Result<(), String> {
5504 if self.domains.is_empty() {
5505 return Err(
5506 "[server.tls.acme] domains must list at least one domain to request a \
5507 certificate for"
5508 .to_owned(),
5509 );
5510 }
5511 if self.contact_email.trim().is_empty() {
5512 return Err(
5513 "[server.tls.acme] contact_email must be set (the ACME CA requires an account \
5514 contact for expiry notifications)"
5515 .to_owned(),
5516 );
5517 }
5518 if self.http_challenge_port == 0 {
5519 return Err(
5520 "[server.tls.acme] http_challenge_port must not be 0: port 0 binds an ephemeral \
5521 OS-assigned port that the ACME HTTP-01 validator (which always connects on port \
5522 80) can never reach, so every issuance fails. Use 80, or the port a front-end \
5523 forwards `:80` to"
5524 .to_owned(),
5525 );
5526 }
5527 if self.renew_before_days >= 90 {
5536 return Err(format!(
5537 "[server.tls.acme] renew_before_days ({}) must be less than 90: it is compared \
5538 against the issued certificate's remaining validity, and publicly-trusted CAs \
5539 (e.g. Let's Encrypt) issue certificates that live at most ~90 days. A value >= \
5540 the certificate lifetime keeps the cert perpetually inside its renew-before \
5541 window, so the renewal loop would order a fresh certificate every hour and burn \
5542 the CA's rate limits. Use a smaller value (default 30)",
5543 self.renew_before_days
5544 ));
5545 }
5546 for (index, domain) in self.domains.iter().enumerate() {
5547 let trimmed = domain.trim();
5548 if trimmed.is_empty() {
5549 return Err(format!(
5550 "[server.tls.acme] domains must not contain blank entries (entry at index \
5551 {index} is empty or whitespace-only)"
5552 ));
5553 }
5554 if trimmed.starts_with("*.") {
5555 return Err(format!(
5556 "[server.tls.acme] wildcard domain `{trimmed}` is not supported: wildcards \
5557 require the DNS-01 challenge, which is out of scope here (tracked in #1620). \
5558 List explicit hostnames instead"
5559 ));
5560 }
5561 }
5562 Ok(())
5563 }
5564}
5565
5566#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
5568#[serde(rename_all = "snake_case")]
5569pub enum AcmeDirectory {
5570 #[default]
5573 Staging,
5574 Production,
5576 Custom {
5578 url: String,
5580 },
5581}
5582
5583#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
5585#[serde(rename_all = "snake_case")]
5586#[non_exhaustive]
5587pub enum ReplicaFallback {
5588 #[default]
5590 FailReadiness,
5591 Primary,
5593}
5594
5595impl std::str::FromStr for ReplicaFallback {
5596 type Err = ();
5597
5598 fn from_str(value: &str) -> Result<Self, Self::Err> {
5599 match value.trim().to_ascii_lowercase().as_str() {
5600 "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
5601 "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
5602 _ => Err(()),
5603 }
5604 }
5605}
5606
5607#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
5619#[serde(rename_all = "snake_case")]
5620#[non_exhaustive]
5621pub enum ReadYourWrites {
5622 #[default]
5625 Off,
5626 Request,
5631 Session,
5637}
5638
5639impl std::str::FromStr for ReadYourWrites {
5640 type Err = ();
5641
5642 fn from_str(value: &str) -> Result<Self, Self::Err> {
5643 match value.trim().to_ascii_lowercase().as_str() {
5644 "off" => Ok(Self::Off),
5645 "request" => Ok(Self::Request),
5646 "session" => Ok(Self::Session),
5647 _ => Err(()),
5648 }
5649 }
5650}
5651
5652#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
5658#[serde(untagged)]
5659pub enum SlotSpec {
5660 Index(u16),
5662 Range(String),
5664}
5665
5666impl SlotSpec {
5667 pub fn expand(&self) -> Result<Vec<u16>, String> {
5674 match self {
5675 Self::Index(slot) => Ok(vec![*slot]),
5676 Self::Range(spec) => {
5677 let spec = spec.trim();
5678 let parse = |s: &str| {
5679 s.trim()
5680 .parse::<u16>()
5681 .map_err(|_| format!("invalid slot {s:?} in {spec:?}"))
5682 };
5683 match spec.split_once('-') {
5684 None => Ok(vec![parse(spec)?]),
5685 Some((start, end)) => {
5686 let (start, end) = (parse(start)?, parse(end)?);
5687 if start > end {
5688 return Err(format!("inverted slot range {spec:?}"));
5689 }
5690 Ok((start..=end).collect())
5691 }
5692 }
5693 }
5694 }
5695 }
5696}
5697
5698#[derive(Debug, Clone, Default, Deserialize)]
5742pub struct ShardConfig {
5743 pub name: String,
5749
5750 pub primary_url: String,
5752
5753 #[serde(default)]
5761 pub slots: Option<Vec<SlotSpec>>,
5762
5763 #[serde(default)]
5765 pub replica_url: Option<String>,
5766
5767 #[serde(default)]
5770 pub primary_pool_size: Option<usize>,
5771
5772 #[serde(default)]
5775 pub replica_pool_size: Option<usize>,
5776
5777 #[serde(default)]
5780 pub replica_fallback: Option<ReplicaFallback>,
5781}
5782
5783impl ShardConfig {
5784 #[must_use]
5786 pub fn effective_primary_pool_size(&self, defaults: &DatabaseConfig) -> usize {
5787 self.primary_pool_size
5788 .unwrap_or_else(|| defaults.effective_primary_pool_size())
5789 }
5790
5791 #[must_use]
5793 pub fn effective_replica_pool_size(&self, defaults: &DatabaseConfig) -> usize {
5794 self.replica_pool_size
5795 .unwrap_or_else(|| defaults.effective_replica_pool_size())
5796 }
5797
5798 #[must_use]
5800 pub fn effective_replica_fallback(&self, defaults: &DatabaseConfig) -> ReplicaFallback {
5801 self.replica_fallback.unwrap_or(defaults.replica_fallback)
5802 }
5803}
5804
5805#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5828pub enum DatabaseBackend {
5829 Postgres,
5831 Sqlite,
5834}
5835
5836impl DatabaseBackend {
5837 #[must_use]
5841 pub fn detect(target: &str) -> Option<Self> {
5842 if is_sqlite_target(target) {
5845 Some(Self::Sqlite)
5846 } else if is_pg_connection_string(target) {
5847 Some(Self::Postgres)
5848 } else {
5849 None
5850 }
5851 }
5852
5853 const fn as_str(self) -> &'static str {
5855 match self {
5856 Self::Postgres => "postgres",
5857 Self::Sqlite => "sqlite",
5858 }
5859 }
5860}
5861
5862impl std::fmt::Display for DatabaseBackend {
5863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5864 f.write_str(self.as_str())
5865 }
5866}
5867
5868fn is_sqlite_target(s: &str) -> bool {
5872 s.starts_with("sqlite:") || s.starts_with("file:")
5874}
5875
5876#[derive(Debug, Clone, Deserialize)]
5908pub struct DatabaseConfig {
5909 #[serde(default)]
5918 pub url: Option<String>,
5919
5920 #[serde(default)]
5925 pub primary_url: Option<String>,
5926
5927 #[serde(default)]
5932 pub replica_url: Option<String>,
5933
5934 #[serde(default = "default_pool_size")]
5939 pub pool_size: usize,
5940
5941 #[serde(default)]
5943 pub primary_pool_size: Option<usize>,
5944
5945 #[serde(default)]
5947 pub replica_pool_size: Option<usize>,
5948
5949 #[serde(default)]
5952 pub replica_fallback: ReplicaFallback,
5953
5954 #[serde(default)]
5962 pub read_your_writes: ReadYourWrites,
5963
5964 #[serde(default = "default_pin_after_write_secs")]
5972 pub pin_after_write_secs: u64,
5973
5974 #[serde(default = "default_connect_timeout")]
5978 pub connect_timeout_secs: u64,
5979
5980 #[serde(default)]
5989 pub startup_wait_secs: u64,
5990
5991 #[serde(default)]
5997 pub auto_migrate_in_production: bool,
5998
5999 #[serde(deserialize_with = "deserialize_option_duration", default)]
6001 pub statement_timeout: Option<std::time::Duration>,
6002
6003 #[serde(
6005 deserialize_with = "deserialize_duration",
6006 default = "default_slow_query_threshold"
6007 )]
6008 pub slow_query_threshold: std::time::Duration,
6009
6010 #[serde(default)]
6018 pub shards: Vec<ShardConfig>,
6019
6020 #[serde(default)]
6030 pub directory_shard_router: bool,
6031
6032 #[serde(default = "default_max_connections_warn_threshold")]
6041 pub max_connections_warn_threshold: usize,
6042}
6043
6044pub(crate) const fn should_warn_total_connections(total: usize, threshold: usize) -> bool {
6049 threshold != 0 && total >= threshold
6050}
6051
6052fn format_slot_ranges(slots: &[usize]) -> String {
6055 fn render(start: usize, end: usize) -> String {
6056 if start == end {
6057 start.to_string()
6058 } else {
6059 format!("{start}-{end}")
6060 }
6061 }
6062 let mut ranges: Vec<String> = Vec::new();
6063 let mut iter = slots.iter().copied();
6064 let Some(mut start) = iter.next() else {
6065 return String::new();
6066 };
6067 let mut end = start;
6068 for slot in iter {
6069 if slot != end + 1 {
6070 ranges.push(render(start, end));
6071 start = slot;
6072 }
6073 end = slot;
6074 }
6075 ranges.push(render(start, end));
6076 ranges.join(", ")
6077}
6078
6079pub const SLOT_COUNT: u16 = 16384;
6091
6092#[derive(Debug, Clone, PartialEq, Eq)]
6100pub struct ShardSlotAssignment {
6101 pub name: String,
6102 pub ranges: String,
6103}
6104
6105pub fn check_stored_slot_map(
6122 auto_split: bool,
6123 computed: &[ShardSlotAssignment],
6124 stored: Option<&[ShardSlotAssignment]>,
6125) -> Result<(), String> {
6126 fn to_map(assignments: &[ShardSlotAssignment]) -> std::collections::BTreeMap<&str, &str> {
6127 assignments
6128 .iter()
6129 .map(|a| (a.name.as_str(), a.ranges.as_str()))
6130 .collect()
6131 }
6132 if !auto_split {
6133 return Ok(());
6134 }
6135 let Some(stored) = stored else {
6136 return Ok(());
6137 };
6138 if to_map(computed) == to_map(stored) {
6139 return Ok(());
6140 }
6141 let computed_names: Vec<&str> = computed.iter().map(|a| a.name.as_str()).collect();
6142 let stored_names: Vec<&str> = stored.iter().map(|a| a.name.as_str()).collect();
6143 Err(format!(
6144 "shard slot map mismatch — auto-split with {} shards ({}) produces a different \
6145 map than the stored map ({} shards: {}). Set explicit [[database.shards]] slot \
6146 ranges matching the stored map, then move data between shards deliberately \
6147 before changing the topology.",
6148 computed.len(),
6149 computed_names.join(", "),
6150 stored.len(),
6151 stored_names.join(", "),
6152 ))
6153}
6154
6155pub fn database_backend_consistency(
6182 url: Option<&str>,
6183 primary_url: Option<&str>,
6184 replica_url: Option<&str>,
6185 has_shards: bool,
6186) -> Result<(), String> {
6187 let Some(primary_backend) = primary_url.or(url).and_then(DatabaseBackend::detect) else {
6188 return Ok(());
6189 };
6190
6191 if primary_backend == DatabaseBackend::Sqlite {
6192 if replica_url.is_some() {
6195 return Err(
6196 "database.replica_url is set but the primary target is SQLite; \
6197 read replicas require the postgres backend"
6198 .to_owned(),
6199 );
6200 }
6201 if has_shards {
6203 return Err(
6204 "database.shards are configured but the primary target is SQLite; \
6205 database shards require the postgres backend"
6206 .to_owned(),
6207 );
6208 }
6209 }
6210
6211 for (field, url) in [("database.url", url), ("database.replica_url", replica_url)] {
6216 if let Some(url) = url
6217 && DatabaseBackend::detect(url) != Some(primary_backend)
6218 {
6219 return Err(format!(
6220 "{field} does not match the primary database backend \
6221 ({primary_backend}); every configured database role must use \
6222 the same backend"
6223 ));
6224 }
6225 }
6226
6227 Ok(())
6228}
6229
6230impl DatabaseConfig {
6231 #[must_use]
6233 pub fn effective_primary_url(&self) -> Option<&str> {
6234 self.primary_url.as_deref().or(self.url.as_deref())
6235 }
6236
6237 #[must_use]
6239 pub fn effective_primary_pool_size(&self) -> usize {
6240 self.primary_pool_size.unwrap_or(self.pool_size)
6241 }
6242
6243 #[must_use]
6245 pub fn effective_replica_pool_size(&self) -> usize {
6246 self.replica_pool_size.unwrap_or(self.pool_size)
6247 }
6248
6249 #[must_use]
6251 pub const fn has_shards(&self) -> bool {
6252 !self.shards.is_empty()
6253 }
6254
6255 pub fn resolved_slot_map(&self) -> Result<Vec<usize>, ConfigError> {
6273 let slot_count = usize::from(SLOT_COUNT);
6274
6275 if self.shards.is_empty() {
6276 return Ok(Vec::new());
6277 }
6278
6279 let declared = self.shards.iter().filter(|s| s.slots.is_some()).count();
6280 if declared != 0 && declared != self.shards.len() {
6281 return Err(ConfigError::Validation(
6282 "database.shards: either every shard must declare `slots` or none may \
6283 (mixing explicit and auto-assigned slots is ambiguous)"
6284 .to_owned(),
6285 ));
6286 }
6287
6288 if declared == 0 {
6289 if self.shards.len() > slot_count {
6291 return Err(ConfigError::Validation(format!(
6292 "database.shards: at most {slot_count} shards are supported \
6293 (one per logical slot), got {}",
6294 self.shards.len()
6295 )));
6296 }
6297 let n = self.shards.len();
6298 return Ok((0..slot_count).map(|slot| slot * n / slot_count).collect());
6299 }
6300
6301 let mut map: Vec<Option<usize>> = vec![None; slot_count];
6302 for (idx, shard) in self.shards.iter().enumerate() {
6303 let specs = shard.slots.as_deref().unwrap_or_default();
6304 for spec in specs {
6305 let slots = spec.expand().map_err(|e| {
6306 ConfigError::Validation(format!("database.shards[{idx}].slots: {e}"))
6307 })?;
6308 for slot in slots {
6309 if usize::from(slot) >= slot_count {
6310 return Err(ConfigError::Validation(format!(
6311 "database.shards[{idx}].slots: slot {slot} is out of range \
6312 (slots are 0..{slot_count})"
6313 )));
6314 }
6315 if let Some(owner) = map[usize::from(slot)] {
6316 return Err(ConfigError::Validation(format!(
6317 "database.shards[{idx}].slots: slot {slot} is already owned \
6318 by shard {:?}",
6319 self.shards[owner].name
6320 )));
6321 }
6322 map[usize::from(slot)] = Some(idx);
6323 }
6324 }
6325 }
6326 let unassigned: Vec<usize> = map
6327 .iter()
6328 .enumerate()
6329 .filter_map(|(slot, owner)| owner.is_none().then_some(slot))
6330 .collect();
6331 if !unassigned.is_empty() {
6332 return Err(ConfigError::Validation(format!(
6333 "database.shards: slot map must cover every slot in 0..{slot_count}; \
6334 unassigned slots: {}",
6335 format_slot_ranges(&unassigned)
6336 )));
6337 }
6338 Ok(map.into_iter().flatten().collect())
6340 }
6341
6342 #[must_use]
6349 pub fn shards_auto_split(&self) -> bool {
6350 self.has_shards() && self.shards.iter().all(|s| s.slots.is_none())
6351 }
6352
6353 pub fn resolved_shard_assignments(&self) -> Result<Vec<ShardSlotAssignment>, ConfigError> {
6365 let slot_map = self.resolved_slot_map()?;
6366 let n = self.shards.len();
6367 let mut per_shard: Vec<Vec<usize>> = vec![Vec::new(); n];
6368 for (slot, &owner) in slot_map.iter().enumerate() {
6369 per_shard[owner].push(slot);
6370 }
6371 Ok(self
6372 .shards
6373 .iter()
6374 .enumerate()
6375 .map(|(idx, shard)| ShardSlotAssignment {
6376 name: shard.name.clone(),
6377 ranges: format_slot_ranges(&per_shard[idx]),
6378 })
6379 .collect())
6380 }
6381
6382 fn validate_backend_consistency(&self) -> Result<(), ConfigError> {
6390 database_backend_consistency(
6394 self.url.as_deref(),
6395 self.primary_url.as_deref(),
6396 self.replica_url.as_deref(),
6397 !self.shards.is_empty(),
6398 )
6399 .map_err(ConfigError::Validation)
6400 }
6401
6402 pub fn validate(&self) -> Result<(), ConfigError> {
6409 for (field, url) in [
6410 ("database.url", self.url.as_deref()),
6411 ("database.primary_url", self.primary_url.as_deref()),
6412 ("database.replica_url", self.replica_url.as_deref()),
6413 ] {
6414 if let Some(url) = url
6420 && DatabaseBackend::detect(url).is_none()
6421 {
6422 let label = if field == "database.url" {
6423 "database URL"
6424 } else {
6425 field
6426 };
6427 return Err(ConfigError::Validation(format!(
6428 "Invalid {label}: must start with postgres:// or postgresql://, or be a \
6429 keyword/value connection string \
6430 (e.g. \"host=db user=app dbname=app sslmode=require\"), got {url:?}"
6431 )));
6432 }
6433 }
6434
6435 if self.replica_url.is_some() && self.effective_primary_url().is_none() {
6436 return Err(ConfigError::Validation(
6437 "database.replica_url requires database.primary_url or database.url".to_owned(),
6438 ));
6439 }
6440
6441 self.validate_backend_consistency()?;
6442
6443 let mut seen_names = std::collections::HashSet::new();
6444 for (idx, shard) in self.shards.iter().enumerate() {
6445 if shard.name.is_empty() {
6446 return Err(ConfigError::Validation(format!(
6447 "database.shards[{idx}].name must not be empty"
6448 )));
6449 }
6450 if !shard
6451 .name
6452 .chars()
6453 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
6454 {
6455 return Err(ConfigError::Validation(format!(
6456 "database.shards[{idx}].name {:?} is invalid: shard names are used in \
6457 metric tags and health component names and must match [a-z0-9_-]",
6458 shard.name
6459 )));
6460 }
6461 if !seen_names.insert(shard.name.as_str()) {
6462 return Err(ConfigError::Validation(format!(
6463 "database.shards[{idx}].name {:?} is declared more than once; \
6464 shard names must be unique",
6465 shard.name
6466 )));
6467 }
6468 for (field, url) in [
6469 ("primary_url", Some(shard.primary_url.as_str())),
6470 ("replica_url", shard.replica_url.as_deref()),
6471 ] {
6472 if let Some(url) = url
6473 && !is_pg_connection_string(url)
6474 {
6475 return Err(ConfigError::Validation(format!(
6476 "Invalid database.shards[{idx}].{field}: must start with \
6477 postgres:// or postgresql://, or be a keyword/value \
6478 connection string \
6479 (e.g. \"host=db user=app dbname=app sslmode=require\"), got {url:?}"
6480 )));
6481 }
6482 }
6483 }
6484 self.resolved_slot_map()?;
6485 Ok(())
6486 }
6487}
6488
6489fn is_pg_connection_string(s: &str) -> bool {
6496 crate::pg_conn_str::is_url(s) || crate::pg_conn_str::is_keyword_value(s)
6497}
6498
6499#[derive(Debug, Clone, Deserialize)]
6515pub struct LogConfig {
6516 #[serde(default = "default_log_level")]
6521 pub level: String,
6522
6523 #[serde(default)]
6525 pub format: LogFormat,
6526
6527 #[serde(default)]
6529 pub filter_parameters: Vec<String>,
6530
6531 #[serde(default)]
6533 pub unfilter_parameters: Vec<String>,
6534
6535 #[serde(default = "default_access_log")]
6544 pub access_log: bool,
6545
6546 #[serde(default = "default_access_log_exclude")]
6556 pub access_log_exclude: Vec<String>,
6557
6558 #[serde(default)]
6565 pub capture: crate::log::capture::LogCaptureConfig,
6566}
6567
6568#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
6587#[non_exhaustive]
6588pub enum LogFormat {
6589 #[default]
6591 Auto,
6592 Pretty,
6594 Json,
6596}
6597
6598#[derive(Debug, Clone, Deserialize)]
6603pub struct TelemetryConfig {
6604 #[serde(default)]
6606 pub enabled: bool,
6607
6608 #[serde(default = "default_telemetry_service_name")]
6610 pub service_name: String,
6611
6612 #[serde(default)]
6614 pub service_namespace: Option<String>,
6615
6616 #[serde(default = "default_telemetry_service_version")]
6618 pub service_version: String,
6619
6620 #[serde(default = "default_telemetry_environment")]
6622 pub environment: String,
6623
6624 #[serde(default)]
6626 pub otlp_endpoint: Option<String>,
6627
6628 #[serde(default)]
6630 pub protocol: TelemetryProtocol,
6631
6632 #[serde(default)]
6634 pub strict: bool,
6635}
6636
6637#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
6639#[non_exhaustive]
6640pub enum TelemetryProtocol {
6641 #[serde(alias = "grpc", alias = "GRPC")]
6643 #[default]
6644 Grpc,
6645 #[serde(
6647 alias = "http-protobuf",
6648 alias = "http_protobuf",
6649 alias = "HTTP_PROTOBUF"
6650 )]
6651 HttpProtobuf,
6652}
6653
6654impl TelemetryProtocol {
6655 fn from_env_value(value: &str) -> Option<Self> {
6656 match value {
6657 "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
6658 "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
6659 | "httpprotobuf" => Some(Self::HttpProtobuf),
6660 _ => None,
6661 }
6662 }
6663}
6664
6665#[derive(Debug, Clone, Deserialize)]
6683pub struct HealthConfig {
6684 #[serde(default = "default_health_path")]
6688 pub path: String,
6689
6690 #[serde(default = "default_live_path")]
6692 pub live_path: String,
6693
6694 #[serde(default = "default_ready_path")]
6696 pub ready_path: String,
6697
6698 #[serde(default = "default_startup_path")]
6700 pub startup_path: String,
6701
6702 #[serde(default)]
6706 pub detailed: bool,
6707}
6708
6709#[derive(Debug, Clone, Deserialize)]
6715pub struct ActuatorConfig {
6716 #[serde(default = "default_actuator_prefix")]
6718 pub prefix: String,
6719
6720 #[serde(default)]
6723 pub sensitive: bool,
6724
6725 #[serde(default = "default_actuator_prometheus")]
6733 pub prometheus: bool,
6734}
6735
6736impl Default for ActuatorConfig {
6737 fn default() -> Self {
6738 Self {
6739 prefix: default_actuator_prefix(),
6740 sensitive: false,
6741 prometheus: default_actuator_prometheus(),
6742 }
6743 }
6744}
6745
6746fn default_actuator_prefix() -> String {
6747 "/actuator".to_owned()
6748}
6749
6750const fn default_actuator_prometheus() -> bool {
6751 true
6752}
6753
6754#[derive(Debug, Clone, Deserialize)]
6791pub struct CorsConfig {
6792 #[serde(default)]
6797 pub allowed_origins: Vec<String>,
6798
6799 #[serde(default = "default_cors_methods")]
6802 pub allowed_methods: Vec<String>,
6803
6804 #[serde(default = "default_cors_headers")]
6807 pub allowed_headers: Vec<String>,
6808
6809 #[serde(default)]
6812 pub allow_credentials: bool,
6813
6814 #[serde(default = "default_cors_max_age")]
6817 pub max_age_secs: u64,
6818}
6819
6820impl Default for CorsConfig {
6821 fn default() -> Self {
6822 Self {
6823 allowed_origins: Vec::new(),
6824 allowed_methods: default_cors_methods(),
6825 allowed_headers: default_cors_headers(),
6826 allow_credentials: false,
6827 max_age_secs: default_cors_max_age(),
6828 }
6829 }
6830}
6831
6832impl CorsConfig {
6833 pub fn validate(&self) -> Result<(), ConfigError> {
6842 if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
6843 return Err(ConfigError::Validation(
6844 "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
6845 list explicit origins instead (browsers reject the wildcard+credentials combo)"
6846 .to_owned(),
6847 ));
6848 }
6849 Ok(())
6850 }
6851}
6852
6853fn default_cors_methods() -> Vec<String> {
6854 vec![
6855 "GET".to_owned(),
6856 "POST".to_owned(),
6857 "PUT".to_owned(),
6858 "DELETE".to_owned(),
6859 "PATCH".to_owned(),
6860 "OPTIONS".to_owned(),
6861 ]
6862}
6863
6864fn default_cors_headers() -> Vec<String> {
6865 vec!["Content-Type".to_owned(), "Authorization".to_owned()]
6866}
6867
6868const fn default_cors_max_age() -> u64 {
6869 86400
6870}
6871
6872#[derive(Debug, Clone, Deserialize, Default)]
6916pub struct CompressionConfig {
6917 #[serde(default)]
6925 pub enabled: bool,
6926}
6927
6928#[doc(hidden)]
6941pub fn apply_deploy_env_overrides(deploy: &mut Option<DeployConfig>, env: &dyn Env) {
6942 const KEYS: [&str; 11] = [
6943 "AUTUMN_DEPLOY__HOST",
6944 "AUTUMN_DEPLOY__USER",
6945 "AUTUMN_DEPLOY__SSH_PORT",
6946 "AUTUMN_DEPLOY__APP_NAME",
6947 "AUTUMN_DEPLOY__APP_DIR",
6948 "AUTUMN_DEPLOY__SERVICE_NAME",
6949 "AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
6950 "AUTUMN_DEPLOY__KEEP_RELEASES",
6951 "AUTUMN_DEPLOY__PROFILE",
6952 "AUTUMN_DEPLOY__TLS__ENABLED",
6953 "AUTUMN_DEPLOY__TLS__HOST",
6954 ];
6955 if !KEYS.iter().any(|key| env.var(key).is_ok()) {
6956 return;
6957 }
6958 let deploy = deploy.get_or_insert_with(DeployConfig::default);
6959 parse_env_option_string(env, "AUTUMN_DEPLOY__HOST", &mut deploy.host);
6960 parse_env_string(env, "AUTUMN_DEPLOY__USER", &mut deploy.user);
6961 parse_env(env, "AUTUMN_DEPLOY__SSH_PORT", &mut deploy.ssh_port);
6962 parse_env_option_string(env, "AUTUMN_DEPLOY__APP_NAME", &mut deploy.app_name);
6963 parse_env_option_string(env, "AUTUMN_DEPLOY__APP_DIR", &mut deploy.app_dir);
6964 parse_env_option_string(env, "AUTUMN_DEPLOY__SERVICE_NAME", &mut deploy.service_name);
6965 parse_env(
6966 env,
6967 "AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
6968 &mut deploy.readiness_timeout_secs,
6969 );
6970 parse_env(
6971 env,
6972 "AUTUMN_DEPLOY__KEEP_RELEASES",
6973 &mut deploy.keep_releases,
6974 );
6975 parse_env_string(env, "AUTUMN_DEPLOY__PROFILE", &mut deploy.profile);
6976 parse_env_bool(env, "AUTUMN_DEPLOY__TLS__ENABLED", &mut deploy.tls.enabled);
6979 parse_env_option_string(env, "AUTUMN_DEPLOY__TLS__HOST", &mut deploy.tls.host);
6980}
6981
6982fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
6984 if let Ok(val) = env.var(key) {
6985 match val.parse::<T>() {
6986 Ok(v) => *target = v,
6987 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
6988 }
6989 }
6990}
6991
6992fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
6993 if let Ok(val) = env.var(key) {
6994 *target = if val.is_empty() { None } else { Some(val) };
6995 }
6996}
6997
6998fn parse_env_option_secret(env: &dyn Env, key: &str, target: &mut Option<secrecy::SecretString>) {
7003 if let Ok(val) = env.var(key) {
7004 let trimmed = val.trim();
7005 *target = if trimmed.is_empty() {
7006 None
7007 } else {
7008 Some(secrecy::SecretString::from(trimmed.to_owned()))
7009 };
7010 }
7011}
7012
7013fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
7014 if let Ok(val) = env.var(key) {
7015 if val.is_empty() {
7016 *target = None;
7017 } else {
7018 match val.parse::<T>() {
7019 Ok(v) => *target = Some(v),
7020 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
7021 }
7022 }
7023 }
7024}
7025
7026fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
7027 if let Ok(val) = env.var(key) {
7028 *target = val;
7029 }
7030}
7031
7032fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
7033 if let Ok(val) = env.var(key) {
7034 match val.as_str() {
7035 "true" | "1" => *target = true,
7036 "false" | "0" => *target = false,
7037 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
7038 }
7039 }
7040}
7041
7042fn parse_env_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
7043 if let Ok(val) = env.var(key) {
7044 match val.as_str() {
7045 "true" | "1" => *target = Some(true),
7046 "false" | "0" => *target = Some(false),
7047 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
7048 }
7049 }
7050}
7051
7052fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
7053 if let Ok(val) = env.var(key) {
7054 *target = val.split(',').map(|s| s.trim().to_owned()).collect();
7055 }
7056}
7057
7058const fn default_port() -> u16 {
7061 3000
7062}
7063
7064fn default_host() -> String {
7065 "127.0.0.1".to_owned()
7066}
7067
7068const fn default_shutdown_timeout() -> u64 {
7069 30
7070}
7071
7072const fn default_prestop_grace() -> u64 {
7073 5
7074}
7075
7076const fn default_pool_size() -> usize {
7077 10
7078}
7079
7080const fn default_max_connections_warn_threshold() -> usize {
7081 100
7082}
7083
7084const fn default_connect_timeout() -> u64 {
7085 5
7086}
7087
7088const fn default_pin_after_write_secs() -> u64 {
7089 5
7090}
7091
7092fn default_log_level() -> String {
7093 "info".to_owned()
7094}
7095
7096const fn default_access_log() -> bool {
7097 true
7098}
7099
7100fn default_access_log_exclude() -> Vec<String> {
7101 vec![
7102 "/health".to_owned(),
7103 "/live".to_owned(),
7104 "/ready".to_owned(),
7105 "/startup".to_owned(),
7106 "/actuator".to_owned(),
7107 "/static".to_owned(),
7108 ]
7109}
7110
7111fn default_telemetry_service_name() -> String {
7112 "autumn-app".to_owned()
7113}
7114
7115fn default_telemetry_service_version() -> String {
7116 "unknown".to_owned()
7117}
7118
7119fn default_telemetry_environment() -> String {
7120 "development".to_owned()
7121}
7122
7123const fn default_tls_reload_interval_secs() -> u64 {
7129 60
7130}
7131
7132const fn default_tls_handshake_timeout_secs() -> u64 {
7138 10
7139}
7140
7141fn default_deploy_user() -> String {
7143 "root".to_owned()
7144}
7145
7146const fn default_deploy_ssh_port() -> u16 {
7148 22
7149}
7150
7151const fn default_deploy_readiness_timeout_secs() -> u64 {
7153 60
7154}
7155
7156const fn default_deploy_keep_releases() -> u32 {
7158 3
7159}
7160
7161fn default_deploy_profile() -> String {
7164 "prod".to_owned()
7165}
7166
7167fn default_acme_cache_dir() -> PathBuf {
7170 PathBuf::from("config/acme")
7171}
7172
7173const fn default_acme_http_challenge_port() -> u16 {
7176 80
7177}
7178
7179const fn default_acme_renew_before_days() -> u32 {
7183 30
7184}
7185
7186fn default_health_path() -> String {
7187 "/health".to_owned()
7188}
7189
7190fn default_live_path() -> String {
7191 "/live".to_owned()
7192}
7193
7194fn default_ready_path() -> String {
7195 "/ready".to_owned()
7196}
7197
7198fn default_startup_path() -> String {
7199 "/startup".to_owned()
7200}
7201
7202impl Default for ServerConfig {
7205 fn default() -> Self {
7206 Self {
7207 port: default_port(),
7208 host: default_host(),
7209 strict_config: false,
7210 strict_config_enforce_all: false,
7211 shutdown_timeout_secs: default_shutdown_timeout(),
7212 prestop_grace_secs: default_prestop_grace(),
7213 timeouts: RequestTimeoutsConfig::default(),
7214 unix_socket: None,
7215 max_concurrent_requests: None,
7216 tls: None,
7217 }
7218 }
7219}
7220
7221impl Default for DatabaseConfig {
7222 fn default() -> Self {
7223 Self {
7224 url: None,
7225 primary_url: None,
7226 replica_url: None,
7227 pool_size: default_pool_size(),
7228 primary_pool_size: None,
7229 replica_pool_size: None,
7230 replica_fallback: ReplicaFallback::default(),
7231 read_your_writes: ReadYourWrites::default(),
7232 pin_after_write_secs: default_pin_after_write_secs(),
7233 connect_timeout_secs: default_connect_timeout(),
7234 startup_wait_secs: 0,
7235 auto_migrate_in_production: false,
7236 statement_timeout: None,
7237 slow_query_threshold: default_slow_query_threshold(),
7238 shards: Vec::new(),
7239 directory_shard_router: false,
7240 max_connections_warn_threshold: default_max_connections_warn_threshold(),
7241 }
7242 }
7243}
7244
7245impl Default for LogConfig {
7246 fn default() -> Self {
7247 Self {
7248 level: default_log_level(),
7249 format: LogFormat::default(),
7250 filter_parameters: Vec::new(),
7251 unfilter_parameters: Vec::new(),
7252 access_log: default_access_log(),
7253 access_log_exclude: default_access_log_exclude(),
7254 capture: crate::log::capture::LogCaptureConfig::default(),
7255 }
7256 }
7257}
7258
7259impl Default for TelemetryConfig {
7260 fn default() -> Self {
7261 Self {
7262 enabled: false,
7263 service_name: default_telemetry_service_name(),
7264 service_namespace: None,
7265 service_version: default_telemetry_service_version(),
7266 environment: default_telemetry_environment(),
7267 otlp_endpoint: None,
7268 protocol: TelemetryProtocol::default(),
7269 strict: false,
7270 }
7271 }
7272}
7273
7274impl Default for HealthConfig {
7275 fn default() -> Self {
7276 Self {
7277 path: default_health_path(),
7278 live_path: default_live_path(),
7279 ready_path: default_ready_path(),
7280 startup_path: default_startup_path(),
7281 detailed: false,
7282 }
7283 }
7284}
7285
7286pub trait ConfigLoader: Send + Sync + 'static {
7317 fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
7325}
7326
7327#[derive(Debug, Default, Clone)]
7333pub struct TomlEnvConfigLoader {
7334 allowed_plugin_roots: BTreeSet<String>,
7340}
7341
7342impl TomlEnvConfigLoader {
7343 #[must_use]
7345 pub const fn new() -> Self {
7346 Self {
7347 allowed_plugin_roots: BTreeSet::new(),
7348 }
7349 }
7350
7351 #[must_use]
7361 pub fn with_plugin_config_roots(mut self, roots: BTreeSet<String>) -> Self {
7362 self.allowed_plugin_roots = roots;
7363 self
7364 }
7365}
7366
7367impl ConfigLoader for TomlEnvConfigLoader {
7368 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
7369 let base = OsEnv;
7377 let profile = resolve_profile(&base);
7378 let dir = crate::dotenv::dotenv_base_dir(&base);
7383 let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
7384 .map_err(|e| ConfigError::Dotenv(e.to_string()))?;
7385 let env = crate::dotenv::DotenvEnv::new(&base, vars);
7386 AutumnConfig::load_with_env_and_plugin_roots(&env, &self.allowed_plugin_roots)
7387 }
7388}
7389
7390const fn default_slow_query_threshold() -> std::time::Duration {
7391 std::time::Duration::from_millis(500)
7392}
7393
7394pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
7401 if s.is_empty() {
7402 return Err("duration string is empty".to_owned());
7403 }
7404
7405 if let Ok(ms) = s.parse::<u64>() {
7407 return Ok(std::time::Duration::from_millis(ms));
7408 }
7409
7410 if let Some(val_str) = s.strip_suffix("ms") {
7412 let val = val_str
7413 .parse::<u64>()
7414 .map_err(|e| format!("invalid duration integer: {e}"))?;
7415 return Ok(std::time::Duration::from_millis(val));
7416 }
7417
7418 if let Some(val_str) = s.strip_suffix('s') {
7419 let val = val_str
7420 .parse::<u64>()
7421 .map_err(|e| format!("invalid duration integer: {e}"))?;
7422 return Ok(std::time::Duration::from_secs(val));
7423 }
7424
7425 if let Some(val_str) = s.strip_suffix('m') {
7426 let val = val_str
7427 .parse::<u64>()
7428 .map_err(|e| format!("invalid duration integer: {e}"))?;
7429 let secs = val.checked_mul(60).ok_or_else(|| {
7430 format!("duration overflow: '{s}' exceeds maximum representable value")
7431 })?;
7432 return Ok(std::time::Duration::from_secs(secs));
7433 }
7434
7435 if let Some(val_str) = s.strip_suffix('h') {
7436 let val = val_str
7437 .parse::<u64>()
7438 .map_err(|e| format!("invalid duration integer: {e}"))?;
7439 let secs = val.checked_mul(3600).ok_or_else(|| {
7440 format!("duration overflow: '{s}' exceeds maximum representable value")
7441 })?;
7442 return Ok(std::time::Duration::from_secs(secs));
7443 }
7444
7445 Err(format!("invalid duration format: '{s}'"))
7446}
7447
7448pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
7456where
7457 D: serde::Deserializer<'de>,
7458{
7459 use serde::Deserialize;
7460
7461 #[derive(Deserialize)]
7462 #[serde(untagged)]
7463 enum DurationOrStr {
7464 String(String),
7465 Integer(u64),
7466 }
7467
7468 match DurationOrStr::deserialize(deserializer)? {
7469 DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
7470 DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
7471 }
7472}
7473
7474pub fn deserialize_option_duration<'de, D>(
7482 deserializer: D,
7483) -> Result<Option<std::time::Duration>, D::Error>
7484where
7485 D: serde::Deserializer<'de>,
7486{
7487 use serde::Deserialize;
7488
7489 #[derive(Deserialize)]
7490 struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
7491
7492 Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
7493}
7494
7495#[derive(Debug, Clone, Deserialize)]
7497pub struct TenancyConfig {
7498 #[serde(default)]
7500 pub enabled: bool,
7501
7502 #[serde(default = "default_tenancy_source")]
7505 pub source: String,
7506
7507 #[serde(default = "default_tenancy_header_name")]
7509 pub header_name: String,
7510
7511 #[serde(default = "default_tenancy_session_key")]
7513 pub session_key: String,
7514
7515 #[serde(default = "default_tenancy_jwt_claim")]
7517 pub jwt_claim: String,
7518
7519 #[serde(default)]
7525 pub jwt_secret: Option<secrecy::SecretString>,
7526
7527 #[serde(default)]
7529 pub jwt_issuer: Option<String>,
7530
7531 #[serde(default)]
7535 pub jwt_audience: Option<String>,
7536
7537 #[serde(default)]
7539 pub base_domain: Option<String>,
7540
7541 #[serde(default)]
7548 pub public_paths: Vec<String>,
7549
7550 #[serde(default)]
7556 pub login_redirect: Option<String>,
7557
7558 #[serde(default)]
7561 pub quota_bytes: usize,
7562
7563 #[serde(default)]
7566 pub max_cells: usize,
7567
7568 #[serde(default)]
7571 pub idle_ttl_secs: u64,
7572}
7573
7574fn default_tenancy_source() -> String {
7575 "header".to_string()
7576}
7577
7578fn default_tenancy_header_name() -> String {
7579 "x-tenant-id".to_string()
7580}
7581
7582fn default_tenancy_session_key() -> String {
7583 "tenant_id".to_string()
7584}
7585
7586fn default_tenancy_jwt_claim() -> String {
7587 "tenant_id".to_string()
7588}
7589
7590impl Default for TenancyConfig {
7591 fn default() -> Self {
7592 Self {
7593 enabled: false,
7594 source: default_tenancy_source(),
7595 header_name: default_tenancy_header_name(),
7596 session_key: default_tenancy_session_key(),
7597 jwt_claim: default_tenancy_jwt_claim(),
7598 jwt_secret: None,
7599 jwt_issuer: None,
7600 jwt_audience: None,
7601 base_domain: None,
7602 public_paths: Vec::new(),
7603 login_redirect: None,
7604 quota_bytes: 0,
7605 max_cells: 0,
7606 idle_ttl_secs: 0,
7607 }
7608 }
7609}
7610
7611#[derive(Debug, Clone, Default, Deserialize)]
7615pub struct ResilienceConfig {
7616 #[serde(default)]
7618 pub circuit_breaker: CircuitBreakerConfig,
7619}
7620
7621#[derive(Debug, Clone, Default, Deserialize)]
7623pub struct CircuitBreakerConfig {
7624 #[serde(default)]
7626 pub defaults: CircuitBreakerPolicyConfig,
7627 #[serde(default)]
7629 pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
7630}
7631
7632#[derive(Debug, Clone, Default, Deserialize)]
7634pub struct CircuitBreakerPolicyConfig {
7635 pub failure_ratio_threshold: Option<f64>,
7637 pub sample_window_secs: Option<u64>,
7639 pub minimum_sample_count: Option<u64>,
7641 pub open_duration_secs: Option<u64>,
7643 pub half_open_trial_count: Option<u64>,
7645}
7646
7647impl AutumnConfig {
7648 fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
7649 parse_env_option(
7650 env,
7651 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
7652 &mut self
7653 .resilience
7654 .circuit_breaker
7655 .defaults
7656 .failure_ratio_threshold,
7657 );
7658 parse_env_option(
7659 env,
7660 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
7661 &mut self.resilience.circuit_breaker.defaults.sample_window_secs,
7662 );
7663 parse_env_option(
7664 env,
7665 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
7666 &mut self
7667 .resilience
7668 .circuit_breaker
7669 .defaults
7670 .minimum_sample_count,
7671 );
7672 parse_env_option(
7673 env,
7674 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
7675 &mut self.resilience.circuit_breaker.defaults.open_duration_secs,
7676 );
7677 parse_env_option(
7678 env,
7679 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
7680 &mut self
7681 .resilience
7682 .circuit_breaker
7683 .defaults
7684 .half_open_trial_count,
7685 );
7686 }
7687}
7688
7689use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
7690use std::collections::{BTreeSet, HashMap, HashSet};
7691use std::sync::{Arc, Mutex};
7692
7693#[derive(Clone, Copy, PartialEq, Eq)]
7694enum AnyProbe {
7695 Str,
7696 Map,
7697 Seq,
7698}
7699
7700#[derive(Clone)]
7701pub struct SchemaDeserializer {
7702 path: Vec<String>,
7703 schema: Arc<Mutex<HashMap<String, HashSet<String>>>>,
7704 any_probe: Arc<Mutex<HashMap<String, AnyProbe>>>,
7709 rejected: Arc<Mutex<Vec<String>>>,
7711}
7712
7713impl Default for SchemaDeserializer {
7714 fn default() -> Self {
7715 Self::new()
7716 }
7717}
7718
7719impl SchemaDeserializer {
7720 #[must_use]
7721 pub fn new() -> Self {
7722 Self {
7723 path: Vec::new(),
7724 schema: Arc::new(Mutex::new(HashMap::new())),
7725 any_probe: Arc::new(Mutex::new(HashMap::new())),
7726 rejected: Arc::new(Mutex::new(Vec::new())),
7727 }
7728 }
7729
7730 #[must_use]
7731 pub fn into_schema(self) -> HashMap<String, HashSet<String>> {
7732 let lock = self
7733 .schema
7734 .lock()
7735 .unwrap_or_else(std::sync::PoisonError::into_inner);
7736 lock.clone()
7737 }
7738}
7739
7740impl<'de> de::Deserializer<'de> for SchemaDeserializer {
7741 type Error = serde::de::value::Error;
7742
7743 fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7744 where
7745 V: Visitor<'de>,
7746 {
7747 let path = self.path.join(".");
7756 let probe = self
7757 .any_probe
7758 .lock()
7759 .unwrap_or_else(std::sync::PoisonError::into_inner)
7760 .get(&path)
7761 .copied()
7762 .unwrap_or(AnyProbe::Str);
7763 let result = match probe {
7764 AnyProbe::Str => visitor.visit_str("0"),
7767 AnyProbe::Map => visitor.visit_map(SchemaMapAccess {
7771 fields: [].iter(),
7772 current_field: None,
7773 deserializer: self.clone(),
7774 }),
7775 AnyProbe::Seq => visitor.visit_seq(SchemaSeqAccess {
7776 done: true,
7777 deserializer: self.clone(),
7778 }),
7779 };
7780 if result.is_err() {
7781 self.rejected
7782 .lock()
7783 .unwrap_or_else(std::sync::PoisonError::into_inner)
7784 .push(path);
7785 }
7786 result
7787 }
7788
7789 fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7790 where
7791 V: Visitor<'de>,
7792 {
7793 visitor.visit_bool(false)
7794 }
7795
7796 fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7797 where
7798 V: Visitor<'de>,
7799 {
7800 visitor.visit_i8(0)
7801 }
7802
7803 fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7804 where
7805 V: Visitor<'de>,
7806 {
7807 visitor.visit_i16(0)
7808 }
7809
7810 fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7811 where
7812 V: Visitor<'de>,
7813 {
7814 visitor.visit_i32(0)
7815 }
7816
7817 fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7818 where
7819 V: Visitor<'de>,
7820 {
7821 visitor.visit_i64(0)
7822 }
7823
7824 fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7825 where
7826 V: Visitor<'de>,
7827 {
7828 visitor.visit_u8(0)
7829 }
7830
7831 fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7832 where
7833 V: Visitor<'de>,
7834 {
7835 visitor.visit_u16(0)
7836 }
7837
7838 fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7839 where
7840 V: Visitor<'de>,
7841 {
7842 visitor.visit_u32(0)
7843 }
7844
7845 fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7846 where
7847 V: Visitor<'de>,
7848 {
7849 visitor.visit_u64(0)
7850 }
7851
7852 fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7853 where
7854 V: Visitor<'de>,
7855 {
7856 visitor.visit_f32(0.0)
7857 }
7858
7859 fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7860 where
7861 V: Visitor<'de>,
7862 {
7863 visitor.visit_f64(0.0)
7864 }
7865
7866 fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7867 where
7868 V: Visitor<'de>,
7869 {
7870 visitor.visit_char('\0')
7871 }
7872
7873 fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7874 where
7875 V: Visitor<'de>,
7876 {
7877 visitor.visit_str("")
7878 }
7879
7880 fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7881 where
7882 V: Visitor<'de>,
7883 {
7884 visitor.visit_string(String::new())
7885 }
7886
7887 fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7888 where
7889 V: Visitor<'de>,
7890 {
7891 visitor.visit_bytes(&[])
7892 }
7893
7894 fn deserialize_byte_buf<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7895 where
7896 V: Visitor<'de>,
7897 {
7898 visitor.visit_byte_buf(Vec::new())
7899 }
7900
7901 fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7902 where
7903 V: Visitor<'de>,
7904 {
7905 visitor.visit_some(self)
7906 }
7907
7908 fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7909 where
7910 V: Visitor<'de>,
7911 {
7912 visitor.visit_unit()
7913 }
7914
7915 fn deserialize_unit_struct<V>(
7916 self,
7917 _name: &'static str,
7918 visitor: V,
7919 ) -> Result<V::Value, Self::Error>
7920 where
7921 V: Visitor<'de>,
7922 {
7923 visitor.visit_unit()
7924 }
7925
7926 fn deserialize_newtype_struct<V>(
7927 self,
7928 _name: &'static str,
7929 visitor: V,
7930 ) -> Result<V::Value, Self::Error>
7931 where
7932 V: Visitor<'de>,
7933 {
7934 visitor.visit_newtype_struct(self)
7935 }
7936
7937 fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7938 where
7939 V: Visitor<'de>,
7940 {
7941 visitor.visit_seq(SchemaSeqAccess {
7942 done: false,
7943 deserializer: self,
7944 })
7945 }
7946
7947 fn deserialize_tuple<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
7948 where
7949 V: Visitor<'de>,
7950 {
7951 self.deserialize_seq(visitor)
7952 }
7953
7954 fn deserialize_tuple_struct<V>(
7955 self,
7956 _name: &'static str,
7957 _len: usize,
7958 visitor: V,
7959 ) -> Result<V::Value, Self::Error>
7960 where
7961 V: Visitor<'de>,
7962 {
7963 self.deserialize_seq(visitor)
7964 }
7965
7966 fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
7967 where
7968 V: Visitor<'de>,
7969 {
7970 visitor.visit_map(SchemaMapAccess {
7971 fields: [].iter(),
7972 current_field: None,
7973 deserializer: self,
7974 })
7975 }
7976
7977 fn deserialize_struct<V>(
7978 self,
7979 _name: &'static str,
7980 fields: &'static [&'static str],
7981 visitor: V,
7982 ) -> Result<V::Value, Self::Error>
7983 where
7984 V: Visitor<'de>,
7985 {
7986 let path_str = self.path.join(".");
7987 {
7988 let mut schema = self.schema.lock().unwrap();
7989 schema.insert(path_str, fields.iter().map(|&s| s.to_string()).collect());
7990 }
7991
7992 visitor.visit_map(SchemaMapAccess {
7993 fields: fields.iter(),
7994 current_field: None,
7995 deserializer: self,
7996 })
7997 }
7998
7999 fn deserialize_enum<V>(
8000 self,
8001 _name: &'static str,
8002 variants: &'static [&'static str],
8003 visitor: V,
8004 ) -> Result<V::Value, Self::Error>
8005 where
8006 V: Visitor<'de>,
8007 {
8008 visitor.visit_enum(SchemaEnumAccess {
8017 variant: variants.first().copied().unwrap_or_default(),
8018 })
8019 }
8020
8021 fn deserialize_identifier<V>(self, visitor: V) -> Result<V::Value, Self::Error>
8022 where
8023 V: Visitor<'de>,
8024 {
8025 visitor.visit_str("")
8026 }
8027
8028 fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
8029 where
8030 V: Visitor<'de>,
8031 {
8032 visitor.visit_unit()
8033 }
8034}
8035
8036struct SchemaSeqAccess {
8037 done: bool,
8038 deserializer: SchemaDeserializer,
8039}
8040
8041impl<'de> SeqAccess<'de> for SchemaSeqAccess {
8042 type Error = serde::de::value::Error;
8043
8044 fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
8045 where
8046 T: DeserializeSeed<'de>,
8047 {
8048 if self.done {
8049 Ok(None)
8050 } else {
8051 self.done = true;
8052 seed.deserialize(self.deserializer.clone()).map(Some)
8053 }
8054 }
8055}
8056
8057struct SchemaMapAccess {
8058 fields: std::slice::Iter<'static, &'static str>,
8059 current_field: Option<&'static str>,
8060 deserializer: SchemaDeserializer,
8061}
8062
8063impl<'de> MapAccess<'de> for SchemaMapAccess {
8064 type Error = serde::de::value::Error;
8065
8066 fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
8067 where
8068 K: DeserializeSeed<'de>,
8069 {
8070 if let Some(&field) = self.fields.next() {
8071 self.current_field = Some(field);
8072 seed.deserialize(de::value::StrDeserializer::new(field))
8073 .map(Some)
8074 } else {
8075 Ok(None)
8076 }
8077 }
8078
8079 fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
8080 where
8081 V: DeserializeSeed<'de>,
8082 {
8083 let field = self.current_field.take().unwrap();
8084 let mut new_path = self.deserializer.path.clone();
8085 new_path.push(field.to_string());
8086
8087 let nested = SchemaDeserializer {
8088 path: new_path,
8089 schema: self.deserializer.schema.clone(),
8090 any_probe: self.deserializer.any_probe.clone(),
8091 rejected: self.deserializer.rejected.clone(),
8092 };
8093 seed.deserialize(nested)
8094 }
8095}
8096
8097struct SchemaEnumAccess {
8098 variant: &'static str,
8102}
8103
8104impl<'de> de::EnumAccess<'de> for SchemaEnumAccess {
8105 type Error = serde::de::value::Error;
8106 type Variant = Self;
8107
8108 fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
8109 where
8110 V: de::DeserializeSeed<'de>,
8111 {
8112 let val = seed.deserialize(de::value::StrDeserializer::new(self.variant))?;
8113 Ok((val, self))
8114 }
8115}
8116
8117impl<'de> de::VariantAccess<'de> for SchemaEnumAccess {
8118 type Error = serde::de::value::Error;
8119
8120 fn unit_variant(self) -> Result<(), Self::Error> {
8121 Ok(())
8122 }
8123
8124 fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
8125 where
8126 T: de::DeserializeSeed<'de>,
8127 {
8128 seed.deserialize(SchemaDeserializer::new())
8129 }
8130
8131 fn tuple_variant<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
8132 where
8133 V: Visitor<'de>,
8134 {
8135 visitor.visit_unit()
8136 }
8137
8138 fn struct_variant<V>(
8139 self,
8140 _fields: &'static [&'static str],
8141 visitor: V,
8142 ) -> Result<V::Value, Self::Error>
8143 where
8144 V: Visitor<'de>,
8145 {
8146 visitor.visit_unit()
8147 }
8148}
8149
8150#[cfg(test)]
8151mod tests {
8152
8153 use super::*;
8154
8155 struct FakeEnv(std::collections::HashMap<String, String>);
8156 impl Env for FakeEnv {
8157 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
8158 self.0
8159 .get(key)
8160 .cloned()
8161 .ok_or(std::env::VarError::NotPresent)
8162 }
8163 }
8164
8165 #[test]
8166 fn test_schema_extractor() {
8167 let keys = AutumnConfig::get_schema_keys();
8168 assert!(keys.contains_key(""));
8169 let root_keys = &keys[""];
8170 assert!(root_keys.contains("server"));
8171 assert!(root_keys.contains("database"));
8172
8173 assert!(keys.contains_key("server"));
8174 assert!(keys["server"].contains("port"));
8175 assert!(keys["server"].contains("host"));
8176
8177 assert!(keys.contains_key("database"));
8178 assert!(keys["database"].contains("primary_url"));
8179 }
8180
8181 #[cfg(feature = "acme")]
8189 #[test]
8190 fn acme_enum_field_does_not_truncate_schema_traversal() {
8191 let keys = AutumnConfig::get_schema_keys();
8192 assert!(
8193 keys.contains_key("server.tls.acme"),
8194 "acme section must be in the schema"
8195 );
8196 assert!(
8197 keys.contains_key("database"),
8198 "database schema dropped: the acme enum truncated traversal"
8199 );
8200 assert!(keys["database"].contains("primary_url"));
8201
8202 let errs = AutumnConfig::validate_toml("[database]\nprimry_url = \"x\"\n", &keys);
8205 assert_eq!(
8206 errs,
8207 vec![(
8208 "database.primry_url".to_owned(),
8209 Some("database.primary_url".to_owned())
8210 )]
8211 );
8212 }
8213
8214 #[test]
8215 fn test_strict_config_startup_fails_on_typo() {
8216 let temp = tempfile::tempdir().unwrap();
8217 let config_path = temp.path().join("autumn.toml");
8218 std::fs::write(
8219 &config_path,
8220 "[database]\nprimry_url = \"postgres://localhost/db\"",
8221 )
8222 .unwrap();
8223
8224 let env = FakeEnv(
8225 [
8226 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8227 (
8228 "AUTUMN_MANIFEST_DIR".to_owned(),
8229 temp.path().to_str().unwrap().to_owned(),
8230 ),
8231 ]
8232 .into(),
8233 );
8234
8235 let res = AutumnConfig::load_with_env(&env);
8236 assert!(res.is_err());
8237 let err_str = format!("{:?}", res.err().unwrap());
8238 assert!(err_str.contains("primry_url"));
8239 }
8240
8241 fn strict_prod_env_2063(temp: &std::path::Path) -> FakeEnv {
8246 FakeEnv(
8247 [
8248 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8249 (
8250 "AUTUMN_MANIFEST_DIR".to_owned(),
8251 temp.to_str().unwrap().to_owned(),
8252 ),
8253 ]
8254 .into(),
8255 )
8256 }
8257
8258 #[test]
8263 fn deploy_cli_lenient_accepts_plugin_owned_top_level_root() {
8264 let temp = tempfile::tempdir().unwrap();
8265 std::fs::write(
8266 temp.path().join("autumn.toml"),
8267 "[server]\nstrict_config = true\n\n[media]\nmediamtx_host = \"cdn.example\"\n",
8268 )
8269 .unwrap();
8270 let env = strict_prod_env_2063(temp.path());
8271
8272 let strict = AutumnConfig::load_with_env(&env);
8274 assert!(
8275 strict.is_err(),
8276 "app boot must stay strict for unknown plugin roots: {strict:?}"
8277 );
8278 let strict_err = format!("{:?}", strict.err().unwrap());
8279 assert!(
8280 strict_err.contains("media"),
8281 "strict error should name the unknown root: {strict_err}"
8282 );
8283
8284 let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
8286 assert!(
8287 lenient.is_ok(),
8288 "deploy CLI must accept plugin-owned [media] under strict_config: {lenient:?}"
8289 );
8290 }
8291
8292 #[test]
8295 fn deploy_cli_lenient_accepts_arbitrary_unknown_top_level_root() {
8296 let temp = tempfile::tempdir().unwrap();
8297 std::fs::write(
8298 temp.path().join("autumn.toml"),
8299 "[server]\nstrict_config = true\n\n[definitely_not_a_root]\nx = 1\n",
8300 )
8301 .unwrap();
8302 let env = strict_prod_env_2063(temp.path());
8303
8304 assert!(
8305 AutumnConfig::load_with_env(&env).is_err(),
8306 "app boot must reject an unknown top-level root"
8307 );
8308 assert!(
8309 AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_ok(),
8310 "deploy CLI must accept an unknown top-level root as opaque"
8311 );
8312 }
8313
8314 #[test]
8318 fn deploy_cli_lenient_still_rejects_known_section_typo() {
8319 let temp = tempfile::tempdir().unwrap();
8320 std::fs::write(
8321 temp.path().join("autumn.toml"),
8322 "[server]\nstrict_config = true\n\n[database]\nprimry_url = \"postgres://localhost/db\"\n",
8323 )
8324 .unwrap();
8325 let env = strict_prod_env_2063(temp.path());
8326
8327 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
8328 assert!(
8329 res.is_err(),
8330 "known-section typo must still hard-fail under the lenient CLI load: {res:?}"
8331 );
8332 let err = format!("{:?}", res.err().unwrap());
8333 assert!(
8334 err.contains("primry_url"),
8335 "error should name the known-section typo: {err}"
8336 );
8337 }
8338
8339 #[test]
8342 fn deploy_cli_lenient_still_rejects_malformed_toml() {
8343 let temp = tempfile::tempdir().unwrap();
8344 std::fs::write(
8345 temp.path().join("autumn.toml"),
8346 "[server]\nstrict_config = true\n\nthis is not = = valid toml\n",
8347 )
8348 .unwrap();
8349 let env = strict_prod_env_2063(temp.path());
8350
8351 assert!(
8352 AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_err(),
8353 "malformed TOML must still fail under the lenient CLI load"
8354 );
8355 }
8356
8357 #[test]
8366 fn deploy_cli_lenient_still_rejects_profile_prefixed_root() {
8367 let temp = tempfile::tempdir().unwrap();
8368 std::fs::write(
8369 temp.path().join("autumn.toml"),
8370 "[server]\nstrict_config = true\n\n[profile.prod.media]\nmediamtx_host = \"cdn.example\"\n",
8371 )
8372 .unwrap();
8373 let env = strict_prod_env_2063(temp.path());
8374
8375 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
8376 assert!(
8377 res.is_err(),
8378 "a profile-prefixed root ([profile.prod.media]) must stay strict under \
8379 the lenient CLI load — it is not a true top-level root and the deployed \
8380 app rejects it at boot: {res:?}"
8381 );
8382 let err = format!("{:?}", res.err().unwrap());
8383 assert!(
8384 err.contains("media"),
8385 "error should name the profile-prefixed root: {err}"
8386 );
8387 }
8388
8389 #[test]
8394 fn deploy_cli_lenient_still_rejects_profile_prefixed_unknown_root() {
8395 let temp = tempfile::tempdir().unwrap();
8396 std::fs::write(
8397 temp.path().join("autumn.toml"),
8398 "[server]\nstrict_config = true\n\n[profile.prod.definitely_unknown]\nx = 1\n",
8399 )
8400 .unwrap();
8401 let env = strict_prod_env_2063(temp.path());
8402
8403 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
8404 assert!(
8405 res.is_err(),
8406 "a profile-prefixed unknown root must stay strict under the lenient CLI \
8407 load: {res:?}"
8408 );
8409 let err = format!("{:?}", res.err().unwrap());
8410 assert!(
8411 err.contains("definitely_unknown"),
8412 "error should name the profile-prefixed unknown root: {err}"
8413 );
8414 }
8415
8416 #[test]
8425 fn deploy_cli_lenient_still_rejects_non_table_root() {
8426 let temp = tempfile::tempdir().unwrap();
8428 std::fs::write(
8429 temp.path().join("autumn.toml"),
8430 "[server]\nstrict_config = true\n\nmedia = \"enabled\"\n",
8431 )
8432 .unwrap();
8433 let env = strict_prod_env_2063(temp.path());
8434
8435 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
8436 assert!(
8437 res.is_err(),
8438 "a SCALAR top-level root (media = \"enabled\") must hard-fail under the \
8439 lenient CLI load — it is not a table and the deployed app rejects it at \
8440 boot: {res:?}"
8441 );
8442 let err = format!("{:?}", res.err().unwrap());
8443 assert!(
8444 err.contains("media"),
8445 "error should name the non-table root: {err}"
8446 );
8447
8448 let temp2 = tempfile::tempdir().unwrap();
8450 std::fs::write(
8451 temp2.path().join("autumn.toml"),
8452 "[server]\nstrict_config = true\n\nmedia = [\"a\", \"b\"]\n",
8453 )
8454 .unwrap();
8455 let env2 = strict_prod_env_2063(temp2.path());
8456
8457 let res2 = AutumnConfig::load_with_env_lenient_unknown_roots(&env2);
8458 assert!(
8459 res2.is_err(),
8460 "an ARRAY top-level root (media = [\"a\", \"b\"]) must hard-fail under the \
8461 lenient CLI load — it is not a table and the deployed app rejects it at \
8462 boot: {res2:?}"
8463 );
8464 let err2 = format!("{:?}", res2.err().unwrap());
8465 assert!(
8466 err2.contains("media"),
8467 "error should name the non-table root: {err2}"
8468 );
8469 }
8470
8471 #[test]
8481 fn deploy_cli_lenient_accepts_quoted_dotted_top_level_root() {
8482 let temp = tempfile::tempdir().unwrap();
8486 std::fs::write(
8487 temp.path().join("autumn.toml"),
8488 "[server]\nstrict_config = true\n\n[\"my.plugin\"]\nenabled = true\n",
8489 )
8490 .unwrap();
8491 let env = strict_prod_env_2063(temp.path());
8492
8493 let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
8494 assert!(
8495 lenient.is_ok(),
8496 "deploy CLI must leniently accept a quoted-dotted TOP-LEVEL table root \
8497 ([\"my.plugin\"]) — it is a true top-level plugin root the app accepts at \
8498 boot, and top-level-ness is structural (empty parent path), not \
8499 `path.contains('.')`: {lenient:?}"
8500 );
8501
8502 let temp2 = tempfile::tempdir().unwrap();
8506 std::fs::write(
8507 temp2.path().join("autumn.toml"),
8508 "\"my.plugin\" = { enabled = true }\n\n[server]\nstrict_config = true\n",
8509 )
8510 .unwrap();
8511 let env2 = strict_prod_env_2063(temp2.path());
8512 assert!(
8513 AutumnConfig::load_with_env_lenient_unknown_roots(&env2).is_ok(),
8514 "deploy CLI must accept the inline-table quoted-dotted top-level root too"
8515 );
8516 }
8517
8518 #[test]
8522 fn post_database_section_typo_warns_but_does_not_fail() {
8523 let temp = tempfile::tempdir().unwrap();
8524 let config_path = temp.path().join("autumn.toml");
8525 std::fs::write(&config_path, "[log]\nbogus_zzz = true\n").unwrap();
8526
8527 let env = FakeEnv(
8528 [
8529 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8530 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8535 (
8536 "AUTUMN_MANIFEST_DIR".to_owned(),
8537 temp.path().to_str().unwrap().to_owned(),
8538 ),
8539 ]
8540 .into(),
8541 );
8542
8543 let res = AutumnConfig::load_with_env(&env);
8544 assert!(
8545 res.is_ok(),
8546 "a post-database section typo must warn (not fail) under warn-first rollout: {res:?}"
8547 );
8548 }
8549
8550 #[test]
8553 fn post_database_section_typo_fails_under_enforce_all() {
8554 let temp = tempfile::tempdir().unwrap();
8555 let config_path = temp.path().join("autumn.toml");
8556 std::fs::write(
8557 &config_path,
8558 "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n[log]\nbogus_zzz = true\n",
8559 )
8560 .unwrap();
8561
8562 let env = FakeEnv(
8563 [
8564 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8569 (
8570 "AUTUMN_MANIFEST_DIR".to_owned(),
8571 temp.path().to_str().unwrap().to_owned(),
8572 ),
8573 ]
8574 .into(),
8575 );
8576
8577 let res = AutumnConfig::load_with_env(&env);
8578 assert!(
8579 res.is_err(),
8580 "strict_config_enforce_all must hard-fail the post-database typo"
8581 );
8582 let err_str = format!("{:?}", res.err().unwrap());
8583 assert!(
8584 err_str.contains("bogus_zzz"),
8585 "error should name the key: {err_str}"
8586 );
8587 }
8588
8589 #[test]
8592 fn pre_database_section_typo_still_hard_fails() {
8593 let temp = tempfile::tempdir().unwrap();
8594 let config_path = temp.path().join("autumn.toml");
8595 std::fs::write(&config_path, "[server]\nbogus_zzz = true\n").unwrap();
8596
8597 let env = FakeEnv(
8598 [
8599 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8600 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8604 (
8605 "AUTUMN_MANIFEST_DIR".to_owned(),
8606 temp.path().to_str().unwrap().to_owned(),
8607 ),
8608 ]
8609 .into(),
8610 );
8611
8612 let res = AutumnConfig::load_with_env(&env);
8613 assert!(
8614 res.is_err(),
8615 "an unknown [server] key must still hard-fail (pre-fix strictness preserved)"
8616 );
8617 let err_str = format!("{:?}", res.err().unwrap());
8618 assert!(
8619 err_str.contains("bogus_zzz"),
8620 "error should name the key: {err_str}"
8621 );
8622 }
8623
8624 fn plugin_roots(names: &[&str]) -> BTreeSet<String> {
8634 names.iter().map(|s| (*s).to_owned()).collect()
8635 }
8636
8637 fn strict_prod_env(dir: &std::path::Path, enforce_all: bool) -> FakeEnv {
8638 let mut vars = vec![
8639 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8640 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8641 (
8642 "AUTUMN_MANIFEST_DIR".to_owned(),
8643 dir.to_str().unwrap().to_owned(),
8644 ),
8645 ];
8646 if enforce_all {
8647 vars.push((
8648 "AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL".to_owned(),
8649 "true".to_owned(),
8650 ));
8651 }
8652 FakeEnv(vars.into_iter().collect())
8653 }
8654
8655 #[test]
8658 fn strict_config_accepts_registered_plugin_root() {
8659 let temp = tempfile::tempdir().unwrap();
8660 std::fs::write(
8661 temp.path().join("autumn.toml"),
8662 "[media]\nqueue = \"media\"\n[media.mediamtx]\napi_base = \"http://localhost:9997\"\n",
8663 )
8664 .unwrap();
8665
8666 let env = strict_prod_env(temp.path(), false);
8667 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
8668 assert!(
8669 res.is_ok(),
8670 "a registered [media] root must boot under strict_config: {res:?}"
8671 );
8672 }
8673
8674 #[test]
8682 fn strict_config_accepts_quoted_dotted_registered_plugin_root() {
8683 let temp = tempfile::tempdir().unwrap();
8684 std::fs::write(
8685 temp.path().join("autumn.toml"),
8686 "[\"my.plugin\"]\nenabled = true\n[\"my.plugin\".nested]\nx = 1\n",
8687 )
8688 .unwrap();
8689
8690 let env = strict_prod_env(temp.path(), false);
8691 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["my.plugin"]));
8692 assert!(
8693 res.is_ok(),
8694 "a registered quoted-dotted top-level root ([\"my.plugin\"]) must boot \
8695 under strict_config, exactly as deploy leniency accepts it: {res:?}"
8696 );
8697 }
8698
8699 #[test]
8705 fn strict_config_rejects_non_table_registered_plugin_root() {
8706 let temp = tempfile::tempdir().unwrap();
8709 std::fs::write(temp.path().join("autumn.toml"), "media = \"enabled\"\n").unwrap();
8710
8711 let env = strict_prod_env(temp.path(), false);
8712 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
8713 assert!(
8714 res.is_err(),
8715 "a scalar-valued registered root (media = \"enabled\") must hard-fail \
8716 under strict_config, not be exempted as an opaque table: {res:?}"
8717 );
8718 assert!(
8719 format!("{:?}", res.err().unwrap()).contains("media"),
8720 "error should name the malformed media root"
8721 );
8722
8723 let temp_arr = tempfile::tempdir().unwrap();
8726 std::fs::write(
8727 temp_arr.path().join("autumn.toml"),
8728 "media = [\"a\", \"b\"]\n",
8729 )
8730 .unwrap();
8731
8732 let env_arr = strict_prod_env(temp_arr.path(), false);
8733 let res_arr =
8734 AutumnConfig::load_with_env_and_plugin_roots(&env_arr, &plugin_roots(&["media"]));
8735 assert!(
8736 res_arr.is_err(),
8737 "an array-valued registered root (media = [\"a\", \"b\"]) must hard-fail \
8738 under strict_config, not be exempted as an opaque table: {res_arr:?}"
8739 );
8740 assert!(
8741 format!("{:?}", res_arr.err().unwrap()).contains("media"),
8742 "error should name the malformed media array root"
8743 );
8744 }
8745
8746 #[test]
8749 fn strict_config_rejects_unregistered_plugin_root() {
8750 let temp = tempfile::tempdir().unwrap();
8751 std::fs::write(
8752 temp.path().join("autumn.toml"),
8753 "[media]\nqueue = \"media\"\n",
8754 )
8755 .unwrap();
8756
8757 let env = strict_prod_env(temp.path(), false);
8758 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
8759 assert!(
8760 res.is_err(),
8761 "an unregistered [media] root must still hard-fail under strict_config"
8762 );
8763 assert!(format!("{:?}", res.err().unwrap()).contains("media"));
8764 }
8765
8766 #[test]
8769 fn strict_config_still_rejects_other_unknown_root_when_plugin_registered() {
8770 let temp = tempfile::tempdir().unwrap();
8771 std::fs::write(
8772 temp.path().join("autumn.toml"),
8773 "[media]\nqueue = \"media\"\n\n[definitely_not_a_root]\nx = 1\n",
8774 )
8775 .unwrap();
8776
8777 let env = strict_prod_env(temp.path(), false);
8778 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
8779 assert!(
8780 res.is_err(),
8781 "an unrelated unknown root must still hard-fail even with [media] registered"
8782 );
8783 let err_str = format!("{:?}", res.err().unwrap());
8784 assert!(
8785 err_str.contains("definitely_not_a_root"),
8786 "error should name the unknown root: {err_str}"
8787 );
8788 }
8789
8790 #[test]
8794 fn registered_plugin_root_is_opaque_under_enforce_all() {
8795 let temp = tempfile::tempdir().unwrap();
8796 std::fs::write(
8797 temp.path().join("autumn.toml"),
8798 "[media]\nwholly_made_up = true\n[media.deeply.nested]\nalso_bogus = 42\n",
8799 )
8800 .unwrap();
8801
8802 let env = strict_prod_env(temp.path(), true);
8803 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
8804 assert!(
8805 res.is_ok(),
8806 "enforce_all must NOT flag children of a registered opaque root: {res:?}"
8807 );
8808 }
8809
8810 #[test]
8820 fn strict_config_still_rejects_profile_prefixed_plugin_root() {
8821 let temp = tempfile::tempdir().unwrap();
8822 std::fs::write(
8823 temp.path().join("autumn.toml"),
8824 "[profile.prod.media]\nwholly_made_up = true\n\
8825 [profile.prod.media.deeply.nested]\nalso_bogus = 42\n",
8826 )
8827 .unwrap();
8828
8829 let env = strict_prod_env(temp.path(), true);
8830 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
8831 assert!(
8832 res.is_err(),
8833 "a profile-prefixed plugin root ([profile.prod.media]) must stay strict \
8834 and be rejected — the plugin reads only the top-level [media] table, so \
8835 exempting the profile layer would boot silently on default config: {res:?}"
8836 );
8837 let err_str = format!("{:?}", res.err().unwrap());
8838 assert!(
8839 err_str.contains("media"),
8840 "error should name the media/profile root: {err_str}"
8841 );
8842 }
8843
8844 #[test]
8849 fn strict_config_rejects_profile_prefixed_unknown_root() {
8850 let temp = tempfile::tempdir().unwrap();
8851 std::fs::write(
8852 temp.path().join("autumn.toml"),
8853 "[profile.prod.definitely_not_a_root]\nx = 1\n",
8854 )
8855 .unwrap();
8856
8857 let env = strict_prod_env(temp.path(), false);
8858 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
8859 assert!(
8860 res.is_err(),
8861 "a profile-prefixed genuinely-unknown root must still hard-fail even \
8862 with [media] registered (the fix must not blanket-allow profile subtrees)"
8863 );
8864 let err_str = format!("{:?}", res.err().unwrap());
8865 assert!(
8866 err_str.contains("definitely_not_a_root"),
8867 "error should name the unknown root: {err_str}"
8868 );
8869 }
8870
8871 #[test]
8874 fn non_strict_config_tolerates_media_root_without_registration() {
8875 let temp = tempfile::tempdir().unwrap();
8876 std::fs::write(
8877 temp.path().join("autumn.toml"),
8878 "[media]\nqueue = \"media\"\n",
8879 )
8880 .unwrap();
8881
8882 let env = FakeEnv(
8883 [
8884 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8885 (
8886 "AUTUMN_MANIFEST_DIR".to_owned(),
8887 temp.path().to_str().unwrap().to_owned(),
8888 ),
8889 ]
8890 .into(),
8891 );
8892 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
8893 assert!(
8894 res.is_ok(),
8895 "non-strict config must tolerate an unregistered [media] root: {res:?}"
8896 );
8897 }
8898
8899 #[test]
8907 fn malformed_profile_entry_still_hard_fails() {
8908 let temp = tempfile::tempdir().unwrap();
8911 let config_path = temp.path().join("autumn.toml");
8912 std::fs::write(&config_path, "[profile]\ndev = \"prod\"\n").unwrap();
8913
8914 let env = FakeEnv(
8915 [
8916 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8917 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8921 (
8922 "AUTUMN_MANIFEST_DIR".to_owned(),
8923 temp.path().to_str().unwrap().to_owned(),
8924 ),
8925 ]
8926 .into(),
8927 );
8928
8929 let res = AutumnConfig::load_with_env(&env);
8930 assert!(
8931 res.is_err(),
8932 "a malformed [profile] entry is a structural error that must keep \
8933 hard-failing under strict_config (not be demoted to warn-only): {res:?}"
8934 );
8935 assert!(
8936 matches!(res.err().unwrap(), ConfigError::Validation(_)),
8937 "malformed profile entry must fail as a validation error"
8938 );
8939
8940 let temp2 = tempfile::tempdir().unwrap();
8944 let config_path2 = temp2.path().join("autumn.toml");
8945 std::fs::write(&config_path2, "[resilience]\nboguz = 1\n").unwrap();
8946
8947 let env2 = FakeEnv(
8948 [
8949 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8950 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8955 (
8956 "AUTUMN_MANIFEST_DIR".to_owned(),
8957 temp2.path().to_str().unwrap().to_owned(),
8958 ),
8959 ]
8960 .into(),
8961 );
8962
8963 let res2 = AutumnConfig::load_with_env(&env2);
8964 assert!(
8965 res2.is_ok(),
8966 "a newly-#1890-covered section typo must still only warn under \
8967 strict_config (enforce_all off), proving the profile fix is narrow: {res2:?}"
8968 );
8969 }
8970
8971 #[test]
8981 fn dotted_profile_name_preserves_strictness() {
8982 let temp = tempfile::tempdir().unwrap();
8985 let config_path = temp.path().join("autumn.toml");
8986 std::fs::write(
8987 &config_path,
8988 "[profile.\"prod.eu\".server]\nbogus_zzz = true\n",
8989 )
8990 .unwrap();
8991
8992 let env = FakeEnv(
8993 [
8994 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
8995 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
8999 (
9000 "AUTUMN_MANIFEST_DIR".to_owned(),
9001 temp.path().to_str().unwrap().to_owned(),
9002 ),
9003 ]
9004 .into(),
9005 );
9006
9007 let res = AutumnConfig::load_with_env(&env);
9008 assert!(
9009 res.is_err(),
9010 "a [server] typo under a quoted dotted profile name must hard-fail \
9011 (pre-#1890 strictness must not be downgraded by string-splitting the \
9012 joined path): {res:?}"
9013 );
9014 let err_str = format!("{:?}", res.err().unwrap());
9015 assert!(
9016 err_str.contains("server") && err_str.contains("bogus_zzz"),
9017 "hard-fail must be for the [server] typo (right reason): {err_str}"
9018 );
9019
9020 let temp2 = tempfile::tempdir().unwrap();
9023 let config_path2 = temp2.path().join("autumn.toml");
9024 std::fs::write(
9025 &config_path2,
9026 "[profile.\"prod.eu\".resilience]\nboguz = 1\n",
9027 )
9028 .unwrap();
9029
9030 let env2 = FakeEnv(
9031 [
9032 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
9033 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
9034 (
9035 "AUTUMN_MANIFEST_DIR".to_owned(),
9036 temp2.path().to_str().unwrap().to_owned(),
9037 ),
9038 ]
9039 .into(),
9040 );
9041
9042 let res2 = AutumnConfig::load_with_env(&env2);
9043 assert!(
9044 res2.is_ok(),
9045 "a newly-#1890-covered section typo under a quoted dotted profile name \
9046 must still only warn under strict_config (enforce_all off): {res2:?}"
9047 );
9048 }
9049
9050 #[test]
9054 fn statement_timeout_duration_field_loads() {
9055 let temp = tempfile::tempdir().unwrap();
9056 let config_path = temp.path().join("autumn.toml");
9057
9058 std::fs::write(&config_path, "[database]\nstatement_timeout = \"30s\"\n").unwrap();
9059 let env = FakeEnv(
9060 [(
9061 "AUTUMN_MANIFEST_DIR".to_owned(),
9062 temp.path().to_str().unwrap().to_owned(),
9063 )]
9064 .into(),
9065 );
9066 let config =
9067 AutumnConfig::load_with_env(&env).expect("config with duration string must load");
9068 assert_eq!(
9069 config.database.statement_timeout,
9070 Some(std::time::Duration::from_secs(30))
9071 );
9072
9073 std::fs::write(&config_path, "[database]\nstatement_timeout = 250\n").unwrap();
9075 let config =
9076 AutumnConfig::load_with_env(&env).expect("config with integer duration must load");
9077 assert_eq!(
9078 config.database.statement_timeout,
9079 Some(std::time::Duration::from_millis(250))
9080 );
9081 }
9082
9083 #[test]
9084 fn should_warn_total_connections_at_and_above_threshold() {
9085 assert!(should_warn_total_connections(100, 100));
9087 assert!(should_warn_total_connections(250, 100));
9088 assert!(!should_warn_total_connections(99, 100));
9089 }
9090
9091 #[test]
9092 fn should_warn_total_connections_zero_threshold_disables() {
9093 assert!(!should_warn_total_connections(0, 0));
9095 assert!(!should_warn_total_connections(10_000, 0));
9096 }
9097
9098 #[test]
9099 fn database_config_default_warn_threshold_is_100() {
9100 assert_eq!(
9101 DatabaseConfig::default().max_connections_warn_threshold,
9102 100
9103 );
9104 }
9105
9106 struct MockConfigLoader {
9108 config: AutumnConfig,
9109 }
9110
9111 impl ConfigLoader for MockConfigLoader {
9112 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
9113 Ok(self.config.clone())
9114 }
9115 }
9116
9117 #[tokio::test]
9118 async fn config_loader_trait_returns_supplied_config() {
9119 let mut custom = AutumnConfig::default();
9120 custom.server.port = 9999;
9121 custom.profile = Some("integration-test".to_owned());
9122
9123 let loader = MockConfigLoader {
9124 config: custom.clone(),
9125 };
9126 let resolved = loader.load().await.expect("mock loader should succeed");
9127
9128 assert_eq!(resolved.server.port, 9999);
9129 assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
9130 }
9131
9132 #[test]
9133 fn validate_does_not_error_on_redis_backend_without_url() {
9134 let mut config = AutumnConfig::default();
9142 config.session.backend = crate::session::SessionBackend::Redis;
9143 config.session.redis.url = None;
9144
9145 config.validate().expect(
9146 "validate() must accept redis-backend-without-url so custom \
9147 session store overrides aren't blocked at boot",
9148 );
9149 }
9150
9151 #[tokio::test]
9152 async fn default_toml_env_loader_succeeds_without_files() {
9153 let loader = TomlEnvConfigLoader::new();
9156 let resolved = loader.load().await.expect("default loader should succeed");
9157 assert_eq!(resolved.server.port, 3000);
9159 }
9160
9161 #[test]
9162 fn database_config_validate_none() {
9163 let config = DatabaseConfig {
9164 url: None,
9165 ..Default::default()
9166 };
9167 assert!(config.validate().is_ok());
9168 }
9169
9170 #[test]
9171 fn database_config_validate_valid_postgres() {
9172 let config = DatabaseConfig {
9173 url: Some("postgres://user:pass@localhost:5432/db".to_string()),
9174 ..Default::default()
9175 };
9176 assert!(config.validate().is_ok());
9177 }
9178
9179 #[test]
9180 fn database_config_validate_valid_postgresql() {
9181 let config = DatabaseConfig {
9182 url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
9183 ..Default::default()
9184 };
9185 assert!(config.validate().is_ok());
9186 }
9187
9188 #[test]
9189 fn database_config_validate_invalid_scheme() {
9190 let config = DatabaseConfig {
9191 url: Some("mysql://user:pass@localhost:3306/db".to_string()),
9192 ..Default::default()
9193 };
9194 let result = config.validate();
9195 assert!(result.is_err());
9196 match result {
9197 Err(ConfigError::Validation(msg)) => {
9198 assert!(msg.contains("must start with postgres:// or postgresql://"));
9201 }
9202 _ => panic!("Expected ConfigError::Validation"),
9203 }
9204 }
9205
9206 #[test]
9207 fn server_defaults() {
9208 let config = ServerConfig::default();
9209 assert_eq!(config.port, 3000);
9210 assert_eq!(config.host, "127.0.0.1");
9211 assert_eq!(config.shutdown_timeout_secs, 30);
9212 }
9213
9214 #[test]
9215 fn database_defaults() {
9216 let config = DatabaseConfig::default();
9217 assert!(config.url.is_none());
9218 assert_eq!(config.pool_size, 10);
9219 assert_eq!(config.connect_timeout_secs, 5);
9220 }
9221
9222 #[test]
9223 fn database_validate_none_url_is_ok() {
9224 let config = DatabaseConfig {
9225 url: None,
9226 ..Default::default()
9227 };
9228 assert!(config.validate().is_ok());
9229 }
9230
9231 #[test]
9232 fn database_validate_postgres_url_is_ok() {
9233 let config = DatabaseConfig {
9234 url: Some("postgres://user:pass@localhost/db".to_string()),
9235 ..Default::default()
9236 };
9237 assert!(config.validate().is_ok());
9238 }
9239
9240 #[test]
9241 fn database_validate_postgresql_url_is_ok() {
9242 let config = DatabaseConfig {
9243 url: Some("postgresql://user:pass@localhost/db".to_string()),
9244 ..Default::default()
9245 };
9246 assert!(config.validate().is_ok());
9247 }
9248
9249 #[test]
9250 fn database_validate_invalid_url_is_err() {
9251 let config = DatabaseConfig {
9252 url: Some("mysql://user:pass@localhost/db".to_string()),
9253 ..Default::default()
9254 };
9255 let result = config.validate();
9256 assert!(result.is_err());
9257 if let Err(ConfigError::Validation(msg)) = result {
9258 assert!(msg.contains("Invalid database URL"));
9259 assert!(msg.contains("must start with postgres:// or postgresql://"));
9260 } else {
9261 panic!("Expected ConfigError::Validation");
9262 }
9263 }
9264
9265 #[test]
9266 fn database_topology_deserializes_primary_and_replica_urls() {
9267 let config: AutumnConfig = toml::from_str(
9268 r#"
9269[database]
9270primary_url = "postgres://primary.example/app"
9271replica_url = "postgres://replica.example/app"
9272primary_pool_size = 12
9273replica_pool_size = 4
9274replica_fallback = "primary"
9275"#,
9276 )
9277 .expect("database topology config should parse");
9278
9279 assert_eq!(
9280 config.database.primary_url.as_deref(),
9281 Some("postgres://primary.example/app")
9282 );
9283 assert_eq!(
9284 config.database.replica_url.as_deref(),
9285 Some("postgres://replica.example/app")
9286 );
9287 assert_eq!(config.database.primary_pool_size, Some(12));
9288 assert_eq!(config.database.replica_pool_size, Some(4));
9289 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
9290 assert_eq!(
9291 config.database.effective_primary_url(),
9292 Some("postgres://primary.example/app")
9293 );
9294 assert_eq!(config.database.effective_primary_pool_size(), 12);
9295 assert_eq!(config.database.effective_replica_pool_size(), 4);
9296 }
9297
9298 #[test]
9299 fn database_topology_keeps_url_as_single_primary_compatibility_path() {
9300 let config: AutumnConfig = toml::from_str(
9301 r#"
9302[database]
9303url = "postgres://single.example/app"
9304pool_size = 7
9305"#,
9306 )
9307 .expect("legacy database.url config should parse");
9308
9309 assert_eq!(
9310 config.database.effective_primary_url(),
9311 Some("postgres://single.example/app")
9312 );
9313 assert_eq!(config.database.effective_primary_pool_size(), 7);
9314 assert_eq!(config.database.effective_replica_pool_size(), 7);
9315 assert!(config.database.replica_url.is_none());
9316 }
9317
9318 #[test]
9319 fn database_topology_rejects_replica_without_primary() {
9320 let config = DatabaseConfig {
9321 replica_url: Some("postgres://replica.example/app".to_owned()),
9322 ..Default::default()
9323 };
9324
9325 let result = config.validate();
9326
9327 assert!(result.is_err());
9328 let Err(ConfigError::Validation(message)) = result else {
9329 panic!("expected database topology validation error");
9330 };
9331 assert!(message.contains("database.replica_url"));
9332 assert!(message.contains("database.primary_url"));
9333 }
9334
9335 #[test]
9336 fn time_zone_identifier_env_override_applies() {
9337 let env = MockEnv::new().with("AUTUMN_TIME_ZONE__IDENTIFIER", "America/New_York");
9338 let mut config = AutumnConfig::default();
9339 assert_eq!(config.time_zone.identifier, "UTC");
9340
9341 config.apply_env_overrides_with_env(&env);
9342
9343 assert_eq!(config.time_zone.identifier, "America/New_York");
9344 assert!(config.time_zone.validate().is_ok());
9345 }
9346
9347 #[test]
9348 fn alerts_severities_env_overrides_apply() {
9349 let env = MockEnv::new()
9353 .with("AUTUMN_ALERTS__SLACK_SEVERITIES", "critical")
9354 .with("AUTUMN_ALERTS__PAGERDUTY_SEVERITIES", "all")
9355 .with("AUTUMN_ALERTS__DISCORD_SEVERITIES", "critical");
9356 let mut config = AutumnConfig::default();
9357 assert_eq!(
9359 config.alerts.slack_severities,
9360 crate::alerts::AlertRouting::All
9361 );
9362
9363 config.apply_env_overrides_with_env(&env);
9364
9365 assert_eq!(
9366 config.alerts.slack_severities,
9367 crate::alerts::AlertRouting::Critical,
9368 "AUTUMN_ALERTS__SLACK_SEVERITIES=critical must set the Slack channel routing"
9369 );
9370 assert_eq!(
9371 config.alerts.discord_severities,
9372 crate::alerts::AlertRouting::Critical
9373 );
9374 assert_eq!(
9375 config.alerts.pagerduty_severities,
9376 crate::alerts::AlertRouting::All
9377 );
9378 }
9379
9380 #[test]
9381 fn database_topology_env_overrides_role_fields() {
9382 let env = MockEnv::new()
9383 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
9384 .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
9385 .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
9386 .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
9387 .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
9388 let mut config = AutumnConfig::default();
9389
9390 config.apply_env_overrides_with_env(&env);
9391
9392 assert_eq!(
9393 config.database.primary_url.as_deref(),
9394 Some("postgres://primary.env/app")
9395 );
9396 assert_eq!(
9397 config.database.replica_url.as_deref(),
9398 Some("postgres://replica.env/app")
9399 );
9400 assert_eq!(config.database.primary_pool_size, Some(9));
9401 assert_eq!(config.database.replica_pool_size, Some(3));
9402 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
9403 }
9404
9405 #[test]
9406 fn database_shards_parse_from_toml_with_effective_fallbacks() {
9407 let config: AutumnConfig = toml::from_str(
9408 r#"
9409[database]
9410primary_url = "postgres://control.example/app"
9411pool_size = 8
9412replica_fallback = "primary"
9413
9414[[database.shards]]
9415name = "shard0"
9416primary_url = "postgres://shard0.example/app"
9417
9418[[database.shards]]
9419name = "shard1"
9420primary_url = "postgres://shard1.example/app"
9421replica_url = "postgres://shard1-ro.example/app"
9422primary_pool_size = 3
9423replica_pool_size = 2
9424replica_fallback = "fail_readiness"
9425"#,
9426 )
9427 .expect("sharded database config should parse");
9428
9429 let db = &config.database;
9430 assert!(db.has_shards());
9431 assert_eq!(db.shards.len(), 2);
9432
9433 let shard0 = &db.shards[0];
9434 assert_eq!(shard0.name, "shard0");
9435 assert_eq!(shard0.primary_url, "postgres://shard0.example/app");
9436 assert!(shard0.replica_url.is_none());
9437 assert_eq!(shard0.effective_primary_pool_size(db), 8);
9439 assert_eq!(shard0.effective_replica_pool_size(db), 8);
9440 assert_eq!(
9441 shard0.effective_replica_fallback(db),
9442 ReplicaFallback::Primary
9443 );
9444
9445 let shard1 = &db.shards[1];
9446 assert_eq!(shard1.effective_primary_pool_size(db), 3);
9447 assert_eq!(shard1.effective_replica_pool_size(db), 2);
9448 assert_eq!(
9449 shard1.effective_replica_fallback(db),
9450 ReplicaFallback::FailReadiness
9451 );
9452
9453 config.validate().expect("sharded config should validate");
9454 }
9455
9456 #[test]
9457 fn database_shards_default_to_empty() {
9458 let config = AutumnConfig::default();
9459 assert!(!config.database.has_shards());
9460 assert!(config.database.shards.is_empty());
9461 }
9462
9463 #[test]
9464 fn database_shard_env_overrides_existing_entry_fields() {
9465 let mut config: AutumnConfig = toml::from_str(
9466 r#"
9467[[database.shards]]
9468name = "shard0"
9469primary_url = "postgres://toml.example/app"
9470"#,
9471 )
9472 .expect("config should parse");
9473 let env = MockEnv::new()
9474 .with(
9475 "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
9476 "postgres://env.example/app",
9477 )
9478 .with(
9479 "AUTUMN_DATABASE__SHARDS__0__REPLICA_URL",
9480 "postgres://env-ro.example/app",
9481 )
9482 .with("AUTUMN_DATABASE__SHARDS__0__PRIMARY_POOL_SIZE", "5")
9483 .with("AUTUMN_DATABASE__SHARDS__0__REPLICA_FALLBACK", "primary");
9484
9485 config.apply_env_overrides_with_env(&env);
9486
9487 let shard = &config.database.shards[0];
9488 assert_eq!(shard.name, "shard0");
9489 assert_eq!(shard.primary_url, "postgres://env.example/app");
9490 assert_eq!(
9491 shard.replica_url.as_deref(),
9492 Some("postgres://env-ro.example/app")
9493 );
9494 assert_eq!(shard.primary_pool_size, Some(5));
9495 assert_eq!(shard.replica_fallback, Some(ReplicaFallback::Primary));
9496 }
9497
9498 #[test]
9499 fn database_shard_env_appends_new_entry_when_name_and_primary_url_present() {
9500 let mut config = AutumnConfig::default();
9501 let env = MockEnv::new()
9502 .with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0")
9503 .with(
9504 "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
9505 "postgres://shard0.env/app",
9506 )
9507 .with("AUTUMN_DATABASE__SHARDS__1__NAME", "shard1")
9508 .with(
9509 "AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
9510 "postgres://shard1.env/app",
9511 )
9512 .with("AUTUMN_DATABASE__SHARDS__3__NAME", "orphan")
9514 .with(
9515 "AUTUMN_DATABASE__SHARDS__3__PRIMARY_URL",
9516 "postgres://orphan.env/app",
9517 );
9518
9519 config.apply_env_overrides_with_env(&env);
9520
9521 assert_eq!(config.database.shards.len(), 2);
9522 assert_eq!(config.database.shards[0].name, "shard0");
9523 assert_eq!(config.database.shards[1].name, "shard1");
9524 }
9525
9526 #[test]
9527 fn database_shard_env_does_not_append_incomplete_entry() {
9528 let mut config = AutumnConfig::default();
9529 let env = MockEnv::new().with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0");
9531
9532 config.apply_env_overrides_with_env(&env);
9533
9534 assert!(config.database.shards.is_empty());
9535 }
9536
9537 fn shard(name: &str, primary_url: &str) -> ShardConfig {
9538 ShardConfig {
9539 name: name.to_owned(),
9540 primary_url: primary_url.to_owned(),
9541 slots: None,
9542 replica_url: None,
9543 primary_pool_size: None,
9544 replica_pool_size: None,
9545 replica_fallback: None,
9546 }
9547 }
9548
9549 fn shard_with_slots(name: &str, primary_url: &str, slots: &[&str]) -> ShardConfig {
9550 let mut config = shard(name, primary_url);
9551 config.slots = Some(
9552 slots
9553 .iter()
9554 .map(|spec| SlotSpec::Range((*spec).to_owned()))
9555 .collect(),
9556 );
9557 config
9558 }
9559
9560 #[test]
9561 fn slot_spec_expands_indices_and_ranges() {
9562 assert_eq!(SlotSpec::Index(5).expand().unwrap(), vec![5]);
9563 assert_eq!(SlotSpec::Range("7".to_owned()).expand().unwrap(), vec![7]);
9564 assert_eq!(
9565 SlotSpec::Range("3-6".to_owned()).expand().unwrap(),
9566 vec![3, 4, 5, 6]
9567 );
9568 assert!(SlotSpec::Range("6-3".to_owned()).expand().is_err());
9569 assert!(SlotSpec::Range("x-3".to_owned()).expand().is_err());
9570 assert!(SlotSpec::Range(String::new()).expand().is_err());
9571 }
9572
9573 #[test]
9574 fn slot_map_auto_splits_contiguously_by_declaration_order() {
9575 let config = DatabaseConfig {
9576 shards: vec![
9577 shard("a", "postgres://a/app"),
9578 shard("b", "postgres://b/app"),
9579 shard("c", "postgres://c/app"),
9580 ],
9581 ..Default::default()
9582 };
9583 let map = config
9584 .resolved_slot_map()
9585 .expect("auto-split should resolve");
9586 assert_eq!(map.len(), usize::from(SLOT_COUNT));
9587 assert_eq!((map[0], map[5461]), (0, 0));
9589 assert_eq!((map[5462], map[10922]), (1, 1));
9590 assert_eq!((map[10923], map[16383]), (2, 2));
9591 assert!(map.windows(2).all(|w| w[0] <= w[1]), "must be contiguous");
9592 for owner in 0..3 {
9593 let count = map.iter().filter(|&&o| o == owner).count();
9594 assert!(
9595 (5461..=5462).contains(&count),
9596 "shard {owner} owns {count} slots (expected near-even split)"
9597 );
9598 }
9599 }
9600
9601 #[test]
9602 fn slot_map_uses_explicit_assignments_regardless_of_order() {
9603 let config = DatabaseConfig {
9604 shards: vec![
9605 shard_with_slots("late", "postgres://late/app", &["8192-16383"]),
9606 shard_with_slots("early", "postgres://early/app", &["0-8191"]),
9607 ],
9608 ..Default::default()
9609 };
9610 let map = config
9611 .resolved_slot_map()
9612 .expect("explicit map should resolve");
9613 assert!(map[..8192].iter().all(|&owner| owner == 1));
9614 assert!(map[8192..].iter().all(|&owner| owner == 0));
9615 }
9616
9617 #[test]
9618 fn slot_map_allows_drained_shard_with_empty_slots() {
9619 let config = DatabaseConfig {
9620 shards: vec![
9621 shard_with_slots("live", "postgres://live/app", &["0-16383"]),
9622 shard_with_slots("drained", "postgres://drained/app", &[]),
9623 ],
9624 ..Default::default()
9625 };
9626 let map = config
9627 .resolved_slot_map()
9628 .expect("drained shard is allowed");
9629 assert_eq!(map.len(), usize::from(SLOT_COUNT));
9630 assert!(map.iter().all(|&owner| owner == 0));
9631 }
9632
9633 #[test]
9634 fn slot_map_rejects_mixed_declared_and_undeclared_slots() {
9635 let config = DatabaseConfig {
9636 shards: vec![
9637 shard_with_slots("a", "postgres://a/app", &["0-16383"]),
9638 shard("b", "postgres://b/app"),
9639 ],
9640 ..Default::default()
9641 };
9642 assert!(config.resolved_slot_map().is_err());
9643 }
9644
9645 #[test]
9646 fn slot_map_rejects_overlap_gap_and_out_of_range() {
9647 let config = DatabaseConfig {
9649 shards: vec![
9650 shard_with_slots("a", "postgres://a/app", &["0-8192"]),
9651 shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
9652 ],
9653 ..Default::default()
9654 };
9655 let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
9656 panic!("overlapping slots should fail");
9657 };
9658 assert!(message.contains("already owned"));
9659
9660 let config = DatabaseConfig {
9662 shards: vec![
9663 shard_with_slots("a", "postgres://a/app", &["0-8000"]),
9664 shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
9665 ],
9666 ..Default::default()
9667 };
9668 let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
9669 panic!("uncovered slots should fail");
9670 };
9671 assert!(message.contains("unassigned"));
9672 assert!(message.contains("8001-8191"), "got: {message}");
9673
9674 let config = DatabaseConfig {
9676 shards: vec![shard_with_slots("a", "postgres://a/app", &["0-16384"])],
9677 ..Default::default()
9678 };
9679 assert!(config.resolved_slot_map().is_err());
9680 }
9681
9682 #[test]
9683 fn slot_map_rejects_more_shards_than_slots() {
9684 let config = DatabaseConfig {
9685 shards: (0..=usize::from(SLOT_COUNT))
9686 .map(|i| shard(&format!("s{i}"), "postgres://s/app"))
9687 .collect(),
9688 ..Default::default()
9689 };
9690 let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
9691 panic!("more shards than slots cannot auto-split");
9692 };
9693 assert!(message.contains("at most"), "got: {message}");
9694 }
9695
9696 #[test]
9697 fn slots_parse_from_toml_ints_and_ranges() {
9698 let config: AutumnConfig = toml::from_str(
9699 r#"
9700[[database.shards]]
9701name = "a"
9702primary_url = "postgres://a/app"
9703slots = ["0-8191", 8192, "8193"]
9704
9705[[database.shards]]
9706name = "b"
9707primary_url = "postgres://b/app"
9708slots = ["8194-16383"]
9709"#,
9710 )
9711 .expect("slots config should parse");
9712 let map = config
9713 .database
9714 .resolved_slot_map()
9715 .expect("mixed int/range specs should resolve");
9716 assert!(map[..8194].iter().all(|&owner| owner == 0));
9717 assert!(map[8194..].iter().all(|&owner| owner == 1));
9718 config.validate().expect("config should validate");
9719 }
9720
9721 #[test]
9722 fn slot_env_overrides_assignments() {
9723 let mut config = AutumnConfig::default();
9724 let env = MockEnv::new()
9725 .with("AUTUMN_DATABASE__SHARDS__0__NAME", "a")
9726 .with(
9727 "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
9728 "postgres://a/app",
9729 )
9730 .with("AUTUMN_DATABASE__SHARDS__0__SLOTS", "0-8191, 12288-16383")
9731 .with("AUTUMN_DATABASE__SHARDS__1__NAME", "b")
9732 .with(
9733 "AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
9734 "postgres://b/app",
9735 )
9736 .with("AUTUMN_DATABASE__SHARDS__1__SLOTS", "8192-12287");
9737
9738 config.apply_env_overrides_with_env(&env);
9739
9740 let map = config
9741 .database
9742 .resolved_slot_map()
9743 .expect("env slot specs should resolve");
9744 assert!(map[..8192].iter().all(|&owner| owner == 0));
9745 assert!(map[8192..12288].iter().all(|&owner| owner == 1));
9746 assert!(map[12288..].iter().all(|&owner| owner == 0));
9747 }
9748
9749 #[test]
9750 fn slot_ranges_format_compactly() {
9751 assert_eq!(format_slot_ranges(&[]), "");
9752 assert_eq!(format_slot_ranges(&[3]), "3");
9753 assert_eq!(format_slot_ranges(&[0, 1, 2, 5, 7, 8]), "0-2, 5, 7-8");
9754 }
9755
9756 #[test]
9757 fn database_shard_validation_rejects_bad_names() {
9758 for bad_name in ["", "Shard0", "shard 0", "shard:0", "shärd"] {
9759 let config = DatabaseConfig {
9760 shards: vec![shard(bad_name, "postgres://s0.example/app")],
9761 ..Default::default()
9762 };
9763 assert!(
9764 config.validate().is_err(),
9765 "shard name should be rejected: {bad_name:?}"
9766 );
9767 }
9768 }
9769
9770 #[test]
9771 fn database_shard_validation_rejects_duplicate_names() {
9772 let config = DatabaseConfig {
9773 shards: vec![
9774 shard("shard0", "postgres://a.example/app"),
9775 shard("shard0", "postgres://b.example/app"),
9776 ],
9777 ..Default::default()
9778 };
9779 let Err(ConfigError::Validation(message)) = config.validate() else {
9780 panic!("duplicate shard names should fail validation");
9781 };
9782 assert!(message.contains("unique"));
9783 }
9784
9785 #[test]
9786 fn database_shard_validation_rejects_bad_urls() {
9787 let config = DatabaseConfig {
9788 shards: vec![shard("shard0", "mysql://s0.example/app")],
9789 ..Default::default()
9790 };
9791 assert!(config.validate().is_err());
9792
9793 let mut with_bad_replica = shard("shard0", "postgres://s0.example/app");
9794 with_bad_replica.replica_url = Some("http://s0-ro.example/app".to_owned());
9795 let config = DatabaseConfig {
9796 shards: vec![with_bad_replica],
9797 ..Default::default()
9798 };
9799 assert!(config.validate().is_err());
9800 }
9801
9802 #[test]
9803 fn database_shards_without_control_role_are_allowed() {
9804 let config = DatabaseConfig {
9805 shards: vec![shard("shard0", "postgres://s0.example/app")],
9806 ..Default::default()
9807 };
9808 config
9809 .validate()
9810 .expect("shards without a control role should validate");
9811 }
9812
9813 #[test]
9814 fn postgres_scheduler_with_shards_requires_control_database() {
9815 let mut config = AutumnConfig::default();
9816 config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
9817 config.scheduler.backend = SchedulerBackend::Postgres;
9818
9819 let Err(ConfigError::Validation(message)) = config.validate() else {
9820 panic!("postgres scheduler without a control database should fail validation");
9821 };
9822 assert!(message.contains("control database"));
9823
9824 config.database.primary_url = Some("postgres://control.example/app".to_owned());
9825 config
9826 .validate()
9827 .expect("control role should satisfy the scheduler requirement");
9828 }
9829
9830 #[test]
9831 fn postgres_jobs_with_shards_requires_control_database() {
9832 let mut config = AutumnConfig::default();
9833 config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
9834 config.jobs.backend = "postgres".to_owned();
9835
9836 assert!(config.validate().is_err());
9837
9838 config.database.url = Some("postgres://control.example/app".to_owned());
9839 config
9840 .validate()
9841 .expect("legacy url should satisfy the jobs requirement");
9842 }
9843
9844 #[test]
9845 fn database_validate_url_edge_cases() {
9846 let invalid_urls = vec![
9847 "POSTGRES://localhost/db",
9848 "postgres:/localhost/db",
9849 "postgres:localhost/db",
9850 "http://postgres",
9851 " postgres://localhost/db",
9852 "",
9853 ];
9854
9855 for invalid_url in invalid_urls {
9856 let config = DatabaseConfig {
9857 url: Some(invalid_url.to_string()),
9858 ..Default::default()
9859 };
9860 assert!(
9861 config.validate().is_err(),
9862 "URL should be invalid: {invalid_url}"
9863 );
9864 }
9865 }
9866
9867 #[test]
9868 fn autumn_config_validate_ok() {
9869 let config = AutumnConfig::default();
9870 assert!(config.validate().is_ok());
9871 }
9872
9873 #[test]
9874 fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
9875 let mut config = AutumnConfig::default();
9885 config.session.backend = crate::session::SessionBackend::Redis;
9886 config.session.redis.url = None;
9887
9888 config
9889 .validate()
9890 .expect("validate() must accept invalid session backend so custom store can override");
9891 }
9892
9893 #[test]
9894 fn autumn_config_validate_database_err() {
9895 let mut config = AutumnConfig::default();
9896 config.database.url = Some("mysql://localhost/test".to_string());
9897 assert!(config.validate().is_err());
9898 }
9899
9900 #[test]
9901 fn log_defaults() {
9902 let config = LogConfig::default();
9903 assert_eq!(config.level, "info");
9904 assert_eq!(config.format, LogFormat::Auto);
9905 }
9906
9907 #[test]
9908 fn telemetry_defaults() {
9909 let config = TelemetryConfig::default();
9910 assert!(!config.enabled);
9911 assert_eq!(config.service_name, "autumn-app");
9912 assert!(config.service_namespace.is_none());
9913 assert_eq!(config.service_version, "unknown");
9914 assert_eq!(config.environment, "development");
9915 assert!(config.otlp_endpoint.is_none());
9916 assert_eq!(config.protocol, TelemetryProtocol::Grpc);
9917 assert!(!config.strict);
9918 }
9919
9920 #[test]
9921 fn health_defaults() {
9922 let config = HealthConfig::default();
9923 assert_eq!(config.path, "/health");
9924 assert_eq!(config.live_path, "/live");
9925 assert_eq!(config.ready_path, "/ready");
9926 assert_eq!(config.startup_path, "/startup");
9927 assert!(!config.detailed);
9928 }
9929
9930 #[test]
9931 fn top_level_default_populates_all_sections() {
9932 let config = AutumnConfig::default();
9933 assert_eq!(config.server.port, 3000);
9934 assert!(config.database.url.is_none());
9935 assert_eq!(config.log.level, "info");
9936 assert_eq!(config.health.path, "/health");
9937 }
9938
9939 #[test]
9940 fn deserialize_empty_object_uses_all_defaults() {
9941 let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
9942 assert_eq!(config.server.port, 3000);
9943 assert_eq!(config.server.host, "127.0.0.1");
9944 assert_eq!(config.server.shutdown_timeout_secs, 30);
9945 assert!(config.database.url.is_none());
9946 assert_eq!(config.database.pool_size, 10);
9947 assert_eq!(config.database.connect_timeout_secs, 5);
9948 assert!(!config.database.auto_migrate_in_production);
9949 assert_eq!(config.log.level, "info");
9950 assert_eq!(config.log.format, LogFormat::Auto);
9951 assert_eq!(config.health.path, "/health");
9952 }
9953
9954 #[test]
9955 fn deserialize_partial_config_merges_with_defaults() {
9956 let json = r#"{"server": {"port": 8080}}"#;
9957 let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
9958 assert_eq!(config.server.port, 8080);
9959 assert_eq!(config.server.host, "127.0.0.1");
9960 assert_eq!(config.database.pool_size, 10);
9961 assert_eq!(config.log.level, "info");
9962 }
9963
9964 #[test]
9965 fn log_format_variants_deserialize() {
9966 let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
9967 let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
9968 let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
9969 assert_eq!(auto, LogFormat::Auto);
9970 assert_eq!(pretty, LogFormat::Pretty);
9971 assert_eq!(json, LogFormat::Json);
9972 }
9973
9974 #[test]
9977 fn load_missing_file_returns_defaults() {
9978 let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
9979 assert_eq!(config.server.port, 3000);
9980 assert!(config.database.url.is_none());
9981 }
9982
9983 #[test]
9984 fn load_valid_full_config() {
9985 let dir = tempfile::tempdir().unwrap();
9986 let path = dir.path().join("autumn.toml");
9987 std::fs::write(
9988 &path,
9989 r#"
9990[server]
9991port = 8080
9992host = "0.0.0.0"
9993shutdown_timeout_secs = 60
9994
9995[database]
9996url = "postgres://user:pass@db:5432/myapp"
9997pool_size = 20
9998connect_timeout_secs = 10
9999auto_migrate_in_production = true
10000
10001[log]
10002level = "debug"
10003format = "Json"
10004
10005[health]
10006path = "/healthz"
10007"#,
10008 )
10009 .unwrap();
10010
10011 let config = AutumnConfig::load_from(&path).unwrap();
10012 assert_eq!(config.server.port, 8080);
10013 assert_eq!(config.server.host, "0.0.0.0");
10014 assert_eq!(config.server.shutdown_timeout_secs, 60);
10015 assert_eq!(
10016 config.database.url.as_deref(),
10017 Some("postgres://user:pass@db:5432/myapp")
10018 );
10019 assert_eq!(config.database.pool_size, 20);
10020 assert_eq!(config.database.connect_timeout_secs, 10);
10021 assert!(config.database.auto_migrate_in_production);
10022 assert_eq!(config.log.level, "debug");
10023 assert_eq!(config.log.format, LogFormat::Json);
10024 assert_eq!(config.health.path, "/healthz");
10025 }
10026
10027 #[test]
10028 fn load_partial_config_merges_with_defaults() {
10029 let dir = tempfile::tempdir().unwrap();
10030 let path = dir.path().join("autumn.toml");
10031 std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
10032
10033 let config = AutumnConfig::load_from(&path).unwrap();
10034 assert_eq!(config.server.port, 9090);
10035 assert_eq!(config.server.host, "127.0.0.1");
10036 assert_eq!(config.database.pool_size, 10);
10037 assert_eq!(config.log.level, "info");
10038 }
10039
10040 #[test]
10041 fn access_log_defaults_on_with_probe_and_asset_exclusions() {
10042 let log = LogConfig::default();
10043 assert!(log.access_log);
10044 assert_eq!(
10045 log.access_log_exclude,
10046 vec![
10047 "/health",
10048 "/live",
10049 "/ready",
10050 "/startup",
10051 "/actuator",
10052 "/static"
10053 ]
10054 );
10055 }
10056
10057 #[test]
10058 fn env_override_access_log_off() {
10059 let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
10060 let mut config = AutumnConfig::default();
10061 config.apply_env_overrides_with_env(&env);
10062 assert!(!config.log.access_log);
10063 }
10064
10065 #[test]
10066 fn env_override_access_log_exclude_csv() {
10067 let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
10068 let mut config = AutumnConfig::default();
10069 config.apply_env_overrides_with_env(&env);
10070 assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
10071 }
10072
10073 #[test]
10074 fn access_log_is_configurable_from_toml() {
10075 let dir = tempfile::tempdir().unwrap();
10076 let path = dir.path().join("autumn.toml");
10077 std::fs::write(
10078 &path,
10079 "[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
10080 )
10081 .unwrap();
10082
10083 let config = AutumnConfig::load_from(&path).unwrap();
10084 assert!(!config.log.access_log);
10085 assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
10086 }
10087
10088 #[test]
10089 fn load_invalid_toml_returns_error() {
10090 let dir = tempfile::tempdir().unwrap();
10091 let path = dir.path().join("autumn.toml");
10092 std::fs::write(&path, "not valid [[[toml").unwrap();
10093
10094 let result = AutumnConfig::load_from(&path);
10095 assert!(result.is_err());
10096 let err = result.unwrap_err();
10097 assert!(err.to_string().contains("invalid autumn.toml"));
10098 }
10099
10100 #[test]
10101 fn load_empty_file_returns_defaults() {
10102 let dir = tempfile::tempdir().unwrap();
10103 let path = dir.path().join("autumn.toml");
10104 std::fs::write(&path, "").unwrap();
10105
10106 let config = AutumnConfig::load_from(&path).unwrap();
10107 assert_eq!(config.server.port, 3000);
10108 }
10109
10110 #[test]
10113 fn env_override_database_url() {
10114 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
10115 let mut config = AutumnConfig::default();
10116 config.apply_env_overrides_with_env(&env);
10117 assert_eq!(
10118 config.database.url.as_deref(),
10119 Some("postgres://override:5432/test")
10120 );
10121 }
10122
10123 #[test]
10124 fn env_override_actuator_prometheus_disables() {
10125 let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
10128 let mut config = AutumnConfig::default();
10129 assert!(config.actuator.prometheus, "default should be enabled");
10130 config.apply_env_overrides_with_env(&env);
10131 assert!(
10132 !config.actuator.prometheus,
10133 "AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
10134 );
10135 }
10136
10137 #[test]
10138 fn env_override_actuator_sensitive() {
10139 let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
10140 let mut config = AutumnConfig::default();
10141 assert!(!config.actuator.sensitive);
10142 config.apply_env_overrides_with_env(&env);
10143 assert!(config.actuator.sensitive);
10144 }
10145
10146 #[test]
10147 fn env_override_upload_reject_on_content_type_mismatch() {
10148 let env = MockEnv::new().with(
10149 "AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
10150 "true",
10151 );
10152 let mut config = AutumnConfig::default();
10153 assert!(!config.security.upload.reject_on_content_type_mismatch);
10154 config.apply_env_overrides_with_env(&env);
10155 assert!(config.security.upload.reject_on_content_type_mismatch);
10156 }
10157
10158 #[test]
10159 fn env_override_actuator_prefix() {
10160 let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
10161 let mut config = AutumnConfig::default();
10162 config.apply_env_overrides_with_env(&env);
10163 assert_eq!(config.actuator.prefix, "/ops");
10164 }
10165
10166 #[test]
10167 fn env_override_database_url_wins_over_file_primary_url() {
10168 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
10169 let mut config = AutumnConfig::default();
10170 config.database.primary_url = Some("postgres://file.example/app".to_owned());
10171
10172 config.apply_env_overrides_with_env(&env);
10173
10174 assert_eq!(
10175 config.database.effective_primary_url(),
10176 Some("postgres://env.example/app")
10177 );
10178 assert!(config.database.primary_url.is_none());
10179 }
10180
10181 #[test]
10182 fn env_override_database_primary_url_wins_over_legacy_database_url() {
10183 let env = MockEnv::new()
10184 .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
10185 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
10186 let mut config = AutumnConfig::default();
10187 config.database.primary_url = Some("postgres://file.example/app".to_owned());
10188
10189 config.apply_env_overrides_with_env(&env);
10190
10191 assert_eq!(
10192 config.database.effective_primary_url(),
10193 Some("postgres://primary.env/app")
10194 );
10195 assert_eq!(
10196 config.database.url.as_deref(),
10197 Some("postgres://legacy.env/app")
10198 );
10199 }
10200
10201 #[test]
10202 fn env_override_pool_size() {
10203 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
10204 let mut config = AutumnConfig::default();
10205 config.apply_env_overrides_with_env(&env);
10206 assert_eq!(config.database.pool_size, 25);
10207 }
10208
10209 #[cfg(feature = "reporting")]
10210 #[test]
10211 fn env_override_reporting() {
10212 let env = MockEnv::new()
10213 .with("AUTUMN_REPORTING__ENABLED", "false")
10214 .with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
10215 let mut config = AutumnConfig::default();
10216 assert!(config.reporting.enabled);
10217 assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
10218 config.apply_env_overrides_with_env(&env);
10219 assert!(!config.reporting.enabled);
10220 assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
10221 }
10222
10223 #[test]
10224 fn env_override_connect_timeout() {
10225 let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
10226 let mut config = AutumnConfig::default();
10227 config.apply_env_overrides_with_env(&env);
10228 assert_eq!(config.database.connect_timeout_secs, 15);
10229 }
10230
10231 #[test]
10232 fn env_override_read_your_writes() {
10233 let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "request");
10234 let mut config = AutumnConfig::default();
10235 config.apply_env_overrides_with_env(&env);
10236 assert_eq!(config.database.read_your_writes, ReadYourWrites::Request);
10237 }
10238
10239 #[test]
10240 fn env_override_read_your_writes_session() {
10241 let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "session");
10242 let mut config = AutumnConfig::default();
10243 config.apply_env_overrides_with_env(&env);
10244 assert_eq!(config.database.read_your_writes, ReadYourWrites::Session);
10245 }
10246
10247 #[test]
10248 fn env_override_pin_after_write_secs() {
10249 let env = MockEnv::new().with("AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS", "10");
10250 let mut config = AutumnConfig::default();
10251 config.apply_env_overrides_with_env(&env);
10252 assert_eq!(config.database.pin_after_write_secs, 10);
10253 }
10254
10255 #[test]
10256 fn env_override_invalid_pool_size_ignored() {
10257 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
10258 let mut config = AutumnConfig::default();
10259 config.apply_env_overrides_with_env(&env);
10260 assert_eq!(config.database.pool_size, 10);
10261 }
10262
10263 #[test]
10266 fn env_override_magic_link_ttl_minutes() {
10267 let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45");
10268 let mut config = AutumnConfig::default();
10269 config.apply_env_overrides_with_env(&env);
10270 assert_eq!(config.auth.magic_link.ttl_minutes, 45);
10271 }
10272
10273 #[test]
10274 fn env_override_magic_link_email_cooldown_secs() {
10275 let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
10276 let mut config = AutumnConfig::default();
10277 config.apply_env_overrides_with_env(&env);
10278 assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
10279 }
10280
10281 #[test]
10282 fn env_override_magic_link_overrides_toml_value() {
10283 let env = MockEnv::new()
10284 .with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45")
10285 .with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
10286 let mut config = AutumnConfig::default();
10287 config.auth.magic_link.ttl_minutes = 30;
10289 config.auth.magic_link.email_cooldown_secs = 200;
10290 config.apply_env_overrides_with_env(&env);
10291 assert_eq!(config.auth.magic_link.ttl_minutes, 45);
10292 assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
10293 }
10294
10295 #[test]
10296 fn env_unset_leaves_magic_link_toml_value_intact() {
10297 let env = MockEnv::new();
10298 let mut config = AutumnConfig::default();
10299 config.auth.magic_link.ttl_minutes = 30;
10301 config.auth.magic_link.email_cooldown_secs = 200;
10302 config.apply_env_overrides_with_env(&env);
10303 assert_eq!(config.auth.magic_link.ttl_minutes, 30);
10304 assert_eq!(config.auth.magic_link.email_cooldown_secs, 200);
10305 }
10306
10307 #[test]
10308 fn env_override_invalid_magic_link_ttl_minutes_ignored() {
10309 let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "not_a_number");
10310 let mut config = AutumnConfig::default();
10311 config.auth.magic_link.ttl_minutes = 30;
10313 config.apply_env_overrides_with_env(&env);
10314 assert_eq!(config.auth.magic_link.ttl_minutes, 30);
10315 }
10316
10317 #[test]
10320 fn startup_wait_secs_default_is_zero() {
10321 assert_eq!(DatabaseConfig::default().startup_wait_secs, 0);
10322 }
10323
10324 #[test]
10325 fn env_override_startup_wait_secs() {
10326 let env = MockEnv::new().with("AUTUMN_DATABASE__STARTUP_WAIT_SECS", "60");
10327 let mut config = AutumnConfig::default();
10328 config.apply_env_overrides_with_env(&env);
10329 assert_eq!(config.database.startup_wait_secs, 60);
10330 }
10331
10332 #[test]
10333 fn startup_wait_secs_parses_from_toml() {
10334 let config: AutumnConfig = toml::from_str("[database]\nstartup_wait_secs = 30").unwrap();
10335 assert_eq!(config.database.startup_wait_secs, 30);
10336 }
10337
10338 #[cfg(feature = "storage")]
10339 #[test]
10340 fn env_override_storage_fields() {
10341 let env = MockEnv::new()
10342 .with("AUTUMN_STORAGE__BACKEND", "s3")
10343 .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
10344 .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
10345 .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
10346 .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
10347 .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
10348 .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
10349 .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
10350 .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
10351 .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
10352 .with(
10353 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
10354 "https://cdn.example.test",
10355 )
10356 .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
10357 .with(
10358 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
10359 "AWS_SECRET_ACCESS_KEY",
10360 )
10361 .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
10362 .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
10363 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
10364 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
10365 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
10366 let mut config = AutumnConfig::default();
10367
10368 config.apply_env_overrides_with_env(&env);
10369
10370 assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
10371 assert_eq!(config.storage.default_provider, "media");
10372 assert!(config.storage.allow_local_in_production);
10373 assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
10374 assert_eq!(config.storage.local.mount_path, "/files");
10375 assert_eq!(config.storage.local.default_url_expiry_secs, 42);
10376 assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
10377 assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
10378 assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
10379 assert_eq!(
10380 config.storage.s3.endpoint.as_deref(),
10381 Some("https://s3.example.test")
10382 );
10383 assert_eq!(
10384 config.storage.s3.public_base_url.as_deref(),
10385 Some("https://cdn.example.test")
10386 );
10387 assert_eq!(
10388 config.storage.s3.access_key_id_env.as_deref(),
10389 Some("AWS_ACCESS_KEY_ID")
10390 );
10391 assert_eq!(
10392 config.storage.s3.secret_access_key_env.as_deref(),
10393 Some("AWS_SECRET_ACCESS_KEY")
10394 );
10395 assert!(config.storage.s3.force_path_style);
10396 assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
10397 assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
10398 assert_eq!(config.storage.variants.max_source_width, 2_000);
10399 assert_eq!(config.storage.variants.max_source_height, 1_500);
10400 }
10401
10402 #[test]
10403 fn backup_offsite_parses_from_toml() {
10404 let toml = r#"
10405 [backup.offsite]
10406 prefix = "db"
10407 keep = 5
10408 auto_upload = true
10409 allow_shared_bucket = true
10410
10411 [backup.offsite.s3]
10412 bucket = "offsite-backups"
10413 region = "auto"
10414 endpoint = "https://minio.example.test"
10415 access_key_id_env = "OFFSITE_KEY_ID"
10416 secret_access_key_env = "OFFSITE_SECRET"
10417 force_path_style = true
10418 "#;
10419 let config: AutumnConfig = toml::from_str(toml).unwrap();
10420 let offsite = config.backup.offsite.expect("offsite section present");
10421 assert_eq!(offsite.prefix.as_deref(), Some("db"));
10422 assert_eq!(offsite.keep, Some(5));
10423 assert!(offsite.auto_upload);
10424 assert!(offsite.allow_shared_bucket);
10425 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite-backups"));
10426 assert_eq!(offsite.s3.region.as_deref(), Some("auto"));
10427 assert_eq!(
10428 offsite.s3.endpoint.as_deref(),
10429 Some("https://minio.example.test")
10430 );
10431 assert_eq!(
10433 offsite.s3.access_key_id_env.as_deref(),
10434 Some("OFFSITE_KEY_ID")
10435 );
10436 assert_eq!(
10437 offsite.s3.secret_access_key_env.as_deref(),
10438 Some("OFFSITE_SECRET")
10439 );
10440 assert!(offsite.s3.force_path_style);
10441 }
10442
10443 #[test]
10444 fn backup_offsite_defaults_to_none() {
10445 let config = AutumnConfig::default();
10446 assert!(config.backup.offsite.is_none());
10447 }
10448
10449 #[test]
10450 fn env_override_backup_offsite_fields() {
10451 let env = MockEnv::new()
10452 .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
10453 .with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
10454 .with(
10455 "AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
10456 "https://s3.offsite.test",
10457 )
10458 .with("AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV", "OFF_KEY")
10459 .with(
10460 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
10461 "OFF_SECRET",
10462 )
10463 .with("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true")
10464 .with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
10465 .with("AUTUMN_BACKUP__OFFSITE__KEEP", "3")
10466 .with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true")
10467 .with("AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET", "true");
10468 let mut config = AutumnConfig::default();
10469
10470 config.apply_env_overrides_with_env(&env);
10471
10472 let offsite = config.backup.offsite.expect("materialized from env");
10473 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
10474 assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
10475 assert_eq!(
10476 offsite.s3.endpoint.as_deref(),
10477 Some("https://s3.offsite.test")
10478 );
10479 assert_eq!(offsite.s3.access_key_id_env.as_deref(), Some("OFF_KEY"));
10480 assert_eq!(
10481 offsite.s3.secret_access_key_env.as_deref(),
10482 Some("OFF_SECRET")
10483 );
10484 assert!(offsite.s3.force_path_style);
10485 assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
10486 assert_eq!(offsite.keep, Some(3));
10487 assert!(offsite.auto_upload);
10488 assert!(offsite.allow_shared_bucket);
10489 }
10490
10491 #[test]
10492 fn env_override_backup_offsite_absent_stays_none() {
10493 let env = MockEnv::new();
10495 let mut config = AutumnConfig::default();
10496 config.apply_env_overrides_with_env(&env);
10497 assert!(config.backup.offsite.is_none());
10498 }
10499
10500 #[test]
10501 fn env_override_backup_offsite_lone_opt_out_toggle_stays_none() {
10502 for key in [
10506 "AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
10507 "AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
10508 ] {
10509 let env = MockEnv::new().with(key, "false");
10510 let mut config = AutumnConfig::default();
10511 config.apply_env_overrides_with_env(&env);
10512 assert!(
10513 config.backup.offsite.is_none(),
10514 "{key}=false must not materialize an offsite section",
10515 );
10516 }
10517 }
10518
10519 #[test]
10520 fn env_override_backup_offsite_truthy_auto_upload_materializes() {
10521 let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true");
10524 let mut config = AutumnConfig::default();
10525 config.apply_env_overrides_with_env(&env);
10526 let offsite = config
10527 .backup
10528 .offsite
10529 .expect("auto_upload=true materializes offsite");
10530 assert!(offsite.auto_upload);
10531 }
10532
10533 #[test]
10534 fn env_override_backup_offsite_destination_key_materializes() {
10535 let env = MockEnv::new()
10538 .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
10539 .with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "false");
10540 let mut config = AutumnConfig::default();
10541 config.apply_env_overrides_with_env(&env);
10542 let offsite = config
10543 .backup
10544 .offsite
10545 .expect("a bucket key materializes offsite");
10546 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
10547 assert!(!offsite.auto_upload);
10548 }
10549
10550 #[test]
10551 fn env_override_backup_offsite_lone_optional_key_stays_none() {
10552 for (key, val) in [
10558 ("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-east-1"),
10559 ("AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT", "https://s3.test"),
10560 ("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true"),
10561 ("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly"),
10562 ("AUTUMN_BACKUP__OFFSITE__KEEP", "3"),
10563 ] {
10564 let env = MockEnv::new().with(key, val);
10565 let mut config = AutumnConfig::default();
10566 config.apply_env_overrides_with_env(&env);
10567 assert!(
10568 config.backup.offsite.is_none(),
10569 "{key} is optional-only and must not materialize an offsite section",
10570 );
10571 }
10572 }
10573
10574 #[test]
10575 fn env_override_backup_offsite_credential_key_materializes() {
10576 for key in [
10579 "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
10580 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
10581 ] {
10582 let env = MockEnv::new().with(key, "SOME_ENV_NAME");
10583 let mut config = AutumnConfig::default();
10584 config.apply_env_overrides_with_env(&env);
10585 assert!(
10586 config.backup.offsite.is_some(),
10587 "{key} is a required credential signal and must materialize offsite",
10588 );
10589 }
10590 }
10591
10592 #[test]
10593 fn env_override_backup_offsite_bucket_only_materializes() {
10594 let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite");
10596 let mut config = AutumnConfig::default();
10597 config.apply_env_overrides_with_env(&env);
10598 let offsite = config
10599 .backup
10600 .offsite
10601 .expect("a bucket key materializes offsite");
10602 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
10603 }
10604
10605 #[test]
10606 fn env_override_backup_offsite_region_only_applied_when_materialized() {
10607 let env = MockEnv::new()
10610 .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
10611 .with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
10612 .with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
10613 .with("AUTUMN_BACKUP__OFFSITE__KEEP", "5");
10614 let mut config = AutumnConfig::default();
10615 config.apply_env_overrides_with_env(&env);
10616 let offsite = config.backup.offsite.expect("bucket materializes offsite");
10617 assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
10618 assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
10619 assert_eq!(offsite.keep, Some(5));
10620 }
10621
10622 #[test]
10623 fn env_override_database_auto_migrate_in_production() {
10624 let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
10625 let mut config = AutumnConfig::default();
10626 config.apply_env_overrides_with_env(&env);
10627 assert!(config.database.auto_migrate_in_production);
10628 }
10629
10630 #[test]
10631 fn env_override_jobs_fields() {
10632 let env = MockEnv::new()
10633 .with("AUTUMN_JOBS__BACKEND", "redis")
10634 .with("AUTUMN_JOBS__WORKERS", "8")
10635 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
10636 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
10637 .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
10638 .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
10639 .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
10640 let mut config = AutumnConfig::default();
10641 config.apply_env_overrides_with_env(&env);
10642
10643 assert_eq!(config.jobs.backend, "redis");
10644 assert_eq!(config.jobs.workers, 8);
10645 assert_eq!(config.jobs.max_attempts, 12);
10646 assert_eq!(config.jobs.initial_backoff_ms, 750);
10647 assert_eq!(
10648 config.jobs.redis.url.as_deref(),
10649 Some("redis://jobs:6379/2")
10650 );
10651 assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
10652 assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
10653 }
10654
10655 #[test]
10656 fn job_tracking_config_defaults_ttl_86400_and_route_enabled() {
10657 let config = AutumnConfig::default();
10658 assert_eq!(config.jobs.tracking.ttl_secs, 86_400);
10659 assert!(config.jobs.tracking.route_enabled);
10660 }
10661
10662 #[test]
10663 fn env_override_jobs_tracking_fields() {
10664 let env = MockEnv::new()
10665 .with("AUTUMN_JOBS__TRACKING__TTL_SECS", "3600")
10666 .with("AUTUMN_JOBS__TRACKING__ROUTE_ENABLED", "false");
10667 let mut config = AutumnConfig::default();
10668 config.apply_env_overrides_with_env(&env);
10669
10670 assert_eq!(config.jobs.tracking.ttl_secs, 3_600);
10671 assert!(!config.jobs.tracking.route_enabled);
10672 }
10673
10674 #[test]
10675 fn jobs_toml_deserializes_tracking_fields() {
10676 let config: AutumnConfig = toml::from_str(
10677 r"
10678 [jobs.tracking]
10679 ttl_secs = 7200
10680 route_enabled = false
10681 ",
10682 )
10683 .unwrap();
10684
10685 assert_eq!(config.jobs.tracking.ttl_secs, 7_200);
10686 assert!(!config.jobs.tracking.route_enabled);
10687 }
10688
10689 #[test]
10690 fn jobs_toml_deserializes_redis_visibility_timeout() {
10691 let config: AutumnConfig = toml::from_str(
10692 r#"
10693 [jobs]
10694 backend = "redis"
10695
10696 [jobs.redis]
10697 url = "redis://localhost:6379/5"
10698 key_prefix = "demo:jobs"
10699 visibility_timeout_ms = 15000
10700 "#,
10701 )
10702 .unwrap();
10703
10704 assert_eq!(config.jobs.backend, "redis");
10705 assert_eq!(
10706 config.jobs.redis.url.as_deref(),
10707 Some("redis://localhost:6379/5")
10708 );
10709 assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
10710 assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
10711 }
10712
10713 #[test]
10714 fn job_queues_defaults_to_single_default_queue() {
10715 let config = AutumnConfig::default();
10716 assert!(config.jobs.queues.strict);
10717 assert_eq!(config.jobs.queues.queues.len(), 1);
10718 assert_eq!(config.jobs.queues.queues[0].name, "default");
10719 assert_eq!(config.jobs.queues.queues[0].weight, 1);
10720 }
10721
10722 #[test]
10723 fn jobs_without_queues_key_keeps_single_default_queue() {
10724 let config: AutumnConfig = toml::from_str(
10725 r#"
10726 [jobs]
10727 backend = "local"
10728 workers = 4
10729 "#,
10730 )
10731 .unwrap();
10732 assert!(config.jobs.queues.strict);
10733 assert_eq!(config.jobs.queues.queues.len(), 1);
10734 assert_eq!(config.jobs.queues.queues[0].name, "default");
10735 }
10736
10737 #[test]
10738 fn job_queues_parse_ordered_list_as_strict_priority() {
10739 let config: AutumnConfig = toml::from_str(
10740 r#"
10741 [jobs]
10742 backend = "local"
10743 queues = ["critical", "default", "low"]
10744 "#,
10745 )
10746 .unwrap();
10747 assert!(config.jobs.queues.strict, "list form is strict priority");
10748 let names: Vec<&str> = config
10749 .jobs
10750 .queues
10751 .queues
10752 .iter()
10753 .map(|q| q.name.as_str())
10754 .collect();
10755 assert_eq!(names, ["critical", "default", "low"]);
10756 assert!(config.jobs.queues.queues.iter().all(|q| q.weight == 1));
10757 }
10758
10759 #[test]
10760 fn job_queues_parse_weight_map_as_weighted() {
10761 let config: AutumnConfig = toml::from_str(
10762 r#"
10763 [jobs]
10764 backend = "local"
10765
10766 [jobs.queues]
10767 critical = 4
10768 default = 2
10769 low = 1
10770 "#,
10771 )
10772 .unwrap();
10773 assert!(!config.jobs.queues.strict, "map form is weighted");
10774 let weight = |name: &str| {
10775 config
10776 .jobs
10777 .queues
10778 .queues
10779 .iter()
10780 .find(|q| q.name == name)
10781 .map(|q| q.weight)
10782 };
10783 assert_eq!(weight("critical"), Some(4));
10784 assert_eq!(weight("default"), Some(2));
10785 assert_eq!(weight("low"), Some(1));
10786 }
10787
10788 #[test]
10789 fn job_queues_strict_list_rejects_duplicate_names() {
10790 let err = toml::from_str::<AutumnConfig>(
10791 r#"
10792 [jobs]
10793 queues = ["critical", "default", "critical"]
10794 "#,
10795 )
10796 .unwrap_err()
10797 .to_string();
10798 assert!(
10799 err.contains("duplicate queue name") && err.contains("critical"),
10800 "unexpected error: {err}"
10801 );
10802 }
10803
10804 #[test]
10805 fn job_queues_table_form_parses_caps_and_reserved_slots() {
10806 let config: AutumnConfig = toml::from_str(
10809 r"
10810 [jobs.queues]
10811 critical = { weight = 3, reserved = 2 }
10812 bulk = { weight = 1, concurrency = 4 }
10813 default = 2
10814 ",
10815 )
10816 .unwrap();
10817 assert!(!config.jobs.queues.strict, "table form is weighted");
10818 let find = |name: &str| {
10819 config
10820 .jobs
10821 .queues
10822 .queues
10823 .iter()
10824 .find(|q| q.name == name)
10825 .cloned()
10826 .unwrap()
10827 };
10828 let critical = find("critical");
10829 assert_eq!(critical.weight, 3);
10830 assert_eq!(critical.reserved, Some(2));
10831 assert_eq!(critical.concurrency, None);
10832 let bulk = find("bulk");
10833 assert_eq!(bulk.weight, 1);
10834 assert_eq!(bulk.concurrency, Some(4));
10835 assert_eq!(bulk.reserved, None);
10836 let default = find("default");
10838 assert_eq!(default.weight, 2);
10839 assert_eq!(default.concurrency, None);
10840 assert_eq!(default.reserved, None);
10841 }
10842
10843 #[test]
10844 fn job_queues_table_form_defaults_weight_to_one() {
10845 let config: AutumnConfig = toml::from_str(
10846 r"
10847 [jobs.queues]
10848 critical = { reserved = 1 }
10849 ",
10850 )
10851 .unwrap();
10852 let critical = &config.jobs.queues.queues[0];
10853 assert_eq!(critical.weight, 1, "omitted weight defaults to 1");
10854 assert_eq!(critical.reserved, Some(1));
10855 }
10856
10857 #[test]
10858 fn job_queues_table_form_rejects_zero_weight() {
10859 let err = toml::from_str::<AutumnConfig>(
10860 r"
10861 [jobs.queues]
10862 critical = { weight = 0, reserved = 1 }
10863 ",
10864 )
10865 .unwrap_err()
10866 .to_string();
10867 assert!(
10868 err.contains("weight must be at least 1") && err.contains("critical"),
10869 "unexpected error: {err}"
10870 );
10871 }
10872
10873 #[test]
10874 fn job_queues_table_form_rejects_unknown_setting() {
10875 let err = toml::from_str::<AutumnConfig>(
10876 r"
10877 [jobs.queues]
10878 critical = { weight = 1, bogus = 3 }
10879 ",
10880 )
10881 .unwrap_err()
10882 .to_string();
10883 assert!(err.contains("bogus"), "unexpected error: {err}");
10884 }
10885
10886 #[test]
10887 fn jobs_pin_defaults_empty_and_parses_from_toml() {
10888 let default = AutumnConfig::default();
10889 assert!(default.jobs.pin.is_empty(), "pin is empty by default (AC4)");
10890 let config: AutumnConfig = toml::from_str(
10891 r#"
10892 [jobs]
10893 pin = ["critical", "default"]
10894 "#,
10895 )
10896 .unwrap();
10897 assert_eq!(config.jobs.pin, vec!["critical", "default"]);
10898 }
10899
10900 #[test]
10901 fn jobs_pin_env_override_is_comma_separated() {
10902 let env = MockEnv::new().with("AUTUMN_JOBS__PIN", "critical, bulk ,");
10903 let mut config = AutumnConfig::default();
10904 config.apply_jobs_env_overrides_with_env(&env);
10905 assert_eq!(
10906 config.jobs.pin,
10907 vec!["critical".to_string(), "bulk".to_string()],
10908 "trims whitespace and drops empty entries"
10909 );
10910 }
10911
10912 #[test]
10913 fn job_queues_weighted_rejects_zero_weight() {
10914 let err = toml::from_str::<AutumnConfig>(
10915 r"
10916 [jobs.queues]
10917 critical = 4
10918 default = 0
10919 ",
10920 )
10921 .unwrap_err()
10922 .to_string();
10923 assert!(
10924 err.contains("weight must be at least 1") && err.contains("default"),
10925 "unexpected error: {err}"
10926 );
10927 }
10928
10929 #[test]
10930 fn channels_defaults_to_in_process_backend() {
10931 let config = AutumnConfig::default();
10932
10933 assert_eq!(config.channels.backend, ChannelBackend::InProcess);
10934 assert_eq!(config.channels.capacity, 32);
10935 assert_eq!(config.channels.replay_buffer, 256);
10936 assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
10937 assert!(config.channels.redis.url.is_none());
10938 }
10939
10940 #[test]
10941 fn channels_env_overrides_fields() {
10942 let env = MockEnv::new()
10943 .with("AUTUMN_CHANNELS__BACKEND", "redis")
10944 .with("AUTUMN_CHANNELS__CAPACITY", "128")
10945 .with("AUTUMN_CHANNELS__REPLAY_BUFFER", "512")
10946 .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
10947 .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
10948 let mut config = AutumnConfig::default();
10949
10950 config.apply_env_overrides_with_env(&env);
10951
10952 assert_eq!(config.channels.backend, ChannelBackend::Redis);
10953 assert_eq!(config.channels.capacity, 128);
10954 assert_eq!(config.channels.replay_buffer, 512);
10955 assert_eq!(
10956 config.channels.redis.url.as_deref(),
10957 Some("redis://channels:6379/4")
10958 );
10959 assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
10960 }
10961
10962 #[test]
10963 fn channels_toml_deserializes_redis_backend() {
10964 let config: AutumnConfig = toml::from_str(
10965 r#"
10966 [channels]
10967 backend = "redis"
10968 capacity = 64
10969
10970 [channels.redis]
10971 url = "redis://localhost:6379/5"
10972 key_prefix = "demo:channels"
10973 "#,
10974 )
10975 .unwrap();
10976
10977 assert_eq!(config.channels.backend, ChannelBackend::Redis);
10978 assert_eq!(config.channels.capacity, 64);
10979 assert_eq!(
10980 config.channels.redis.url.as_deref(),
10981 Some("redis://localhost:6379/5")
10982 );
10983 assert_eq!(config.channels.redis.key_prefix, "demo:channels");
10984 }
10985
10986 #[test]
10987 fn env_override_invalid_jobs_numeric_values_ignored() {
10988 let env = MockEnv::new()
10989 .with("AUTUMN_JOBS__WORKERS", "many")
10990 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
10991 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
10992 let mut config = AutumnConfig::default();
10993 config.apply_env_overrides_with_env(&env);
10994
10995 assert_eq!(config.jobs.workers, 1);
10996 assert_eq!(config.jobs.max_attempts, 5);
10997 assert_eq!(config.jobs.initial_backoff_ms, 250);
10998 }
10999
11000 #[test]
11003 fn env_override_server_port() {
11004 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
11005 let mut config = AutumnConfig::default();
11006 config.apply_env_overrides_with_env(&env);
11007 assert_eq!(config.server.port, 8080);
11008 }
11009
11010 #[test]
11011 fn parse_env_works() {
11012 let env = MockEnv::new().with("SOME_NUM", "123");
11013 let mut target: u32 = 0;
11014 parse_env(&env, "SOME_NUM", &mut target);
11015 assert_eq!(target, 123);
11016
11017 let env_err = MockEnv::new().with("SOME_NUM", "abc");
11018 let mut target_err: u32 = 0;
11019 parse_env(&env_err, "SOME_NUM", &mut target_err);
11020 assert_eq!(target_err, 0); }
11022
11023 #[test]
11024 fn parse_env_option_string_works() {
11025 let env = MockEnv::new().with("SOME_OPT", "val");
11026 let mut target = None;
11027 parse_env_option_string(&env, "SOME_OPT", &mut target);
11028 assert_eq!(target, Some("val".to_string()));
11029
11030 let env_empty = MockEnv::new().with("SOME_OPT", "");
11031 let mut target_empty = Some("old".to_string());
11032 parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
11033 assert_eq!(target_empty, None);
11034 }
11035
11036 #[test]
11037 fn parse_env_string_works() {
11038 let env = MockEnv::new().with("SOME_STR", "val");
11039 let mut target = "old".to_string();
11040 parse_env_string(&env, "SOME_STR", &mut target);
11041 assert_eq!(target, "val");
11042 }
11043
11044 fn cfg_with_profile(profile: Option<&str>) -> AutumnConfig {
11047 AutumnConfig {
11048 profile: profile.map(str::to_owned),
11049 ..Default::default()
11050 }
11051 }
11052
11053 #[test]
11054 fn server_timing_defaults_on_in_dev_profile() {
11055 let cfg = cfg_with_profile(Some("dev"));
11056 assert!(server_timing_enabled(&cfg));
11057
11058 let cfg = cfg_with_profile(Some("development"));
11059 assert!(server_timing_enabled(&cfg));
11060 }
11061
11062 #[test]
11063 fn server_timing_defaults_off_in_prod_and_test_profiles() {
11064 let cfg = cfg_with_profile(Some("prod"));
11065 assert!(!server_timing_enabled(&cfg));
11066
11067 let cfg = cfg_with_profile(Some("production"));
11068 assert!(!server_timing_enabled(&cfg));
11069
11070 let cfg = cfg_with_profile(Some("test"));
11071 assert!(!server_timing_enabled(&cfg));
11072
11073 let cfg = cfg_with_profile(None);
11074 assert!(!server_timing_enabled(&cfg));
11075 }
11076
11077 #[test]
11078 fn server_timing_explicit_config_overrides_profile_default() {
11079 let mut cfg = cfg_with_profile(Some("prod"));
11080 cfg.observability.server_timing = Some(true);
11081 assert!(server_timing_enabled(&cfg));
11082
11083 let mut cfg = cfg_with_profile(Some("dev"));
11084 cfg.observability.server_timing = Some(false);
11085 assert!(!server_timing_enabled(&cfg));
11086 }
11087
11088 #[test]
11089 fn server_timing_env_override_wires_into_dispatcher() {
11090 let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "true");
11091 let mut config = cfg_with_profile(Some("prod"));
11092 config.apply_env_overrides_with_env(&env);
11093 assert_eq!(config.observability.server_timing, Some(true));
11094 assert!(server_timing_enabled(&config));
11095
11096 let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "false");
11097 let mut config = cfg_with_profile(Some("dev"));
11098 config.apply_env_overrides_with_env(&env);
11099 assert_eq!(config.observability.server_timing, Some(false));
11100 assert!(!server_timing_enabled(&config));
11101 }
11102
11103 #[test]
11104 fn parse_env_bool_works() {
11105 let env = MockEnv::new().with("SOME_BOOL", "true");
11106 let mut target = false;
11107 parse_env_bool(&env, "SOME_BOOL", &mut target);
11108 assert!(target);
11109
11110 let env2 = MockEnv::new().with("SOME_BOOL", "1");
11111 let mut target2 = false;
11112 parse_env_bool(&env2, "SOME_BOOL", &mut target2);
11113 assert!(target2);
11114
11115 let env3 = MockEnv::new().with("SOME_BOOL", "0");
11116 let mut target3 = true;
11117 parse_env_bool(&env3, "SOME_BOOL", &mut target3);
11118 assert!(!target3);
11119
11120 let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
11121 let mut target_err = true;
11122 parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
11123 assert!(target_err); }
11125
11126 #[test]
11127 fn parse_env_csv_works() {
11128 let env = MockEnv::new().with("SOME_CSV", "a, b,c");
11129 let mut target = vec![];
11130 parse_env_csv(&env, "SOME_CSV", &mut target);
11131 assert_eq!(target, vec!["a", "b", "c"]);
11132 }
11133
11134 #[test]
11135 fn env_override_tenancy_quota_bytes() {
11136 let env = MockEnv::new();
11138 let mut config = AutumnConfig::default();
11139 config.apply_env_overrides_with_env(&env);
11140 assert_eq!(config.tenancy.quota_bytes, 0);
11141
11142 let env = MockEnv::new().with("AUTUMN_TENANCY__QUOTA_BYTES", "1048576");
11144 let mut config = AutumnConfig::default();
11145 config.apply_env_overrides_with_env(&env);
11146 assert_eq!(config.tenancy.quota_bytes, 1_048_576);
11147 }
11148
11149 #[test]
11150 fn env_override_tenancy_enabled() {
11151 let env = MockEnv::new();
11153 let mut config = AutumnConfig::default();
11154 config.apply_env_overrides_with_env(&env);
11155 assert!(!config.tenancy.enabled);
11156
11157 let env = MockEnv::new().with("AUTUMN_TENANCY__ENABLED", "true");
11158 let mut config = AutumnConfig::default();
11159 config.apply_env_overrides_with_env(&env);
11160 assert!(config.tenancy.enabled);
11161 }
11162
11163 #[test]
11164 fn env_override_tenancy_string_fields() {
11165 let env = MockEnv::new()
11166 .with("AUTUMN_TENANCY__SOURCE", "jwt")
11167 .with("AUTUMN_TENANCY__HEADER_NAME", "x-org")
11168 .with("AUTUMN_TENANCY__SESSION_KEY", "org_id")
11169 .with("AUTUMN_TENANCY__JWT_CLAIM", "org")
11170 .with("AUTUMN_TENANCY__JWT_ISSUER", "https://issuer.example")
11171 .with("AUTUMN_TENANCY__JWT_AUDIENCE", "autumn-api")
11172 .with("AUTUMN_TENANCY__BASE_DOMAIN", "apps.example.com")
11173 .with("AUTUMN_TENANCY__LOGIN_REDIRECT", "/login")
11174 .with("AUTUMN_TENANCY__PUBLIC_PATHS", "/login, /signup ,/assets");
11175 let mut config = AutumnConfig::default();
11176 config.apply_env_overrides_with_env(&env);
11177 assert_eq!(config.tenancy.source, "jwt");
11178 assert_eq!(config.tenancy.header_name, "x-org");
11179 assert_eq!(config.tenancy.session_key, "org_id");
11180 assert_eq!(config.tenancy.jwt_claim, "org");
11181 assert_eq!(
11182 config.tenancy.jwt_issuer.as_deref(),
11183 Some("https://issuer.example")
11184 );
11185 assert_eq!(config.tenancy.jwt_audience.as_deref(), Some("autumn-api"));
11186 assert_eq!(
11187 config.tenancy.base_domain.as_deref(),
11188 Some("apps.example.com")
11189 );
11190 assert_eq!(config.tenancy.login_redirect.as_deref(), Some("/login"));
11191 assert_eq!(
11192 config.tenancy.public_paths,
11193 vec!["/login", "/signup", "/assets"]
11194 );
11195 }
11196
11197 #[test]
11198 fn env_override_tenancy_eviction_knobs() {
11199 let env = MockEnv::new();
11201 let mut config = AutumnConfig::default();
11202 config.apply_env_overrides_with_env(&env);
11203 assert_eq!(config.tenancy.max_cells, 0);
11204 assert_eq!(config.tenancy.idle_ttl_secs, 0);
11205
11206 let env = MockEnv::new()
11207 .with("AUTUMN_TENANCY__MAX_CELLS", "512")
11208 .with("AUTUMN_TENANCY__IDLE_TTL_SECS", "900");
11209 let mut config = AutumnConfig::default();
11210 config.apply_env_overrides_with_env(&env);
11211 assert_eq!(config.tenancy.max_cells, 512);
11212 assert_eq!(config.tenancy.idle_ttl_secs, 900);
11213 }
11214
11215 #[test]
11216 fn env_override_tenancy_secret() {
11217 use secrecy::ExposeSecret;
11218
11219 let env = MockEnv::new();
11221 let mut config = AutumnConfig::default();
11222 config.apply_env_overrides_with_env(&env);
11223 assert!(config.tenancy.jwt_secret.is_none());
11224
11225 let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " s3cr3t-signing-key ");
11227 let mut config = AutumnConfig::default();
11228 config.apply_env_overrides_with_env(&env);
11229 assert_eq!(
11230 config
11231 .tenancy
11232 .jwt_secret
11233 .as_ref()
11234 .map(|s| s.expose_secret().to_owned()),
11235 Some("s3cr3t-signing-key".to_string())
11236 );
11237
11238 let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " ");
11240 let mut config = AutumnConfig::default();
11241 config.tenancy.jwt_secret = Some(secrecy::SecretString::from("preexisting".to_string()));
11242 config.apply_env_overrides_with_env(&env);
11243 assert!(config.tenancy.jwt_secret.is_none());
11244 }
11245
11246 #[test]
11247 fn env_override_rate_limit_trusted_proxies() {
11248 let env = MockEnv::new().with(
11249 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
11250 "10.0.0.10, 203.0.113.0/24",
11251 );
11252 let mut config = AutumnConfig::default();
11253 config.apply_env_overrides_with_env(&env);
11254 assert_eq!(
11255 config.security.rate_limit.trusted_proxies,
11256 vec!["10.0.0.10", "203.0.113.0/24"]
11257 );
11258 }
11259
11260 #[test]
11261 fn env_override_rate_limit_backend_redis() {
11262 use crate::security::config::RateLimitBackend;
11263 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
11264 let mut config = AutumnConfig::default();
11265 config.apply_env_overrides_with_env(&env);
11266 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
11267 }
11268
11269 #[test]
11270 fn env_override_rate_limit_backend_memory() {
11271 use crate::security::config::RateLimitBackend;
11272 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
11273 let mut config = AutumnConfig::default();
11274 config.apply_env_overrides_with_env(&env);
11275 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
11276 }
11277
11278 #[test]
11279 fn env_override_rate_limit_backend_invalid_ignored() {
11280 use crate::security::config::RateLimitBackend;
11281 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
11282 let mut config = AutumnConfig::default();
11283 config.apply_env_overrides_with_env(&env);
11284 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
11285 }
11286
11287 #[cfg(feature = "redis")]
11288 #[test]
11289 fn env_override_rate_limit_on_backend_failure_fail_closed() {
11290 use crate::security::config::RateLimitBackendFailure;
11291 let env = MockEnv::new().with(
11292 "AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
11293 "fail_closed",
11294 );
11295 let mut config = AutumnConfig::default();
11296 config.apply_env_overrides_with_env(&env);
11297 assert_eq!(
11298 config.security.rate_limit.on_backend_failure,
11299 RateLimitBackendFailure::FailClosed
11300 );
11301 }
11302
11303 #[cfg(feature = "redis")]
11304 #[test]
11305 fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
11306 use crate::security::config::RateLimitBackendFailure;
11307 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
11308 let mut config = AutumnConfig::default();
11309 config.apply_env_overrides_with_env(&env);
11310 assert_eq!(
11311 config.security.rate_limit.on_backend_failure,
11312 RateLimitBackendFailure::FailOpen
11313 );
11314 }
11315
11316 #[cfg(feature = "redis")]
11317 #[test]
11318 fn env_override_rate_limit_redis_url() {
11319 let env = MockEnv::new().with(
11320 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
11321 "redis://myhost:6379",
11322 );
11323 let mut config = AutumnConfig::default();
11324 config.apply_env_overrides_with_env(&env);
11325 assert_eq!(
11326 config.security.rate_limit.redis.url.as_deref(),
11327 Some("redis://myhost:6379")
11328 );
11329 }
11330
11331 #[cfg(feature = "redis")]
11332 #[test]
11333 fn env_override_rate_limit_redis_key_prefix() {
11334 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
11335 let mut config = AutumnConfig::default();
11336 config.apply_env_overrides_with_env(&env);
11337 assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
11338 }
11339
11340 #[test]
11341 fn env_override_server_host() {
11342 let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
11343 let mut config = AutumnConfig::default();
11344 config.apply_env_overrides_with_env(&env);
11345 assert_eq!(config.server.host, "0.0.0.0");
11346 }
11347
11348 #[test]
11349 fn env_override_server_shutdown_timeout() {
11350 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
11351 let mut config = AutumnConfig::default();
11352 config.apply_env_overrides_with_env(&env);
11353 assert_eq!(config.server.shutdown_timeout_secs, 60);
11354 }
11355
11356 #[test]
11357 fn env_override_invalid_server_port_ignored() {
11358 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
11359 let mut config = AutumnConfig::default();
11360 config.apply_env_overrides_with_env(&env);
11361 assert_eq!(config.server.port, 3000);
11362 }
11363
11364 #[test]
11365 fn env_override_invalid_shutdown_timeout_ignored() {
11366 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
11367 let mut config = AutumnConfig::default();
11368 config.apply_env_overrides_with_env(&env);
11369 assert_eq!(config.server.shutdown_timeout_secs, 30);
11370 }
11371
11372 #[test]
11373 fn server_config_defaults_unix_socket_none() {
11374 let config = AutumnConfig::default();
11375 assert!(config.server.unix_socket.is_none());
11376 }
11377
11378 #[test]
11379 fn env_override_server_unix_socket() {
11380 let env = MockEnv::new().with("AUTUMN_SERVER__UNIX_SOCKET", "/run/autumn/app.sock");
11381 let mut config = AutumnConfig::default();
11382 config.apply_env_overrides_with_env(&env);
11383 assert_eq!(
11384 config.server.unix_socket.as_deref(),
11385 Some("/run/autumn/app.sock")
11386 );
11387 }
11388
11389 #[test]
11390 fn unix_socket_parses_from_toml() {
11391 let config: AutumnConfig = toml::from_str(
11392 r#"
11393 [server]
11394 unix_socket = "/tmp/autumn.sock"
11395 "#,
11396 )
11397 .expect("config with server.unix_socket should parse");
11398 assert_eq!(
11399 config.server.unix_socket.as_deref(),
11400 Some("/tmp/autumn.sock")
11401 );
11402 }
11403
11404 #[test]
11407 fn server_config_defaults_tls_none() {
11408 let config = AutumnConfig::default();
11410 assert!(config.server.tls.is_none());
11411 }
11412
11413 #[test]
11414 fn server_tls_parses_from_toml() {
11415 let config: AutumnConfig = toml::from_str(
11416 r#"
11417 [server.tls]
11418 cert_path = "/etc/autumn/tls/fullchain.pem"
11419 key_path = "/etc/autumn/tls/privkey.pem"
11420 "#,
11421 )
11422 .expect("config with [server.tls] should parse");
11423 let tls = config.server.tls.expect("tls configured");
11424 assert_eq!(
11425 tls.cert_path,
11426 Some(std::path::PathBuf::from("/etc/autumn/tls/fullchain.pem"))
11427 );
11428 assert_eq!(
11429 tls.key_path,
11430 Some(std::path::PathBuf::from("/etc/autumn/tls/privkey.pem"))
11431 );
11432 assert_eq!(tls.reload_interval_secs, 60);
11434 assert_eq!(tls.handshake_timeout_secs, 10);
11435 assert!(tls.acme.is_none());
11437 assert!(tls.validate().is_ok());
11438 }
11439
11440 #[test]
11441 fn server_tls_handshake_timeout_parses_from_toml() {
11442 let config: AutumnConfig = toml::from_str(
11443 r#"
11444 [server.tls]
11445 cert_path = "cert.pem"
11446 key_path = "key.pem"
11447 handshake_timeout_secs = 25
11448 "#,
11449 )
11450 .expect("config with [server.tls] handshake_timeout_secs should parse");
11451 assert_eq!(config.server.tls.unwrap().handshake_timeout_secs, 25);
11452 }
11453
11454 #[test]
11455 fn server_tls_reload_interval_parses_from_toml() {
11456 let config: AutumnConfig = toml::from_str(
11457 r#"
11458 [server.tls]
11459 cert_path = "cert.pem"
11460 key_path = "key.pem"
11461 reload_interval_secs = 120
11462 "#,
11463 )
11464 .expect("config with [server.tls] reload_interval_secs should parse");
11465 assert_eq!(config.server.tls.unwrap().reload_interval_secs, 120);
11466 }
11467
11468 #[test]
11469 fn env_override_materializes_server_tls() {
11470 let env = MockEnv::new()
11473 .with("AUTUMN_SERVER__TLS__CERT_PATH", "/env/cert.pem")
11474 .with("AUTUMN_SERVER__TLS__KEY_PATH", "/env/key.pem")
11475 .with("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS", "90")
11476 .with("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS", "5");
11477 let mut config = AutumnConfig::default();
11478 assert!(config.server.tls.is_none());
11479 config.apply_env_overrides_with_env(&env);
11480 let tls = config.server.tls.expect("env should materialize tls");
11481 assert_eq!(
11482 tls.cert_path,
11483 Some(std::path::PathBuf::from("/env/cert.pem"))
11484 );
11485 assert_eq!(tls.key_path, Some(std::path::PathBuf::from("/env/key.pem")));
11486 assert_eq!(tls.reload_interval_secs, 90);
11487 assert_eq!(tls.handshake_timeout_secs, 5);
11488 }
11489
11490 #[test]
11491 fn env_override_updates_existing_server_tls_cert() {
11492 let mut config: AutumnConfig = toml::from_str(
11495 r#"
11496 [server.tls]
11497 cert_path = "toml-cert.pem"
11498 key_path = "toml-key.pem"
11499 "#,
11500 )
11501 .unwrap();
11502 let env = MockEnv::new().with("AUTUMN_SERVER__TLS__CERT_PATH", "override-cert.pem");
11503 config.apply_env_overrides_with_env(&env);
11504 let tls = config.server.tls.expect("tls configured");
11505 assert_eq!(
11506 tls.cert_path,
11507 Some(std::path::PathBuf::from("override-cert.pem"))
11508 );
11509 assert_eq!(tls.key_path, Some(std::path::PathBuf::from("toml-key.pem")));
11510 }
11511
11512 #[test]
11513 fn no_tls_env_leaves_tls_none() {
11514 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
11515 let mut config = AutumnConfig::default();
11516 config.apply_env_overrides_with_env(&env);
11517 assert!(config.server.tls.is_none());
11518 }
11519
11520 #[test]
11523 fn deploy_absent_is_none() {
11524 let config = AutumnConfig::default();
11527 assert!(config.deploy.is_none());
11528 let parsed: AutumnConfig = toml::from_str("[server]\nport = 3000\n")
11529 .expect("config without [deploy] should parse");
11530 assert!(parsed.deploy.is_none());
11531 }
11532
11533 #[test]
11534 fn deploy_defaults_from_bare_table() {
11535 let config: AutumnConfig =
11538 toml::from_str("[deploy]\n").expect("bare [deploy] table should parse");
11539 let deploy = config.deploy.expect("deploy configured");
11540 assert_eq!(deploy.host, None);
11541 assert_eq!(deploy.user, "root");
11542 assert_eq!(deploy.ssh_port, 22);
11543 assert_eq!(deploy.app_name, None);
11544 assert_eq!(deploy.app_dir, None);
11545 assert_eq!(deploy.service_name, None);
11546 assert_eq!(deploy.readiness_timeout_secs, 60);
11547 assert_eq!(deploy.keep_releases, 3);
11548 }
11549
11550 #[test]
11551 fn deploy_full_table_parses() {
11552 let config: AutumnConfig = toml::from_str(
11553 r#"
11554 [deploy]
11555 host = "203.0.113.10"
11556 user = "deploy"
11557 ssh_port = 2222
11558 app_name = "myapp"
11559 app_dir = "/srv/myapp"
11560 service_name = "myapp-web"
11561 readiness_timeout_secs = 90
11562 keep_releases = 5
11563 "#,
11564 )
11565 .expect("full [deploy] table should parse");
11566 let deploy = config.deploy.expect("deploy configured");
11567 assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
11568 assert_eq!(deploy.user, "deploy");
11569 assert_eq!(deploy.ssh_port, 2222);
11570 assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
11571 assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
11572 assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
11573 assert_eq!(deploy.readiness_timeout_secs, 90);
11574 assert_eq!(deploy.keep_releases, 5);
11575 assert!(deploy.validate().is_ok());
11576 }
11577
11578 #[test]
11579 fn deploy_validate_rejects_missing_host() {
11580 let missing = DeployConfig::default();
11582 let err = missing
11583 .validate()
11584 .expect_err("missing host must be rejected");
11585 assert!(
11586 err.contains("host"),
11587 "error should name the missing key: {err}"
11588 );
11589
11590 let blank = DeployConfig {
11592 host: Some(" ".to_owned()),
11593 ..DeployConfig::default()
11594 };
11595 assert!(blank.validate().is_err());
11596
11597 let ok = DeployConfig {
11599 host: Some("example.com".to_owned()),
11600 ..DeployConfig::default()
11601 };
11602 assert!(ok.validate().is_ok());
11603 }
11604
11605 #[test]
11606 fn env_override_materializes_deploy() {
11607 let env = MockEnv::new()
11610 .with("AUTUMN_DEPLOY__HOST", "203.0.113.10")
11611 .with("AUTUMN_DEPLOY__USER", "deploy")
11612 .with("AUTUMN_DEPLOY__SSH_PORT", "2222")
11613 .with("AUTUMN_DEPLOY__APP_NAME", "myapp")
11614 .with("AUTUMN_DEPLOY__APP_DIR", "/srv/myapp")
11615 .with("AUTUMN_DEPLOY__SERVICE_NAME", "myapp-web")
11616 .with("AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS", "90")
11617 .with("AUTUMN_DEPLOY__KEEP_RELEASES", "5")
11618 .with("AUTUMN_DEPLOY__PROFILE", "staging");
11619 let mut config = AutumnConfig::default();
11620 assert!(config.deploy.is_none());
11621 config.apply_env_overrides_with_env(&env);
11622 let deploy = config.deploy.expect("env should materialize deploy");
11623 assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
11624 assert_eq!(deploy.user, "deploy");
11625 assert_eq!(deploy.ssh_port, 2222);
11626 assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
11627 assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
11628 assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
11629 assert_eq!(deploy.readiness_timeout_secs, 90);
11630 assert_eq!(deploy.keep_releases, 5);
11631 assert_eq!(deploy.profile, "staging");
11632 assert!(deploy.validate().is_ok());
11633 }
11634
11635 #[test]
11636 fn env_override_sets_deploy_tls_enabled_and_host() {
11637 let env = MockEnv::new()
11641 .with("AUTUMN_DEPLOY__TLS__ENABLED", "true")
11642 .with("AUTUMN_DEPLOY__TLS__HOST", "app.example.com");
11643 let mut config = AutumnConfig::default();
11644 assert!(config.deploy.is_none());
11645 config.apply_env_overrides_with_env(&env);
11646 let deploy = config.deploy.expect("env should materialize deploy");
11647 assert!(deploy.tls.enabled);
11648 assert_eq!(deploy.tls.host.as_deref(), Some("app.example.com"));
11649 }
11650
11651 #[test]
11652 fn env_override_wins_over_toml_deploy_tls_host() {
11653 let mut config: AutumnConfig = toml::from_str(
11655 r#"
11656 [deploy]
11657 host = "203.0.113.10"
11658
11659 [deploy.tls]
11660 enabled = true
11661 host = "toml.example.com"
11662 "#,
11663 )
11664 .unwrap();
11665 assert_eq!(
11666 config.deploy.as_ref().unwrap().tls.host.as_deref(),
11667 Some("toml.example.com"),
11668 );
11669
11670 let env = MockEnv::new().with("AUTUMN_DEPLOY__TLS__HOST", "env.example.com");
11672 config.apply_env_overrides_with_env(&env);
11673 let deploy = config.deploy.unwrap();
11674 assert!(deploy.tls.enabled);
11675 assert_eq!(deploy.tls.host.as_deref(), Some("env.example.com"));
11676 }
11677
11678 #[test]
11679 fn deploy_profile_defaults_to_production() {
11680 let config: AutumnConfig = toml::from_str(
11683 r#"
11684 [deploy]
11685 host = "203.0.113.10"
11686 "#,
11687 )
11688 .unwrap();
11689 let deploy = config.deploy.expect("deploy configured");
11690 assert_eq!(deploy.profile, "prod");
11691 assert_eq!(DeployConfig::default().profile, "prod");
11693 }
11694
11695 #[test]
11696 fn deploy_profile_honors_toml_and_env_override() {
11697 let mut config: AutumnConfig = toml::from_str(
11699 r#"
11700 [deploy]
11701 host = "toml-host"
11702 profile = "staging"
11703 "#,
11704 )
11705 .unwrap();
11706 assert_eq!(config.deploy.as_ref().unwrap().profile, "staging");
11707
11708 let env = MockEnv::new().with("AUTUMN_DEPLOY__PROFILE", "prod");
11710 config.apply_env_overrides_with_env(&env);
11711 assert_eq!(config.deploy.unwrap().profile, "prod");
11712 }
11713
11714 #[test]
11715 fn env_override_materializes_deploy_from_single_host() {
11716 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "198.51.100.7");
11719 let mut config = AutumnConfig::default();
11720 assert!(config.deploy.is_none());
11721 config.apply_env_overrides_with_env(&env);
11722 let deploy = config.deploy.expect("env should materialize deploy");
11723 assert_eq!(deploy.host.as_deref(), Some("198.51.100.7"));
11724 assert_eq!(deploy.user, "root");
11726 assert_eq!(deploy.ssh_port, 22);
11727 assert_eq!(deploy.readiness_timeout_secs, 60);
11728 assert_eq!(deploy.keep_releases, 3);
11729 }
11730
11731 #[test]
11732 fn env_override_updates_existing_deploy_host() {
11733 let mut config: AutumnConfig = toml::from_str(
11736 r#"
11737 [deploy]
11738 host = "toml-host"
11739 user = "deploy"
11740 ssh_port = 2200
11741 "#,
11742 )
11743 .unwrap();
11744 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "env-host");
11745 config.apply_env_overrides_with_env(&env);
11746 let deploy = config.deploy.expect("deploy configured");
11747 assert_eq!(deploy.host.as_deref(), Some("env-host"));
11748 assert_eq!(deploy.user, "deploy");
11749 assert_eq!(deploy.ssh_port, 2200);
11750 }
11751
11752 #[test]
11753 fn env_override_parses_deploy_ssh_port_u16() {
11754 let env = MockEnv::new()
11755 .with("AUTUMN_DEPLOY__HOST", "example.com")
11756 .with("AUTUMN_DEPLOY__SSH_PORT", "65535");
11757 let mut config = AutumnConfig::default();
11758 config.apply_env_overrides_with_env(&env);
11759 let deploy = config.deploy.expect("env should materialize deploy");
11760 assert_eq!(deploy.ssh_port, 65_535_u16);
11761 }
11762
11763 #[test]
11764 fn no_deploy_env_leaves_deploy_none() {
11765 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
11766 let mut config = AutumnConfig::default();
11767 config.apply_env_overrides_with_env(&env);
11768 assert!(config.deploy.is_none());
11769 }
11770
11771 fn tls_static(cert: Option<&str>, key: Option<&str>) -> TlsConfig {
11774 TlsConfig {
11775 cert_path: cert.map(PathBuf::from),
11776 key_path: key.map(PathBuf::from),
11777 reload_interval_secs: default_tls_reload_interval_secs(),
11778 handshake_timeout_secs: default_tls_handshake_timeout_secs(),
11779 acme: None,
11780 }
11781 }
11782
11783 fn acme_cfg(domains: &[&str], email: &str) -> AcmeConfig {
11784 AcmeConfig {
11785 domains: domains.iter().map(|d| (*d).to_owned()).collect(),
11786 contact_email: email.to_owned(),
11787 directory: AcmeDirectory::Staging,
11788 cache_dir: default_acme_cache_dir(),
11789 http_challenge_port: default_acme_http_challenge_port(),
11790 renew_before_days: default_acme_renew_before_days(),
11791 }
11792 }
11793
11794 #[test]
11795 fn acme_parses_from_toml_with_defaults() {
11796 let config: AutumnConfig = toml::from_str(
11797 r#"
11798 [server.tls.acme]
11799 domains = ["app.example.com"]
11800 contact_email = "ops@example.com"
11801 "#,
11802 )
11803 .expect("config with [server.tls.acme] should parse");
11804 let tls = config.server.tls.expect("tls configured");
11805 let acme = tls.acme.as_ref().expect("acme configured");
11806 assert_eq!(acme.domains, vec!["app.example.com".to_owned()]);
11807 assert_eq!(acme.contact_email, "ops@example.com");
11808 assert_eq!(acme.directory, AcmeDirectory::Staging);
11810 assert_eq!(acme.cache_dir, PathBuf::from("config/acme"));
11811 assert_eq!(acme.http_challenge_port, 80);
11812 assert_eq!(acme.renew_before_days, 30);
11813 assert!(tls.validate().is_ok());
11814 }
11815
11816 #[test]
11817 fn acme_directory_custom_parses() {
11818 let config: AutumnConfig = toml::from_str(
11819 r#"
11820 [server.tls.acme]
11821 domains = ["a.example.com"]
11822 contact_email = "ops@example.com"
11823 directory = { custom = { url = "https://pebble.test/dir" } }
11824 "#,
11825 )
11826 .expect("custom directory should parse");
11827 let acme = config.server.tls.unwrap().acme.unwrap();
11828 assert_eq!(
11829 acme.directory,
11830 AcmeDirectory::Custom {
11831 url: "https://pebble.test/dir".to_owned()
11832 }
11833 );
11834 }
11835
11836 #[test]
11837 fn validate_static_only_ok() {
11838 assert!(tls_static(Some("c.pem"), Some("k.pem")).validate().is_ok());
11839 }
11840
11841 #[test]
11842 fn validate_acme_only_ok() {
11843 let mut cfg = tls_static(None, None);
11844 cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
11845 assert!(cfg.validate().is_ok());
11846 }
11847
11848 #[test]
11849 fn validate_both_static_and_acme_rejected() {
11850 let mut cfg = tls_static(Some("c.pem"), Some("k.pem"));
11851 cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
11852 let err = cfg.validate().unwrap_err();
11853 assert!(err.contains("choose exactly one"), "got: {err}");
11854 }
11855
11856 #[test]
11857 fn validate_neither_static_nor_acme_rejected() {
11858 let err = tls_static(None, None).validate().unwrap_err();
11859 assert!(err.contains("exactly one of"), "got: {err}");
11860 }
11861
11862 #[test]
11863 fn validate_cert_without_key_rejected() {
11864 let err = tls_static(Some("c.pem"), None).validate().unwrap_err();
11865 assert!(err.contains("set together"), "got: {err}");
11866 }
11867
11868 #[test]
11869 fn validate_acme_empty_domains_rejected() {
11870 let mut cfg = tls_static(None, None);
11871 cfg.acme = Some(acme_cfg(&[], "ops@example.com"));
11872 let err = cfg.validate().unwrap_err();
11873 assert!(err.contains("at least one domain"), "got: {err}");
11874 }
11875
11876 #[test]
11877 fn validate_acme_empty_email_rejected() {
11878 let mut cfg = tls_static(None, None);
11879 cfg.acme = Some(acme_cfg(&["app.example.com"], " "));
11880 let err = cfg.validate().unwrap_err();
11881 assert!(err.contains("contact_email"), "got: {err}");
11882 }
11883
11884 #[test]
11885 fn validate_acme_wildcard_domain_rejected_mentions_1620() {
11886 let mut cfg = tls_static(None, None);
11887 cfg.acme = Some(acme_cfg(&["*.example.com"], "ops@example.com"));
11888 let err = cfg.validate().unwrap_err();
11889 assert!(err.contains("#1620"), "got: {err}");
11890 assert!(err.contains("wildcard"), "got: {err}");
11891 }
11892
11893 #[test]
11897 fn validate_acme_blank_domain_entry_rejected() {
11898 let mut cfg = tls_static(None, None);
11899 cfg.acme = Some(acme_cfg(&[""], "ops@example.com"));
11900 let err = cfg.validate().unwrap_err();
11901 assert!(err.contains("blank entries"), "got: {err}");
11902
11903 let mut cfg = tls_static(None, None);
11905 cfg.acme = Some(acme_cfg(&[" "], "ops@example.com"));
11906 let err = cfg.validate().unwrap_err();
11907 assert!(err.contains("blank entries"), "got: {err}");
11908 }
11909
11910 #[test]
11914 fn validate_acme_zero_http_challenge_port_rejected() {
11915 let mut cfg = tls_static(None, None);
11916 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
11917 acme.http_challenge_port = 0;
11918 cfg.acme = Some(acme);
11919 let err = cfg.validate().unwrap_err();
11920 assert!(err.contains("http_challenge_port"), "got: {err}");
11921 }
11922
11923 #[test]
11929 fn validate_acme_renew_before_days_at_or_above_cert_lifetime_rejected() {
11930 let mut cfg = tls_static(None, None);
11932 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
11933 acme.renew_before_days = 100;
11934 cfg.acme = Some(acme);
11935 let err = cfg.validate().unwrap_err();
11936 assert!(err.contains("renew_before_days"), "got: {err}");
11937 assert!(err.contains("rate limits"), "got: {err}");
11938
11939 let mut cfg = tls_static(None, None);
11942 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
11943 acme.renew_before_days = 90;
11944 cfg.acme = Some(acme);
11945 assert!(
11946 cfg.validate().is_err(),
11947 "renew_before_days == 90 must be rejected"
11948 );
11949
11950 let mut cfg = tls_static(None, None);
11952 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
11953 acme.renew_before_days = 30;
11954 cfg.acme = Some(acme);
11955 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
11956
11957 let mut cfg = tls_static(None, None);
11959 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
11960 acme.renew_before_days = 89;
11961 cfg.acme = Some(acme);
11962 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
11963 }
11964
11965 #[test]
11968 fn validate_acme_valid_domains_and_port_ok() {
11969 let mut cfg = tls_static(None, None);
11970 cfg.acme = Some(acme_cfg(
11971 &["app.example.com", "www.example.com"],
11972 "ops@example.com",
11973 ));
11974 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
11975 }
11976
11977 #[test]
11980 fn server_config_defaults_max_concurrent_requests_none() {
11981 let config = AutumnConfig::default();
11984 assert!(config.server.max_concurrent_requests.is_none());
11985 }
11986
11987 #[test]
11988 fn max_concurrent_requests_parses_from_toml() {
11989 let config: AutumnConfig = toml::from_str(
11990 r"
11991 [server]
11992 max_concurrent_requests = 64
11993 ",
11994 )
11995 .expect("config with server.max_concurrent_requests should parse");
11996 assert_eq!(config.server.max_concurrent_requests, Some(64));
11997 }
11998
11999 #[test]
12000 fn env_override_server_max_concurrent_requests() {
12001 let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "128");
12002 let mut config = AutumnConfig::default();
12003 config.apply_env_overrides_with_env(&env);
12004 assert_eq!(config.server.max_concurrent_requests, Some(128));
12005 }
12006
12007 #[test]
12008 fn env_override_invalid_max_concurrent_requests_ignored() {
12009 let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "not_a_number");
12010 let mut config = AutumnConfig::default();
12011 config.apply_env_overrides_with_env(&env);
12012 assert!(config.server.max_concurrent_requests.is_none());
12013 }
12014
12015 #[test]
12016 fn env_override_empty_max_concurrent_requests_clears_to_none() {
12017 let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "");
12019 let mut config = AutumnConfig::default();
12020 config.server.max_concurrent_requests = Some(64);
12021 config.apply_env_overrides_with_env(&env);
12022 assert!(config.server.max_concurrent_requests.is_none());
12023 }
12024
12025 #[test]
12028 fn env_override_log_level() {
12029 let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
12030 let mut config = AutumnConfig::default();
12031 config.apply_env_overrides_with_env(&env);
12032 assert_eq!(config.log.level, "debug");
12033 }
12034
12035 #[test]
12036 fn env_override_log_format_json() {
12037 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
12038 let mut config = AutumnConfig::default();
12039 config.apply_env_overrides_with_env(&env);
12040 assert_eq!(config.log.format, LogFormat::Json);
12041 }
12042
12043 #[test]
12044 fn env_override_log_format_pretty() {
12045 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
12046 let mut config = AutumnConfig::default();
12047 config.apply_env_overrides_with_env(&env);
12048 assert_eq!(config.log.format, LogFormat::Pretty);
12049 }
12050
12051 #[test]
12052 fn env_override_invalid_log_format_ignored() {
12053 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
12054 let mut config = AutumnConfig::default();
12055 config.apply_env_overrides_with_env(&env);
12056 assert_eq!(config.log.format, LogFormat::Auto);
12057 }
12058
12059 #[test]
12062 fn env_override_telemetry_fields() {
12063 let env = MockEnv::new()
12064 .with("AUTUMN_TELEMETRY__ENABLED", "true")
12065 .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
12066 .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
12067 .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
12068 .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
12069 .with(
12070 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
12071 "http://otel-collector:4317",
12072 )
12073 .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
12074 .with("AUTUMN_TELEMETRY__STRICT", "true");
12075 let mut config = AutumnConfig::default();
12076 config.apply_env_overrides_with_env(&env);
12077 assert!(config.telemetry.enabled);
12078 assert_eq!(config.telemetry.service_name, "orders-api");
12079 assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
12080 assert_eq!(config.telemetry.service_version, "1.2.3");
12081 assert_eq!(config.telemetry.environment, "production");
12082 assert_eq!(
12083 config.telemetry.otlp_endpoint.as_deref(),
12084 Some("http://otel-collector:4317")
12085 );
12086 assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
12087 assert!(config.telemetry.strict);
12088 }
12089
12090 #[test]
12091 fn env_override_invalid_telemetry_protocol_ignored() {
12092 let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
12093 let mut config = AutumnConfig::default();
12094 config.apply_env_overrides_with_env(&env);
12095 assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
12096 }
12097
12098 #[test]
12099 fn env_override_health_path() {
12100 let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
12101 let mut config = AutumnConfig::default();
12102 config.apply_env_overrides_with_env(&env);
12103 assert_eq!(config.health.path, "/healthz");
12104 }
12105
12106 #[test]
12107 fn env_override_probe_paths() {
12108 let env = MockEnv::new()
12109 .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
12110 .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
12111 .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
12112 let mut config = AutumnConfig::default();
12113 config.apply_env_overrides_with_env(&env);
12114 assert_eq!(config.health.live_path, "/livez");
12115 assert_eq!(config.health.ready_path, "/readyz");
12116 assert_eq!(config.health.startup_path, "/startupz");
12117 }
12118
12119 #[test]
12122 fn env_overrides_toml_values() {
12123 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
12124 let dir = tempfile::tempdir().unwrap();
12125 let path = dir.path().join("autumn.toml");
12126 std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
12127 let mut config = AutumnConfig::load_from(&path).unwrap();
12128 config.apply_env_overrides_with_env(&env);
12129 assert_eq!(config.server.port, 9999); }
12131
12132 #[test]
12135 fn validate_rejects_invalid_url_scheme() {
12136 let config = DatabaseConfig {
12137 url: Some("mysql://localhost/test".to_owned()),
12138 ..Default::default()
12139 };
12140 let result = config.validate();
12141 assert!(result.is_err());
12142 assert!(
12143 result
12144 .unwrap_err()
12145 .to_string()
12146 .contains("must start with postgres://")
12147 );
12148 }
12149
12150 #[test]
12151 fn validate_accepts_postgres_url() {
12152 let config = DatabaseConfig {
12153 url: Some("postgres://localhost/test".to_owned()),
12154 ..Default::default()
12155 };
12156 assert!(config.validate().is_ok());
12157 }
12158
12159 #[test]
12160 fn validate_accepts_postgresql_url() {
12161 let config = DatabaseConfig {
12162 url: Some("postgresql://localhost/test".to_owned()),
12163 ..Default::default()
12164 };
12165 assert!(config.validate().is_ok());
12166 }
12167
12168 #[test]
12169 fn validate_accepts_no_url() {
12170 let config = DatabaseConfig::default();
12171 assert!(config.validate().is_ok());
12172 }
12173
12174 #[test]
12175 fn validate_accepts_keyword_value_connection_strings() {
12176 for url in [
12180 "host=db user=app dbname=app",
12181 "host=db user=app sslmode=require",
12182 "host=db sslmode = require",
12183 "host=db password='p w' sslmode='verify-full'",
12184 "host=db password=https://looks-like-a-url sslmode=require",
12185 ] {
12186 let config = DatabaseConfig {
12187 url: Some(url.to_owned()),
12188 ..Default::default()
12189 };
12190 assert!(
12191 config.validate().is_ok(),
12192 "keyword/value string must validate: {url}"
12193 );
12194 }
12195 let config = DatabaseConfig {
12197 primary_url: Some("host=db user=app sslmode=require".to_owned()),
12198 ..Default::default()
12199 };
12200 assert!(config.validate().is_ok());
12201 let config = DatabaseConfig {
12202 primary_url: Some("postgres://db-control/app".to_owned()),
12203 shards: vec![ShardConfig {
12204 name: "s0".to_owned(),
12205 primary_url: "host=db-shard0 user=app dbname=app".to_owned(),
12206 replica_url: None,
12207 slots: None,
12208 primary_pool_size: None,
12209 replica_pool_size: None,
12210 replica_fallback: None,
12211 }],
12212 ..Default::default()
12213 };
12214 assert!(
12215 config.validate().is_ok(),
12216 "shard URLs accept the keyword form too: {:?}",
12217 config.validate()
12218 );
12219 }
12220
12221 #[test]
12222 fn validate_still_rejects_garbage_connection_strings() {
12223 for url in [
12224 "mysql://localhost/test",
12225 "mysql://localhost/test?a=b",
12226 "not a connection string",
12227 "localhost",
12228 "host=",
12229 "host='unterminated",
12230 ] {
12231 let config = DatabaseConfig {
12232 url: Some(url.to_owned()),
12233 ..Default::default()
12234 };
12235 let err = config
12236 .validate()
12237 .expect_err(&format!("garbage must be rejected: {url:?}"))
12238 .to_string();
12239 assert!(
12240 err.contains("must start with postgres:// or postgresql://"),
12241 "the error must stay clear about accepted forms, got: {err}"
12242 );
12243 }
12244 }
12245
12246 #[test]
12249 fn detect_backend_postgres_urls() {
12250 for url in [
12251 "postgres://localhost/app",
12252 "postgresql://user:pass@db:5432/app",
12253 ] {
12254 assert_eq!(
12255 DatabaseBackend::detect(url),
12256 Some(DatabaseBackend::Postgres),
12257 "{url} should detect as postgres"
12258 );
12259 }
12260 }
12261
12262 #[test]
12263 fn detect_backend_postgres_keyword_value() {
12264 assert_eq!(
12267 DatabaseBackend::detect("host=db user=app sslmode=require"),
12268 Some(DatabaseBackend::Postgres)
12269 );
12270 }
12271
12272 #[test]
12273 fn detect_backend_sqlite_schemes() {
12274 for url in [
12275 "sqlite:///var/lib/app.db", "sqlite://./relative.db",
12277 "sqlite::memory:",
12278 "sqlite:app.db", "file:app.db", ] {
12281 assert_eq!(
12282 DatabaseBackend::detect(url),
12283 Some(DatabaseBackend::Sqlite),
12284 "{url} should detect as sqlite"
12285 );
12286 }
12287 }
12288
12289 #[test]
12290 fn detect_backend_bare_path_is_unrecognized() {
12291 for target in ["/var/lib/app.db", "./app.db", "app.db", "C:\\db\\app.db"] {
12295 assert_eq!(
12296 DatabaseBackend::detect(target),
12297 None,
12298 "{target} must not be auto-detected as a backend"
12299 );
12300 }
12301 }
12302
12303 #[test]
12304 fn detect_backend_garbage_is_unrecognized() {
12305 for target in ["mysql://localhost/app", "not a connection string", "host="] {
12306 assert_eq!(DatabaseBackend::detect(target), None, "{target}");
12307 }
12308 }
12309
12310 #[test]
12313 fn validate_accepts_sqlite_url() {
12314 let config = DatabaseConfig {
12315 url: Some("sqlite:///var/lib/app.db".to_owned()),
12316 ..Default::default()
12317 };
12318 assert!(
12319 config.validate().is_ok(),
12320 "a sqlite:// target must be accepted as valid config: {:?}",
12321 config.validate()
12322 );
12323 }
12324
12325 #[test]
12326 fn validate_accepts_sqlite_primary_url() {
12327 let config = DatabaseConfig {
12328 primary_url: Some("sqlite::memory:".to_owned()),
12329 ..Default::default()
12330 };
12331 assert!(config.validate().is_ok(), "{:?}", config.validate());
12332 }
12333
12334 #[test]
12335 fn validate_rejects_replica_url_on_sqlite() {
12336 let config = DatabaseConfig {
12337 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
12338 replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
12339 ..Default::default()
12340 };
12341 let err = config
12342 .validate()
12343 .expect_err("read replicas must be refused on sqlite")
12344 .to_string();
12345 assert!(
12346 err.contains("read replicas require the postgres backend"),
12347 "message must name the postgres requirement, got: {err}"
12348 );
12349 }
12350
12351 #[test]
12352 fn validate_rejects_shards_on_sqlite() {
12353 let config = DatabaseConfig {
12354 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
12355 shards: vec![ShardConfig {
12356 name: "s0".to_owned(),
12357 primary_url: "postgres://db-shard0/app".to_owned(),
12358 replica_url: None,
12359 slots: None,
12360 primary_pool_size: None,
12361 replica_pool_size: None,
12362 replica_fallback: None,
12363 }],
12364 ..Default::default()
12365 };
12366 let err = config
12367 .validate()
12368 .expect_err("shards must be refused on sqlite")
12369 .to_string();
12370 assert!(
12371 err.contains("database shards require the postgres backend"),
12372 "message must name the postgres requirement, got: {err}"
12373 );
12374 }
12375
12376 #[test]
12377 fn validate_rejects_backend_mismatch_across_roles() {
12378 let config = DatabaseConfig {
12381 primary_url: Some("postgres://db-primary/app".to_owned()),
12382 replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
12383 ..Default::default()
12384 };
12385 let err = config
12386 .validate()
12387 .expect_err("mixed backends must be refused")
12388 .to_string();
12389 assert!(
12390 err.contains("database.replica_url")
12391 && err.contains("does not match the primary database backend"),
12392 "message must name the offending field and the mismatch, got: {err}"
12393 );
12394 }
12395
12396 #[test]
12397 fn validate_rejects_sqlite_primary_with_postgres_url() {
12398 let config = DatabaseConfig {
12401 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
12402 url: Some("postgres://db-primary/app".to_owned()),
12403 ..Default::default()
12404 };
12405 let err = config
12406 .validate()
12407 .expect_err("mixed backends must be refused")
12408 .to_string();
12409 assert!(
12410 err.contains("database.url")
12411 && err.contains("does not match the primary database backend"),
12412 "got: {err}"
12413 );
12414 }
12415
12416 #[test]
12417 fn validate_postgres_app_with_replica_still_valid() {
12418 let config = DatabaseConfig {
12421 primary_url: Some("postgres://db-primary/app".to_owned()),
12422 replica_url: Some("postgres://db-replica/app".to_owned()),
12423 ..Default::default()
12424 };
12425 assert!(config.validate().is_ok(), "{:?}", config.validate());
12426 }
12427
12428 #[test]
12431 fn resolve_profile_from_autumn_env() {
12432 let env = MockEnv::new().with("AUTUMN_ENV", "prod");
12433 let profile = resolve_profile(&env);
12434 assert_eq!(profile, "prod");
12435 }
12436
12437 #[test]
12438 fn resolve_profile_from_legacy_env() {
12439 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
12440 let profile = resolve_profile(&env);
12441 assert_eq!(profile, "staging");
12442 }
12443
12444 #[test]
12445 fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
12446 let env = MockEnv::new()
12447 .with("AUTUMN_ENV", "dev")
12448 .with("AUTUMN_PROFILE", "prod");
12449 let profile = resolve_profile(&env);
12450 assert_eq!(profile, "dev");
12451 }
12452
12453 #[test]
12454 fn resolve_profile_normalizes_production_alias() {
12455 let env = MockEnv::new().with("AUTUMN_ENV", "production");
12456 let profile = resolve_profile(&env);
12457 assert_eq!(profile, "prod");
12458 }
12459
12460 #[test]
12461 fn resolve_profile_normalizes_development_alias_with_whitespace() {
12462 let env = MockEnv::new().with("AUTUMN_ENV", " development ");
12463 let profile = resolve_profile(&env);
12464 assert_eq!(profile, "dev");
12465 }
12466
12467 #[test]
12468 fn resolve_profile_normalizes_uppercase_dev_and_prod() {
12469 let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
12470 let prod = resolve_profile(&prod_env);
12471 assert_eq!(prod, "prod");
12472
12473 let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
12474 let dev = resolve_profile(&dev_env);
12475 assert_eq!(dev, "dev");
12476 }
12477
12478 #[test]
12479 fn resolve_profile_preserves_case_for_custom_profiles() {
12480 let env = MockEnv::new().with("AUTUMN_ENV", "QA");
12481 let profile = resolve_profile(&env);
12482 assert_eq!(profile, "QA");
12483 }
12484
12485 #[test]
12486 fn resolve_profile_auto_detect_debug() {
12487 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
12488 let profile = resolve_profile(&env);
12489 assert_eq!(profile, "dev");
12490 }
12491
12492 #[test]
12493 fn resolve_profile_auto_detect_release() {
12494 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
12495 let profile = resolve_profile(&env);
12496 assert_eq!(profile, "prod");
12497 }
12498
12499 #[test]
12500 fn resolve_profile_defaults_to_dev_when_no_signal_present() {
12501 let env = MockEnv::new();
12502 let profile = resolve_profile(&env);
12503 assert_eq!(profile, "dev");
12504 }
12505
12506 #[test]
12507 fn dev_profile_smart_defaults() {
12508 let defaults = profile_defaults_as_toml("dev");
12509 let toml_str = toml::to_string(&defaults).unwrap();
12510 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
12511
12512 assert_eq!(config.log.level, "debug");
12513 assert_eq!(config.log.format, LogFormat::Pretty);
12514 assert_eq!(config.server.host, "127.0.0.1");
12515 assert_eq!(config.server.shutdown_timeout_secs, 1);
12516 assert_eq!(
12517 config.server.prestop_grace_secs, 0,
12518 "dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
12519 );
12520 assert_eq!(config.telemetry.environment, "development");
12521 assert!(config.health.detailed);
12522 assert_eq!(config.cors.allowed_origins, vec!["*"]);
12523 assert!(
12524 config.security.trusted_proxies.trust_forwarded_headers,
12525 "dev profile must trust forwarded headers from loopback"
12526 );
12527 assert!(
12528 config
12529 .security
12530 .trusted_proxies
12531 .ranges
12532 .contains(&"127.0.0.0/8".to_owned()),
12533 "dev profile must include 127.0.0.0/8 as trusted proxy range"
12534 );
12535 assert!(
12536 config
12537 .security
12538 .trusted_proxies
12539 .ranges
12540 .contains(&"::1/128".to_owned()),
12541 "dev profile must include ::1/128 as trusted proxy range"
12542 );
12543 }
12544
12545 #[test]
12546 fn prod_profile_smart_defaults() {
12547 let defaults = profile_defaults_as_toml("prod");
12548 let toml_str = toml::to_string(&defaults).unwrap();
12549 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
12550
12551 assert_eq!(config.log.level, "info");
12552 assert_eq!(config.log.format, LogFormat::Json);
12553 assert_eq!(config.server.host, "0.0.0.0");
12554 assert_eq!(config.server.shutdown_timeout_secs, 30);
12555 assert_eq!(config.telemetry.environment, "production");
12556 assert!(!config.health.detailed);
12557 assert!(
12559 config.security.headers.strict_transport_security,
12560 "prod profile must auto-enable Strict-Transport-Security"
12561 );
12562 assert_eq!(config.security.headers.x_frame_options, "DENY");
12564 assert!(config.security.headers.x_content_type_options);
12565 assert!(!config.security.headers.content_security_policy.is_empty());
12566 }
12567
12568 #[test]
12569 fn dev_profile_does_not_auto_enable_hsts() {
12570 let defaults = profile_defaults_as_toml("dev");
12571 let toml_str = toml::to_string(&defaults).unwrap();
12572 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
12573
12574 assert!(
12575 !config.security.headers.strict_transport_security,
12576 "dev profile must not force HSTS on (local http development)"
12577 );
12578 }
12579
12580 #[test]
12581 fn custom_profile_no_smart_defaults() {
12582 let defaults = profile_defaults_as_toml("staging");
12583 assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
12584 }
12585
12586 #[test]
12587 fn deep_merge_tables() {
12588 let mut base: toml::Value = toml::from_str(
12589 r#"
12590 [server]
12591 port = 3000
12592 host = "127.0.0.1"
12593 [database]
12594 pool_size = 10
12595 "#,
12596 )
12597 .unwrap();
12598
12599 let overlay: toml::Value = toml::from_str(
12600 r#"
12601 [server]
12602 port = 8080
12603 [database]
12604 url = "postgres://localhost/test"
12605 "#,
12606 )
12607 .unwrap();
12608
12609 deep_merge(&mut base, overlay);
12610
12611 assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
12613 assert_eq!(
12615 base["server"]["host"],
12616 toml::Value::String("127.0.0.1".into())
12617 );
12618 assert_eq!(
12620 base["database"]["url"],
12621 toml::Value::String("postgres://localhost/test".into())
12622 );
12623 assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
12625 }
12626
12627 #[test]
12628 fn profile_toml_overrides_base_toml() {
12629 let dir = tempfile::tempdir().unwrap();
12630 let base_path = dir.path().join("autumn.toml");
12631 let dev_path = dir.path().join("autumn-dev.toml");
12632
12633 std::fs::write(
12634 &base_path,
12635 r"
12636 [server]
12637 port = 3000
12638 [database]
12639 pool_size = 10
12640 ",
12641 )
12642 .unwrap();
12643
12644 std::fs::write(
12645 &dev_path,
12646 r#"
12647 [database]
12648 url = "postgres://localhost/myapp_dev"
12649 "#,
12650 )
12651 .unwrap();
12652
12653 let mut merged = toml::Value::Table(toml::map::Map::new());
12655 let base = load_raw_toml(&base_path).unwrap().unwrap();
12656 deep_merge(&mut merged, base);
12657 let profile = load_raw_toml(&dev_path).unwrap().unwrap();
12658 deep_merge(&mut merged, profile);
12659
12660 let toml_str = toml::to_string(&merged).unwrap();
12661 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
12662
12663 assert_eq!(config.server.port, 3000); assert_eq!(config.database.pool_size, 10); assert_eq!(
12666 config.database.url.as_deref(),
12667 Some("postgres://localhost/myapp_dev")
12668 ); }
12670
12671 #[test]
12672 fn inline_profile_section_overrides_base_toml() {
12673 let mut merged = toml::Value::Table(toml::map::Map::new());
12674 let base: toml::Value = toml::from_str(
12675 r#"
12676 [server]
12677 port = 3000
12678
12679 [log]
12680 level = "info"
12681
12682 [profile.dev.log]
12683 level = "debug"
12684 "#,
12685 )
12686 .unwrap();
12687
12688 deep_merge(&mut merged, base.clone());
12689 let inline = profile_section_from_base_toml(&base, "dev").unwrap();
12690 deep_merge(&mut merged, inline);
12691
12692 let toml_str = toml::to_string(&merged).unwrap();
12693 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
12694 assert_eq!(config.server.port, 3000);
12695 assert_eq!(config.log.level, "debug");
12696 }
12697
12698 #[test]
12699 fn levenshtein_basic() {
12700 assert_eq!(levenshtein("dev", "dev"), 0);
12701 assert_eq!(levenshtein("dev", "dve"), 2); assert_eq!(levenshtein("prod", "prodd"), 1);
12703 assert_eq!(levenshtein("prod", "prd"), 1);
12704 assert_eq!(levenshtein("staging", "dev"), 7);
12705 }
12706
12707 #[test]
12708 fn env_override_health_detailed() {
12709 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
12710 let mut config = AutumnConfig::default();
12711 config.apply_env_overrides_with_env(&env);
12712 assert!(config.health.detailed);
12713 }
12714
12715 #[test]
12716 fn profile_name_accessor() {
12717 let mut config = AutumnConfig::default();
12718 assert!(config.profile_name().is_none());
12719
12720 config.profile = Some("dev".to_owned());
12721 assert_eq!(config.profile_name(), Some("dev"));
12722 }
12723
12724 #[test]
12727 fn find_config_file_falls_back_to_cwd() {
12728 let env = MockEnv::new();
12730 let path = find_config_file_named("autumn.toml", &env);
12731 assert_eq!(path, PathBuf::from("autumn.toml"));
12732 }
12733
12734 #[test]
12735 fn find_config_file_uses_manifest_dir_when_file_exists() {
12736 let dir = tempfile::tempdir().unwrap();
12737 let config_path = dir.path().join("autumn.toml");
12738 std::fs::write(&config_path, "").unwrap();
12739
12740 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
12741 let path = find_config_file_named("autumn.toml", &env);
12742 assert_eq!(path, config_path);
12743 }
12744
12745 #[test]
12746 fn find_config_file_falls_back_when_manifest_dir_missing_file() {
12747 let dir = tempfile::tempdir().unwrap();
12748 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
12750 let path = find_config_file_named("nonexistent.toml", &env);
12751 assert_eq!(path, PathBuf::from("nonexistent.toml"));
12752 }
12753
12754 #[test]
12755 fn resolve_profile_cli_flag_exact_match() {
12756 let env = MockEnv::new();
12761 let profile = resolve_profile(&env);
12763 drop(profile);
12767 }
12768
12769 #[test]
12770 fn deep_merge_non_table_overlay_replaces_base() {
12771 let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
12774 let overlay = toml::Value::String("not_a_table".into());
12775
12776 deep_merge(&mut base, overlay);
12779 assert!(base.is_table());
12781 assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
12782 }
12783
12784 #[test]
12785 fn deep_merge_when_base_not_table() {
12786 let mut base = toml::Value::String("original".into());
12788 let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
12789
12790 deep_merge(&mut base, overlay);
12791 assert_eq!(base, toml::Value::String("original".into()));
12793 }
12794
12795 #[test]
12796 fn suggest_profile_close_match() {
12797 assert_eq!(suggest_profile("dve"), Some("dev"));
12799 }
12800
12801 #[test]
12802 fn suggest_profile_no_match_when_distant() {
12803 assert_eq!(suggest_profile("xyz"), None);
12805 }
12806
12807 #[test]
12808 fn suggest_profile_exact_known_profile() {
12809 assert_eq!(suggest_profile("dev"), Some("dev"));
12811 assert_eq!(suggest_profile("prod"), Some("prod"));
12812 }
12813
12814 #[test]
12815 fn suggest_profile_prd() {
12816 assert_eq!(suggest_profile("prd"), Some("prod"));
12818 }
12819
12820 #[test]
12821 fn warn_profile_typo_runs_without_panic() {
12822 warn_profile_typo("dve");
12823 warn_profile_typo("xyz");
12824 }
12825
12826 #[test]
12827 fn should_warn_missing_profile_file_custom_without_inline() {
12828 assert!(should_warn_missing_profile_file("staging", false));
12829 }
12830
12831 #[test]
12832 fn should_not_warn_missing_profile_file_custom_with_inline() {
12833 assert!(!should_warn_missing_profile_file("staging", true));
12834 }
12835
12836 #[test]
12837 fn should_not_warn_missing_profile_file_dev_or_prod() {
12838 assert!(!should_warn_missing_profile_file("dev", false));
12839 assert!(!should_warn_missing_profile_file("prod", false));
12840 }
12841
12842 #[test]
12843 fn levenshtein_threshold_in_warn_profile_typo() {
12844 assert!(levenshtein("dve", "dev") <= 2);
12845 assert!(levenshtein("xyz", "dev") > 2);
12846 assert!(levenshtein("xyz", "prod") > 2);
12847 }
12848
12849 #[test]
12850 fn env_override_cors_allowed_origins() {
12851 let env = MockEnv::new().with(
12852 "AUTUMN_CORS__ALLOWED_ORIGINS",
12853 "https://a.com, https://b.com",
12854 );
12855 let mut config = AutumnConfig::default();
12856 config.apply_env_overrides_with_env(&env);
12857 assert_eq!(
12858 config.cors.allowed_origins,
12859 vec!["https://a.com", "https://b.com"]
12860 );
12861 }
12862
12863 #[test]
12864 fn env_override_cors_allow_credentials() {
12865 let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
12866 let mut config = AutumnConfig::default();
12867 config.apply_env_overrides_with_env(&env);
12868 assert!(config.cors.allow_credentials);
12869 }
12870
12871 #[test]
12872 fn env_override_cors_max_age() {
12873 let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
12874 let mut config = AutumnConfig::default();
12875 config.apply_env_overrides_with_env(&env);
12876 assert_eq!(config.cors.max_age_secs, 3600);
12877 }
12878
12879 #[test]
12880 fn cors_validate_rejects_wildcard_with_credentials() {
12881 let mut config = AutumnConfig::default();
12882 config.cors.allowed_origins = vec!["*".to_owned()];
12883 config.cors.allow_credentials = true;
12884
12885 let result = config.validate();
12886 match result {
12887 Err(ConfigError::Validation(msg)) => {
12888 assert!(
12889 msg.contains("allow_credentials") && msg.contains('*'),
12890 "message should mention credentials and wildcard, got: {msg}"
12891 );
12892 }
12893 other => panic!("expected ConfigError::Validation, got {other:?}"),
12894 }
12895 }
12896
12897 #[test]
12898 fn cors_validate_accepts_wildcard_without_credentials() {
12899 let mut config = AutumnConfig::default();
12900 config.cors.allowed_origins = vec!["*".to_owned()];
12901 config.cors.allow_credentials = false;
12902 assert!(config.validate().is_ok());
12903 }
12904
12905 #[test]
12906 fn cors_validate_accepts_explicit_origins_with_credentials() {
12907 let mut config = AutumnConfig::default();
12908 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
12909 config.cors.allow_credentials = true;
12910 assert!(config.validate().is_ok());
12911 }
12912
12913 #[test]
12914 fn load_uses_profile_layering() {
12915 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
12918
12919 let config = AutumnConfig::load_with_env(&env).unwrap();
12920 assert_eq!(config.profile.as_deref(), Some("dev"));
12922 assert_eq!(config.log.level, "debug"); assert_eq!(config.log.format, LogFormat::Pretty); assert!(config.health.detailed); }
12926
12927 #[test]
12928 fn load_custom_profile_without_toml_warns() {
12929 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
12933
12934 let config = AutumnConfig::load_with_env(&env).unwrap();
12935 assert_eq!(config.profile.as_deref(), Some("staging"));
12936 assert_eq!(config.server.port, 3000);
12938 assert_eq!(config.log.level, "info");
12939 }
12940
12941 #[test]
12942 fn load_dev_profile_no_profile_toml_no_warn() {
12943 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
12946
12947 let config = AutumnConfig::load_with_env(&env).unwrap();
12948 assert_eq!(config.profile.as_deref(), Some("dev"));
12949 }
12950
12951 #[test]
12952 fn load_custom_profile_uses_inline_profile_without_legacy_file() {
12953 let dir = tempfile::tempdir().unwrap();
12954 let base_path = dir.path().join("autumn.toml");
12955 std::fs::write(
12956 &base_path,
12957 r"
12958 [server]
12959 port = 3000
12960
12961 [profile.staging.server]
12962 port = 4100
12963 ",
12964 )
12965 .unwrap();
12966
12967 let env = MockEnv::new()
12968 .with("AUTUMN_ENV", "staging")
12969 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
12970
12971 let config = AutumnConfig::load_with_env(&env).unwrap();
12972 assert_eq!(config.profile.as_deref(), Some("staging"));
12973 assert_eq!(config.server.port, 4100);
12974 }
12975
12976 #[test]
12977 fn load_production_profile_reads_inline_profile_production_section() {
12978 let dir = tempfile::tempdir().unwrap();
12979 let base_path = dir.path().join("autumn.toml");
12980 std::fs::write(
12981 &base_path,
12982 r"
12983 [profile.production.server]
12984 port = 4200
12985 ",
12986 )
12987 .unwrap();
12988
12989 let env = MockEnv::new()
12990 .with("AUTUMN_ENV", "production")
12991 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
12992
12993 let config = AutumnConfig::load_with_env(&env).unwrap();
12994 assert_eq!(config.profile.as_deref(), Some("prod"));
12995 assert_eq!(config.server.port, 4200);
12996 }
12997
12998 #[test]
12999 fn load_production_profile_reads_legacy_autumn_production_toml() {
13000 let dir = tempfile::tempdir().unwrap();
13001 let production_path = dir.path().join("autumn-production.toml");
13002 std::fs::write(
13003 &production_path,
13004 r"
13005 [server]
13006 port = 4300
13007 ",
13008 )
13009 .unwrap();
13010
13011 let env = MockEnv::new()
13012 .with("AUTUMN_ENV", "production")
13013 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
13014
13015 let config = AutumnConfig::load_with_env(&env).unwrap();
13016 assert_eq!(config.profile.as_deref(), Some("prod"));
13017 assert_eq!(config.server.port, 4300);
13018 }
13019
13020 #[test]
13021 fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
13022 let dir = tempfile::tempdir().unwrap();
13023 let prod_path = dir.path().join("autumn-prod.toml");
13024 let production_path = dir.path().join("autumn-production.toml");
13025
13026 std::fs::write(
13027 &prod_path,
13028 r"
13029 [server]
13030 port = 4400
13031 ",
13032 )
13033 .unwrap();
13034 std::fs::write(&production_path, "[server\nport = 4500").unwrap();
13036
13037 let env = MockEnv::new()
13038 .with("AUTUMN_ENV", "prod")
13039 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
13040
13041 let config = AutumnConfig::load_with_env(&env).unwrap();
13042 assert_eq!(config.profile.as_deref(), Some("prod"));
13043 assert_eq!(config.server.port, 4400);
13044 }
13045
13046 #[test]
13047 fn load_production_prefers_autumn_production_toml_before_prod_alias() {
13048 let dir = tempfile::tempdir().unwrap();
13049 let prod_path = dir.path().join("autumn-prod.toml");
13050 let production_path = dir.path().join("autumn-production.toml");
13051
13052 std::fs::write(
13053 &production_path,
13054 r"
13055 [server]
13056 port = 4500
13057 ",
13058 )
13059 .unwrap();
13060 std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
13062
13063 let env = MockEnv::new()
13064 .with("AUTUMN_ENV", "production")
13065 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
13066
13067 let config = AutumnConfig::load_with_env(&env).unwrap();
13068 assert_eq!(config.profile.as_deref(), Some("prod"));
13069 assert_eq!(config.server.port, 4500);
13070 }
13071
13072 #[test]
13073 fn load_from_io_error_is_not_swallowed() {
13074 let dir = tempfile::tempdir().unwrap();
13077 let result = AutumnConfig::load_from(dir.path());
13078 assert!(result.is_err());
13079 }
13080
13081 #[test]
13082 fn load_raw_toml_missing_file_returns_none() {
13083 let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
13084 assert!(result.is_none());
13085 }
13086
13087 #[test]
13088 fn load_raw_toml_directory_returns_io_error() {
13089 let dir = tempfile::tempdir().unwrap();
13093 let result = load_raw_toml(dir.path());
13094 assert!(result.is_err());
13095 }
13096
13097 #[test]
13098 fn load_raw_toml_valid_file_returns_some() {
13099 let dir = tempfile::tempdir().unwrap();
13100 let path = dir.path().join("test.toml");
13101 std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
13102 let result = load_raw_toml(&path).unwrap();
13103 assert!(result.is_some());
13104 assert_eq!(
13105 result.unwrap()["server"]["port"],
13106 toml::Value::Integer(3000)
13107 );
13108 }
13109
13110 #[test]
13111 fn env_override_log_format_auto() {
13112 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
13114 let mut config = AutumnConfig::default();
13115 config.log.format = LogFormat::Json;
13117 config.apply_env_overrides_with_env(&env);
13118 assert_eq!(config.log.format, LogFormat::Auto);
13119 }
13120
13121 #[test]
13122 fn env_override_health_detailed_false() {
13123 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
13125 let mut config = AutumnConfig::default();
13126 config.health.detailed = true; config.apply_env_overrides_with_env(&env);
13128 assert!(!config.health.detailed);
13129 }
13130
13131 #[test]
13132 fn env_override_health_detailed_zero() {
13133 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
13134 let mut config = AutumnConfig::default();
13135 config.health.detailed = true;
13136 config.apply_env_overrides_with_env(&env);
13137 assert!(!config.health.detailed);
13138 }
13139
13140 #[test]
13141 fn cors_defaults() {
13142 let cors = CorsConfig::default();
13143 assert!(cors.allowed_origins.is_empty());
13144 assert_eq!(cors.allowed_methods.len(), 6);
13145 assert!(cors.allowed_methods.contains(&"GET".to_owned()));
13146 assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
13147 assert!(!cors.allow_credentials);
13148 assert_eq!(cors.max_age_secs, 86400);
13149 }
13150
13151 #[test]
13152 fn cors_in_full_config_defaults() {
13153 let config = AutumnConfig::default();
13154 assert!(config.cors.allowed_origins.is_empty());
13155 }
13156
13157 #[test]
13158 fn actuator_defaults() {
13159 let config = ActuatorConfig::default();
13160 assert_eq!(config.prefix, "/actuator");
13161 assert!(!config.sensitive);
13162 assert!(config.prometheus);
13165 }
13166
13167 #[test]
13168 fn actuator_prometheus_can_be_disabled_via_toml() {
13169 let toml = r"
13170 sensitive = false
13171 prometheus = false
13172 ";
13173 let config: ActuatorConfig = toml::from_str(toml).unwrap();
13174 assert!(!config.sensitive);
13175 assert!(!config.prometheus);
13176 }
13177
13178 #[test]
13179 fn actuator_prefix_in_full_config() {
13180 let config = AutumnConfig::default();
13181 assert_eq!(config.actuator.prefix, "/actuator");
13182 }
13183
13184 #[test]
13185 fn deep_merge_handles_deep_nesting() {
13186 let mut base = toml::Value::Table(toml::map::Map::new());
13187 let mut overlay = toml::Value::Table(toml::map::Map::new());
13188
13189 let mut current_base = &mut base;
13191 let mut current_overlay = &mut overlay;
13192
13193 for _ in 0..10_000 {
13194 if let toml::Value::Table(t) = current_base {
13195 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
13196 current_base = t.get_mut("x").unwrap();
13197 }
13198 if let toml::Value::Table(t) = current_overlay {
13199 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
13200 current_overlay = t.get_mut("x").unwrap();
13201 }
13202 }
13203
13204 if let toml::Value::Table(t) = current_overlay {
13206 t.insert("y".to_owned(), toml::Value::Integer(42));
13207 }
13208
13209 std::thread::Builder::new()
13212 .stack_size(32 * 1024 * 1024)
13213 .spawn(move || {
13214 deep_merge(&mut base, overlay);
13215 std::mem::forget(base);
13217 })
13218 .unwrap()
13219 .join()
13220 .unwrap();
13221 }
13222
13223 #[test]
13224 fn deep_merge_stops_at_max_depth() {
13225 let mut base = toml::Value::Table(toml::map::Map::new());
13226 let mut overlay = toml::Value::Table(toml::map::Map::new());
13227
13228 let mut current_base = &mut base;
13230 let mut current_overlay = &mut overlay;
13231
13232 for _ in 0..=MAX_MERGE_DEPTH {
13233 if let toml::Value::Table(t) = current_base {
13234 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
13235 current_base = t.get_mut("x").unwrap();
13236 }
13237 if let toml::Value::Table(t) = current_overlay {
13238 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
13239 current_overlay = t.get_mut("x").unwrap();
13240 }
13241 }
13242
13243 if let toml::Value::Table(t) = current_overlay {
13245 t.insert("deep_value".to_owned(), toml::Value::Integer(123));
13246 }
13247
13248 deep_merge(&mut base, overlay);
13249
13250 let mut current_base_check = &base;
13252 for _ in 0..=MAX_MERGE_DEPTH {
13253 if let toml::Value::Table(t) = current_base_check {
13254 current_base_check = t.get("x").unwrap();
13255 }
13256 }
13257
13258 if let toml::Value::Table(t) = current_base_check {
13259 assert!(
13260 !t.contains_key("deep_value"),
13261 "Value beyond MAX_MERGE_DEPTH should not be merged"
13262 );
13263 } else {
13264 panic!("Expected a table");
13265 }
13266 }
13267
13268 #[test]
13271 fn env_override_forbidden_response_403() {
13272 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
13273 let mut config = AutumnConfig::default();
13274 config.apply_env_overrides_with_env(&env);
13275 assert_eq!(
13276 config.security.forbidden_response,
13277 crate::authorization::ForbiddenResponse::Forbidden403
13278 );
13279 }
13280
13281 #[test]
13282 fn env_override_forbidden_response_404() {
13283 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
13284 let mut config = AutumnConfig::default();
13285 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
13287 config.apply_env_overrides_with_env(&env);
13288 assert_eq!(
13289 config.security.forbidden_response,
13290 crate::authorization::ForbiddenResponse::NotFound404
13291 );
13292 }
13293
13294 #[test]
13295 fn env_override_forbidden_response_invalid_keeps_existing() {
13296 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
13297 let mut config = AutumnConfig::default();
13298 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
13299 config.apply_env_overrides_with_env(&env);
13300 assert_eq!(
13302 config.security.forbidden_response,
13303 crate::authorization::ForbiddenResponse::Forbidden403
13304 );
13305 }
13306
13307 #[test]
13308 fn env_override_allow_unauthorized_repository_api() {
13309 let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
13310 let mut config = AutumnConfig::default();
13311 assert!(!config.security.allow_unauthorized_repository_api);
13312 config.apply_env_overrides_with_env(&env);
13313 assert!(config.security.allow_unauthorized_repository_api);
13314 }
13315
13316 #[test]
13317 fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
13318 let env = MockEnv::new().with(
13319 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
13320 "false",
13321 );
13322 let mut config = AutumnConfig::default();
13323 config.security.allow_unauthorized_repository_api = true;
13324 config.apply_env_overrides_with_env(&env);
13325 assert!(!config.security.allow_unauthorized_repository_api);
13326 }
13327
13328 #[test]
13329 fn env_override_csrf_token_scan_bytes() {
13330 let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "8388608");
13331 let mut config = AutumnConfig::default();
13332 assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
13334 config.apply_env_overrides_with_env(&env);
13335 assert_eq!(config.security.csrf.token_scan_bytes, 8_388_608);
13336 }
13337
13338 #[test]
13339 fn env_override_csrf_token_scan_bytes_invalid_is_ignored() {
13340 let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "not-a-number");
13341 let mut config = AutumnConfig::default();
13342 config.apply_env_overrides_with_env(&env);
13343 assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
13345 }
13346
13347 #[test]
13350 fn openapi_runtime_config_defaults_enabled() {
13351 let config = AutumnConfig::default();
13353 assert!(
13354 config.openapi_runtime.enabled,
13355 "[openapi] must default to enabled = true"
13356 );
13357 assert_eq!(
13358 config.openapi_runtime.path, "/openapi.json",
13359 "[openapi] must default to path = \"/openapi.json\""
13360 );
13361 }
13362
13363 #[test]
13364 fn openapi_runtime_config_can_be_disabled_via_toml() {
13365 let toml_str = "
13366[openapi]
13367enabled = false
13368";
13369 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
13370 assert!(
13371 !config.openapi_runtime.enabled,
13372 "[openapi] enabled = false must deserialize correctly"
13373 );
13374 }
13375
13376 #[test]
13377 fn openapi_runtime_config_path_can_be_customized() {
13378 let toml_str = r#"
13379[openapi]
13380path = "/api-spec.json"
13381"#;
13382 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
13383 assert_eq!(
13384 config.openapi_runtime.path, "/api-spec.json",
13385 "[openapi] path must deserialize correctly"
13386 );
13387 }
13388
13389 #[test]
13390 fn cache_env_overrides_fields() {
13391 let env = MockEnv::new()
13392 .with("AUTUMN_CACHE__BACKEND", "redis")
13393 .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
13394 .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
13395 let mut config = AutumnConfig::default();
13396
13397 config.apply_env_overrides_with_env(&env);
13398
13399 assert!(config.cache.is_redis(), "backend should be redis");
13400 assert_eq!(
13401 config.cache.redis.url.as_deref(),
13402 Some("redis://cache:6379/1")
13403 );
13404 assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
13405 }
13406
13407 #[test]
13408 fn cache_backend_from_env_value_invalid_is_none() {
13409 assert!(CacheBackend::from_env_value("postgres").is_none());
13410 assert!(CacheBackend::from_env_value("").is_none());
13411 }
13412
13413 #[test]
13414 fn scheduler_validate_rejects_zero_lease_ttl() {
13415 let cfg = SchedulerConfig {
13416 lease_ttl_secs: 0,
13417 ..SchedulerConfig::default()
13418 };
13419 assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
13420 }
13421
13422 #[test]
13423 fn scheduler_validate_rejects_empty_key_prefix() {
13424 let cfg = SchedulerConfig {
13425 key_prefix: " ".to_owned(),
13426 ..SchedulerConfig::default()
13427 };
13428 assert!(cfg.validate().is_err(), "blank key_prefix must fail");
13429 }
13430
13431 #[test]
13432 fn scheduler_validate_ok_with_defaults() {
13433 assert!(SchedulerConfig::default().validate().is_ok());
13434 }
13435
13436 #[test]
13437 fn scheduler_resolved_replica_id_uses_explicit_value() {
13438 let cfg = SchedulerConfig {
13439 replica_id: Some("my-pod".to_owned()),
13440 ..SchedulerConfig::default()
13441 };
13442 assert_eq!(cfg.resolved_replica_id(), "my-pod");
13443 }
13444
13445 #[test]
13446 fn scheduler_resolved_replica_id_falls_back_to_pid() {
13447 let cfg = SchedulerConfig {
13448 replica_id: None,
13449 ..SchedulerConfig::default()
13450 };
13451 assert!(!cfg.resolved_replica_id().is_empty());
13454 }
13455
13456 #[cfg(feature = "mail")]
13457 #[test]
13458 fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
13459 let env = MockEnv::new()
13460 .with(
13461 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
13462 "true",
13463 )
13464 .with("AUTUMN_MAIL__TRANSPORT", "smtp")
13465 .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
13466
13467 let mut config = AutumnConfig::default();
13468 config.apply_mail_env_overrides_with_env(&env);
13469
13470 assert!(
13471 config.mail.allow_in_process_deliver_later_in_production,
13472 "env var should set allow_in_process_deliver_later_in_production"
13473 );
13474 }
13475
13476 #[cfg(feature = "mail")]
13477 #[test]
13478 fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
13479 let env = MockEnv::new();
13480 let mut config = AutumnConfig::default();
13481 config.apply_mail_env_overrides_with_env(&env);
13482
13483 assert!(
13484 !config.mail.allow_in_process_deliver_later_in_production,
13485 "flag should default to false when env var is not set"
13486 );
13487 }
13488
13489 #[cfg(feature = "mail")]
13490 #[test]
13491 fn mail_inline_css_is_overridable_via_env() {
13492 let env = MockEnv::new().with("AUTUMN_MAIL__INLINE_CSS", "true");
13493
13494 let mut config = AutumnConfig::default();
13495 config.apply_mail_env_overrides_with_env(&env);
13496
13497 assert!(
13498 config.mail.inline_css,
13499 "AUTUMN_MAIL__INLINE_CSS=true should enable inline_css"
13500 );
13501 }
13502
13503 #[cfg(feature = "mail")]
13504 #[test]
13505 fn mail_inline_css_defaults_false() {
13506 let env = MockEnv::new();
13507 let mut config = AutumnConfig::default();
13508 config.apply_mail_env_overrides_with_env(&env);
13509
13510 assert!(
13511 !config.mail.inline_css,
13512 "inline_css should default to false when env var is not set"
13513 );
13514 }
13515
13516 #[test]
13519 fn config_credentials_empty_when_no_directory() {
13520 let env = MockEnv::new();
13521 let config = AutumnConfig::load_with_env(&env).unwrap();
13522 assert!(
13523 config.credentials().is_empty(),
13524 "existing apps without config/credentials/ must boot with an empty credentials store"
13525 );
13526 }
13527
13528 #[test]
13529 fn config_has_credentials_accessor() {
13530 let config = AutumnConfig::default();
13531 let _store = config.credentials();
13532 }
13533
13534 #[test]
13535 fn config_credentials_loaded_when_file_present() {
13536 use crate::credentials::{MasterKey, encrypt};
13537 use tempfile::TempDir;
13538
13539 let tmp = TempDir::new().unwrap();
13540 let key = MasterKey::generate();
13541 let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
13542 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
13543 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
13544
13545 let env = MockEnv::new()
13546 .with("AUTUMN_MASTER_KEY", &key.to_hex())
13547 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
13548 let config = AutumnConfig::load_with_env(&env).unwrap();
13549 let val: Option<String> = config.credentials().get("stripe_key");
13550 assert_eq!(val.as_deref(), Some("sk_test_xyz"));
13551 }
13552
13553 #[cfg(feature = "oauth2")]
13554 #[test]
13555 fn config_resolves_oauth_credentials_by_convention() {
13556 use crate::credentials::{MasterKey, encrypt};
13557 use tempfile::TempDir;
13558
13559 let tmp = TempDir::new().unwrap();
13560 let key = MasterKey::generate();
13561 let ct = encrypt(
13562 &key,
13563 b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
13564 );
13565 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
13566 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
13567
13568 std::fs::create_dir_all(tmp.path().join("config")).unwrap();
13570 let config_toml = r#"
13571[auth.oauth2.github]
13572client_id = ""
13573client_secret = ""
13574authorize_url = "https://github.com/login/oauth/authorize"
13575token_url = "https://github.com/login/oauth/access_token"
13576redirect_uri = "http://localhost:3000/auth/github/callback"
13577"#;
13578 std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
13579
13580 let env = MockEnv::new()
13581 .with("AUTUMN_MASTER_KEY", &key.to_hex())
13582 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
13583 let config = AutumnConfig::load_with_env(&env).unwrap();
13584 let github = config.auth.oauth2.providers.get("github").unwrap();
13585 assert_eq!(github.client_id, "git-id-123");
13586 assert_eq!(github.client_secret, "git-secret-456");
13587 }
13588
13589 #[test]
13590 fn config_fails_with_credentials_error_when_key_is_invalid() {
13591 use crate::credentials::encrypt;
13592 use tempfile::TempDir;
13593
13594 let tmp = TempDir::new().unwrap();
13595 let bogus_key = "zz".repeat(32); let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
13598 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
13599 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
13600
13601 let env = MockEnv::new()
13602 .with("AUTUMN_MASTER_KEY", &bogus_key)
13603 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
13604 let err = AutumnConfig::load_with_env(&env).unwrap_err();
13605 assert!(
13606 matches!(err, ConfigError::Credentials(_)),
13607 "bad master key should produce ConfigError::Credentials, got {err:?}"
13608 );
13609 }
13610
13611 #[test]
13612 fn test_parse_duration_str() {
13613 assert_eq!(
13614 parse_duration_str("500ms").unwrap(),
13615 std::time::Duration::from_millis(500)
13616 );
13617 assert_eq!(
13618 parse_duration_str("5s").unwrap(),
13619 std::time::Duration::from_secs(5)
13620 );
13621 assert_eq!(
13622 parse_duration_str("2m").unwrap(),
13623 std::time::Duration::from_secs(120)
13624 );
13625 assert_eq!(
13626 parse_duration_str("1h").unwrap(),
13627 std::time::Duration::from_secs(3600)
13628 );
13629 assert_eq!(
13630 parse_duration_str("1000").unwrap(),
13631 std::time::Duration::from_secs(1)
13632 );
13633 assert!(parse_duration_str("abc").is_err());
13634 assert!(parse_duration_str("").is_err());
13635 }
13636
13637 #[test]
13638 fn test_database_config_duration_deserialization() {
13639 #[derive(Debug, Deserialize)]
13640 struct TestConfig {
13641 #[serde(deserialize_with = "deserialize_option_duration", default)]
13642 timeout: Option<std::time::Duration>,
13643 #[serde(deserialize_with = "deserialize_duration")]
13644 threshold: std::time::Duration,
13645 }
13646
13647 let toml_str = r#"
13648 timeout = "2s"
13649 threshold = "100ms"
13650 "#;
13651 let parsed: TestConfig = toml::from_str(toml_str).unwrap();
13652 assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
13653 assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
13654
13655 let toml_str_null = r#"
13656 threshold = "500"
13657 "#;
13658 let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
13659 assert_eq!(parsed_null.timeout, None);
13660 assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
13661 }
13662
13663 #[test]
13666 fn request_timeouts_config_defaults_to_none() {
13667 let config = RequestTimeoutsConfig::default();
13668 assert!(config.request_timeout_ms.is_none());
13669 }
13670
13671 #[test]
13672 fn server_config_timeouts_defaults_to_disabled() {
13673 let config = ServerConfig::default();
13674 assert!(config.timeouts.request_timeout_ms.is_none());
13675 }
13676
13677 #[test]
13678 fn request_timeouts_config_can_be_set_via_toml() {
13679 let toml_str = "request_timeout_ms = 5000";
13680 let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
13681 assert_eq!(config.request_timeout_ms, Some(5000));
13682 }
13683
13684 #[test]
13685 fn server_config_timeouts_deserialize_nested() {
13686 let toml_str = r#"
13687 port = 3000
13688 host = "127.0.0.1"
13689 shutdown_timeout_secs = 30
13690 prestop_grace_secs = 5
13691
13692 [timeouts]
13693 request_timeout_ms = 15000
13694 "#;
13695 let config: ServerConfig = toml::from_str(toml_str).unwrap();
13696 assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
13697 }
13698
13699 #[test]
13700 fn autumn_config_server_timeouts_roundtrip() {
13701 let mut config = AutumnConfig::default();
13702 config.server.timeouts.request_timeout_ms = Some(20_000);
13703 assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
13704 }
13705
13706 #[test]
13707 fn server_timeouts_env_var_override() {
13708 struct FakeEnv(std::collections::HashMap<String, String>);
13709 impl Env for FakeEnv {
13710 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
13711 self.0
13712 .get(key)
13713 .cloned()
13714 .ok_or(std::env::VarError::NotPresent)
13715 }
13716 }
13717
13718 let mut config = AutumnConfig::default();
13719 let env = FakeEnv(
13720 [(
13721 "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
13722 "8000".to_owned(),
13723 )]
13724 .into(),
13725 );
13726 config.apply_server_env_overrides_with_env(&env);
13727 assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
13728 }
13729
13730 #[test]
13731 fn prod_profile_sets_request_timeout_30s() {
13732 let defaults = profile_defaults_as_toml("prod");
13733 let toml_str = toml::to_string(&defaults).unwrap();
13734 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
13735 assert_eq!(
13736 config.server.timeouts.request_timeout_ms,
13737 Some(30_000),
13738 "prod profile must enable the 30-second request timeout by default"
13739 );
13740 }
13741
13742 #[test]
13743 fn dev_profile_leaves_request_timeout_disabled() {
13744 let defaults = profile_defaults_as_toml("dev");
13745 let toml_str = toml::to_string(&defaults).unwrap();
13746 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
13747 assert!(
13748 config.server.timeouts.request_timeout_ms.is_none(),
13749 "dev profile must not enable a request timeout by default"
13750 );
13751 }
13752
13753 #[test]
13754 fn test_resilience_config_defaults() {
13755 let config = AutumnConfig::default();
13756 assert!(
13757 config
13758 .resilience
13759 .circuit_breaker
13760 .defaults
13761 .failure_ratio_threshold
13762 .is_none()
13763 );
13764 }
13765
13766 #[test]
13767 fn test_resilience_config_parsing() {
13768 let toml_str = r#"
13769 [resilience.circuit_breaker.defaults]
13770 failure_ratio_threshold = 0.6
13771 sample_window_secs = 20
13772 minimum_sample_count = 15
13773 open_duration_secs = 30
13774 half_open_trial_count = 5
13775
13776 [resilience.circuit_breaker.hosts."api.github.com"]
13777 failure_ratio_threshold = 0.3
13778 open_duration_secs = 10
13779 "#;
13780 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
13781 let cb = &config.resilience.circuit_breaker;
13782 assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
13783 assert_eq!(cb.defaults.sample_window_secs, Some(20));
13784 assert_eq!(cb.defaults.minimum_sample_count, Some(15));
13785 assert_eq!(cb.defaults.open_duration_secs, Some(30));
13786 assert_eq!(cb.defaults.half_open_trial_count, Some(5));
13787
13788 let host_cb = cb.hosts.get("api.github.com").unwrap();
13789 assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
13790 assert_eq!(host_cb.open_duration_secs, Some(10));
13791 assert!(host_cb.sample_window_secs.is_none());
13792 }
13793
13794 #[test]
13795 fn test_resilience_config_env_overrides() {
13796 struct FakeEnv(std::collections::HashMap<String, String>);
13797 impl Env for FakeEnv {
13798 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
13799 self.0
13800 .get(key)
13801 .cloned()
13802 .ok_or(std::env::VarError::NotPresent)
13803 }
13804 }
13805
13806 let mut config = AutumnConfig::default();
13807 let env = FakeEnv(
13808 [(
13809 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
13810 "0.7".to_owned(),
13811 )]
13812 .into(),
13813 );
13814 config.apply_resilience_env_overrides_with_env(&env);
13815 assert_eq!(
13816 config
13817 .resilience
13818 .circuit_breaker
13819 .defaults
13820 .failure_ratio_threshold,
13821 Some(0.7)
13822 );
13823 }
13824
13825 const TEST_REGISTRY: &[DeprecatedKey] = &[DeprecatedKey {
13829 path: "a.b.c",
13830 replacement: Some("a.b.d"),
13831 since: "0.1.0",
13832 remove_in: "1.0.0",
13833 }];
13834
13835 fn merged_with_abc(value: toml::Value) -> toml::Table {
13836 let mut root = toml::Table::new();
13837 let mut b = toml::Table::new();
13838 b.insert("c".to_owned(), value);
13839 let mut a = toml::Table::new();
13840 a.insert("b".to_owned(), toml::Value::Table(b));
13841 root.insert("a".to_owned(), toml::Value::Table(a));
13842 root
13843 }
13844
13845 #[test]
13846 fn red_detect_from_toml_present_emits_finding() {
13847 let merged = merged_with_abc(toml::Value::Integer(1));
13848 let env = MockEnv::new(); let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
13850 assert_eq!(findings.len(), 1);
13851 let f = &findings[0];
13852 assert_eq!(f.path, "a.b.c");
13853 assert_eq!(f.replacement.as_deref(), Some("a.b.d"));
13854 assert_eq!(f.since, "0.1.0");
13855 assert_eq!(f.remove_in, "1.0.0");
13856 assert_eq!(f.source, DeprecationSource::Toml);
13857 }
13858
13859 #[test]
13860 fn red_detect_from_env_present_emits_finding() {
13861 let merged = toml::Table::new(); let env = MockEnv::new().with("AUTUMN_A__B__C", "val");
13863 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
13864 assert_eq!(findings.len(), 1);
13865 assert_eq!(findings[0].source, DeprecationSource::Env);
13866 }
13867
13868 #[test]
13869 fn red_detect_dedupe_toml_and_env_single_finding() {
13870 let merged = merged_with_abc(toml::Value::Boolean(true));
13871 let env = MockEnv::new().with("AUTUMN_A__B__C", "true");
13872 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
13873 assert_eq!(findings.len(), 1, "TOML+env should collapse to one finding");
13874 assert_eq!(findings[0].source, DeprecationSource::Both);
13875 }
13876
13877 #[test]
13878 fn red_detect_replacement_only_no_finding() {
13879 let mut merged = toml::Table::new();
13881 let mut b = toml::Table::new();
13882 b.insert("d".to_owned(), toml::Value::Integer(1)); let mut a = toml::Table::new();
13884 a.insert("b".to_owned(), toml::Value::Table(b));
13885 merged.insert("a".to_owned(), toml::Value::Table(a));
13886
13887 let env = MockEnv::new();
13888 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
13889 assert!(
13890 findings.is_empty(),
13891 "only replacement key set — no deprecation warning"
13892 );
13893 }
13894
13895 #[test]
13896 fn red_detect_absent_everywhere_no_finding() {
13897 let merged = toml::Table::new();
13898 let env = MockEnv::new();
13899 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
13900 assert!(findings.is_empty());
13901 }
13902
13903 #[test]
13904 fn red_env_var_name_mapping() {
13905 assert_eq!(
13906 deprecated_env_var_name("security.rate_limit.trusted_proxies"),
13907 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
13908 );
13909 assert_eq!(deprecated_env_var_name("a.b.c"), "AUTUMN_A__B__C");
13910 }
13911
13912 #[test]
13913 fn red_toml_path_non_table_mid_segment_not_present() {
13914 let mut root = toml::Table::new();
13916 root.insert("a".to_owned(), toml::Value::Integer(42)); assert!(!toml_path_present(&root, "a.b.c"));
13918 }
13919
13920 #[test]
13921 fn red_schema_leaf_paths_includes_known_paths() {
13922 let leaves = AutumnConfig::schema_leaf_paths();
13930 assert!(
13931 leaves.contains("server.port"),
13932 "server.port must be a schema leaf"
13933 );
13934 assert!(
13935 leaves.contains("server.host"),
13936 "server.host must be a schema leaf"
13937 );
13938 assert!(
13939 leaves.contains("database.url"),
13940 "database.url must be a schema leaf"
13941 );
13942 assert!(
13945 leaves.contains("security"),
13946 "security must appear as a root-level leaf"
13947 );
13948 assert!(
13949 leaves.contains("session"),
13950 "session must appear as a root-level leaf"
13951 );
13952 }
13953
13954 #[test]
13957 fn shards_auto_split_true_when_all_slots_none() {
13958 let config = DatabaseConfig {
13959 shards: vec![
13960 shard("a", "postgres://a/app"),
13961 shard("b", "postgres://b/app"),
13962 ],
13963 ..Default::default()
13964 };
13965 assert!(config.shards_auto_split());
13966 }
13967
13968 #[test]
13969 fn shards_auto_split_false_when_no_shards() {
13970 assert!(!DatabaseConfig::default().shards_auto_split());
13971 }
13972
13973 #[test]
13974 fn shards_auto_split_false_when_any_shard_declares_slots() {
13975 let config = DatabaseConfig {
13976 shards: vec![
13977 shard_with_slots("a", "postgres://a/app", &["0-8191"]),
13978 shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
13979 ],
13980 ..Default::default()
13981 };
13982 assert!(!config.shards_auto_split());
13983 }
13984
13985 #[test]
13986 fn resolved_shard_assignments_two_shards() {
13987 let config = DatabaseConfig {
13988 shards: vec![
13989 shard("s0", "postgres://s0/app"),
13990 shard("s1", "postgres://s1/app"),
13991 ],
13992 ..Default::default()
13993 };
13994 let assignments = config
13995 .resolved_shard_assignments()
13996 .expect("two-shard auto-split should resolve");
13997 assert_eq!(assignments.len(), 2);
13998 assert_eq!(assignments[0].name, "s0");
13999 assert_eq!(assignments[0].ranges, "0-8191");
14000 assert_eq!(assignments[1].name, "s1");
14001 assert_eq!(assignments[1].ranges, "8192-16383");
14002 }
14003
14004 #[test]
14005 fn resolved_shard_assignments_three_shards() {
14006 let config = DatabaseConfig {
14007 shards: vec![
14008 shard("s0", "postgres://s0/app"),
14009 shard("s1", "postgres://s1/app"),
14010 shard("s2", "postgres://s2/app"),
14011 ],
14012 ..Default::default()
14013 };
14014 let assignments = config
14015 .resolved_shard_assignments()
14016 .expect("three-shard auto-split should resolve");
14017 assert_eq!(assignments.len(), 3);
14018 assert_eq!(assignments[0].ranges, "0-5461");
14019 assert_eq!(assignments[1].ranges, "5462-10922");
14020 assert_eq!(assignments[2].ranges, "10923-16383");
14021 }
14022
14023 fn assignment(name: &str, ranges: &str) -> ShardSlotAssignment {
14026 ShardSlotAssignment {
14027 name: name.to_owned(),
14028 ranges: ranges.to_owned(),
14029 }
14030 }
14031
14032 #[test]
14033 fn check_stored_slot_map_explicit_mode_always_ok() {
14034 let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
14036 let stored = vec![
14037 assignment("s0", "0-5460"),
14038 assignment("s1", "5461-10922"),
14039 assignment("s2", "10923-16383"),
14040 ];
14041 assert!(check_stored_slot_map(false, &computed, Some(&stored)).is_ok());
14042 }
14043
14044 #[test]
14045 fn check_stored_slot_map_first_boot_no_stored_ok() {
14046 let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
14047 assert!(check_stored_slot_map(true, &computed, None).is_ok());
14048 }
14049
14050 #[test]
14051 fn check_stored_slot_map_matching_map_ok() {
14052 let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
14053 let stored = vec![assignment("s1", "8192-16383"), assignment("s0", "0-8191")];
14055 assert!(check_stored_slot_map(true, &computed, Some(&stored)).is_ok());
14056 }
14057
14058 #[test]
14059 fn check_stored_slot_map_mismatch_two_to_three_shards_returns_err() {
14060 let computed = vec![
14061 assignment("s0", "0-5460"),
14062 assignment("s1", "5461-10922"),
14063 assignment("s2", "10923-16383"),
14064 ];
14065 let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
14066 let err = check_stored_slot_map(true, &computed, Some(&stored))
14067 .expect_err("3-shard auto-split vs 2-shard stored map must fail");
14068 assert!(err.contains("shard slot map mismatch"), "message: {err}");
14069 assert!(err.contains("3 shards"), "message: {err}");
14070 assert!(err.contains("2 shards"), "message: {err}");
14071 }
14072
14073 #[test]
14074 fn check_stored_slot_map_mismatch_shard_rename_returns_err() {
14075 let computed = vec![
14076 assignment("alpha", "0-8191"),
14077 assignment("beta", "8192-16383"),
14078 ];
14079 let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
14080 let err = check_stored_slot_map(true, &computed, Some(&stored))
14081 .expect_err("renamed shards must be detected as mismatch");
14082 assert!(err.contains("shard slot map mismatch"), "message: {err}");
14083 assert!(
14084 err.contains("alpha"),
14085 "message must name computed shards: {err}"
14086 );
14087 assert!(err.contains("s0"), "message must name stored shards: {err}");
14088 }
14089
14090 #[test]
14093 fn process_role_default_is_combined() {
14094 assert_eq!(ProcessRole::default(), ProcessRole::Combined);
14095 assert_eq!(AutumnConfig::default().role, ProcessRole::Combined);
14096 }
14097
14098 #[test]
14099 fn process_role_from_env_value_accepts_aliases_case_insensitively() {
14100 for v in [
14101 "combined",
14102 "COMBINED",
14103 " all ",
14104 "web_and_worker",
14105 "server_and_worker",
14106 ] {
14107 assert_eq!(
14108 ProcessRole::from_env_value(v),
14109 Some(ProcessRole::Combined),
14110 "{v}"
14111 );
14112 }
14113 for v in ["web", "Web", " SERVER ", "http"] {
14114 assert_eq!(
14115 ProcessRole::from_env_value(v),
14116 Some(ProcessRole::Web),
14117 "{v}"
14118 );
14119 }
14120 for v in ["worker", "WORKER", " jobs ", "worker_only"] {
14121 assert_eq!(
14122 ProcessRole::from_env_value(v),
14123 Some(ProcessRole::Worker),
14124 "{v}"
14125 );
14126 }
14127 for v in ["", "webby", "workers", "scheduler", "both"] {
14128 assert_eq!(ProcessRole::from_env_value(v), None, "{v}");
14129 }
14130 }
14131
14132 #[test]
14133 fn process_role_as_str_round_trips_through_from_env_value() {
14134 for role in [ProcessRole::Combined, ProcessRole::Web, ProcessRole::Worker] {
14135 assert_eq!(ProcessRole::from_env_value(role.as_str()), Some(role));
14136 }
14137 }
14138
14139 #[test]
14140 fn process_role_serves_http_and_runs_workers_truth_table() {
14141 assert!(ProcessRole::Combined.serves_http());
14142 assert!(ProcessRole::Combined.runs_workers());
14143 assert!(ProcessRole::Web.serves_http());
14144 assert!(!ProcessRole::Web.runs_workers());
14145 assert!(!ProcessRole::Worker.serves_http());
14146 assert!(ProcessRole::Worker.runs_workers());
14147 }
14148
14149 #[test]
14150 fn process_role_deserializes_from_toml() {
14151 let web: AutumnConfig = toml::from_str("role = \"web\"\n").expect("web role");
14152 assert_eq!(web.role, ProcessRole::Web);
14153 let worker: AutumnConfig = toml::from_str("role = \"worker\"\n").expect("worker role");
14154 assert_eq!(worker.role, ProcessRole::Worker);
14155 let combined: AutumnConfig =
14156 toml::from_str("role = \"combined\"\n").expect("combined role");
14157 assert_eq!(combined.role, ProcessRole::Combined);
14158 let aliased: AutumnConfig = toml::from_str("role = \"all\"\n").expect("all alias");
14160 assert_eq!(aliased.role, ProcessRole::Combined);
14161 let absent: AutumnConfig = toml::from_str("").expect("empty config");
14163 assert_eq!(absent.role, ProcessRole::Combined);
14164 }
14165
14166 #[test]
14167 fn split_role_requires_durable_backend_truth_table() {
14168 assert!(!split_role_requires_durable_backend(
14171 ProcessRole::Combined,
14172 "local"
14173 ));
14174 assert!(!split_role_requires_durable_backend(
14175 ProcessRole::Combined,
14176 "postgres"
14177 ));
14178 assert!(split_role_requires_durable_backend(
14182 ProcessRole::Web,
14183 "local"
14184 ));
14185 assert!(split_role_requires_durable_backend(
14186 ProcessRole::Worker,
14187 "local"
14188 ));
14189 assert!(split_role_requires_durable_backend(
14190 ProcessRole::Web,
14191 "postgresql"
14192 ));
14193 assert!(split_role_requires_durable_backend(ProcessRole::Web, ""));
14194 assert!(split_role_requires_durable_backend(
14195 ProcessRole::Web,
14196 "unknown"
14197 ));
14198 assert!(split_role_requires_durable_backend(
14201 ProcessRole::Web,
14202 "LOCAL"
14203 ));
14204 assert!(!split_role_requires_durable_backend(
14206 ProcessRole::Web,
14207 "postgres"
14208 ));
14209 assert!(!split_role_requires_durable_backend(
14210 ProcessRole::Worker,
14211 "redis"
14212 ));
14213 }
14214
14215 #[test]
14216 fn autumn_role_env_override_sets_role() {
14217 let env = MockEnv::new().with("AUTUMN_ROLE", "worker");
14218 let mut config = AutumnConfig::default();
14219 config.apply_env_overrides_with_env(&env);
14220 assert_eq!(config.role, ProcessRole::Worker);
14221
14222 let env = MockEnv::new().with("AUTUMN_ROLE", " WEB ");
14223 let mut config = AutumnConfig::default();
14224 config.apply_env_overrides_with_env(&env);
14225 assert_eq!(config.role, ProcessRole::Web);
14226 }
14227
14228 #[test]
14229 fn autumn_role_env_override_ignores_invalid_value_keeping_default() {
14230 let env = MockEnv::new().with("AUTUMN_ROLE", "nonsense");
14231 let mut config = AutumnConfig {
14234 role: ProcessRole::Worker,
14235 ..Default::default()
14236 };
14237 config.apply_env_overrides_with_env(&env);
14238 assert_eq!(config.role, ProcessRole::Worker);
14239
14240 let mut config = AutumnConfig::default();
14242 config.apply_env_overrides_with_env(&env);
14243 assert_eq!(config.role, ProcessRole::Combined);
14244 }
14245}