Skip to main content

autumn_web/
config.rs

1//! Framework configuration with sensible defaults and profile-based layering.
2//!
3//! Autumn uses a five-layer configuration system where each layer
4//! overrides the previous one:
5//!
6//! 1. **Framework defaults** (this module) -- compiled into the binary.
7//! 2. **Profile smart defaults** -- per-profile values for `dev`/`prod`.
8//! 3. **`autumn.toml`** -- project-level overrides checked into source control.
9//! 4. **`[profile.{name}]` in `autumn.toml`** -- profile-specific overrides.
10//! 5. **`autumn-{profile}.toml`** -- legacy profile-specific overrides.
11//! 6. **`AUTUMN_*` environment variables** -- deployment/CI overrides.
12//!
13//! An Autumn application runs with zero configuration -- every field
14//! has a sensible default value. Override only what you need.
15//!
16//! # Local-dev `.env` files
17//!
18//! A project-root `.env` file is a **local-dev feeder for the highest layer**
19//! (the `AUTUMN_*` env-var layer) -- it does *not* add a new precedence tier.
20//! Values parsed from `.env` populate env-layer keys that are still unset; a
21//! real environment variable of the same name always wins. Auto-loaded in the
22//! `dev` and `test` profiles and ignored in `prod` unless `AUTUMN_DOTENV=1`.
23//! Files load in order `.env` -> `.env.local` -> `.env.{profile}` ->
24//! `.env.{profile}.local`, and earlier files (and real env vars) win. See the
25//! [`dotenv`](crate::dotenv) module.
26//!
27//! # Profiles
28//!
29//! Profiles are resolved in precedence order:
30//! 1. `AUTUMN_ENV` environment variable
31//! 2. `AUTUMN_PROFILE` environment variable (legacy alias)
32//! 3. `--profile` CLI flag
33//! 4. Auto-detect from debug/release build mode
34//!
35//! # Example
36//!
37//! ```rust
38//! use autumn_web::config::AutumnConfig;
39//!
40//! // All defaults -- no file needed
41//! let config = AutumnConfig::default();
42//! assert_eq!(config.server.port, 3000);
43//! assert_eq!(config.server.host, "127.0.0.1");
44//! assert!(config.database.url.is_none());
45//! ```
46//!
47//! # Environment variable reference
48//!
49//! | Variable | Config field | Type |
50//! |----------|-------------|------|
51//! | `AUTUMN_SERVER__PORT` | `server.port` | `u16` |
52//! | `AUTUMN_SERVER__HOST` | `server.host` | `String` |
53//! | `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` | `server.shutdown_timeout_secs` | `u64` |
54//! | `AUTUMN_SERVER__PRESTOP_GRACE_SECS` | `server.prestop_grace_secs` | `u64` |
55//! | `AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS` | `server.timeouts.request_timeout_ms` | `u64` |
56//! | `AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS` | `server.max_concurrent_requests` | `usize` |
57//! | `AUTUMN_DATABASE__URL` | `database.url` | `String` |
58//! | `AUTUMN_DATABASE__PRIMARY_URL` | `database.primary_url` | `String` |
59//! | `AUTUMN_DATABASE__REPLICA_URL` | `database.replica_url` | `String` |
60//! | `AUTUMN_DATABASE__POOL_SIZE` | `database.pool_size` | `usize` |
61//! | `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` | `database.primary_pool_size` | `usize` |
62//! | `AUTUMN_DATABASE__REPLICA_POOL_SIZE` | `database.replica_pool_size` | `usize` |
63//! | `AUTUMN_DATABASE__REPLICA_FALLBACK` | `database.replica_fallback` | `fail_readiness` / `primary` |
64//! | `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` | `database.connect_timeout_secs` | `u64` |
65//! | `AUTUMN_DATABASE__STARTUP_WAIT_SECS` | `database.startup_wait_secs` | `u64` |
66//! | `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` | `database.auto_migrate_in_production` | `bool` |
67//! | `AUTUMN_DATABASE__SHARDS__{i}__NAME` | `database.shards[i].name` | `String` |
68//! | `AUTUMN_DATABASE__SHARDS__{i}__PRIMARY_URL` | `database.shards[i].primary_url` | `String` |
69//! | `AUTUMN_DATABASE__SHARDS__{i}__SLOTS` | `database.shards[i].slots` | CSV of indices / `A-B` ranges |
70//! | `AUTUMN_DATABASE__SHARDS__{i}__REPLICA_URL` | `database.shards[i].replica_url` | `String` |
71//! | `AUTUMN_DATABASE__SHARDS__{i}__PRIMARY_POOL_SIZE` | `database.shards[i].primary_pool_size` | `usize` |
72//! | `AUTUMN_DATABASE__SHARDS__{i}__REPLICA_POOL_SIZE` | `database.shards[i].replica_pool_size` | `usize` |
73//! | `AUTUMN_DATABASE__SHARDS__{i}__REPLICA_FALLBACK` | `database.shards[i].replica_fallback` | `fail_readiness` / `primary` |
74//! | `AUTUMN_LOG__LEVEL` | `log.level` | tracing filter directive |
75//! | `AUTUMN_LOG__FORMAT` | `log.format` | `Auto` / `Pretty` / `Json` |
76//! | `AUTUMN_TELEMETRY__ENABLED` | `telemetry.enabled` | `bool` |
77//! | `AUTUMN_TELEMETRY__SERVICE_NAME` | `telemetry.service_name` | `String` |
78//! | `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` | `telemetry.service_namespace` | `String` |
79//! | `AUTUMN_TELEMETRY__SERVICE_VERSION` | `telemetry.service_version` | `String` |
80//! | `AUTUMN_TELEMETRY__ENVIRONMENT` | `telemetry.environment` | `String` |
81//! | `AUTUMN_TELEMETRY__OTLP_ENDPOINT` | `telemetry.otlp_endpoint` | `String` |
82//! | `AUTUMN_TELEMETRY__PROTOCOL` | `telemetry.protocol` | `Grpc` / `HttpProtobuf` |
83//! | `AUTUMN_TELEMETRY__STRICT` | `telemetry.strict` | `bool` |
84//! | `AUTUMN_HEALTH__PATH` | `health.path` | `String` |
85//! | `AUTUMN_HEALTH__LIVE_PATH` | `health.live_path` | `String` |
86//! | `AUTUMN_HEALTH__READY_PATH` | `health.ready_path` | `String` |
87//! | `AUTUMN_HEALTH__STARTUP_PATH` | `health.startup_path` | `String` |
88//! | `AUTUMN_HEALTH__DETAILED` | `health.detailed` | `bool` |
89//! | `AUTUMN_CORS__ALLOWED_ORIGINS` | `cors.allowed_origins` | comma-separated `String` |
90//! | `AUTUMN_CORS__ALLOWED_METHODS` | `cors.allowed_methods` | comma-separated `String` |
91//! | `AUTUMN_CORS__ALLOWED_HEADERS` | `cors.allowed_headers` | comma-separated `String` |
92//! | `AUTUMN_CORS__ALLOW_CREDENTIALS` | `cors.allow_credentials` | `bool` |
93//! | `AUTUMN_CORS__MAX_AGE_SECS` | `cors.max_age_secs` | `u64` |
94//! | `AUTUMN_CACHE__BACKEND` | `cache.backend` | `memory` / `redis` |
95//! | `AUTUMN_CACHE__REDIS__URL` | `cache.redis.url` | `String` |
96//! | `AUTUMN_CACHE__REDIS__KEY_PREFIX` | `cache.redis.key_prefix` | `String` |
97//! | `AUTUMN_SESSION__BACKEND` | `session.backend` | `memory` / `redis` |
98//! | `AUTUMN_SESSION__COOKIE_NAME` | `session.cookie_name` | `String` |
99//! | `AUTUMN_SESSION__MAX_AGE_SECS` | `session.max_age_secs` | `u64` |
100//! | `AUTUMN_SESSION__SECURE` | `session.secure` | `bool` |
101//! | `AUTUMN_SESSION__SAME_SITE` | `session.same_site` | `String` |
102//! | `AUTUMN_SESSION__HTTP_ONLY` | `session.http_only` | `bool` |
103//! | `AUTUMN_SESSION__PATH` | `session.path` | `String` |
104//! | `AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION` | `session.allow_memory_in_production` | `bool` |
105//! | `AUTUMN_SESSION__REDIS__URL` | `session.redis.url` | `String` |
106//! | `AUTUMN_SESSION__REDIS__KEY_PREFIX` | `session.redis.key_prefix` | `String` |
107//! | `AUTUMN_CHANNELS__BACKEND` | `channels.backend` | `in_process` / `redis` |
108//! | `AUTUMN_CHANNELS__CAPACITY` | `channels.capacity` | `usize` |
109//! | `AUTUMN_CHANNELS__REPLAY_BUFFER` | `channels.replay_buffer` | `usize` |
110//! | `AUTUMN_CHANNELS__REDIS__URL` | `channels.redis.url` | `String` |
111//! | `AUTUMN_CHANNELS__REDIS__KEY_PREFIX` | `channels.redis.key_prefix` | `String` |
112//! | `AUTUMN_JOBS__BACKEND` | `jobs.backend` | `local` / `postgres` / `redis` |
113//! | `AUTUMN_JOBS__WORKERS` | `jobs.workers` | `usize` |
114//! | `AUTUMN_JOBS__PIN` | `jobs.pin` | comma-separated queue names |
115//! | `AUTUMN_JOBS__MAX_ATTEMPTS` | `jobs.max_attempts` | `u32` |
116//! | `AUTUMN_JOBS__INITIAL_BACKOFF_MS` | `jobs.initial_backoff_ms` | `u64` |
117//! | `AUTUMN_JOBS__REDIS__URL` | `jobs.redis.url` | `String` |
118//! | `AUTUMN_JOBS__REDIS__KEY_PREFIX` | `jobs.redis.key_prefix` | `String` |
119//! | `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` | `jobs.redis.visibility_timeout_ms` | `u64` |
120//! | `AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS` | `jobs.postgres.visibility_timeout_ms` | `u64` |
121//! | `AUTUMN_JOBS__TRACKING__TTL_SECS` | `jobs.tracking.ttl_secs` | `u64` |
122//! | `AUTUMN_JOBS__TRACKING__ROUTE_ENABLED` | `jobs.tracking.route_enabled` | `bool` |
123//! | `AUTUMN_SCHEDULER__BACKEND` | `scheduler.backend` | `in_process` / `postgres` |
124//! | `AUTUMN_SCHEDULER__LEASE_TTL_SECS` | `scheduler.lease_ttl_secs` | `u64` |
125//! | `AUTUMN_SCHEDULER__REPLICA_ID` | `scheduler.replica_id` | `String` |
126//! | `AUTUMN_SCHEDULER__KEY_PREFIX` | `scheduler.key_prefix` | `String` |
127//! | `AUTUMN_SECURITY__RATE_LIMIT__ENABLED` | `security.rate_limit.enabled` | `bool` |
128//! | `AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND` | `security.rate_limit.requests_per_second` | `f64` |
129//! | `AUTUMN_SECURITY__RATE_LIMIT__BURST` | `security.rate_limit.burst` | `u32` |
130//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS` | `security.rate_limit.trust_forwarded_headers` | `bool` |
131//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES` | `security.rate_limit.trusted_proxies` | comma-separated `String` |
132//! | `AUTUMN_ENV` | active profile | `String` |
133//! | `AUTUMN_PROFILE` | active profile (legacy alias) | `String` |
134//! | `AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES` | `security.upload.max_request_size_bytes` | `usize` |
135//! | `AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES` | `security.upload.max_file_size_bytes` | `usize` |
136//! | `AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES` | `security.upload.allowed_mime_types` | comma-separated `String` |
137//! | `AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH` | `security.upload.reject_on_content_type_mismatch` | `bool` |
138//! | `AUTUMN_SECURITY__FORBIDDEN_RESPONSE` | `security.forbidden_response` | `"403"` or `"404"` |
139//! | `AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API` | `security.allow_unauthorized_repository_api` | `bool` |
140//! | `AUTUMN_SECURITY__SIGNING_SECRET` | `security.signing_secret.secret` | `String` |
141//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` | `security.webhooks.replay.backend` | `memory` / `redis` |
142//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` | `security.webhooks.replay.redis.url` | `String` |
143//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` | `security.webhooks.replay.redis.key_prefix` | `String` |
144//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` | `security.webhooks.replay.allow_memory_in_production` | `bool` |
145//! | `AUTUMN_DEV__INSPECTOR_PATH` | `dev.inspector_path` | `String` |
146//! | `AUTUMN_DEV__INSPECTOR_CAPACITY` | `dev.inspector_capacity` | `usize` |
147//! | `AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD` | `dev.inspector_n_plus_one_threshold` | `usize` |
148//! | `AUTUMN_OBSERVABILITY__SERVER_TIMING` | `observability.server_timing` | `bool` |
149//! | `AUTUMN_COMPRESSION__ENABLED` | `compression.enabled` | `bool` |
150//! | `AUTUMN_STORIES__ENABLED` | `stories.enabled` | `bool` |
151//! | `AUTUMN_AUTH__LOCKOUT__ENABLED` | `auth.lockout.enabled` | `bool` |
152//! | `AUTUMN_AUTH__LOCKOUT__THRESHOLD` | `auth.lockout.threshold` | `i32` |
153//! | `AUTUMN_AUTH__LOCKOUT__WINDOW_SECS` | `auth.lockout.window_secs` | `u64` |
154//! | `AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS` | `auth.lockout.cooloff_secs` | `u64` |
155//! | `AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES` | `auth.magic_link.ttl_minutes` | `u64` |
156//! | `AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS` | `auth.magic_link.email_cooldown_secs` | `u64` |
157//! | `AUTUMN_TIME_ZONE__IDENTIFIER` | `time_zone.identifier` | IANA id `String` |
158
159use std::path::{Path, PathBuf};
160
161use serde::Deserialize;
162use thiserror::Error;
163
164/// Abstraction for reading environment variables, supporting dependency injection for testing.
165use 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
176/// Trait for environment variable reading to allow testing overrides.
177///
178/// This abstracts the OS environment (`std::env::var`) so that
179/// configuration loading logic can be unit-tested deterministically
180/// by supplying a mock environment.
181pub trait Env {
182    /// Read an environment variable.
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use autumn_web::config::{Env, OsEnv};
188    /// let env = OsEnv;
189    /// let val = env.var("NON_EXISTENT_VAR");
190    /// assert!(val.is_err());
191    /// ```
192    ///
193    /// # Errors
194    ///
195    /// Returns [`std::env::VarError`] if the variable is not present or is not valid Unicode.
196    fn var(&self, key: &str) -> Result<String, std::env::VarError>;
197}
198
199/// Production implementation of `Env` that reads from the OS environment.
200#[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            // Process env takes priority over the compile-time baked-in path so
207            // installed apps (e.g. Tauri sidecars) can redirect config loading to
208            // their bundled resource dir by setting AUTUMN_MANIFEST_DIR at launch.
209            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/// Mock implementation of `Env` for testing.
229#[derive(Clone, Default)]
230pub struct MockEnv {
231    vars: std::collections::HashMap<String, String>,
232}
233
234impl MockEnv {
235    /// Create a new, empty `MockEnv`.
236    #[must_use]
237    pub fn new() -> Self {
238        Self {
239            vars: std::collections::HashMap::new(),
240        }
241    }
242
243    /// Set an environment variable in the mock.
244    #[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    /// Remove an environment variable from the mock.
251    #[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
267/// Locate a config file by checking the app's crate directory first, then CWD.
268fn 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
278/// Load a TOML file as a raw `toml::Value` table.
279/// Returns `Ok(None)` if the file doesn't exist.
280fn 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
291/// Resolve the active profile using the precedence chain.
292///
293/// 1. `AUTUMN_ENV` env var (highest priority)
294/// 2. `AUTUMN_PROFILE` env var (legacy alias)
295/// 3. `--profile <name>` CLI flag
296/// 4. Auto-detect from build mode (`AUTUMN_IS_DEBUG` set by `#[autumn_web::main]`)
297/// 5. Fallback to `dev`
298pub(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
303/// Resolve the raw profile selector value (before normalization).
304///
305/// The env-var keys consulted here (`AUTUMN_ENV`, `AUTUMN_PROFILE`,
306/// `AUTUMN_IS_DEBUG`) are the profile *selectors*; they are deliberately
307/// excluded from the `.env` overlay (see [`crate::dotenv`]'s
308/// `PROFILE_SELECTOR_KEYS`) so a `.env` file cannot switch the active profile.
309/// Keep the two lists in sync.
310fn resolve_profile_input(env: &dyn Env) -> String {
311    // 1. Preferred env var
312    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    // 2. Legacy env var
320    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    // 3. CLI flag
328    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    // 4. Auto-detect from build mode
347    if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
348        return "prod".to_owned();
349    }
350    "dev".to_owned()
351}
352
353/// Normalize profile aliases and trim whitespace.
354///
355/// Supported aliases:
356/// - `production` -> `prod`
357/// - `development` -> `dev`
358/// - `prod`/`PROD` -> `prod`
359/// - `dev`/`DEV` -> `dev`
360///
361/// `pub` so the deploy CLI (`autumn-cli`) can mirror the runtime's profile
362/// normalization exactly when picking which local `autumn-<profile>.toml` to
363/// upload — a single source of truth prevents deploy/runtime drift (#1952).
364#[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    // Preserve user-specified case for custom profile names.
385    Some(trimmed.to_owned())
386}
387
388/// Profile names to check for inline/file overrides.
389///
390/// For canonical profiles, include legacy aliases for compatibility so
391/// `production` and `development` profile sources are still loaded.
392fn 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/// Ordered file lookup names for profile override file compatibility.
401///
402/// Only one profile override file is loaded: the first existing file in this
403/// ordered list. The order prefers the explicitly-selected spelling.
404///
405/// `pub` so the deploy CLI (`autumn-cli`) can mirror the runtime's
406/// override-file lookup exactly when picking which local `autumn-<profile>.toml`
407/// to upload — a single source of truth prevents deploy/runtime drift (#1952).
408#[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
426/// Extract `[profile.<name>]` table from a parsed `autumn.toml`.
427fn 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
435/// Profile-specific smart defaults as a TOML table.
436///
437/// Only `dev` and `prod` have smart defaults. Custom profiles
438/// (staging, test, etc.) get no smart defaults — they rely on
439/// their profile TOML file and env overrides.
440fn 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            // Zero-out the prestop grace in dev: there is no load balancer to
458            // deregister, so the 5-second default would add unnecessary latency
459            // on every Ctrl-C.
460            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            // Dev: enable the local-disk blob store rooted at
479            // `target/blobs/` automatically when the `storage` feature
480            // is on. `prod` deliberately leaves `backend = "disabled"`
481            // so the operator has to opt into either `local` (with
482            // `allow_local_in_production = true`) or `s3`.
483            let mut storage = toml::map::Map::new();
484            storage.insert("backend".into(), "local".into());
485            table.insert("storage".into(), toml::Value::Table(storage));
486            // Dev: trust X-Forwarded-* from loopback only so local reverse
487            // proxies (nginx, caddy, etc. on 127.0.0.1/::1) work out of the box.
488            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            // Dev: CSRF disabled (default), HSTS off (default)
504        }
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            // Prod: strict security -- HSTS on, CSRF enabled, secure cookies
528            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        _ => {} // Custom profiles get no smart defaults
545    }
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
563/// Maximum recursion depth for merging TOML tables.
564const MAX_MERGE_DEPTH: usize = 16;
565
566/// Deep-merge two TOML values. Tables are merged recursively;
567/// non-table values in `overlay` replace those in `base`.
568fn 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
601/// Suggest a close match for a custom profile name.
602///
603/// Returns `Some(name)` when a known profile is within edit distance 2.
604fn 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
615/// Warn when a custom profile has no TOML file, suggesting close matches.
616fn 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/// Levenshtein edit distance between two strings.
630///
631/// ⚡ Bolt Optimization:
632/// Reduces memory allocations by using a single `Vec` instead of two and
633/// iterating directly over `Chars` to avoid `Vec<char>` allocations.
634#[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// ── Deprecation channel ───────────────────────────────────────────────────────
652
653/// A configuration key (or its corresponding `AUTUMN_*` env var) that is
654/// deprecated but still honored for the current minor-release line.
655///
656/// Register entries in [`DEPRECATED_CONFIG_KEYS`]. The config loader emits a
657/// structured `WARN` for each entry whose key is present in the resolved config,
658/// and `autumn doctor` surfaces them as ⚠️ checks.
659///
660/// # Env-var contract
661///
662/// A registered `path` MUST correspond to the mechanical env-var name produced
663/// by [`deprecated_env_var_name`] (`a.b.c` → `AUTUMN_A__B__C`), which is the
664/// same name the loader's `apply_*_env_overrides` reads to honor the value. If
665/// a key's loader override uses a non-mechanical env-var name, env-var detection
666/// here would diverge from what the loader actually applies. The integration
667/// tests in `autumn/tests/config_deprecation.rs` lock this for every entry by
668/// loading config with each key set via its env var and asserting the value is
669/// honored.
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671pub struct DeprecatedKey {
672    /// Dotted config path, e.g. `"security.rate_limit.trusted_proxies"`.
673    pub path: &'static str,
674    /// The replacement key path, or `None` meaning "remove it; no replacement".
675    pub replacement: Option<&'static str>,
676    /// Version the deprecation was introduced (e.g. `"0.5.0"`).
677    pub since: &'static str,
678    /// Version the key is scheduled for removal (e.g. `"1.0.0"`).
679    pub remove_in: &'static str,
680}
681
682/// The canonical registry of deprecated config keys.
683///
684/// Add entries here when retiring a key; never silently delete a schema field
685/// without first registering it here. The schema-snapshot CI guard
686/// (`autumn/tests/schema_drift_guard.rs`) enforces this rule.
687pub 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/// Returns the full registry of deprecated config keys.
703#[must_use]
704pub fn deprecated_config_keys() -> &'static [DeprecatedKey] {
705    DEPRECATED_CONFIG_KEYS
706}
707
708/// Converts a dotted config key path to its `AUTUMN_*` env var name.
709///
710/// # Examples
711/// ```
712/// # use autumn_web::config::deprecated_env_var_name;
713/// assert_eq!(
714///     deprecated_env_var_name("security.rate_limit.trusted_proxies"),
715///     "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
716/// );
717/// ```
718#[must_use]
719pub fn deprecated_env_var_name(path: &str) -> String {
720    format!("AUTUMN_{}", path.to_uppercase().replace('.', "__"))
721}
722
723/// Where a deprecated key was detected: TOML only, env-var only, or both.
724#[derive(Debug, Clone, Copy, PartialEq, Eq)]
725pub enum DeprecationSource {
726    Toml,
727    Env,
728    Both,
729}
730
731/// One detected use of a deprecated config key.
732#[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
741/// Tests whether a dotted key path is present in a TOML table (any value type).
742///
743/// Non-table mid-segments are treated as absent (no panic).
744fn 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/// Scans the merged config table and env for any registered deprecated key.
761///
762/// Returns at most one [`DeprecationFinding`] per registry entry (even if the key
763/// is set in both TOML and env, the two sources are collapsed into [`DeprecationSource::Both`]).
764/// Registry order is preserved for deterministic output.
765#[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/// Detects deprecated keys the way [`AutumnConfig::load_with_env`] would, given a
796/// profile and a file-merged TOML table a tool has already built.
797///
798/// Seeds `profile_defaults_as_toml` as the base layer and deep-merges
799/// `file_table` on top before running [`detect_deprecated_keys`], so external
800/// tools (e.g. `autumn doctor`) evaluate the *same* layered config the runtime
801/// loader does — a key set only in a profile default is still detected.
802#[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/// Errors that can occur when loading or validating configuration.
817///
818/// Returned by [`AutumnConfig::load`], [`AutumnConfig::load_from`], and
819/// [`DatabaseConfig::validate`].
820///
821/// # Examples
822///
823/// ```rust
824/// use autumn_web::config::{AutumnConfig, ConfigError};
825/// use std::path::Path;
826///
827/// let result = AutumnConfig::load_from(Path::new("nonexistent.toml"));
828/// // Returns Ok(defaults) when file is missing -- not an error
829/// assert!(result.is_ok());
830/// ```
831#[derive(Debug, Error)]
832#[non_exhaustive]
833pub enum ConfigError {
834    /// The config file exists but could not be read.
835    #[error("failed to read autumn.toml: {0}")]
836    Io(#[from] std::io::Error),
837
838    /// The config file contains invalid TOML syntax.
839    #[error("invalid autumn.toml: {0}")]
840    Parse(#[from] toml::de::Error),
841
842    /// A configuration value failed semantic validation (e.g., invalid
843    /// database URL scheme).
844    #[error("configuration error: {0}")]
845    Validation(String),
846
847    /// The credentials file exists but could not be decrypted.
848    #[error("credentials error: {0}")]
849    Credentials(String),
850
851    /// A project-root `.env` file exists but could not be read or parsed.
852    #[error("dotenv error: {0}")]
853    Dotenv(String),
854}
855
856/// Top-level framework configuration.
857///
858/// All sections are optional -- missing sections use their defaults.
859/// Deserialized from `autumn.toml` (TOML format).
860///
861/// # `autumn.toml` example
862///
863/// ```toml
864/// [server]
865/// port = 8080
866///
867/// [database]
868/// url = "postgres://user:pass@db:5432/myapp"
869/// pool_size = 20
870/// ```
871///
872/// # Examples
873///
874/// ```rust
875/// use autumn_web::config::AutumnConfig;
876///
877/// let config = AutumnConfig::default();
878/// assert_eq!(config.server.port, 3000);
879/// assert_eq!(config.database.pool_size, 10);
880/// assert_eq!(config.log.level, "info");
881/// assert_eq!(config.health.path, "/health");
882/// ```
883/// `[backup]` configuration section (issue #1619).
884///
885/// Groups database-backup destinations. Currently only an offsite S3-compatible
886/// destination is supported. NOT feature-gated: this section (`[backup.offsite]`)
887/// is recognized by every autumn-web build so a strict-config app compiled
888/// WITHOUT the `storage` feature still accepts its own `autumn.toml` `[backup]`
889/// keys (the offsite upload client lives in the CLI and is independent of the
890/// storage feature).
891#[derive(Debug, Clone, Default, Deserialize)]
892pub struct BackupConfig {
893    /// Offsite upload destination (`[backup.offsite]`). `None` (the default)
894    /// means no offsite destination is configured; `autumn db backup --upload`
895    /// then errors with configuration guidance rather than silently no-op'ing.
896    ///
897    /// Boxed so an unconfigured `[backup]` costs one pointer rather than the full
898    /// [`OffsiteBackupConfig`] inline: `AutumnConfig` is held across awaits in the
899    /// app-run future, and keeping this field small avoids bloating that future.
900    #[serde(default)]
901    pub offsite: Option<Box<OffsiteBackupConfig>>,
902}
903
904/// `[backup.offsite]` — an S3-compatible offsite backup destination (issue #1619).
905///
906/// Credentials are supplied by env-var *indirection* only: `s3.access_key_id_env`
907/// / `s3.secret_access_key_env` name the environment variables the secrets are
908/// read from at upload time. The secret values themselves never live in config,
909/// argv, logs, or error messages.
910#[derive(Debug, Clone, Default, Deserialize)]
911pub struct OffsiteBackupConfig {
912    /// S3-compatible connection + credential-indirection settings
913    /// (`[backup.offsite.s3]`). Works against AWS S3 / `MinIO` / R2 / B2 / Garage.
914    #[serde(default)]
915    pub s3: OffsiteS3Config,
916
917    /// Key prefix under which run directories are stored. Defaults to `""`
918    /// (bucket root). Objects are keyed `{prefix}/{profile}/{timestamp}/{file}`.
919    #[serde(default)]
920    pub prefix: Option<String>,
921
922    /// Independent remote retention: keep only the newest `N` uploaded runs per
923    /// profile, pruning older ones *after* a verified upload. `None` (default)
924    /// keeps all remote runs. Distinct from the local `--keep`.
925    #[serde(default)]
926    pub keep: Option<usize>,
927
928    /// Upload after every successful `autumn db backup` even without `--upload`
929    /// (the "configured default" upload, AC #1). Off by default.
930    #[serde(default)]
931    pub auto_upload: bool,
932
933    /// Opt-in to pointing the offsite destination at the same bucket+endpoint as
934    /// the app's user-facing blob storage (`[storage.s3]`). Off by default so a
935    /// shared bucket is a deliberate choice (AC #3).
936    #[serde(default)]
937    pub allow_shared_bucket: bool,
938}
939
940/// `[backup.offsite.s3]` — S3-compatible connection settings for offsite backups.
941///
942/// A dedicated, NON-feature-gated mirror of the storage backend's S3 shape so the
943/// `[backup]` section is available in every autumn-web build (the storage
944/// module — and its `StorageS3Config` — only exist under the `storage` feature).
945/// Credentials are named via `*_env` indirection, never inlined.
946#[derive(Debug, Clone, Default, Deserialize)]
947pub struct OffsiteS3Config {
948    /// Target bucket.
949    #[serde(default)]
950    pub bucket: Option<String>,
951
952    /// AWS region or region-shaped string (R2 uses `auto`). Used for the `SigV4`
953    /// credential scope; many S3-compatible endpoints ignore it.
954    #[serde(default)]
955    pub region: Option<String>,
956
957    /// Custom endpoint URL. Required for non-AWS providers (R2, `MinIO`, B2,
958    /// Garage). Leave unset for AWS.
959    #[serde(default)]
960    pub endpoint: Option<String>,
961
962    /// Environment variable the access-key id is read from.
963    #[serde(default)]
964    pub access_key_id_env: Option<String>,
965
966    /// Environment variable the secret access key is read from.
967    #[serde(default)]
968    pub secret_access_key_env: Option<String>,
969
970    /// Path-style addressing toggle (R2 / `MinIO` need this `true`).
971    #[serde(default)]
972    pub force_path_style: bool,
973}
974
975#[derive(Debug, Clone, Default, Deserialize)]
976pub struct AutumnConfig {
977    /// Active profile name (e.g., "dev", "prod", "staging").
978    /// Resolved at load time, not deserialized from TOML.
979    #[serde(skip)]
980    pub profile: Option<String>,
981
982    /// HTTP server settings (port, host, shutdown behavior).
983    #[serde(default)]
984    pub server: ServerConfig,
985
986    /// Push-button VPS deploy settings (`[deploy]` section, issue #1607).
987    ///
988    /// Operator-facing configuration for `autumn deploy` — the SSH-reachable
989    /// target host plus the remote install layout and rollout tuning knobs.
990    /// Top-level (not nested under `[server]`) because it describes *where and
991    /// how* the app is deployed, not how the running server behaves.
992    ///
993    /// Absent by default (`None`), so an app that never runs `autumn deploy`
994    /// is unaffected. A bare `[deploy]` table is valid at rest — `host` is only
995    /// required when a deploy actually runs, enforced by
996    /// [`DeployConfig::validate`].
997    ///
998    /// # Field ordering (load-bearing — do not move below `database`)
999    ///
1000    /// `deploy` is declared here, before [`database`](Self::database), so that
1001    /// [`get_schema_keys`](Self::get_schema_keys)'s `SchemaDeserializer`
1002    /// traversal recurses into [`DeployConfig`]'s child keys and the strict
1003    /// unknown-key validator (`validate_toml` / `server.strict_config` /
1004    /// `autumn check --config`) rejects a typo like `[deploy] app_dr = "…"`.
1005    /// `DatabaseConfig` has a `deserialize_with` duration field
1006    /// (`statement_timeout`, via the untagged [`deserialize_duration`] parser)
1007    /// whose parser rejects the `SchemaDeserializer`'s placeholder value and
1008    /// returns an error, which aborts the remainder of `AutumnConfig`'s field
1009    /// traversal — so any section declared *after* `database` is recorded only
1010    /// as an opaque root leaf, never descended into. Keeping `deploy` ahead of
1011    /// `database` sidesteps that abort. The regression guard
1012    /// `deploy_child_keys_are_strictly_validated` fails if this ordering breaks.
1013    #[serde(default)]
1014    pub deploy: Option<DeployConfig>,
1015
1016    /// Database connection settings (URL, pool size, timeouts).
1017    #[serde(default)]
1018    pub database: DatabaseConfig,
1019
1020    /// Logging configuration (level, format).
1021    #[serde(default)]
1022    pub log: LogConfig,
1023
1024    /// Telemetry configuration (OTLP tracing and service metadata).
1025    #[serde(default)]
1026    pub telemetry: TelemetryConfig,
1027
1028    /// Health check endpoint settings.
1029    #[serde(default)]
1030    pub health: HealthConfig,
1031
1032    /// Actuator endpoint settings.
1033    #[serde(default)]
1034    pub actuator: ActuatorConfig,
1035
1036    /// CORS (Cross-Origin Resource Sharing) settings.
1037    #[serde(default)]
1038    pub cors: CorsConfig,
1039
1040    /// Session management settings.
1041    #[serde(default)]
1042    pub session: crate::session::SessionConfig,
1043
1044    /// Cache backend settings.
1045    #[serde(default)]
1046    pub cache: CacheConfig,
1047
1048    /// Row-level multi-tenancy settings.
1049    #[serde(default)]
1050    pub tenancy: TenancyConfig,
1051
1052    /// HTTP idempotency-key middleware settings.
1053    #[serde(default)]
1054    pub idempotency: IdempotencyConfig,
1055
1056    /// Real-time channel backend settings.
1057    #[serde(default)]
1058    pub channels: ChannelConfig,
1059
1060    /// Background job backend and runtime settings.
1061    #[serde(default)]
1062    pub jobs: JobConfig,
1063
1064    /// Scheduled task coordination backend settings.
1065    #[serde(default)]
1066    pub scheduler: SchedulerConfig,
1067
1068    /// Process role: which slice of the runtime this replica runs (web tier,
1069    /// worker tier, or both). Defaults to [`ProcessRole::Combined`] so existing
1070    /// single-process deployments are unaffected. Also settable via the flat
1071    /// `AUTUMN_ROLE` env var.
1072    #[serde(default)]
1073    pub role: ProcessRole,
1074
1075    /// Authentication settings.
1076    #[serde(default)]
1077    pub auth: crate::auth::AuthConfig,
1078
1079    /// Security settings (headers, CSRF).
1080    #[serde(default)]
1081    pub security: crate::security::config::SecurityConfig,
1082
1083    /// Internationalization settings (default locale, supported locales,
1084    /// fallback chain). Populated from the `[i18n]` block in
1085    /// `autumn.toml`.
1086    #[cfg(feature = "i18n")]
1087    #[serde(default)]
1088    pub i18n: crate::i18n::I18nConfig,
1089
1090    /// Per-user time zone settings (`[time_zone]` block in `autumn.toml`).
1091    ///
1092    /// Controls the default IANA zone and the source resolution chain for the
1093    /// [`TimeZone`](crate::time_zone::TimeZone) extractor.
1094    ///
1095    /// # Example
1096    ///
1097    /// ```toml
1098    /// [time_zone]
1099    /// identifier = "America/New_York"
1100    /// ```
1101    #[serde(default)]
1102    pub time_zone: crate::time_zone::TimeZoneConfig,
1103    /// Pluggable file storage configuration. Honored only when the
1104    /// `storage` cargo feature is enabled.
1105    #[cfg(feature = "storage")]
1106    #[serde(default)]
1107    pub storage: crate::storage::StorageConfig,
1108
1109    /// Offsite database-backup destination (`[backup]` section, issue #1619).
1110    ///
1111    /// Composes the verified local-backup artifact (issue #1595) with an
1112    /// S3-compatible offsite destination. Always present (not feature-gated) so
1113    /// every autumn-web build recognizes `[backup.offsite]` — a strict-config app
1114    /// compiled without the `storage` feature still accepts its own `[backup]`
1115    /// keys. The offsite upload client lives in the CLI and needs no storage
1116    /// feature.
1117    #[serde(default)]
1118    pub backup: BackupConfig,
1119    /// Transactional email settings.
1120    #[cfg(feature = "mail")]
1121    #[serde(default)]
1122    pub mail: crate::mail::MailConfig,
1123    /// `OpenAPI` spec runtime exposure settings.
1124    ///
1125    /// Controls whether the generated `OpenAPI` spec is served at runtime
1126    /// and at which path. Use `[openapi] enabled = false` in `autumn.toml`
1127    /// to suppress the spec endpoint in production.
1128    #[serde(default, rename = "openapi")]
1129    pub openapi_runtime: OpenApiRuntimeConfig,
1130
1131    /// Encrypted credentials store loaded from `config/credentials/<env>.toml.enc`.
1132    ///
1133    /// Empty when no credentials file exists (existing apps continue to boot unchanged).
1134    /// Prefer using `config.credentials().get::<String>("stripe_key")` for type-safe access.
1135    #[serde(skip)]
1136    pub credentials: crate::credentials::CredentialsStore,
1137
1138    /// Outbound HTTP settings (`[http]` section in `autumn.toml`).
1139    ///
1140    /// The nested `[http.client]` sub-table configures the outbound client.
1141    #[cfg(feature = "http-client")]
1142    #[serde(default, rename = "http")]
1143    pub http: HttpConfig,
1144
1145    /// Developer-experience settings (`[dev]` section in `autumn.toml`).
1146    ///
1147    /// Controls the request inspector and other dev-only features.
1148    /// These settings have no effect outside the `dev` profile.
1149    #[serde(default)]
1150    pub dev: DevConfig,
1151
1152    /// Widget story gallery settings (`[stories]` section in `autumn.toml`).
1153    ///
1154    /// Off by default; opt-in per profile (e.g. `[profile.dev.stories]
1155    /// enabled = true` for a dev-only gallery, or a prod profile for a
1156    /// public showcase). See `docs/guide/stories.md`.
1157    #[cfg(feature = "maud")]
1158    #[serde(default)]
1159    pub stories: crate::stories::StoriesConfig,
1160
1161    /// Error-reporting settings (`[reporting]` section in `autumn.toml`).
1162    ///
1163    /// Controls delivery of panic + 5xx [`ErrorEvent`](crate::reporting::ErrorEvent)s
1164    /// to registered reporters. Honored only when the `reporting` cargo
1165    /// feature is enabled.
1166    #[cfg(feature = "reporting")]
1167    #[serde(default)]
1168    pub reporting: ReportingConfig,
1169
1170    /// Response compression settings (`[compression]` section in `autumn.toml`).
1171    ///
1172    /// Compression is **off by default**. Enable with:
1173    /// ```toml
1174    /// [compression]
1175    /// enabled = true
1176    /// ```
1177    /// or via `AUTUMN_COMPRESSION__ENABLED=true`.
1178    #[serde(default)]
1179    pub compression: CompressionConfig,
1180
1181    /// Bot protection / CAPTCHA settings (`[bot_protection]` section in `autumn.toml`).
1182    ///
1183    /// Requires a CAPTCHA token on mutating requests (POST/PUT/PATCH/DELETE) to
1184    /// protect public-facing forms against automated abuse.
1185    ///
1186    /// # Example
1187    ///
1188    /// ```toml
1189    /// [bot_protection]
1190    /// enabled    = true
1191    /// provider   = "turnstile"      # "turnstile" (default) or "hcaptcha"
1192    /// site_key   = "0x4AAAA..."     # public key — safe to commit
1193    /// secret_key = "..."            # private key — use env var!
1194    /// dev_bypass = false
1195    /// ```
1196    #[serde(default)]
1197    pub bot_protection: crate::security::captcha::BotProtectionConfig,
1198
1199    /// Resilience settings (circuit breakers, fallbacks).
1200    #[serde(default)]
1201    pub resilience: ResilienceConfig,
1202
1203    /// SEO settings (`[seo]` section in `autumn.toml`).
1204    ///
1205    /// Controls sitemap generation, robots.txt behavior, and canonical URL
1206    /// computation. See [`crate::seo`] for the full surface.
1207    ///
1208    /// # Example `autumn.toml`
1209    ///
1210    /// ```toml
1211    /// [seo]
1212    /// base_url = "https://example.com"
1213    ///
1214    /// [seo.robots]
1215    /// additional_rules = ["Disallow: /admin"]
1216    /// ```
1217    #[serde(default)]
1218    pub seo: SeoConfig,
1219
1220    /// Observability settings (`[observability]` section in `autumn.toml`).
1221    ///
1222    /// Controls opt-in framework-emitted telemetry that supplements the
1223    /// access log — currently the `Server-Timing` response header. See
1224    /// [`ObservabilityConfig`] and `docs/guide/observability/server-timing.md`.
1225    #[serde(default)]
1226    pub observability: ObservabilityConfig,
1227
1228    /// Operator alerts settings (`[alerts]` section in `autumn.toml`).
1229    ///
1230    /// Configure an operator email and/or a webhook URL to receive alerts for
1231    /// built-in failure conditions (dead-lettered jobs, Down health indicators,
1232    /// 5xx-rate spikes, scheduled-task failures) with zero application code.
1233    /// See [`crate::alerts::AlertConfig`] and `docs/guide/operator-alerts.md`.
1234    ///
1235    /// Boxed so the large `[alerts]` struct is stored behind a pointer rather
1236    /// than inline in `AutumnConfig`: `AutumnConfig` is held by value on the
1237    /// `app().run()` stack frame across await points, and inlining
1238    /// `AlertConfig` (many `Option<String>` destinations + tuning knobs) grew
1239    /// that future past the `clippy::large_futures` threshold. `Box<T>` keeps
1240    /// `Default`/`Deserialize` (both hold when `T` does), and field
1241    /// reads/writes still work through `Deref`/`DerefMut`.
1242    #[serde(default)]
1243    pub alerts: Box<crate::alerts::AlertConfig>,
1244}
1245
1246/// Opt-in TLS termination at the deploy-managed reverse proxy (`[deploy.tls]`
1247/// table, issue #1969).
1248///
1249/// Absent/disabled by default, so a deploy without this table is byte-for-byte
1250/// the historical HTTP-only behavior. When `enabled = true`, `autumn deploy`
1251/// wires the public `host` into kamal-proxy (`--host`/`--tls`) so the proxy
1252/// terminates TLS on 443 with an automatic Let's Encrypt certificate.
1253///
1254/// TLS terminates at the PROXY only — the app itself keeps serving plain HTTP on
1255/// its private loopback port, and its readiness/health probes are unaffected. Do
1256/// NOT also enable in-process `[server.tls]`/ACME on a deploy-managed app.
1257///
1258/// # `autumn.toml` example
1259///
1260/// ```toml
1261/// [deploy.tls]
1262/// enabled = true
1263/// host = "app.example.com"
1264/// ```
1265#[derive(Debug, Clone, Default, Deserialize)]
1266pub struct DeployTlsConfig {
1267    /// Whether the deploy-managed proxy terminates TLS on 443. Default: `false`
1268    /// (HTTP-only, unchanged behavior).
1269    #[serde(default)]
1270    pub enabled: bool,
1271
1272    /// Public hostname the certificate is issued for (the DNS name pointing at
1273    /// the server). Required when `enabled = true`; enforced at resolve time by
1274    /// the CLI's `ResolvedDeployConfig::resolve`.
1275    #[serde(default)]
1276    pub host: Option<String>,
1277}
1278
1279/// Push-button VPS deploy settings (`[deploy]` section, issue #1607).
1280///
1281/// Describes the SSH-reachable target server and the remote install layout for
1282/// `autumn deploy`'s zero-downtime rollout. Everything except `host` has a
1283/// sensible default, and `app_name`/`app_dir`/`service_name` are resolved from
1284/// the project's package name at deploy time (not during deserialization) so an
1285/// unset value stays `None` here.
1286///
1287/// # `autumn.toml` example
1288///
1289/// ```toml
1290/// [deploy]
1291/// host = "203.0.113.10"      # required at deploy time; SSH-reachable address
1292/// user = "deploy"            # SSH user (default: "root")
1293/// ssh_port = 22              # SSH port (default: 22)
1294/// app_name = "myapp"         # default: the crate's package name
1295/// app_dir = "/srv/myapp"     # default: /srv/autumn/{app_name}
1296/// service_name = "myapp"     # systemd unit name; default: {app_name}
1297/// readiness_timeout_secs = 60 # readiness window before rollback (default: 60)
1298/// keep_releases = 3          # releases retained on the host (default: 3)
1299/// profile = "prod"           # profile the deployed app runs under (default: "prod")
1300/// ```
1301#[derive(Debug, Clone, Deserialize)]
1302pub struct DeployConfig {
1303    /// SSH-reachable address (hostname or IP) of the target server.
1304    ///
1305    /// Required when a deploy actually runs (`autumn deploy`), but `None` is
1306    /// valid at rest so a bare `[deploy]` table parses. Enforced by
1307    /// [`validate`](Self::validate).
1308    #[serde(default)]
1309    pub host: Option<String>,
1310
1311    /// SSH user to connect as. Default: `"root"`.
1312    #[serde(default = "default_deploy_user")]
1313    pub user: String,
1314
1315    /// SSH port on the target host. Default: `22`.
1316    #[serde(default = "default_deploy_ssh_port")]
1317    pub ssh_port: u16,
1318
1319    /// Application name used to derive remote paths and the service unit.
1320    /// Resolved to the project's package name when unset (at deploy time, not
1321    /// during deserialization).
1322    #[serde(default)]
1323    pub app_name: Option<String>,
1324
1325    /// Remote install directory. Resolved to `/srv/autumn/{app_name}` when
1326    /// unset (at deploy time).
1327    #[serde(default)]
1328    pub app_dir: Option<String>,
1329
1330    /// systemd unit name. Resolved to `{app_name}` when unset (at deploy time).
1331    #[serde(default)]
1332    pub service_name: Option<String>,
1333
1334    /// Bounded readiness window, in seconds, the new release has to report
1335    /// `/ready` before the deploy rolls back. Default: `60`.
1336    #[serde(default = "default_deploy_readiness_timeout_secs")]
1337    pub readiness_timeout_secs: u64,
1338
1339    /// Number of prior releases retained on the host for rollback. Default: `3`.
1340    #[serde(default = "default_deploy_keep_releases")]
1341    pub keep_releases: u32,
1342
1343    /// The profile the deployed app runs under (written into the host env file
1344    /// as `AUTUMN_ENV`). Defaults to the production profile (`"prod"`) so a
1345    /// deploy never silently runs the `dev` profile; set to e.g. `"staging"`
1346    /// for non-prod targets.
1347    #[serde(default = "default_deploy_profile")]
1348    pub profile: String,
1349
1350    /// Opt-in TLS termination at the deploy-managed reverse proxy
1351    /// (`[deploy.tls]`). Disabled by default — an absent table is byte-for-byte
1352    /// the historical HTTP-only behavior. See [`DeployTlsConfig`].
1353    #[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    /// Validate the `[deploy]` section for a context that actually runs a deploy.
1376    ///
1377    /// A bare `[deploy]` table is valid at rest, but a deploy needs a target:
1378    /// this rejects a missing or blank `host` with an actionable message so the
1379    /// operator knows exactly which key to set.
1380    ///
1381    /// # Errors
1382    ///
1383    /// Returns a message when `host` is unset or empty.
1384    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/// Observability configuration (`[observability]` section in `autumn.toml`).
1397///
1398/// Controls opt-in telemetry that supplements the default access log.
1399///
1400/// # Server-Timing header
1401///
1402/// When `server_timing = true`, Autumn emits a W3C-conformant
1403/// [`Server-Timing`](https://www.w3.org/TR/server-timing/) header on every
1404/// non-streaming response with at minimum a `total` metric (whole-request
1405/// wall time, matching the access-log `duration_ms`) and a `db` metric
1406/// summarising cumulative query time plus a query count (`db;dur=…;desc="N queries"`)
1407/// when at least one query ran during the request.
1408///
1409/// The default is **off in production** and **on in the `dev` profile**;
1410/// leave the field unset for that behavior, or pin it to `true` / `false`
1411/// explicitly. Requires opt-in in prod because timings can leak
1412/// infrastructure detail to anonymous clients.
1413///
1414/// # Example
1415///
1416/// ```toml
1417/// # Force on in a staging profile where dev-team browsers inspect timings.
1418/// [observability]
1419/// server_timing = true
1420/// ```
1421///
1422/// ```toml
1423/// # Force off during a dev-profile perf comparison against production.
1424/// [observability]
1425/// server_timing = false
1426/// ```
1427#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
1428pub struct ObservabilityConfig {
1429    /// Emit the `Server-Timing` response header on served requests.
1430    ///
1431    /// `None` (unset) means the effective value follows the profile default:
1432    /// on in `dev`/`development`, off everywhere else. `Some(true)` or
1433    /// `Some(false)` pin the choice explicitly.
1434    #[serde(default)]
1435    pub server_timing: Option<bool>,
1436}
1437
1438/// Resolve the effective value of `[observability] server_timing` for a
1439/// given [`AutumnConfig`].
1440///
1441/// The rules are:
1442/// - Explicit `Some(true)` / `Some(false)` in config or env → returned as-is.
1443/// - `None` (unset) → `true` iff the active profile is `"dev"` or
1444///   `"development"`, otherwise `false`. This keeps production off by
1445///   default so timings never leak to anonymous clients without opt-in.
1446pub(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/// SEO configuration (`[seo]` section in `autumn.toml`).
1454///
1455/// # Example
1456///
1457/// ```toml
1458/// [seo]
1459/// base_url = "https://example.com"
1460///
1461/// [seo.robots]
1462/// additional_rules = ["Disallow: /admin"]
1463/// ```
1464#[derive(Debug, Clone, Default, Deserialize)]
1465pub struct SeoConfig {
1466    /// Base URL used for canonical URL computation and sitemap auto-injection.
1467    ///
1468    /// E.g. `"https://example.com"`. When set, the `Sitemap:` directive is
1469    /// automatically injected into `robots.txt`.
1470    pub base_url: Option<String>,
1471
1472    /// Robots.txt overrides.
1473    #[serde(default)]
1474    pub robots: RobotsConfig,
1475}
1476
1477/// Per-profile `robots.txt` overrides (`[seo.robots]` in `autumn.toml`).
1478///
1479/// The framework default behavior (dev/test → disallow all; prod → allow all)
1480/// can be overridden here.
1481#[derive(Debug, Clone, Default, Deserialize)]
1482pub struct RobotsConfig {
1483    /// Override the profile-driven allow/disallow default.
1484    ///
1485    /// `None` means: use the profile default (dev → disallow, prod → allow).
1486    /// `Some(true)` forces `Allow: /`; `Some(false)` forces `Disallow: /`.
1487    pub allow_all: Option<bool>,
1488
1489    /// Additional directives appended after the main `User-agent` block.
1490    ///
1491    /// Example: `["Disallow: /admin", "Crawl-delay: 5"]`
1492    #[serde(default)]
1493    pub additional_rules: Vec<String>,
1494
1495    /// Explicit `Sitemap:` URL.
1496    ///
1497    /// When `None`, the URL is auto-computed from `[seo] base_url` if set.
1498    pub sitemap_url: Option<String>,
1499}
1500
1501/// Error-reporting settings (`[reporting]` section in `autumn.toml`).
1502///
1503/// # Example `autumn.toml`
1504///
1505/// ```toml
1506/// [reporting]
1507/// enabled = true      # deliver events to reporters (default: true)
1508/// sample_rate = 0.25  # report ~25% of events (default: 1.0 = all)
1509/// ```
1510///
1511/// Note: `enabled = false` only suppresses *delivery* to reporters. Handler
1512/// panics are still caught and converted to a clean 500 response regardless of
1513/// this setting.
1514#[cfg(feature = "reporting")]
1515#[derive(Debug, Clone, Deserialize)]
1516pub struct ReportingConfig {
1517    /// Whether error events are delivered to registered reporters.
1518    ///
1519    /// Defaults to `true`. When `false`, panics are still caught and turned
1520    /// into clean 500 responses, but no [`ErrorEvent`](crate::reporting::ErrorEvent)
1521    /// is dispatched.
1522    #[serde(default = "default_reporting_enabled")]
1523    pub enabled: bool,
1524    /// Fraction of events to deliver, in `[0.0, 1.0]`.
1525    ///
1526    /// `1.0` (the default) reports every event; `0.0` reports none. Values
1527    /// outside the range are clamped at the extremes.
1528    #[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/// Developer-experience settings (`[dev]` section in `autumn.toml`).
1553///
1554/// All fields are ignored outside the `dev` profile.
1555///
1556/// # Example `autumn.toml`
1557///
1558/// ```toml
1559/// [dev]
1560/// inspector_path = "/_autumn/inspect"
1561/// inspector_capacity = 200
1562/// inspector_n_plus_one_threshold = 3
1563/// ```
1564#[derive(Debug, Clone, Deserialize)]
1565pub struct DevConfig {
1566    /// Mount path for the request inspector UI.
1567    ///
1568    /// Default: `"/_autumn/inspect"`. Only active in the `dev` profile;
1569    /// ignored everywhere else.
1570    #[serde(default = "default_inspector_path")]
1571    pub inspector_path: String,
1572
1573    /// Maximum number of requests retained in the in-memory ring buffer.
1574    ///
1575    /// Default: `100`. Set to `0` to disable recording without removing
1576    /// the middleware.
1577    #[serde(default = "default_inspector_capacity")]
1578    pub inspector_capacity: usize,
1579
1580    /// Minimum number of structurally identical SQL statements in a single
1581    /// request before an N+1 warning is emitted.
1582    ///
1583    /// Default: `5`. Set to `0` to disable N+1 detection.
1584    #[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/// Top-level `[http]` configuration section.
1611#[cfg(feature = "http-client")]
1612#[derive(Debug, Clone, Default, Deserialize)]
1613pub struct HttpConfig {
1614    /// Outbound HTTP client settings (`[http.client]`).
1615    #[serde(default)]
1616    pub client: HttpClientConfig,
1617}
1618
1619/// Configuration for the outbound HTTP client (`[http.client]` in `autumn.toml`).
1620///
1621/// # Example `autumn.toml`
1622///
1623/// ```toml
1624/// [http.client]
1625/// timeout_secs = 30
1626/// max_retries  = 3
1627///
1628/// [http.client.base_urls]
1629/// stripe   = "https://api.stripe.com"
1630/// sendgrid = "https://api.sendgrid.com"
1631/// ```
1632#[cfg(feature = "http-client")]
1633#[derive(Debug, Clone, Deserialize)]
1634pub struct HttpClientConfig {
1635    /// Per-request timeout in seconds. Default: 30.
1636    #[serde(default = "default_http_timeout_secs")]
1637    pub timeout_secs: u64,
1638
1639    /// Maximum retry attempts for transient failures on idempotent methods.
1640    /// Default: 3 (four total attempts).
1641    #[serde(default = "default_http_max_retries")]
1642    pub max_retries: u32,
1643
1644    /// Maximum Retry-After sleep duration in seconds to accept before clamping.
1645    /// Default: 10.
1646    #[serde(default = "default_http_max_retry_after_secs")]
1647    pub max_retry_after_secs: u64,
1648
1649    /// Named base URL aliases, e.g. `stripe = "https://api.stripe.com"`.
1650    ///
1651    /// A [`Client`](crate::http_client::Client) configured with `.named("stripe")` will
1652    /// prepend this URL to relative request paths and match against mocks
1653    /// registered for that alias via
1654    /// [`TestApp::http_mock`](crate::test::TestApp::http_mock).
1655    #[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/// Real-time channel backend selection.
1702#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1703#[serde(rename_all = "snake_case")]
1704pub enum ChannelBackend {
1705    /// In-process Tokio broadcast channels. Default, zero config.
1706    #[serde(alias = "local", alias = "memory")]
1707    #[default]
1708    InProcess,
1709    /// Redis pub/sub fan-out across application replicas.
1710    Redis,
1711}
1712
1713impl ChannelBackend {
1714    /// Parse an environment variable value for channel backend selection.
1715    #[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/// Real-time channel runtime configuration.
1726#[derive(Debug, Clone, Deserialize)]
1727pub struct ChannelConfig {
1728    /// Runtime backend selection.
1729    #[serde(default)]
1730    pub backend: ChannelBackend,
1731    /// Per-topic broadcast ring buffer capacity.
1732    #[serde(default = "default_channel_capacity")]
1733    pub capacity: usize,
1734    /// Per-topic replay ring buffer capacity (`N`).
1735    ///
1736    /// Number of most-recent events retained per topic for `Last-Event-ID`
1737    /// replay via [`crate::sse::stream_resumable`]. Memory is `O(N)` per topic
1738    /// regardless of throughput.
1739    #[serde(default = "default_channel_replay_buffer")]
1740    pub replay_buffer: usize,
1741    /// Redis backend options.
1742    #[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/// Redis channel backend configuration.
1758#[derive(Debug, Clone, Deserialize)]
1759pub struct ChannelRedisConfig {
1760    /// Redis URL used when `channels.backend = "redis"`.
1761    #[serde(default)]
1762    pub url: Option<String>,
1763    /// Redis pub/sub channel prefix.
1764    #[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// ── Cache configuration ──────────────────────────────────────────────────────
1790
1791/// Cache backend selection for `#[cached]` and `CacheResponseLayer`.
1792#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
1793#[serde(rename_all = "lowercase")]
1794#[non_exhaustive]
1795pub enum CacheBackend {
1796    /// In-process Moka cache (default). Each replica has an independent store.
1797    #[default]
1798    Memory,
1799    /// Shared Redis cache. Invalidations propagate across all replicas.
1800    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/// Configuration for the shared application cache.
1814///
1815/// Placed in `autumn.toml` under `[cache]`.
1816///
1817/// # Examples
1818///
1819/// ```toml
1820/// [cache]
1821/// backend = "redis"
1822///
1823/// [cache.redis]
1824/// url = "redis://redis:6379"
1825/// key_prefix = "myapp:cache"
1826/// ```
1827#[derive(Debug, Clone, Default, serde::Deserialize)]
1828pub struct CacheConfig {
1829    /// Active cache backend.
1830    #[serde(default)]
1831    pub backend: CacheBackend,
1832
1833    /// Redis backend options.
1834    #[serde(default)]
1835    pub redis: CacheRedisConfig,
1836}
1837
1838impl CacheConfig {
1839    /// Returns `true` when the memory (Moka) backend is selected.
1840    #[must_use]
1841    pub fn is_memory(&self) -> bool {
1842        self.backend == CacheBackend::Memory
1843    }
1844
1845    /// Returns `true` when the Redis backend is selected.
1846    #[must_use]
1847    pub fn is_redis(&self) -> bool {
1848        self.backend == CacheBackend::Redis
1849    }
1850}
1851
1852/// Redis cache backend configuration.
1853#[derive(Debug, Clone, serde::Deserialize)]
1854pub struct CacheRedisConfig {
1855    /// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
1856    #[serde(default)]
1857    pub url: Option<String>,
1858
1859    /// Prefix for all cache keys stored in Redis.
1860    #[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/// Scheduled task coordination backend selection.
1878#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1879#[serde(rename_all = "snake_case")]
1880pub enum SchedulerBackend {
1881    /// Per-process scheduler timers. This preserves existing single-replica behavior.
1882    #[serde(alias = "local", alias = "memory")]
1883    #[default]
1884    InProcess,
1885    /// Fleet coordination with Postgres advisory locks.
1886    Postgres,
1887}
1888
1889impl SchedulerBackend {
1890    /// Parse an environment variable value for scheduler backend selection.
1891    #[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/// Process role: which slice of the framework runtime this replica runs.
1902///
1903/// The same binary can be deployed under different roles so a fleet can scale
1904/// its HTTP tier independently of its background-work tier. The role is chosen
1905/// by config (`role = "..."`) or the `AUTUMN_ROLE` env var only — application
1906/// code never changes. The default, [`Combined`](ProcessRole::Combined),
1907/// preserves today's single-process behavior exactly.
1908///
1909/// - [`Combined`](ProcessRole::Combined): serves HTTP **and** runs job workers
1910///   + the cron scheduler (default).
1911/// - [`Web`](ProcessRole::Web): serves HTTP and can still **enqueue** jobs, but
1912///   runs no `#[job]` worker loops and no `#[scheduled]`/cron scheduler.
1913/// - [`Worker`](ProcessRole::Worker): runs job workers + the cron scheduler and
1914///   does **not** serve user routes, but still binds the HTTP listener to serve
1915///   only the liveness/readiness probes and the actuator (so orchestrators can
1916///   supervise it and `/actuator/jobs` works).
1917#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1918#[serde(rename_all = "snake_case")]
1919pub enum ProcessRole {
1920    /// Serve HTTP and run background workers + scheduler (default; unchanged
1921    /// single-process behavior).
1922    #[serde(
1923        alias = "all",
1924        alias = "combined",
1925        alias = "web_and_worker",
1926        alias = "server_and_worker"
1927    )]
1928    #[default]
1929    Combined,
1930    /// Serve HTTP (and enqueue jobs) only — no worker loops, no scheduler.
1931    #[serde(alias = "server", alias = "http")]
1932    Web,
1933    /// Run workers + scheduler only — probe/actuator HTTP only, no user routes.
1934    #[serde(alias = "jobs", alias = "worker_only")]
1935    Worker,
1936}
1937
1938impl ProcessRole {
1939    /// Parse an environment variable / flag value for process-role selection.
1940    ///
1941    /// Accepts (case-insensitive, trimmed): `combined`/`all`, `web`/`server`/
1942    /// `http`, `worker`/`jobs`. Returns `None` for anything else so callers can
1943    /// warn and keep the default.
1944    #[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    /// Stable lowercase identifier for the role (round-trips `from_env_value`).
1955    #[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    /// Whether this role serves user HTTP routes ([`Combined`](Self::Combined)
1965    /// or [`Web`](Self::Web)).
1966    #[must_use]
1967    pub const fn serves_http(self) -> bool {
1968        matches!(self, Self::Combined | Self::Web)
1969    }
1970
1971    /// Whether this role runs background job workers and the cron scheduler
1972    /// ([`Combined`](Self::Combined) or [`Worker`](Self::Worker)).
1973    #[must_use]
1974    pub const fn runs_workers(self) -> bool {
1975        matches!(self, Self::Combined | Self::Worker)
1976    }
1977}
1978
1979/// Whether a `role` / `jobs.backend` combination is invalid because a split
1980/// (web/worker) role sits on a non-durable jobs backend.
1981///
1982/// A split role runs the HTTP tier and the job/scheduler tier in **separate
1983/// processes**, so it needs a jobs backend the two processes can share. Only the
1984/// recognized durable backends [`start_runtime`](crate::job::start_runtime)
1985/// dispatches to durably — exactly `"postgres"` or `"redis"` — qualify. Every
1986/// other value (the in-process `"local"` queue, a typo like `"postgresql"`, or a
1987/// blank backend) falls through to the per-process local runtime, where a
1988/// [`Web`](ProcessRole::Web) replica would enqueue into a queue no separate
1989/// worker can drain and a [`Worker`](ProcessRole::Worker) replica's queue starts
1990/// empty. The match is intentionally exact (no trim/case-fold) so this guard and
1991/// `start_runtime`'s dispatch agree precisely on which backends are durable.
1992///
1993/// The combined role is always valid because it enqueues and drains in one
1994/// process. Returns `true` when the combination is **invalid**.
1995#[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/// Scheduled task coordination runtime configuration.
2001#[derive(Debug, Clone, Deserialize)]
2002pub struct SchedulerConfig {
2003    /// Runtime backend selection.
2004    #[serde(default)]
2005    pub backend: SchedulerBackend,
2006    /// Lease duration used by distributed backends for run visibility and timeout guidance.
2007    #[serde(default = "default_scheduler_lease_ttl_secs")]
2008    pub lease_ttl_secs: u64,
2009    /// Stable replica identifier surfaced in actuator metadata.
2010    #[serde(default)]
2011    pub replica_id: Option<String>,
2012    /// Prefix included when deriving Postgres advisory lock keys.
2013    #[serde(default = "default_scheduler_key_prefix")]
2014    pub key_prefix: String,
2015}
2016
2017impl SchedulerConfig {
2018    /// Resolve a stable-ish replica identifier for actuator metadata and lock ownership.
2019    #[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    /// Validate scheduler-specific config shape.
2031    ///
2032    /// # Errors
2033    ///
2034    /// Returns [`ConfigError::Validation`] when values are syntactically valid TOML
2035    /// but cannot be used by the runtime.
2036    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/// Storage backend selection for HTTP idempotency keys.
2071#[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    /// Parse an environment variable value for idempotency backend selection.
2082    #[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/// Redis connection settings for the idempotency backend.
2093#[derive(Debug, Clone, Deserialize)]
2094pub struct IdempotencyRedisConfig {
2095    /// Redis connection URL (e.g. `redis://localhost:6379`).
2096    pub url: Option<String>,
2097    /// Key prefix for all idempotency entries and locks stored in Redis.
2098    #[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/// HTTP idempotency-key middleware settings.
2116#[derive(Debug, Clone, Deserialize)]
2117pub struct IdempotencyConfig {
2118    /// Enable the idempotency-key middleware.
2119    ///
2120    /// When `true`, mutating requests that carry an `Idempotency-Key` header
2121    /// are deduplicated using the configured backend.
2122    ///
2123    /// `None` means the field was absent from the config file; the
2124    /// `AppBuilder::idempotent()` builder flag may still enable it.
2125    /// `Some(false)` is an explicit operator opt-out that overrides the builder.
2126    #[serde(default)]
2127    pub enabled: Option<bool>,
2128    /// Storage backend for idempotency records.
2129    #[serde(default)]
2130    pub backend: IdempotencyBackend,
2131    /// Time-to-live in seconds for stored idempotency records.
2132    #[serde(default = "default_idempotency_ttl_secs")]
2133    pub ttl_secs: u64,
2134    /// Maximum stale lifetime for distributed in-flight locks.
2135    ///
2136    /// The lock is released as soon as the handler finishes. This value is only
2137    /// the backend safety expiry for crashes or lost unlocks, so it should be
2138    /// comfortably longer than any supported mutating request duration.
2139    #[serde(default = "default_idempotency_in_flight_ttl_secs")]
2140    pub in_flight_ttl_secs: u64,
2141    /// Allow the in-memory backend in production environments.
2142    #[serde(default)]
2143    pub allow_memory_in_production: bool,
2144    /// Redis connection settings (used when `backend = "redis"`).
2145    #[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/// `OpenAPI` spec runtime exposure settings.
2171///
2172/// Populated from the `[openapi]` block in `autumn.toml`. When
2173/// `AppBuilder::openapi(...)` is called and `enabled = true`, the framework
2174/// mounts the spec at `path`. Set `enabled = false` in a production profile
2175/// to prevent exposing the spec publicly.
2176///
2177/// # `autumn.toml` example
2178///
2179/// ```toml
2180/// [openapi]
2181/// enabled = false   # disable in prod
2182/// path = "/openapi.json"
2183/// ```
2184#[derive(Debug, Clone, Deserialize)]
2185pub struct OpenApiRuntimeConfig {
2186    /// Whether the `OpenAPI` spec endpoint is served.
2187    ///
2188    /// Defaults to `true` so new projects get the spec immediately.
2189    /// Set to `false` in production profiles to suppress the endpoint.
2190    #[serde(default = "default_openapi_enabled")]
2191    pub enabled: bool,
2192    /// URL path at which `openapi.json` is served.
2193    ///
2194    /// Defaults to `/openapi.json`.
2195    #[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/// Background job runtime configuration.
2217#[derive(Debug, Clone, Deserialize)]
2218pub struct JobConfig {
2219    /// Runtime backend selection.
2220    ///
2221    /// - `local` (default): in-process Tokio queue
2222    /// - `postgres`: Postgres-backed durable queue (requires `db` feature)
2223    /// - `redis`: Redis-backed durable queue (requires `redis` feature)
2224    #[serde(default = "default_job_backend")]
2225    pub backend: String,
2226    /// Number of concurrent worker loops to spawn.
2227    #[serde(default = "default_job_workers")]
2228    pub workers: usize,
2229    /// Default max attempts when `#[job(max_attempts = ...)]` is not set.
2230    #[serde(default = "default_job_max_attempts")]
2231    pub max_attempts: u32,
2232    /// Default initial retry backoff in milliseconds.
2233    #[serde(default = "default_job_backoff_ms")]
2234    pub initial_backoff_ms: u64,
2235    /// Ordered/weighted list of queues workers drain, highest priority first.
2236    ///
2237    /// Unset = a single `default` queue (today's behavior). A TOML array such as
2238    /// `queues = ["critical", "default", "low"]` is **strict priority**; a table
2239    /// such as `[jobs.queues] critical = 4` / `default = 1` is **weighted**
2240    /// (probabilistic fair draining that never starves lower queues).
2241    #[serde(default)]
2242    pub queues: JobQueuesConfig,
2243    /// Queues this process is pinned to. Empty (default) = claim every
2244    /// configured/declared queue (today's behavior). When non-empty, this
2245    /// worker process only ever claims jobs from queues in this set — on every
2246    /// backend — so a worker tier can be dedicated to a subset of queues
2247    /// (issue #1623, AC3). Names outside the configured/declared topology are
2248    /// ignored. Set from `AUTUMN_JOBS__PIN` (comma-separated) too.
2249    #[serde(default)]
2250    pub pin: Vec<String>,
2251    /// Redis backend options.
2252    #[serde(default)]
2253    pub redis: JobRedisConfig,
2254    /// Postgres backend options.
2255    #[serde(default)]
2256    pub postgres: JobPostgresConfig,
2257    /// Tracked-job progress/result store options (`enqueue_tracked`, the
2258    /// built-in `GET /_autumn/jobs/{token}` status route).
2259    #[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/// A single named queue and its draining weight, plus optional per-queue
2280/// worker-pool controls.
2281#[derive(Debug, Clone, PartialEq, Eq)]
2282pub struct JobQueue {
2283    /// Queue name, as declared by `#[job(queue = "...")]`.
2284    pub name: String,
2285    /// Relative draining weight (used only for weighted draining; `1` for the
2286    /// strict-priority list form).
2287    pub weight: u32,
2288    /// Optional hard cap on how many of the process's worker slots this queue
2289    /// may occupy at once. `None` = uncapped (may use the whole shared pool).
2290    /// Lets a bulk queue never exceed its configured share (issue #1623, AC2).
2291    pub concurrency: Option<usize>,
2292    /// Optional number of worker slots dedicated to this queue that no other
2293    /// queue may consume. `None`/`0` = no reservation. Guarantees a queue keeps
2294    /// making progress even while another queue floods (issue #1623, AC1).
2295    pub reserved: Option<usize>,
2296}
2297
2298impl JobQueue {
2299    /// A weight-only queue (no per-queue caps or reservations).
2300    #[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/// Worker queue drain configuration parsed from `[jobs] queues`.
2312///
2313/// Accepts **either** a TOML array (strict priority, in order) **or** a TOML
2314/// table of `name = weight` (weighted, fair). Empty or unset falls back to a
2315/// single `default` queue so an app that doesn't opt in behaves exactly as today.
2316#[derive(Debug, Clone, PartialEq, Eq)]
2317pub struct JobQueuesConfig {
2318    /// Configured queues, highest priority first.
2319    pub queues: Vec<JobQueue>,
2320    /// `true` for the ordered-list form (strict priority); `false` for the
2321    /// weighted-table form (deficit weighted round-robin).
2322    pub strict: bool,
2323}
2324
2325impl Default for JobQueuesConfig {
2326    fn default() -> Self {
2327        Self::single_default()
2328    }
2329}
2330
2331impl JobQueuesConfig {
2332    /// The zero-config default: one strict `default` queue.
2333    #[must_use]
2334    pub fn single_default() -> Self {
2335        Self {
2336            queues: vec![JobQueue::new("default", 1)],
2337            strict: true,
2338        }
2339    }
2340
2341    /// Build a strict-priority schedule from an ordered list of queue names.
2342    #[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    /// Build a weighted schedule from `(name, weight)` pairs. Weights are
2363    /// clamped to a minimum of `1` so every configured queue makes progress.
2364    #[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    /// Build a weighted schedule from fully-specified [`JobQueue`] entries
2385    /// (weight plus optional per-queue `concurrency` cap and `reserved` slots).
2386    /// Weights are clamped to a minimum of `1`. Empty input falls back to the
2387    /// zero-config single `default` queue.
2388    #[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/// One value in the `[jobs.queues]` weight table: either a bare integer weight
2408/// (`critical = 4`) or a table with per-queue pool controls
2409/// (`critical = { weight = 4, concurrency = 8, reserved = 2 }`).
2410#[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/// Redis backend configuration options for the job runner.
2543#[derive(Debug, Clone, Deserialize)]
2544pub struct JobRedisConfig {
2545    /// Redis URL used when `jobs.backend = "redis"`.
2546    #[serde(default)]
2547    pub url: Option<String>,
2548    /// Key prefix for all queue keys.
2549    #[serde(default = "default_jobs_redis_prefix")]
2550    pub key_prefix: String,
2551    /// Duration before an in-flight job claim is considered stale.
2552    #[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/// Postgres backend configuration options for the job runner.
2567#[derive(Debug, Clone, Deserialize)]
2568pub struct JobPostgresConfig {
2569    /// Duration before an in-flight job claim is considered stale and recovered.
2570    ///
2571    /// Workers that crash mid-job have their claim reclaimed by another worker
2572    /// within this bound. Default: 30 seconds.
2573    #[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/// Tracked-job progress/result store configuration.
2586#[derive(Debug, Clone, Deserialize)]
2587pub struct JobTrackingConfig {
2588    /// How long a tracked job's progress/result record is retained after its
2589    /// last write, in seconds. Default: 24 hours.
2590    #[serde(default = "default_jobs_tracking_ttl_secs")]
2591    pub ttl_secs: u64,
2592    /// Whether the built-in `GET /_autumn/jobs/{token}` status route is
2593    /// mounted. Default: `true`.
2594    #[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
2643/// Parent config paths whose child keys were already covered by strict
2644/// validation BEFORE the #1890 schema-walk fix. Captured verbatim from
2645/// `get_schema_keys()` on the pre-fix code (the walk aborted at
2646/// `database.statement_timeout`, so only these parents were reachable). Used by
2647/// the warn-first rollout: an unknown key whose PARENT is in this set hard-fails
2648/// under `strict_config` exactly as before; every key the #1890 fix newly
2649/// reveals is warned about instead (until `strict_config_enforce_all` promotes
2650/// it). Transitional — removed when enforcement becomes the default.
2651const PRE_1890_STRICT_PARENTS: &[&str] = &[
2652    "",
2653    "database",
2654    "deploy",
2655    "server",
2656    "server.timeouts",
2657    "server.tls",
2658    "server.tls.acme",
2659];
2660
2661/// Whether an unknown-key error whose (profile-stripped, segment-derived) schema
2662/// parent is `schema_parent` was already hard-failing before #1890. Malformed
2663/// top-level profile entries surface with parent `"profile"` (always fatal
2664/// structural errors); everything else keys off the pre-#1890 parent set.
2665fn unknown_key_was_previously_strict(schema_parent: &str) -> bool {
2666    schema_parent == "profile" || PRE_1890_STRICT_PARENTS.contains(&schema_parent)
2667}
2668
2669/// Policy for how the strict unknown-key check treats a genuinely-unknown
2670/// TOP-LEVEL config root — an unknown key whose schema parent is the document
2671/// root `""` (e.g. a plugin-owned `[media]` section that no core-schema key
2672/// covers).
2673///
2674/// App boot uses [`Strict`](UnknownRootPolicy::Strict): an unknown top-level
2675/// root is a hard error, exactly as before. Tooling that structurally cannot
2676/// know the application's plugin set — the deploy CLI, which has no
2677/// `AppBuilder`, no plugin list, and no plugin-crate dependency — uses
2678/// [`LenientWarn`](UnknownRootPolicy::LenientWarn): unknown top-level roots are
2679/// accepted as opaque with a single doctor-style warning, because app boot
2680/// (which DOES know the plugin set via the `config_section` seam / #2061 /
2681/// #1974) remains the authoritative strict gate for plugin-owned roots (#2063).
2682///
2683/// The leniency is scoped to top-level roots ONLY: unknown keys INSIDE a known
2684/// section (schema parent != `""`, e.g. a `[database] primry_url` typo) keep
2685/// their normal (hard) classification under both policies, and malformed TOML
2686/// still fails everywhere.
2687#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2688pub(crate) enum UnknownRootPolicy {
2689    /// Unknown top-level roots hard-fail (the app-boot path).
2690    Strict,
2691    /// Unknown top-level roots are accepted as opaque with one warning (the
2692    /// deploy-CLI config-load path).
2693    LenientWarn,
2694}
2695
2696/// Child schema keys for config sections whose `Deserialize` is OPAQUE to the
2697/// schema walker and must be declared by hand.
2698///
2699/// `#[serde(untagged)]` "scalar shorthand OR table" enums (e.g. `TimeZoneConfig`:
2700/// `time_zone = "UTC"` or `[time_zone] identifier = ...`) deserialize by first
2701/// buffering into serde's `Content` and then matching variants against that
2702/// buffer — so the table variant's fields are read from the buffer, never from
2703/// `SchemaDeserializer`. The walker therefore cannot see them, and the section
2704/// would otherwise be a childless leaf that strict validation skips (accepting
2705/// typos even under `strict_config_enforce_all`). Register such sections here so
2706/// `validate_toml` descends into them.
2707///
2708/// KEEP IN SYNC with the corresponding type's table fields (serialized names).
2709/// The guard test `manual_schema_sections_are_registered` pins the behavior.
2710const MANUAL_SCHEMA_SECTIONS: &[(&str, &[&str])] = &[
2711    // `crate::time_zone::TimeZoneConfig` — untagged Scalar|Table.
2712    ("time_zone", &["identifier", "sources"]),
2713];
2714
2715impl AutumnConfig {
2716    /// Recursively extracts all valid configuration schema keys and nested fields.
2717    #[must_use]
2718    #[allow(clippy::significant_drop_tightening)]
2719    pub fn get_schema_keys() -> HashMap<String, HashSet<String>> {
2720        // Adaptive multi-pass schema walk. `deserialize_any` probes with a scalar
2721        // by default; any path whose visitor rejects that probe (a seq/map-only
2722        // type such as `JobQueuesConfig` at `jobs.queues`) is escalated to a
2723        // map- then seq-probe on the next pass, so the walk stops aborting there
2724        // and enumerates every later section (#1890). Converges in two passes for
2725        // the current config; the loop is bounded and monotonic (each escalated
2726        // path only advances Str→Map→Seq), so it always terminates.
2727        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            // No path could be escalated further and the rejected set is stable:
2766            // any remaining aborter accepts none of str/map/seq — stop (leaf it).
2767            if !advanced && rejected == prev_rejected {
2768                break;
2769            }
2770            prev_rejected = rejected;
2771        }
2772        // Register walker-opaque sections (untagged scalar-or-table types whose
2773        // table fields buffer through serde `Content` and are invisible to the
2774        // walk). See MANUAL_SCHEMA_SECTIONS.
2775        {
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    /// Returns a sorted set of all schema leaf key paths (e.g. `"server.port"`).
2791    ///
2792    /// Used by the schema-snapshot CI guard (`autumn/tests/schema_drift_guard.rs`)
2793    /// to detect when a config key disappears without a registered deprecation entry.
2794    /// Regenerate the snapshot with:
2795    /// ```text
2796    /// UPDATE_SCHEMA_SNAPSHOT=1 cargo test -p autumn-web schema_keys_snapshot_guard
2797    /// ```
2798    ///
2799    /// **Note:** Always run the guard under a consistent feature set (e.g. `--all-features`)
2800    /// in CI, since feature-gated fields only appear when their feature is enabled.
2801    #[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    /// Recursively validates TOML content against the derived schema.
2819    /// Returns a list of errors: (`dotted_path`, `option_suggestion`)
2820    #[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    /// Like [`validate_toml`](Self::validate_toml), but also returns each error's
2832    /// profile-stripped schema parent path (computed from path SEGMENTS, so it is
2833    /// correct even for quoted dotted profile names like
2834    /// `[profile."prod.eu".server]`) AND whether the offending TOML value was
2835    /// itself a table (`is_table`), AND whether the offending key sat at the
2836    /// STRUCTURAL document top level (`is_top_level`, i.e. its parent path was
2837    /// empty at push time). Used by strict-config classification; `validate_toml`
2838    /// maps this down to `(path, suggestion)`.
2839    ///
2840    /// The `is_table` flag lets the deploy-CLI leniency (#2067) demote ONLY a
2841    /// true top-level TABLE root, mirroring the app-boot `config_section` seam
2842    /// (#2061) which exempts a registered plugin root only when `val.is_table()`.
2843    ///
2844    /// The `is_top_level` flag carries the same STRUCTURAL top-level signal the
2845    /// app-boot exemption uses (`path.is_empty()`), so deploy leniency can tell a
2846    /// genuine top-level root from a profile-prefixed one WITHOUT inspecting the
2847    /// rendered dotted `path` string — which is ambiguous, since a quoted top-level
2848    /// key like `["my.plugin"]` and a 2-level path both render `my.plugin`.
2849    ///
2850    /// `plugin_config_roots` lists top-level roots a plugin has declared via
2851    /// [`config_section`](crate::app::AppBuilder::config_section): each is
2852    /// treated as a known, opaque table — accepted at the root and never
2853    /// descended into. An empty set restores the pre-seam behavior.
2854    #[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                    // A plugin has declared this TOP-LEVEL root as its own config
2937                    // section (via `AppBuilder::config_section`). It is known AND
2938                    // opaque: accept it and do NOT descend — the plugin, not core,
2939                    // owns validation of its subtree. The `path.is_empty()` guard
2940                    // keeps this strictly the TRUE top-level root (`[media]`,
2941                    // path `[]`), so a key that merely shares a plugin-root name
2942                    // while nested inside a known section is still validated
2943                    // normally (fail-closed).
2944                    //
2945                    // The `val.is_table()` guard keeps the exemption TABLE-only:
2946                    // `config_section` declares a top-level config TABLE (`[media]`),
2947                    // so a registered root written as a scalar or array
2948                    // (`media = "enabled"`, `media = ["a", "b"]`) is a malformed
2949                    // section — nothing would deserialize it and the app would boot
2950                    // on default plugin config. A non-table value therefore does NOT
2951                    // match here and falls through to the normal unknown-root strict
2952                    // rejection below, failing loudly instead of booting on defaults.
2953                    //
2954                    // A profile-prefixed plugin root (`[profile.<env>.media]`,
2955                    // path `["profile","<env>"]`) is deliberately NOT exempted and
2956                    // falls through to the normal unknown-root strict rejection:
2957                    // the plugin consumes ONLY the top-level `[media]` table (its
2958                    // reader deserializes `root.media` directly and does not apply
2959                    // Autumn's profile merge), so exempting a profile layer the
2960                    // plugin cannot read would let a strict app with media settings
2961                    // only under `[profile.<env>.media]` boot SILENTLY on default
2962                    // plugin config instead of failing loudly. Profile-aware plugin
2963                    // config is a separate, larger enhancement. Deliberately NOT
2964                    // added to `valid_keys`, which would make the walk recurse and
2965                    // flag every one of the plugin's children as unknown.
2966                } 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                    // A plugin has declared this top-level root as its own config
3041                    // section (via `AppBuilder::config_section`). It is known AND
3042                    // opaque: accept it and do NOT descend — the plugin, not core,
3043                    // owns validation of its subtree. Deliberately NOT injected
3044                    // into `root_keys`, which would make the walk recurse and flag
3045                    // every one of the plugin's children as unknown.
3046                    //
3047                    // The `val.is_table()` guard keeps the exemption TABLE-only
3048                    // (`config_section` declares a top-level `[media]` TABLE): a
3049                    // registered root written as a scalar/array is malformed and
3050                    // falls through to the unknown-root strict rejection below
3051                    // instead of being silently exempted and booting on defaults.
3052                } 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    /// Access the decrypted credentials store.
3075    ///
3076    /// Returns an empty store when no credentials file was found (the feature is opt-in).
3077    /// Use `config.credentials().get::<String>("stripe_key")` to access values.
3078    #[must_use]
3079    pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
3080        &self.credentials
3081    }
3082
3083    /// Load configuration with profile-aware layering.
3084    ///
3085    /// Applies the six-layer configuration system:
3086    /// 1. Framework defaults
3087    /// 2. Profile smart defaults (dev/prod)
3088    /// 3. `autumn.toml` (base config)
3089    /// 4. `[profile.{name}]` section in `autumn.toml`
3090    /// 5. `autumn-{profile}.toml` (legacy profile overrides)
3091    /// 6. `AUTUMN_*` environment variables
3092    ///
3093    /// # Errors
3094    ///
3095    /// Returns [`ConfigError::Io`] if a config file cannot be read,
3096    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
3097    /// [`ConfigError::Validation`] if a value is invalid.
3098    ///
3099    /// # Panics
3100    ///
3101    /// Panics if the internally-built TOML table fails to re-serialize
3102    /// (should never happen with well-formed profile defaults).
3103    pub fn load() -> Result<Self, ConfigError> {
3104        Self::load_policy(UnknownRootPolicy::Strict)
3105    }
3106
3107    /// Like [`load`](Self::load), but accepts unknown TOP-LEVEL config roots as
3108    /// opaque-with-a-warning instead of hard-failing.
3109    ///
3110    /// For tooling that cannot know the application's plugin set (e.g. the
3111    /// deploy CLI). Keeps STRICT validation of every known/core section
3112    /// `AutumnConfig` owns (`[server]`, `[database]`, `[deploy]`, …) — including
3113    /// child-key typos inside them — and still fails on malformed TOML; only a
3114    /// genuinely-unknown top-level root (very likely a plugin-owned table such
3115    /// as `[media]`) is spared, with a single doctor-style warning. App boot
3116    /// remains the authoritative strict gate for plugin-owned roots (see the
3117    /// `config_section` seam / #2061 / #1974 / #2063).
3118    ///
3119    /// # Errors
3120    ///
3121    /// Returns [`ConfigError::Io`] if a config file cannot be read,
3122    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
3123    /// [`ConfigError::Validation`] if a value is invalid (including an unknown
3124    /// key inside a known section under `strict_config`).
3125    ///
3126    /// # Panics
3127    ///
3128    /// Panics if the internally-built TOML table fails to re-serialize.
3129    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        // Feed a project-root `.env` into the `AUTUMN_*` env layer before
3135        // resolving config from the real environment. Rather than mutating the
3136        // process environment, `.env` values are layered *under* the real
3137        // environment via an overlay `Env`, so a real env var always wins. A
3138        // malformed `.env` fails loudly here rather than silently skipping
3139        // developer-provided values.
3140        let base = OsEnv;
3141        let profile = resolve_profile(&base);
3142        // Resolve `.env` from the same base directory config uses for
3143        // `autumn.toml` (AUTUMN_MANIFEST_DIR when set, else the process CWD),
3144        // so a binary launched from outside its crate root reads the `.env`
3145        // next to its config instead of the process working directory.
3146        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        // The zero-arg loaders (`load` / `load_lenient_unknown_roots`) have no
3151        // AppBuilder and therefore no plugin-declared config roots; plugin roots
3152        // arrive only via `TomlEnvConfigLoader::with_plugin_config_roots` →
3153        // `load_with_env_and_plugin_roots`. Pass an empty set here.
3154        Self::load_with_env_and_plugin_roots_policy(&env, &BTreeSet::new(), root_policy)
3155    }
3156
3157    /// Load configuration with profile-aware layering, using a provided
3158    /// environment abstraction instead of the OS environment. Useful for testing.
3159    ///
3160    /// # Errors
3161    /// Returns [`ConfigError::Io`] if a config file cannot be read,
3162    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
3163    /// [`ConfigError::Validation`] if a value is invalid.
3164    ///
3165    /// # Panics
3166    /// Panics if the internally-built TOML table fails to re-serialize.
3167    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    /// Like [`load_with_env`](Self::load_with_env), but treats each top-level
3176    /// root in `plugin_config_roots` as a **known, opaque** config table under
3177    /// `server.strict_config`.
3178    ///
3179    /// A plugin owns a top-level `[root]` table (e.g. `[media]`) that core's
3180    /// closed [`AutumnConfig`] schema knows nothing about. Without a
3181    /// registration seam, the strict unknown-key check hard-rejects that root as
3182    /// an unknown key and a plugin-enabled app cannot boot under
3183    /// `strict_config = true`. Passing the plugin's declared roots here exempts
3184    /// exactly those roots from the check: each listed root is accepted and its
3185    /// subtree is **not** descended into (the plugin, not core, validates its own
3186    /// section). Every other unknown root still hard-fails — the seam is
3187    /// fail-closed, never a blanket "allow unknown roots" escape hatch.
3188    ///
3189    /// This is the roots-aware path the [`AppBuilder`](crate::app::AppBuilder)
3190    /// wires up from [`config_section`](crate::app::AppBuilder::config_section)
3191    /// declarations; the plain [`load_with_env`](Self::load_with_env) delegates
3192    /// here with an empty set, so all existing callers are unaffected.
3193    ///
3194    /// # Errors
3195    /// Returns [`ConfigError::Io`] if a config file cannot be read,
3196    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
3197    /// [`ConfigError::Validation`] if a value is invalid.
3198    ///
3199    /// # Panics
3200    /// Panics if the internally-built TOML table fails to re-serialize.
3201    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    /// Like [`load_with_env`](Self::load_with_env), but accepts unknown
3213    /// TOP-LEVEL config roots as opaque-with-a-warning instead of hard-failing.
3214    ///
3215    /// For tooling that cannot know the application's plugin set (e.g. the
3216    /// deploy CLI). Keeps STRICT validation of every known/core section — and
3217    /// of child-key typos inside them — and still fails on malformed TOML; only
3218    /// a genuinely-unknown top-level root (very likely a plugin-owned table such
3219    /// as `[media]`) is spared, with a single doctor-style warning. App boot
3220    /// remains the authoritative strict gate for plugin-owned roots (see the
3221    /// `config_section` seam / #2061 / #1974 / #2063).
3222    ///
3223    /// # Errors
3224    /// Returns [`ConfigError::Io`] if a config file cannot be read,
3225    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
3226    /// [`ConfigError::Validation`] if a value is invalid (including an unknown
3227    /// key inside a known section under `strict_config`).
3228    ///
3229    /// # Panics
3230    /// Panics if the internally-built TOML table fails to re-serialize.
3231    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    /// Shared config-loading worker threading BOTH the plugin-declared config
3240    /// roots (#2061) AND the unknown-top-level-root policy (#2063).
3241    ///
3242    /// The two knobs are orthogonal: `plugin_config_roots` exempts SPECIFIC
3243    /// declared table roots (they produce no error to classify), while
3244    /// `root_policy` decides whether the REMAINING unknown top-level roots
3245    /// hard-fail ([`Strict`](UnknownRootPolicy::Strict), app boot) or are
3246    /// accepted opaque-with-a-warning
3247    /// ([`LenientWarn`](UnknownRootPolicy::LenientWarn), the deploy CLI).
3248    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        // Build merged TOML:
3259        // profile smart defaults ← autumn.toml ← [profile.{name}] ← autumn-{profile}.toml
3260        let mut merged = profile_defaults_as_toml(&profile);
3261
3262        // Layer 3: base autumn.toml
3263        if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
3264            deep_merge(&mut merged, base.clone());
3265
3266            // Layer 4: [profile.{name}] in autumn.toml
3267            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        // Layer 5: autumn-{profile}.toml (legacy compatibility)
3276        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        // Deserialize the merged TOML table into AutumnConfig
3292        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        // Layer 6: env var overrides (highest priority)
3298        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        // ── Deprecation channel (purely additive; never mutates `config`). ──────
3317        // Emit exactly one structured WARN per deprecated key that is present in
3318        // the resolved config (via TOML or env var). The old value is already
3319        // honoured above; this is observation only.
3320        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! ensures the warning is visible on stderr even before the
3324            // tracing subscriber is installed (config loads before telemetry init in
3325            // the normal startup path).  The tracing::warn! below is kept so apps
3326            // that pre-install their own subscriber still receive structured events.
3327            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    /// Runs the strict unknown-key check against the merged `toml_str`.
3374    ///
3375    /// Unknown keys are partitioned by [`unknown_key_was_previously_strict`]:
3376    /// keys whose section was already strictly validated before the #1890
3377    /// schema-walk fix (or all keys when `enforce_all` is set) hard-fail; keys
3378    /// in sections that only became covered by the fix are warned about but
3379    /// tolerated for one release (warn-first rollout).
3380    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            // Deploy-CLI leniency (#2063/#2067): a genuinely-unknown TRUE
3394            // top-level root — one sitting directly at the document root, i.e.
3395            // whose ACTUAL path is a bare root key (no `profile.<name>` prefix)
3396            // AND whose schema parent is the document root `""` — is accepted as
3397            // opaque rather than failing. It is almost certainly a plugin-owned
3398            // config table (e.g. `[media]`) the CLI structurally cannot know
3399            // about, and app boot stays the strict gate for it.
3400            //
3401            // Top-level-ness is STRUCTURAL, taken from the error's `is_top_level`
3402            // flag (the offending key's parent path was empty at push time) — the
3403            // SAME signal #2061's app-boot exemption keys on (`path.is_empty()`
3404            // in `validate_toml_table`). It is deliberately NOT inferred from the
3405            // rendered dotted `path` string: that string is AMBIGUOUS, because a
3406            // legitimately quoted-dotted top-level key like `["my.plugin"]` (from
3407            // `config_section("my.plugin")`) and a 2-level path both render
3408            // `my.plugin`. The earlier `!path.contains('.')` heuristic therefore
3409            // HARD-FAILED a quoted-dotted top-level plugin root at deploy even
3410            // though app boot ACCEPTS it (its exemption keys on the RAW table key
3411            // with `path.is_empty()`). Gating on `is_top_level` closes that gap:
3412            // a quoted-dotted top-level table matches here exactly as it is
3413            // exempted at boot.
3414            //
3415            // It is also NOT merely `schema_parent.is_empty()`:
3416            // `validate_toml_detailed` reports an EMPTY schema parent for a
3417            // profile-prefixed section like `[profile.prod.media]` too (the
3418            // profile prefix is stripped before root-schema validation, so
3419            // `[profile.prod.media]` and top-level `[media]` both surface with
3420            // schema parent `""`). But a profile-prefixed section is pushed with a
3421            // NON-EMPTY parent path (`["profile","prod"]`), so `is_top_level` is
3422            // false for it — and the deployed app, whose `config_section` seam
3423            // (#2061) exempts ONLY the TRUE top-level `[media]` via
3424            // `path.is_empty()`, still REJECTS `[profile.prod.media]` at boot. So
3425            // deploy and app boot AGREE: both accept top-level `[media]` /
3426            // `["my.plugin"]`, both reject `[profile.prod.media]`.
3427            //
3428            // A profile-prefixed root therefore no longer matches this branch
3429            // and falls through to the normal (hard, since schema_parent `""` ∈
3430            // PRE_1890_STRICT_PARENTS) classification below → strict rejection at
3431            // deploy, matching app boot. ONLY a true root is spared: an unknown
3432            // key inside a KNOWN section (schema_parent != "") also falls
3433            // through, so a `[database] primry_url` typo still hard-fails. The
3434            // app-boot path passes `Strict`, so its behavior is unchanged.
3435            //
3436            // Leniency additionally requires the root's TOML value to be a TABLE
3437            // (`is_table`), mirroring the #2061 `config_section` app-boot
3438            // exemption, which accepts a registered plugin root only when
3439            // `val.is_table()`. A non-table true-top-level root — a scalar or
3440            // array like `media = "enabled"` / `media = ["a", "b"]` — is a
3441            // malformed section nothing would deserialize, so it does NOT match
3442            // here and falls through to the normal (hard) classification →
3443            // strict rejection at deploy, EXACTLY as the deployed app rejects it
3444            // at boot. Without this check deploy would accept a non-table root
3445            // that app boot rejects, reopening the "deploy accepts what boot
3446            // rejects" gap this branch exists to close.
3447            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        // Deploy-CLI opaque top-level roots (#2063): surface exactly one
3463        // doctor-style line (never fatal) so an accepted plugin root is
3464        // observable and a typo'd root is not silently swallowed — it will be
3465        // rejected authoritatively when the app itself boots. `eprintln!`
3466        // guarantees visibility before a tracing subscriber is installed; the
3467        // `tracing::warn!` keeps structured output for apps that pre-install one.
3468        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        // Warn-first rollout (#1890): unknown keys in sections that only became
3488        // strictly validated by the schema-walk fix are surfaced loudly but do
3489        // NOT fail startup for one release, so configs that silently passed
3490        // before keep booting. `eprintln!` guarantees visibility before the
3491        // tracing subscriber is installed; the `tracing::warn!` keeps structured
3492        // output for apps that pre-install one. Set
3493        // `server.strict_config_enforce_all = true` (or
3494        // AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL=1) to promote these to hard
3495        // errors now.
3496        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    /// Helper method to expand `OAuth2` preset configurations and resolve credentials-backed values.
3533    #[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            // 1. Expand from preset if available
3538            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            // 2. Resolve credentials-backed secrets/IDs
3566            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    /// Load configuration from a specific TOML file path.
3600    ///
3601    /// Used internally and for testing. Does **not** apply profile
3602    /// layering or environment overrides. Prefer [`load()`](Self::load)
3603    /// in application code.
3604    ///
3605    /// # Errors
3606    ///
3607    /// Returns [`ConfigError::Io`] if the file cannot be read, or
3608    /// [`ConfigError::Parse`] if the file contains invalid TOML.
3609    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    /// Validate the resolved configuration for semantic errors.
3622    ///
3623    /// # Errors
3624    /// Returns [`ConfigError::Validation`] when a field combination is
3625    /// syntactically well-formed TOML but semantically invalid.
3626    pub fn validate(&self) -> Result<(), ConfigError> {
3627        self.database.validate()?;
3628        self.cors.validate()?;
3629        self.scheduler.validate()?;
3630        // Framework state (autumn_jobs, scheduler advisory locks) lives on
3631        // the control topology and is never sharded. Sharded apps that use a
3632        // Postgres-backed jobs or scheduler backend therefore need a control
3633        // role alongside their shards.
3634        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        // Session backend validation deliberately lives in
3655        // `crate::session::apply_session_layer`, not here. That function
3656        // short-circuits when a custom `SessionStore` was installed via
3657        // `AppBuilder::with_session_store(...)`, so the (then-irrelevant)
3658        // `session.backend = "redis"` config without a redis URL doesn't
3659        // need to fail the boot. Validating the same thing here would
3660        // defeat the override and exit the app before the custom store
3661        // ever gets a chance to apply. The "prod profile + memory backend"
3662        // warning lives in `apply_session_layer` for the same reason.
3663        Ok(())
3664    }
3665
3666    /// Apply environment variable overrides to the loaded config.
3667    ///
3668    /// All fields can be overridden via `AUTUMN_SECTION__FIELD` environment
3669    /// variables. Double underscore `__` separates nested config sections.
3670    ///
3671    /// # Server
3672    /// - `AUTUMN_SERVER__PORT` → `server.port` (u16)
3673    /// - `AUTUMN_SERVER__HOST` → `server.host` (String)
3674    /// - `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` → `server.shutdown_timeout_secs` (u64)
3675    /// - `AUTUMN_SERVER__PRESTOP_GRACE_SECS` → `server.prestop_grace_secs` (u64)
3676    ///
3677    /// # Database
3678    /// - `AUTUMN_DATABASE__PRIMARY_URL` -> `database.primary_url` (String)
3679    /// - `AUTUMN_DATABASE__REPLICA_URL` -> `database.replica_url` (String)
3680    /// - `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` -> `database.primary_pool_size` (usize)
3681    /// - `AUTUMN_DATABASE__REPLICA_POOL_SIZE` -> `database.replica_pool_size` (usize)
3682    /// - `AUTUMN_DATABASE__REPLICA_FALLBACK` -> `database.replica_fallback` (`fail_readiness` | `primary`)
3683    /// - `AUTUMN_DATABASE__URL` → `database.url` (String)
3684    /// - `AUTUMN_DATABASE__POOL_SIZE` → `database.pool_size` (usize)
3685    /// - `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` → `database.connect_timeout_secs` (u64)
3686    /// - `AUTUMN_DATABASE__STARTUP_WAIT_SECS` → `database.startup_wait_secs` (u64)
3687    /// - `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` -> `database.auto_migrate_in_production` (bool)
3688    ///
3689    /// # Log
3690    /// - `AUTUMN_LOG__LEVEL` → `log.level` (String, tracing filter directive)
3691    /// - `AUTUMN_LOG__FORMAT` → `log.format` (Auto | Pretty | Json)
3692    ///
3693    /// # Telemetry
3694    /// - `AUTUMN_TELEMETRY__ENABLED` -> `telemetry.enabled` (bool)
3695    /// - `AUTUMN_TELEMETRY__SERVICE_NAME` -> `telemetry.service_name` (String)
3696    /// - `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` -> `telemetry.service_namespace` (String)
3697    /// - `AUTUMN_TELEMETRY__SERVICE_VERSION` -> `telemetry.service_version` (String)
3698    /// - `AUTUMN_TELEMETRY__ENVIRONMENT` -> `telemetry.environment` (String)
3699    /// - `AUTUMN_TELEMETRY__OTLP_ENDPOINT` -> `telemetry.otlp_endpoint` (String)
3700    /// - `AUTUMN_TELEMETRY__PROTOCOL` -> `telemetry.protocol` (`Grpc` | `HttpProtobuf`)
3701    /// - `AUTUMN_TELEMETRY__STRICT` -> `telemetry.strict` (bool)
3702    ///
3703    /// # Health / Probes
3704    /// - `AUTUMN_HEALTH__PATH` → `health.path` (String)
3705    /// - `AUTUMN_HEALTH__LIVE_PATH` → `health.live_path` (String)
3706    /// - `AUTUMN_HEALTH__READY_PATH` → `health.ready_path` (String)
3707    /// - `AUTUMN_HEALTH__STARTUP_PATH` → `health.startup_path` (String)
3708    /// - `AUTUMN_HEALTH__DETAILED` → `health.detailed` (bool)
3709    ///
3710    /// # Jobs
3711    /// - `AUTUMN_JOBS__BACKEND` → `jobs.backend` (`local` / `redis`)
3712    /// - `AUTUMN_JOBS__WORKERS` → `jobs.workers` (`usize`)
3713    /// - `AUTUMN_JOBS__PIN` → `jobs.pin` (comma-separated queue names)
3714    /// - `AUTUMN_JOBS__MAX_ATTEMPTS` → `jobs.max_attempts` (`u32`)
3715    /// - `AUTUMN_JOBS__INITIAL_BACKOFF_MS` → `jobs.initial_backoff_ms` (`u64`)
3716    /// - `AUTUMN_JOBS__REDIS__URL` → `jobs.redis.url` (`String`)
3717    /// - `AUTUMN_JOBS__REDIS__KEY_PREFIX` → `jobs.redis.key_prefix` (`String`)
3718    /// - `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` → `jobs.redis.visibility_timeout_ms` (`u64`)
3719    /// - `AUTUMN_JOBS__TRACKING__TTL_SECS` → `jobs.tracking.ttl_secs` (`u64`)
3720    /// - `AUTUMN_JOBS__TRACKING__ROUTE_ENABLED` → `jobs.tracking.route_enabled` (`bool`)
3721    ///
3722    /// # Signed webhooks
3723    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` -> `security.webhooks.replay.backend` (`memory` / `redis`)
3724    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` -> `security.webhooks.replay.redis.url` (`String`)
3725    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` -> `security.webhooks.replay.redis.key_prefix` (`String`)
3726    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` -> `security.webhooks.replay.allow_memory_in_production` (`bool`)
3727    pub fn apply_env_overrides(&mut self) {
3728        self.apply_env_overrides_with_env(&OsEnv);
3729    }
3730
3731    /// Apply environment overrides using the provided env abstraction.
3732    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        // Per-channel severity routing (`all` / `critical`). `AlertRouting`'s
3868        // `FromStr` accepts the same spellings as the TOML/serde path; an invalid
3869        // value is logged and ignored by `parse_env`, leaving the current value.
3870        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        // Security-sensitive: operators disable the Prometheus scrape endpoint
3986        // with AUTUMN_ACTUATOR__PROMETHEUS=false; the override must be honored
3987        // so the endpoint is not left exposed against the operator's intent.
3988        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        // `[server.tls]` is a nested optional. Materialize it from the
4066        // environment when any of its keys are set (seeding an empty struct if
4067        // the TOML section was absent), so a fully env-driven deployment can
4068        // enable direct HTTPS without an `autumn.toml` section. A partially
4069        // specified pair (e.g. only the cert) leaves the other path empty and
4070        // is caught by the startup fail-fast validation.
4071        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    /// Apply `AUTUMN_DATABASE__SHARDS__{i}__*` environment overrides.
4169    ///
4170    /// The [`Env`] abstraction can only probe known keys, so shard entries
4171    /// are addressed positionally: index `i` corresponds to the i-th
4172    /// `[[database.shards]]` entry in declaration order. Existing entries
4173    /// can have individual fields overridden; a brand-new entry is appended
4174    /// when both `__NAME` and `__PRIMARY_URL` are provided for the next
4175    /// free index. Probing stops at the first index that neither exists in
4176    /// TOML nor defines a complete new shard (bounded at 64).
4177    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            // Comma-separated indices and/or "A-B" ranges, e.g. "0-15,40,62-63".
4201            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        // ── Health ──────────────────────────────────────────────
4238        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    /// Apply `AUTUMN_SECURITY__*` environment variable overrides.
4612    #[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        // CSRF
4651        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        // Multipart uploads
4675        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        // Authorization deny shape + repository-API escape hatch.
4697        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        // Signing secret (canonical env var documented in deployment guide)
4712        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        // Top-level trusted-proxy policy
4724        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        // BACKEND is always parsed so misconfiguration is surfaced even without
4828        // the redis feature (build_backend will warn and fall back to memory).
4829        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    /// Apply `AUTUMN_BACKUP__OFFSITE__*` overrides to the `[backup.offsite]`
4960    /// section (issue #1619). Mirrors the storage overrides so the offsite
4961    /// destination honors the same `AUTUMN_*` env convention. When no offsite
4962    /// section exists in TOML, a default one is materialized only if at least
4963    /// one offsite env var is present, so an all-env deployment still works.
4964    fn apply_backup_env_overrides_with_env(&mut self, env: &dyn Env) {
4965        // Keys that signal a genuine intent to CONFIGURE an offsite destination —
4966        // presence of any REQUIRED destination/credential key materializes the
4967        // `[backup.offsite]` section (issue #1791). This is limited to the keys
4968        // that a working upload genuinely requires — a bucket, or the access /
4969        // secret key-env names. Optional-only keys (`region`,
4970        // `force_path_style`, `endpoint`, `prefix`, `keep`) do NOT materialize
4971        // the section on their own: a bare `AUTUMN_BACKUP__OFFSITE__S3__REGION`
4972        // (or endpoint) with no bucket cannot upload, so it must leave offsite
4973        // UNCONFIGURED rather than produce an empty section that then fails
4974        // validation / `doctor` with "backup.offsite.s3.bucket is unset". Those
4975        // optional keys are still APPLIED below once the section IS materialized
4976        // by a required key. This also EXCLUDES the two opt-out toggles: a lone
4977        // `AUTO_UPLOAD=false` / `ALLOW_SHARED_BUCKET=false` must NOT create an
4978        // otherwise-empty section (issue #1619 P2 #18). A truthy
4979        // `AUTO_UPLOAD=true` DOES materialize, since it requires a validated
4980        // destination to act on.
4981        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    /// Returns the active profile name, if any.
5124    #[must_use]
5125    pub fn profile_name(&self) -> Option<&str> {
5126        self.profile.as_deref()
5127    }
5128}
5129
5130/// HTTP server configuration.
5131///
5132/// Controls which address the server binds to and how graceful shutdown
5133/// behaves.
5134///
5135/// # Defaults
5136///
5137/// | Field | Default |
5138/// |-------|---------|
5139/// | `port` | `3000` |
5140/// | `host` | `"127.0.0.1"` |
5141/// | `shutdown_timeout_secs` | `30` |
5142///
5143/// # Examples
5144///
5145/// ```rust
5146/// use autumn_web::config::ServerConfig;
5147///
5148/// let server = ServerConfig::default();
5149/// assert_eq!(server.port, 3000);
5150/// assert_eq!(server.host, "127.0.0.1");
5151/// ```
5152/// Per-request timeout configuration.
5153///
5154/// Controls how long the server waits for a complete request-response cycle
5155/// before returning `408 Request Timeout`. A value of `None` or `0` disables
5156/// the timeout (the default, so existing applications are unaffected).
5157///
5158/// # `autumn.toml` example
5159///
5160/// ```toml
5161/// [server.timeouts]
5162/// request_timeout_ms = 30000  # 30 seconds
5163/// ```
5164#[derive(Debug, Clone, Default, Deserialize)]
5165pub struct RequestTimeoutsConfig {
5166    /// Maximum time in milliseconds allowed for a complete request-response
5167    /// cycle. When exceeded the framework returns `503 Service Unavailable`
5168    /// rendered as Problem Details JSON for API clients (and the standard error
5169    /// page for browser requests). `None` (default) or `0` disables the timeout.
5170    ///
5171    /// The deadline bounds the time to produce the response *head*: once the
5172    /// status and headers are sent, the streaming body is not interrupted, so
5173    /// SSE, chunked responses, and WebSocket upgrades (all of which emit their
5174    /// head promptly and then stream) run unbounded afterward. Long-poll
5175    /// handlers are the exception — they intentionally withhold the response
5176    /// head while waiting for data, so they *are* subject to this deadline and
5177    /// will return `503` if it fires before they respond. Give such routes a
5178    /// per-route override via the route macro
5179    /// (`#[get("/poll", timeout_ms = 120000)]` or `timeout = "off"`), which is
5180    /// also how any other slow route can raise or disable its own deadline.
5181    ///
5182    /// A second exception applies to *mutating* requests carrying an
5183    /// `Idempotency-Key`: the idempotency layer buffers the full response body
5184    /// (so the response can be cached and replayed) before the head is returned,
5185    /// so those responses are bounded by the deadline even when the handler
5186    /// streams them. Give such endpoints a per-route override if they
5187    /// legitimately produce slow or large idempotent bodies.
5188    ///
5189    /// The `prod` profile smart-defaults this to `30000` (30s); `dev` and custom
5190    /// profiles leave it disabled. Configured via
5191    /// `AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS`.
5192    #[serde(default)]
5193    pub request_timeout_ms: Option<u64>,
5194}
5195
5196#[derive(Debug, Clone, Deserialize)]
5197pub struct ServerConfig {
5198    /// Port to listen on. Default: `3000`.
5199    #[serde(default = "default_port")]
5200    pub port: u16,
5201
5202    /// Host/IP to bind to. Default: `"127.0.0.1"`.
5203    ///
5204    /// Set to `"0.0.0.0"` to accept connections from all interfaces
5205    /// (typical for containerized deployments).
5206    #[serde(default = "default_host")]
5207    pub host: String,
5208
5209    /// Exit startup if any unknown config keys are found in autumn.toml/profiles.
5210    #[serde(default)]
5211    pub strict_config: bool,
5212
5213    /// When `strict_config` is enabled, also hard-fail on unknown keys in the
5214    /// config sections that only became strictly validated by the #1890
5215    /// schema-walk fix (everything except `server`, `deploy`, and `database`,
5216    /// whose keys were already validated). Defaults to `false` for one release:
5217    /// unknown keys in those newly-covered sections WARN loudly at startup
5218    /// instead of failing, so configs that silently passed before keep booting.
5219    /// Set to `true` to enforce immediately; a future release makes `true` the
5220    /// default and removes this transitional gate.
5221    #[serde(default)]
5222    pub strict_config_enforce_all: bool,
5223
5224    /// Seconds to wait for in-flight requests during graceful shutdown.
5225    /// Default: `30`.
5226    ///
5227    /// When the server receives a shutdown signal, it stops accepting
5228    /// new connections and waits up to this many seconds for in-flight
5229    /// requests to complete before forcibly terminating.
5230    #[serde(default = "default_shutdown_timeout")]
5231    pub shutdown_timeout_secs: u64,
5232
5233    /// Seconds between `/ready` returning 503 and the TCP listener
5234    /// closing to new connections. Default: `5`.
5235    ///
5236    /// This gap gives upstream load balancers time to deregister the
5237    /// replica before it stops accepting new connections, preventing
5238    /// connection resets on in-flight requests from the LB tier.
5239    /// Must be tuned to match the LB's health-check interval + deregistration
5240    /// propagation time. Set to `0` to disable the grace period.
5241    #[serde(default = "default_prestop_grace")]
5242    pub prestop_grace_secs: u64,
5243
5244    /// Per-request timeout configuration.
5245    ///
5246    /// Controls request-cycle timeouts for `DoS` protection. By default
5247    /// all timeouts are disabled so existing applications are unaffected.
5248    /// Set `request_timeout_ms` in `[server.timeouts]` to enable.
5249    #[serde(default)]
5250    pub timeouts: RequestTimeoutsConfig,
5251
5252    /// Bind to a Unix domain socket at this path instead of `host:port`.
5253    ///
5254    /// When set, the server binds a `UnixListener` at the given path
5255    /// (replacing the TCP `host:port` bind) — the local-daemon transport
5256    /// used by `autumn serve`. The socket is created with `0600`
5257    /// permissions and removed on graceful shutdown. Unix-only; on other
5258    /// platforms a configured value is rejected at startup.
5259    ///
5260    /// Configured via `AUTUMN_SERVER__UNIX_SOCKET`. Default: `None` (TCP).
5261    #[serde(default)]
5262    pub unix_socket: Option<String>,
5263
5264    /// Ceiling on concurrent in-flight requests (admission control / load
5265    /// shedding). `None` or `0` (the default) disables the ceiling — today's
5266    /// unlimited behavior — so no existing application silently changes
5267    /// throughput.
5268    ///
5269    /// Once this many requests are admitted and still in flight, additional
5270    /// requests receive an immediate `503 Service Unavailable` with a
5271    /// `Retry-After` header, before the handler runs or the request body is
5272    /// read. This bounds total concurrent work (and therefore memory) under
5273    /// a traffic spike or a slow dependency, trading a fast, clean "try
5274    /// another replica" signal for the alternative — admitted requests
5275    /// piling up unbounded until the process is OOM-killed.
5276    ///
5277    /// Liveness/readiness/health probe routes (`health.*` paths and the
5278    /// actuator prefix) are never shed, so a merely-busy replica is not
5279    /// killed by its orchestrator.
5280    ///
5281    /// A reasonable starting point is the number of worker threads times a
5282    /// small multiple (e.g. 2-4x), sized to keep admitted-request tail
5283    /// latency stable under the expected peak concurrency; tune based on
5284    /// observed `autumn_requests_shed_total` and per-route latency.
5285    ///
5286    /// Configured via `AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS`.
5287    #[serde(default)]
5288    pub max_concurrent_requests: Option<usize>,
5289
5290    /// Terminate HTTPS directly in the app process (issue #1603).
5291    ///
5292    /// When set, the server serves TLS on `host:port` using the configured
5293    /// certificate chain and private key — no sidecar reverse proxy required.
5294    /// Absent (the default), the server keeps serving plain HTTP, so existing
5295    /// applications are unaffected.
5296    ///
5297    /// This field is always parsed, regardless of build features, so a
5298    /// misconfiguration is a clear "built without the `tls` feature" error
5299    /// rather than a silently-ignored section. The serving code itself is
5300    /// gated behind the off-by-default `tls` feature.
5301    ///
5302    /// Configured via `[server.tls]` (`cert_path`, `key_path`,
5303    /// `reload_interval_secs`, `handshake_timeout_secs`) or the matching
5304    /// `AUTUMN_SERVER__TLS__CERT_PATH` / `AUTUMN_SERVER__TLS__KEY_PATH` /
5305    /// `AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS` /
5306    /// `AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS` env vars.
5307    #[serde(default)]
5308    pub tls: Option<TlsConfig>,
5309}
5310
5311/// Direct-HTTPS (native TLS termination) settings (issue #1603).
5312///
5313/// Present under `[server.tls]`; when present the server terminates TLS
5314/// in-process. Both paths point at PEM files: `cert_path` at the leaf
5315/// certificate followed by any intermediates, `key_path` at the matching
5316/// private key (PKCS#8, PKCS#1, or SEC1).
5317///
5318/// # `autumn.toml` example
5319///
5320/// ```toml
5321/// [server.tls]
5322/// cert_path = "/etc/autumn/tls/fullchain.pem"
5323/// key_path = "/etc/autumn/tls/privkey.pem"
5324/// # optional; how often (seconds) to poll for a renewed cert. Default: 60.
5325/// reload_interval_secs = 60
5326/// # optional; per-handshake timeout (seconds). Default: 10.
5327/// handshake_timeout_secs = 10
5328/// ```
5329#[derive(Debug, Clone, Deserialize)]
5330pub struct TlsConfig {
5331    /// Path to the PEM certificate chain (leaf first, then intermediates).
5332    ///
5333    /// Optional so a `[server.tls]` section can instead enable automatic ACME
5334    /// provisioning (issue #1608) via [`acme`](Self::acme). In static-cert mode
5335    /// this must be set together with [`key_path`](Self::key_path); in ACME mode
5336    /// both must be unset. The startup [`validate`](Self::validate) guard
5337    /// rejects any other combination.
5338    #[serde(default)]
5339    pub cert_path: Option<PathBuf>,
5340
5341    /// Path to the PEM private key matching the leaf certificate. Optional; see
5342    /// [`cert_path`](Self::cert_path).
5343    #[serde(default)]
5344    pub key_path: Option<PathBuf>,
5345
5346    /// How often, in seconds, the running server polls the certificate and key
5347    /// files' modification times to pick up an external renewal (e.g. after
5348    /// `certbot`/ACME writes new files) without a restart. Default: `60`.
5349    #[serde(default = "default_tls_reload_interval_secs")]
5350    pub reload_interval_secs: u64,
5351
5352    /// Maximum time, in seconds, allowed for a single inbound TLS handshake
5353    /// before the connection is dropped. Bounds a client that opens TCP but
5354    /// never completes (or starts) the handshake so it cannot park the accept
5355    /// loop and deny service to everyone else. Default: `10`. A value of `0` is
5356    /// clamped to `1` second.
5357    #[serde(default = "default_tls_handshake_timeout_secs")]
5358    pub handshake_timeout_secs: u64,
5359
5360    /// Automatic ACME (Let's Encrypt) certificate provisioning + renewal
5361    /// (issue #1608). When present the server obtains and auto-renews its own
5362    /// certificate over the ACME HTTP-01 challenge instead of loading a static
5363    /// cert from disk. Mutually exclusive with
5364    /// [`cert_path`](Self::cert_path)/[`key_path`](Self::key_path); the startup
5365    /// [`validate`](Self::validate) guard enforces exactly one mode. The serving
5366    /// code is gated behind the off-by-default `acme` feature.
5367    #[serde(default)]
5368    pub acme: Option<AcmeConfig>,
5369}
5370
5371impl TlsConfig {
5372    /// An empty `TlsConfig` used only as the seed for env-var overrides of a
5373    /// section that was absent from TOML. Both paths are unset (which fails
5374    /// fast at startup if neither a static cert nor ACME is configured), and the
5375    /// reload interval takes its default.
5376    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    /// Validate the `[server.tls]` wiring before the listener binds.
5387    ///
5388    /// Exactly one provisioning mode must be selected:
5389    /// - **static cert**: both [`cert_path`](Self::cert_path) and
5390    ///   [`key_path`](Self::key_path) set (and no `[server.tls.acme]`), or
5391    /// - **ACME**: `[server.tls.acme]` present (and neither path set).
5392    ///
5393    /// Every rejection names the offending combination so the operator can act
5394    /// on it without guesswork.
5395    ///
5396    /// # Errors
5397    ///
5398    /// Returns a message describing the first problem found.
5399    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/// Automatic ACME (Let's Encrypt) certificate provisioning settings (issue
5441/// #1608). Present under `[server.tls.acme]`.
5442///
5443/// # Deployment scope
5444///
5445/// HTTP-01 ACME here is **single-host**: the challenge-token map is per-process
5446/// in-memory and certificates are stored on local disk, so behind a load
5447/// balancer the CA's `:80` validation can hit a replica without the token
5448/// (→ 404) and non-leader replicas cannot adopt a cert from a non-shared store.
5449/// Single-replica deployments are fully correct; multi-replica needs a shared
5450/// token store or DNS-01 (tracked in #1620). Configuring ACME alongside a
5451/// distributed scheduler backend logs a startup warning.
5452///
5453/// # `autumn.toml` example
5454///
5455/// ```toml
5456/// [server.tls.acme]
5457/// domains = ["app.example.com"]
5458/// contact_email = "ops@example.com"
5459/// # optional; "staging" (default), "production", or a custom directory URL.
5460/// directory = "staging"
5461/// ```
5462#[derive(Debug, Clone, Deserialize)]
5463pub struct AcmeConfig {
5464    /// Domains to include on the issued certificate (SANs). At least one is
5465    /// required. Wildcards (`*.example.com`) are rejected — they require the
5466    /// DNS-01 challenge, tracked in issue #1620.
5467    pub domains: Vec<String>,
5468
5469    /// Contact email registered with the ACME account (used for expiry
5470    /// notifications from the CA). Required.
5471    pub contact_email: String,
5472
5473    /// Which ACME directory to use. Defaults to Let's Encrypt **staging** on
5474    /// purpose, so a first run or CI cannot burn the strict production rate
5475    /// limit before the deployment is known good.
5476    #[serde(default)]
5477    pub directory: AcmeDirectory,
5478
5479    /// Directory that stores the ACME account key and issued certificates.
5480    /// Default: `config/acme`.
5481    #[serde(default = "default_acme_cache_dir")]
5482    pub cache_dir: PathBuf,
5483
5484    /// Port to serve the HTTP-01 challenge (and the HTTP→HTTPS redirect) on.
5485    /// The ACME CA always validates HTTP-01 over port 80, so this defaults to
5486    /// `80`; override it when a front-end forwards `:80` to another port.
5487    #[serde(default = "default_acme_http_challenge_port")]
5488    pub http_challenge_port: u16,
5489
5490    /// Renew the certificate once it has fewer than this many days of validity
5491    /// left. Default: `30`.
5492    #[serde(default = "default_acme_renew_before_days")]
5493    pub renew_before_days: u32,
5494}
5495
5496impl AcmeConfig {
5497    /// Validate the ACME wiring: at least one non-wildcard domain and a
5498    /// non-empty contact email.
5499    ///
5500    /// # Errors
5501    ///
5502    /// Returns a message describing the first problem found.
5503    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        // The renew-before window is compared against the issued certificate's
5528        // REMAINING validity. Publicly-trusted CAs (Let's Encrypt) issue
5529        // ~90-day certificates, so treat 90 days as the effective maximum cert
5530        // lifetime: a `renew_before_days >= 90` keeps the freshly-issued
5531        // certificate perpetually inside its renew-before window, so `needs_renewal`
5532        // stays true immediately after every successful renewal and the hourly
5533        // loop orders a brand-new certificate every tick until the CA's rate
5534        // limits are hit. Reject it up front.
5535        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/// Which ACME directory endpoint to provision against.
5567#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
5568#[serde(rename_all = "snake_case")]
5569pub enum AcmeDirectory {
5570    /// Let's Encrypt **staging** (the default): untrusted certificates, but
5571    /// generous rate limits — safe for first runs and CI.
5572    #[default]
5573    Staging,
5574    /// Let's Encrypt production: trusted certificates, strict rate limits.
5575    Production,
5576    /// A custom ACME directory URL (e.g. a private CA or a pebble test server).
5577    Custom {
5578        /// The directory URL (e.g. `https://acme.example.com/directory`).
5579        url: String,
5580    },
5581}
5582
5583/// Behavior when a configured read replica is unavailable or stale.
5584#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
5585#[serde(rename_all = "snake_case")]
5586#[non_exhaustive]
5587pub enum ReplicaFallback {
5588    /// Readiness should fail when the configured replica cannot safely serve reads.
5589    #[default]
5590    FailReadiness,
5591    /// Read paths may use the primary when the replica is unavailable or stale.
5592    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/// Strategy for routing reads that follow a write within the same request or
5608/// client session.
5609///
5610/// Replication is asynchronous: a read immediately after a write can land on a
5611/// lagging replica and return stale data (the read-your-own-writes anomaly).
5612/// This setting lets Autumn pin such reads to the primary.
5613///
5614/// Configured via `database.read_your_writes` in `autumn.toml` or
5615/// `AUTUMN_DATABASE__READ_YOUR_WRITES` in the environment.
5616///
5617/// Default: `off` (preserves today's behavior — no post-write pinning).
5618#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
5619#[serde(rename_all = "snake_case")]
5620#[non_exhaustive]
5621pub enum ReadYourWrites {
5622    /// No post-write read pinning. Replica reads are always served from the
5623    /// replica. This is the default and preserves existing behavior exactly.
5624    #[default]
5625    Off,
5626    /// Once the current request checks out a **primary** connection (via `Db`
5627    /// or a generated mutating repository method), all subsequent
5628    /// replica-eligible reads within the same request are redirected to the
5629    /// primary. Analogous to Laravel's "sticky" behavior.
5630    Request,
5631    /// Like `request`, and additionally pins a client's reads to the primary
5632    /// for [`pin_after_write_secs`](DatabaseConfig::pin_after_write_secs)
5633    /// seconds after a write, via a signed `autumn.ryw` cookie. Reads within
5634    /// that window are served from the primary even if the request itself
5635    /// performed no write. Analogous to Rails' automatic role switching.
5636    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/// A logical slot assignment entry in a shard's `slots` list.
5653///
5654/// Accepts a single slot index (`5`) or an inclusive range written as a
5655/// string (`"0-31"`). A string holding a single number (`"5"`) is also
5656/// accepted so environment-variable overrides can pass everything as text.
5657#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
5658#[serde(untagged)]
5659pub enum SlotSpec {
5660    /// A single slot index.
5661    Index(u16),
5662    /// `"A-B"` inclusive range, or `"N"` single index.
5663    Range(String),
5664}
5665
5666impl SlotSpec {
5667    /// Expand into concrete slot indices.
5668    ///
5669    /// # Errors
5670    ///
5671    /// Returns a human-readable message when a range string is malformed
5672    /// or inverted (`"31-0"`).
5673    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/// One horizontal shard of the application's data, declared via
5699/// `[[database.shards]]` in `autumn.toml`.
5700///
5701/// Each shard is a full primary/replica topology of its own, so the
5702/// replica story composes with sharding: any shard may have a read
5703/// replica, role-specific pool sizes, and its own fallback behavior.
5704/// Fields left unset fall back to the corresponding `[database]` value.
5705///
5706/// # Routing: keys → logical slots → shards
5707///
5708/// Routing keys hash onto a fixed set of [`SLOT_COUNT`] (16384) **logical
5709/// slots**, and each slot maps to one shard. The key→slot hash is a
5710/// permanent contract; the slot→shard map is plain configuration. Growing
5711/// from two shards to three means moving whole slots — copy a slot's rows
5712/// to the new shard, flip its `slots` entry, deploy — without rehashing
5713/// any keys.
5714///
5715/// When **every** shard declares [`slots`](Self::slots), declaration
5716/// order is meaningless and entries can be reordered, renamed, or
5717/// removed freely (as long as the map still covers every slot exactly
5718/// once). When **no** shard declares `slots`, the framework auto-splits
5719/// the slot space into contiguous even ranges **by declaration order**
5720/// — convenient to start with, but reordering entries then moves data.
5721/// Pin explicit `slots` before making any topology change.
5722///
5723/// # Example
5724///
5725/// ```toml
5726/// [database]
5727/// primary_url = "postgres://db-control/app"   # control role: jobs, sessions, flags
5728///
5729/// [[database.shards]]
5730/// name = "shard0"
5731/// primary_url = "postgres://db-shard0/app"
5732/// slots = ["0-8191"]
5733///
5734/// [[database.shards]]
5735/// name = "shard1"
5736/// primary_url = "postgres://db-shard1/app"
5737/// slots = ["8192-16383"]
5738/// replica_url = "postgres://db-shard1-ro/app"
5739/// replica_fallback = "primary"
5740/// ```
5741#[derive(Debug, Clone, Default, Deserialize)]
5742pub struct ShardConfig {
5743    /// Stable shard identity used in logs, metric tags, health component
5744    /// names (`db:shard:<name>`), and `autumn migrate --shard <name>`.
5745    ///
5746    /// Must be non-empty, unique across shards, and restricted to
5747    /// `[a-z0-9_-]` so it can be embedded in metric/health keys.
5748    pub name: String,
5749
5750    /// Postgres URL for this shard's primary/write role. Required.
5751    pub primary_url: String,
5752
5753    /// Logical slots this shard owns, as indices and/or `"A-B"` inclusive
5754    /// ranges (e.g. `slots = ["0-8191", 16000, "16382-16383"]`).
5755    ///
5756    /// All-or-none across shards: either every shard declares `slots`
5757    /// (explicit map covering `0..16384` exactly once; an empty list
5758    /// marks a drained shard being decommissioned) or none does
5759    /// (contiguous auto-split by declaration order).
5760    #[serde(default)]
5761    pub slots: Option<Vec<SlotSpec>>,
5762
5763    /// Optional Postgres URL for this shard's read-replica role.
5764    #[serde(default)]
5765    pub replica_url: Option<String>,
5766
5767    /// Optional primary pool size override. Falls back to
5768    /// `database.primary_pool_size`, then `database.pool_size`.
5769    #[serde(default)]
5770    pub primary_pool_size: Option<usize>,
5771
5772    /// Optional replica pool size override. Falls back to
5773    /// `database.replica_pool_size`, then `database.pool_size`.
5774    #[serde(default)]
5775    pub replica_pool_size: Option<usize>,
5776
5777    /// Optional replica fallback override. Falls back to
5778    /// `database.replica_fallback`.
5779    #[serde(default)]
5780    pub replica_fallback: Option<ReplicaFallback>,
5781}
5782
5783impl ShardConfig {
5784    /// Resolved primary pool size for this shard.
5785    #[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    /// Resolved replica pool size for this shard.
5792    #[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    /// Resolved replica fallback behavior for this shard.
5799    #[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/// Which database engine a configured connection target names.
5806///
5807/// Autumn recognizes two backends. Postgres is the fully wired runtime; `SQLite`
5808/// (issue #1614) is recognized at config time so a `SQLite` target validates and
5809/// is reported honestly, while the runtime pool that would serve it refuses at
5810/// boot until the pool rework lands (see [`create_pool`](crate::db::create_pool)).
5811///
5812/// # Detection rules
5813///
5814/// [`DatabaseBackend::detect`] classifies a target string by its scheme:
5815///
5816/// - `postgres://` / `postgresql://` URLs, and libpq keyword/value strings
5817///   (`host=db user=app sslmode=require`), are [`Postgres`](Self::Postgres) —
5818///   exactly the shapes the connection pool already accepts.
5819/// - `sqlite://<path>`, `sqlite:<path>`, and `file:<path>` targets are
5820///   [`Sqlite`](Self::Sqlite). `sqlite://` is the canonical, unambiguous form
5821///   and should be preferred.
5822/// - Anything else (including a **bare filesystem path** like
5823///   `/var/lib/app.db`) is deliberately *not* recognized and returns `None`.
5824///   A bare path is ambiguous — it carries no scheme distinguishing it from a
5825///   typo'd URL — so callers must spell `SQLite` targets with an explicit
5826///   `sqlite://` (or `sqlite:` / `file:`) scheme.
5827#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5828pub enum DatabaseBackend {
5829    /// `PostgreSQL` — the fully wired runtime backend.
5830    Postgres,
5831    /// `SQLite` — recognized at config time; the runtime pool is not yet wired
5832    /// (issue #1614).
5833    Sqlite,
5834}
5835
5836impl DatabaseBackend {
5837    /// Detect the backend named by a database target string, or `None` when the
5838    /// target matches no recognized shape. See the [type docs](Self) for the
5839    /// full rule table, including why a bare filesystem path is not recognized.
5840    #[must_use]
5841    pub fn detect(target: &str) -> Option<Self> {
5842        // Check the SQLite schemes first: they are unambiguous prefixes and
5843        // never overlap with a Postgres URL or keyword/value string.
5844        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    /// Lowercase name used in boot-time error messages.
5854    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
5868/// Whether `s` names a `SQLite` target: the canonical `sqlite://<path>` URL, the
5869/// shorter `sqlite:<path>` form, or a `file:<path>` target. A bare filesystem
5870/// path is intentionally excluded (see [`DatabaseBackend`]).
5871fn is_sqlite_target(s: &str) -> bool {
5872    // `sqlite://` is subsumed by the `sqlite:` prefix; both are accepted.
5873    s.starts_with("sqlite:") || s.starts_with("file:")
5874}
5875
5876/// Database connection configuration.
5877///
5878/// When `url` is `None` (the default), the application runs without a
5879/// database -- useful for static-site or API-gateway use cases. Set a
5880/// Postgres URL to enable the connection pool and the [`Db`](crate::Db)
5881/// extractor.
5882///
5883/// # Defaults
5884///
5885/// | Field | Default |
5886/// |-------|---------|
5887/// | `url` | `None` |
5888/// | `primary_url` | `None` |
5889/// | `replica_url` | `None` |
5890/// | `pool_size` | `10` |
5891/// | `primary_pool_size` | `None` |
5892/// | `replica_pool_size` | `None` |
5893/// | `replica_fallback` | `fail_readiness` |
5894/// | `connect_timeout_secs` | `5` |
5895/// | `auto_migrate_in_production` | `false` |
5896/// | `shards` | `[]` |
5897///
5898/// # Examples
5899///
5900/// ```rust
5901/// use autumn_web::config::DatabaseConfig;
5902///
5903/// let db = DatabaseConfig::default();
5904/// assert!(db.url.is_none());
5905/// assert_eq!(db.pool_size, 10);
5906/// ```
5907#[derive(Debug, Clone, Deserialize)]
5908pub struct DatabaseConfig {
5909    /// Postgres connection URL. `None` means no database is configured.
5910    ///
5911    /// Compatibility alias for the primary/write role. New multi-role
5912    /// deployments should prefer [`primary_url`](Self::primary_url).
5913    ///
5914    /// When present, must start with `postgres://` or `postgresql://`, or be
5915    /// a libpq-style keyword/value connection string
5916    /// (`host=db user=app dbname=app sslmode=require`).
5917    #[serde(default)]
5918    pub url: Option<String>,
5919
5920    /// Postgres URL for the primary/write role.
5921    ///
5922    /// All writes, transactions, advisory locks, and migrations use this role.
5923    /// When unset, [`url`](Self::url) remains the single-primary fallback.
5924    #[serde(default)]
5925    pub primary_url: Option<String>,
5926
5927    /// Optional Postgres URL for the read/replica role.
5928    ///
5929    /// Read-only paths may use this pool when configured. If omitted, read
5930    /// paths use the primary role.
5931    #[serde(default)]
5932    pub replica_url: Option<String>,
5933
5934    /// Maximum number of connections in the pool. Default: `10`.
5935    ///
5936    /// Compatibility/default pool size used for both roles unless a
5937    /// role-specific size is set.
5938    #[serde(default = "default_pool_size")]
5939    pub pool_size: usize,
5940
5941    /// Optional primary/write role pool size.
5942    #[serde(default)]
5943    pub primary_pool_size: Option<usize>,
5944
5945    /// Optional read/replica role pool size.
5946    #[serde(default)]
5947    pub replica_pool_size: Option<usize>,
5948
5949    /// Deterministic behavior for configured replicas that cannot safely serve
5950    /// reads. Default: fail readiness.
5951    #[serde(default)]
5952    pub replica_fallback: ReplicaFallback,
5953
5954    /// Post-write read pinning strategy. Default: `off` (no pinning).
5955    ///
5956    /// Set to `request` to pin reads to the primary for the remainder of the
5957    /// request after the first write. Set to `session` to additionally pin
5958    /// reads across requests via a signed cookie.
5959    ///
5960    /// Override via `AUTUMN_DATABASE__READ_YOUR_WRITES`.
5961    #[serde(default)]
5962    pub read_your_writes: ReadYourWrites,
5963
5964    /// Duration (seconds) for cross-request session pins.
5965    ///
5966    /// Only used when `read_your_writes = "session"`. A signed `autumn.ryw`
5967    /// cookie pins the client's reads to the primary for this many seconds
5968    /// after a write. Default: `5`.
5969    ///
5970    /// Override via `AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS`.
5971    #[serde(default = "default_pin_after_write_secs")]
5972    pub pin_after_write_secs: u64,
5973
5974    /// Seconds to wait while acquiring a pooled connection, including
5975    /// creating a new connection when the pool grows.
5976    /// Default: `5`.
5977    #[serde(default = "default_connect_timeout")]
5978    pub connect_timeout_secs: u64,
5979
5980    /// Bounded startup wait (seconds) for the database to become reachable
5981    /// before the migrator fails. `0` (the default) disables the wait and
5982    /// preserves the current fail-fast behaviour — a single connection attempt,
5983    /// no retry.  Set a non-zero value (e.g. `60`) to have `autumn migrate`
5984    /// retry with capped exponential backoff until either the database accepts
5985    /// connections or the window elapses.
5986    ///
5987    /// Override via `AUTUMN_DATABASE__STARTUP_WAIT_SECS`.
5988    #[serde(default)]
5989    pub startup_wait_secs: u64,
5990
5991    /// When true, permits automatic migration application while running with
5992    /// `prod`/`production` profile. Default: `false`.
5993    ///
5994    /// Keep this disabled for multi-replica production fleets and use an
5995    /// explicit migration job (`autumn migrate`) instead.
5996    #[serde(default)]
5997    pub auto_migrate_in_production: bool,
5998
5999    /// Optional database statement timeout.
6000    #[serde(deserialize_with = "deserialize_option_duration", default)]
6001    pub statement_timeout: Option<std::time::Duration>,
6002
6003    /// Slow query threshold. Default: `500ms`.
6004    #[serde(
6005        deserialize_with = "deserialize_duration",
6006        default = "default_slow_query_threshold"
6007    )]
6008    pub slow_query_threshold: std::time::Duration,
6009
6010    /// Horizontal shards, declared as `[[database.shards]]` entries.
6011    ///
6012    /// Empty (the default) means the application is unsharded and only the
6013    /// `url`/`primary_url`/`replica_url` roles above apply. When non-empty,
6014    /// those top-level roles become the **control** topology — framework
6015    /// state (jobs, scheduler locks, sessions, feature flags) lives there
6016    /// while tenant data is routed across the shards. See [`ShardConfig`].
6017    #[serde(default)]
6018    pub shards: Vec<ShardConfig>,
6019
6020    /// Route tenants through the control-plane `_autumn_shard_directory` table
6021    /// (a [`DirectoryShardRouter`](crate::sharding::DirectoryShardRouter))
6022    /// instead of pure slot-hash routing. Default: `false`.
6023    ///
6024    /// Tenants with a directory row are pinned to the named shard; everyone
6025    /// else falls back to the hash router. Usually set via
6026    /// [`AppBuilder::with_directory_shard_router`](crate::app::AppBuilder::with_directory_shard_router).
6027    /// Ignored when no shards are configured or an explicit
6028    /// [`with_shard_router`](crate::app::AppBuilder::with_shard_router) is set.
6029    #[serde(default)]
6030    pub directory_shard_router: bool,
6031
6032    /// Emit a startup warning when the aggregate maximum connection count
6033    /// across the control topology and every shard pool reaches this value.
6034    /// Default: `100`.
6035    ///
6036    /// Pool sizes multiply across shards: an N-shard fleet with a pool size
6037    /// of 20 opens up to `20 * N` connections, which can exhaust Postgres's
6038    /// `max_connections` (default 100) long before the app looks busy. This
6039    /// threshold surfaces that footgun at boot. Set to `0` to disable.
6040    #[serde(default = "default_max_connections_warn_threshold")]
6041    pub max_connections_warn_threshold: usize,
6042}
6043
6044/// Decide whether the aggregate connection count warrants a startup warning.
6045///
6046/// Pure so the boundary condition is unit-testable without booting an app.
6047/// A `threshold` of `0` disables the warning entirely.
6048pub(crate) const fn should_warn_total_connections(total: usize, threshold: usize) -> bool {
6049    threshold != 0 && total >= threshold
6050}
6051
6052/// Render a sorted slot list as compact `A-B` ranges for error messages
6053/// (a gap in a 16384-slot map would otherwise print thousands of indices).
6054fn 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
6079/// Number of logical routing slots shared across all shards. Fixed,
6080/// not configurable — the same constant for every Autumn deployment,
6081/// matching Redis Cluster and Valkey.
6082///
6083/// Keys hash onto `0..SLOT_COUNT` and each slot maps to one shard, so
6084/// resharding means moving whole slots between shards rather than
6085/// rehashing keys. Slots are pure routing-table entries (no pools, no
6086/// per-slot resources), so the fixed count costs almost nothing while
6087/// removing the classic "chose too few partitions on day one"
6088/// failure mode: there is no value to pick and nothing to outgrow
6089/// short of 16384 physical shards.
6090pub const SLOT_COUNT: u16 = 16384;
6091
6092/// The resolved slot assignment for a single shard, expressed as a name and a
6093/// compact range string (e.g. `"0-8191"` or `"0-5460, 10923-16383"`).
6094///
6095/// Used by the boot-time shard-map guard to compare the freshly-computed
6096/// auto-split against the map stored on first boot. An empty `ranges` string
6097/// represents a drained shard (all slots moved away); that only arises in
6098/// explicit-slot mode, where the guard is inert.
6099#[derive(Debug, Clone, PartialEq, Eq)]
6100pub struct ShardSlotAssignment {
6101    pub name: String,
6102    pub ranges: String,
6103}
6104
6105/// Guard: compare the freshly-computed slot map against the stored map.
6106///
6107/// Returns `Ok(())` — no action required — when:
6108/// - `auto_split` is `false` (explicit-slot mode: operator-managed, no guard),
6109/// - `stored` is `None` (first boot: nothing to compare against), or
6110/// - the computed and stored maps are identical (order-insensitive).
6111///
6112/// Returns `Err` with a human-readable message when auto-split is active, a
6113/// stored map exists, and the maps differ.
6114///
6115/// Pure and sync so it can be unit-tested without a database.
6116///
6117/// # Errors
6118///
6119/// Returns a `String` description when the auto-split map differs from the
6120/// stored map.
6121pub 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
6155/// The cross-backend consistency rule, as a single source of truth.
6156///
6157/// Shared by boot-time validation ([`DatabaseConfig::validate`], via
6158/// `DatabaseConfig::validate_backend_consistency`) and out-of-process callers
6159/// such as `autumn doctor`, so both agree for *every* role/backend mismatch
6160/// without re-deriving the rule.
6161///
6162/// The roles map to the config fields: `url` is the legacy `database.url`,
6163/// `primary_url` is `database.primary_url`, `replica_url` is
6164/// `database.replica_url`, and `has_shards` is whether any `[[database.shards]]`
6165/// are configured. The effective primary backend is `primary_url` if set, else
6166/// the legacy `url` (mirroring [`DatabaseConfig::effective_primary_url`]).
6167///
6168/// `SQLite` is a valid *target* but a narrower runtime than Postgres, so several
6169/// Postgres-only topologies (read replicas, horizontal shards, mixed backends)
6170/// are refused up front with actionable messages. The Postgres path is
6171/// behaviourally unchanged: a Postgres primary with Postgres roles and no
6172/// `SQLite` anywhere hits none of these branches and returns `Ok(())`.
6173///
6174/// This is the single source of truth for the rule; do not re-implement it.
6175///
6176/// # Errors
6177///
6178/// Returns `Err(message)` describing the first offending role when the topology
6179/// mixes backends or pairs a `SQLite` primary with a Postgres-only feature. The
6180/// message is byte-identical to what boot-time validation reports.
6181pub 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        // Read replicas are a Postgres topology concept; SQLite has no
6193        // replica role to serve reads from.
6194        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        // Horizontal sharding is Postgres-only.
6202        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    // Every configured connection role must name the same backend. Mixing
6212    // (e.g. a Postgres primary with a SQLite replica, or vice versa) cannot
6213    // work and is a boot-time misconfiguration rather than a first-query
6214    // surprise.
6215    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    /// Resolved primary/write database URL.
6232    #[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    /// Resolved primary/write role pool size.
6238    #[must_use]
6239    pub fn effective_primary_pool_size(&self) -> usize {
6240        self.primary_pool_size.unwrap_or(self.pool_size)
6241    }
6242
6243    /// Resolved read/replica role pool size.
6244    #[must_use]
6245    pub fn effective_replica_pool_size(&self) -> usize {
6246        self.replica_pool_size.unwrap_or(self.pool_size)
6247    }
6248
6249    /// Whether any `[[database.shards]]` entries are configured.
6250    #[must_use]
6251    pub const fn has_shards(&self) -> bool {
6252        !self.shards.is_empty()
6253    }
6254
6255    /// Resolve the slot→shard map: element `s` is the index (into
6256    /// [`shards`](Self::shards)) of the shard that owns slot `s`.
6257    ///
6258    /// This is the single source of truth for slot assignment, used by both
6259    /// configuration validation and runtime
6260    /// [`ShardSet`](crate::sharding::ShardSet) construction:
6261    ///
6262    /// - When **no** shard declares `slots`, the slot space is auto-split
6263    ///   into contiguous even ranges by declaration order.
6264    /// - When **every** shard declares `slots`, the explicit assignments are
6265    ///   used and must cover <code>0..[SLOT_COUNT]</code> exactly once.
6266    /// - Mixing declared and undeclared `slots` is an error.
6267    ///
6268    /// # Errors
6269    ///
6270    /// Returns [`ConfigError::Validation`] for mixed declarations,
6271    /// malformed/out-of-range/duplicate slots, or incomplete coverage.
6272    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            // Contiguous even auto-split by declaration order.
6290            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        // Coverage was just verified, so flatten cannot drop entries.
6339        Ok(map.into_iter().flatten().collect())
6340    }
6341
6342    /// Whether all shards are using auto-split (no shard declares `slots`).
6343    ///
6344    /// Returns `false` when no shards are configured or any shard has an
6345    /// explicit `slots` declaration. Mixed declarations already error in
6346    /// [`resolved_slot_map`](Self::resolved_slot_map), so this is a simple
6347    /// all-or-none check.
6348    #[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    /// Resolve the per-shard slot assignment as compact range strings.
6354    ///
6355    /// Inverts [`resolved_slot_map`](Self::resolved_slot_map) (slot→shard-index)
6356    /// into per-shard slot lists rendered via the same compact range notation
6357    /// used in slot-map error messages. Agrees with runtime routing by
6358    /// construction: the output derives from the same slot map that builds the
6359    /// live [`ShardSet`](crate::sharding::ShardSet).
6360    ///
6361    /// # Errors
6362    ///
6363    /// Propagates any [`ConfigError`] from `resolved_slot_map`.
6364    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    /// Cross-backend consistency checks (issue #1614).
6383    ///
6384    /// `SQLite` is a valid *target* but a narrower runtime than Postgres, so
6385    /// several Postgres-only knobs are refused at boot (not at first query)
6386    /// with actionable messages. The Postgres path is behaviourally unchanged:
6387    /// a Postgres primary with Postgres roles and no `SQLite` anywhere hits none
6388    /// of these branches.
6389    fn validate_backend_consistency(&self) -> Result<(), ConfigError> {
6390        // Single source of truth: delegate to the free
6391        // [`database_backend_consistency`] rule so boot and out-of-process
6392        // callers (e.g. `autumn doctor`) agree for every role/backend mismatch.
6393        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    /// Validate database configuration.
6403    ///
6404    /// # Errors
6405    ///
6406    /// Returns a validation error if a connection string is malformed or a
6407    /// shard declaration is malformed.
6408    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            // A SQLite target (issue #1614) is now a recognized shape and
6415            // passes this per-field check; only strings that are neither a
6416            // Postgres nor a SQLite target are rejected here. The message is
6417            // unchanged for the Postgres-shaped forms so existing deployments
6418            // and diagnostics see byte-for-byte identical errors.
6419            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
6489/// Whether `s` is an acceptable Postgres connection string: a
6490/// `postgres://`/`postgresql://` URL, or a libpq-style keyword/value string
6491/// (`host=db user=app sslmode=require`) — recognized with the SAME parser
6492/// the pool's TLS module uses ([`crate::pg_conn_str`]), so every string the
6493/// pool supports also passes config validation (issue #1585 review: the
6494/// keyword form was rejected here before ever reaching the pool).
6495fn 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/// Logging configuration.
6500///
6501/// Controls the tracing subscriber's filter level and output format.
6502/// See [`LogFormat`] for output format options.
6503///
6504/// # Examples
6505///
6506/// ```rust
6507/// use autumn_web::config::{LogConfig, LogFormat};
6508///
6509/// let log = LogConfig::default();
6510/// assert_eq!(log.level, "info");
6511/// assert_eq!(log.format, LogFormat::Auto);
6512/// assert!(log.access_log);
6513/// ```
6514#[derive(Debug, Clone, Deserialize)]
6515pub struct LogConfig {
6516    /// Tracing filter directive. Default: `"info"`.
6517    ///
6518    /// Supports the full `tracing` filter syntax, e.g.
6519    /// `"autumn=debug,tower_http=trace"`.
6520    #[serde(default = "default_log_level")]
6521    pub level: String,
6522
6523    /// Log output format. Default: [`LogFormat::Auto`].
6524    #[serde(default)]
6525    pub format: LogFormat,
6526
6527    /// Additional sensitive parameter keys to scrub from logs/traces.
6528    #[serde(default)]
6529    pub filter_parameters: Vec<String>,
6530
6531    /// Explicitly remove default sensitive keys from the built-in deny-list.
6532    #[serde(default)]
6533    pub unfilter_parameters: Vec<String>,
6534
6535    /// Emit one structured access-log event per served HTTP request.
6536    /// Default: `true`.
6537    ///
6538    /// The event (target `autumn::access`, level `INFO`) carries `method`,
6539    /// `route` (the matched low-cardinality template), `status`,
6540    /// `duration_ms`, and `request_id`, and is rendered by the standard
6541    /// subscriber according to [`format`](Self::format). It requires no
6542    /// telemetry feature or collector.
6543    #[serde(default = "default_access_log")]
6544    pub access_log: bool,
6545
6546    /// Path prefixes excluded from access logging so steady-state probe and
6547    /// asset traffic does not drown application signal. Default:
6548    /// `["/health", "/live", "/ready", "/startup", "/actuator", "/static"]`
6549    /// (the built-in probe, actuator, and static-asset mounts).
6550    ///
6551    /// Prefixes match whole path segments: `"/actuator"` excludes
6552    /// `/actuator/health` but not `/actuators`. Setting this replaces the
6553    /// default set entirely — and if you move the probe endpoints
6554    /// (`health.path` etc.), mirror the new paths here.
6555    #[serde(default = "default_access_log_exclude")]
6556    pub access_log_exclude: Vec<String>,
6557
6558    /// In-memory log capture buffer for `/actuator/logfile`.
6559    ///
6560    /// When enabled, recent structured log entries are visible over HTTP
6561    /// through the sensitive actuator endpoint without SSH access or an
6562    /// external log aggregator.  The buffer is bounded and never grows
6563    /// unbounded.
6564    #[serde(default)]
6565    pub capture: crate::log::capture::LogCaptureConfig,
6566}
6567
6568/// Log output format.
6569///
6570/// Controls how tracing events are rendered. The default ([`Auto`](Self::Auto))
6571/// auto-detects based on the `AUTUMN_ENV` environment variable.
6572///
6573/// | Variant | Behaviour |
6574/// |---------|-----------|
6575/// | [`Auto`](Self::Auto) | Pretty in dev, JSON when `AUTUMN_ENV=production` |
6576/// | [`Pretty`](Self::Pretty) | Always human-readable, colorized |
6577/// | [`Json`](Self::Json) | Always structured JSON (for log aggregators) |
6578///
6579/// # Examples
6580///
6581/// ```rust
6582/// use autumn_web::config::LogFormat;
6583///
6584/// assert_eq!(LogFormat::default(), LogFormat::Auto);
6585/// ```
6586#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
6587#[non_exhaustive]
6588pub enum LogFormat {
6589    /// Pretty in dev, JSON in production (based on `AUTUMN_ENV`).
6590    #[default]
6591    Auto,
6592    /// Human-readable, colorized output.
6593    Pretty,
6594    /// Structured JSON output suitable for log aggregation pipelines.
6595    Json,
6596}
6597
6598/// Telemetry configuration.
6599///
6600/// Controls whether Autumn enables OTLP trace export and how the process
6601/// identifies itself in resource metadata.
6602#[derive(Debug, Clone, Deserialize)]
6603pub struct TelemetryConfig {
6604    /// Enable framework-managed telemetry. Default: `false`.
6605    #[serde(default)]
6606    pub enabled: bool,
6607
6608    /// Logical service name. Default: `"autumn-app"`.
6609    #[serde(default = "default_telemetry_service_name")]
6610    pub service_name: String,
6611
6612    /// Optional service namespace (e.g. team, domain, or product family).
6613    #[serde(default)]
6614    pub service_namespace: Option<String>,
6615
6616    /// Service version string advertised in resource metadata.
6617    #[serde(default = "default_telemetry_service_version")]
6618    pub service_version: String,
6619
6620    /// Deployment environment label for trace resource metadata.
6621    #[serde(default = "default_telemetry_environment")]
6622    pub environment: String,
6623
6624    /// OTLP collector endpoint. Required when telemetry is enabled.
6625    #[serde(default)]
6626    pub otlp_endpoint: Option<String>,
6627
6628    /// OTLP transport protocol. Default: [`TelemetryProtocol::Grpc`].
6629    #[serde(default)]
6630    pub protocol: TelemetryProtocol,
6631
6632    /// When `true`, telemetry initialization failures abort startup.
6633    #[serde(default)]
6634    pub strict: bool,
6635}
6636
6637/// OTLP transport protocol selection.
6638#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
6639#[non_exhaustive]
6640pub enum TelemetryProtocol {
6641    /// OTLP over gRPC.
6642    #[serde(alias = "grpc", alias = "GRPC")]
6643    #[default]
6644    Grpc,
6645    /// OTLP over HTTP/protobuf.
6646    #[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/// Health check endpoint configuration.
6666///
6667/// The health check is automatically mounted by [`AppBuilder::run`](crate::app::AppBuilder::run).
6668/// See the [`health`](crate::health) module for response format details.
6669///
6670/// # Examples
6671///
6672/// ```rust
6673/// use autumn_web::config::HealthConfig;
6674///
6675/// let health = HealthConfig::default();
6676/// assert_eq!(health.path, "/health");
6677/// assert_eq!(health.live_path, "/live");
6678/// assert_eq!(health.ready_path, "/ready");
6679/// assert_eq!(health.startup_path, "/startup");
6680/// assert!(!health.detailed);
6681/// ```
6682#[derive(Debug, Clone, Deserialize)]
6683pub struct HealthConfig {
6684    /// Compatibility alias path for readiness. Default: `"/health"`.
6685    ///
6686    /// Common alternatives: `"/healthz"`, `"/_health"`.
6687    #[serde(default = "default_health_path")]
6688    pub path: String,
6689
6690    /// URL path for the liveness probe. Default: `"/live"`.
6691    #[serde(default = "default_live_path")]
6692    pub live_path: String,
6693
6694    /// URL path for the readiness probe. Default: `"/ready"`.
6695    #[serde(default = "default_ready_path")]
6696    pub ready_path: String,
6697
6698    /// URL path for the startup probe. Default: `"/startup"`.
6699    #[serde(default = "default_startup_path")]
6700    pub startup_path: String,
6701
6702    /// When `true`, the health endpoint includes detailed info (profile,
6703    /// uptime, pool stats). Default: `false` (overridden to `true` for
6704    /// `dev` profile via smart defaults).
6705    #[serde(default)]
6706    pub detailed: bool,
6707}
6708
6709/// Actuator endpoint configuration.
6710///
6711/// Controls which operational endpoints are exposed. The `sensitive` flag
6712/// determines whether sensitive endpoints (env, configprops, loggers,
6713/// tasks) are available. Defaults to `true` for `dev`, `false` for `prod`.
6714#[derive(Debug, Clone, Deserialize)]
6715pub struct ActuatorConfig {
6716    /// URL prefix for actuator endpoints. Default: `"/actuator"`.
6717    #[serde(default = "default_actuator_prefix")]
6718    pub prefix: String,
6719
6720    /// When `true`, expose sensitive endpoints (env, loggers, tasks).
6721    /// Defaults vary by profile: `true` for dev, `false` for prod.
6722    #[serde(default)]
6723    pub sensitive: bool,
6724
6725    /// When `true`, mount the `/actuator/prometheus` scrape endpoint.
6726    ///
6727    /// This is **independent of [`Self::sensitive`]**: a production app can
6728    /// expose Prometheus metrics for platform scraping (e.g. Fly.io `[metrics]`)
6729    /// while keeping `sensitive = false` so env/configprops/loggers/tasks/jobs
6730    /// stay off the public surface. Set to `false` to remove the scrape
6731    /// endpoint entirely (it then returns `404`). Default: `true`.
6732    #[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/// CORS (Cross-Origin Resource Sharing) configuration.
6755///
6756/// Controls which origins, methods, and headers are allowed for
6757/// cross-origin requests. Disabled by default -- enable by setting
6758/// `allowed_origins` in `autumn.toml` or via environment variables.
6759///
6760/// # Defaults
6761///
6762/// | Field | Default |
6763/// |-------|---------|
6764/// | `allowed_origins` | `[]` (CORS disabled) |
6765/// | `allowed_methods` | `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]` |
6766/// | `allowed_headers` | `["Content-Type", "Authorization"]` |
6767/// | `allow_credentials` | `false` |
6768/// | `max_age_secs` | `86400` (24 hours) |
6769///
6770/// # Profile smart defaults
6771///
6772/// The `dev` profile enables permissive CORS (`allowed_origins = ["*"]`)
6773/// so local front-end development works out of the box.
6774///
6775/// # Examples
6776///
6777/// ```toml
6778/// [cors]
6779/// allowed_origins = ["https://example.com", "https://app.example.com"]
6780/// allow_credentials = true
6781/// ```
6782///
6783/// ```rust
6784/// use autumn_web::config::CorsConfig;
6785///
6786/// let cors = CorsConfig::default();
6787/// assert!(cors.allowed_origins.is_empty());
6788/// assert!(!cors.allow_credentials);
6789/// ```
6790#[derive(Debug, Clone, Deserialize)]
6791pub struct CorsConfig {
6792    /// Origins allowed to make cross-origin requests.
6793    ///
6794    /// Use `["*"]` to allow any origin (not recommended for production
6795    /// with credentials). When empty, CORS middleware is not applied.
6796    #[serde(default)]
6797    pub allowed_origins: Vec<String>,
6798
6799    /// HTTP methods allowed for cross-origin requests.
6800    /// Default: `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]`.
6801    #[serde(default = "default_cors_methods")]
6802    pub allowed_methods: Vec<String>,
6803
6804    /// Headers allowed in cross-origin requests.
6805    /// Default: `["Content-Type", "Authorization"]`.
6806    #[serde(default = "default_cors_headers")]
6807    pub allowed_headers: Vec<String>,
6808
6809    /// Whether to include `Access-Control-Allow-Credentials: true`.
6810    /// Default: `false`.
6811    #[serde(default)]
6812    pub allow_credentials: bool,
6813
6814    /// How long (in seconds) browsers may cache preflight responses.
6815    /// Default: `86400` (24 hours).
6816    #[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    /// Validate CORS configuration for combinations rejected by browsers.
6834    ///
6835    /// # Errors
6836    ///
6837    /// Returns a validation error when `allow_credentials = true` is combined
6838    /// with a wildcard `"*"` origin. Browsers refuse this combination per the
6839    /// Fetch spec, and `tower-http`'s `CorsLayer` panics when asked to build
6840    /// it, so we fail fast at config load with an actionable message.
6841    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// ── CompressionConfig ──────────────────────────────────────────────────────
6873
6874/// Response compression settings (`[compression]` section in `autumn.toml`).
6875///
6876/// Compression is **off by default** to avoid the [BREACH/CRIME] class of
6877/// compression side-channel attacks, where an attacker can infer secret
6878/// content (e.g. CSRF tokens) by observing how the compressed size changes as
6879/// they inject attacker-controlled bytes alongside the secret. Enable only when
6880/// you understand the tradeoff — or when a CDN / reverse-proxy handles TLS and
6881/// terminates there.
6882///
6883/// [BREACH/CRIME]: https://breachattack.com/
6884///
6885/// # One-liner opt-in
6886///
6887/// ```toml
6888/// [compression]
6889/// enabled = true
6890/// ```
6891///
6892/// # Environment variable override
6893///
6894/// | Variable | Field | Type |
6895/// |----------|-------|------|
6896/// | `AUTUMN_COMPRESSION__ENABLED` | `enabled` | `bool` |
6897///
6898/// # `ETag` compatibility
6899///
6900/// Autumn's framework-managed compression layer is applied **outside** any
6901/// user-registered `EtagLayer`, so `ETags` are computed on the uncompressed body.
6902/// Because `CompressionLayer` sets `Vary: Accept-Encoding`, caches correctly
6903/// store separate entries per encoding. Using weak `ETags` (`W/`) when
6904/// compression is enabled is safe per RFC 7232 §2.1 (weak comparison allows
6905/// encoding variations).
6906///
6907/// # Example
6908///
6909/// ```rust
6910/// use autumn_web::config::CompressionConfig;
6911///
6912/// let cfg = CompressionConfig::default();
6913/// assert!(!cfg.enabled);
6914/// ```
6915#[derive(Debug, Clone, Deserialize, Default)]
6916pub struct CompressionConfig {
6917    /// Enable response compression. Default: `false`.
6918    ///
6919    /// When `true`, the framework inserts a `CompressionLayer` that honors the
6920    /// client's `Accept-Encoding` header (gzip and brotli supported) and sets
6921    /// `Vary: Accept-Encoding` on all compressible responses.
6922    /// Non-compressible content types (images, archives) and responses that
6923    /// already carry `Content-Encoding` are passed through unchanged.
6924    #[serde(default)]
6925    pub enabled: bool,
6926}
6927
6928/// Apply `AUTUMN_DEPLOY__*` environment overrides to an optional deploy config.
6929///
6930/// `[deploy]` is a top-level optional section. This materializes it from the
6931/// environment when any of its keys are set (seeding the documented defaults if
6932/// the section was absent/`None`), so a CI/VPS deploy can keep the target host
6933/// out of `autumn.toml` and drive it entirely through `AUTUMN_DEPLOY__*`. Env
6934/// overrides win over any TOML-provided values.
6935///
6936/// Shared by [`AutumnConfig::load`] and `autumn doctor`'s deploy preflight so
6937/// both surfaces resolve the identical deploy target (host, `ssh_port`, …) for the
6938/// same environment + profile + TOML.
6939// Exposed for autumn-cli's `autumn deploy` preflight (doctor) to reuse the deploy env-override logic; not yet a stable public API.
6940#[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    // Opt-in TLS for the deploy-managed proxy (#1969). Env wins over TOML, matching
6977    // every other deploy override above.
6978    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
6982/// Parse an environment variable into a typed target, logging a warning on failure.
6983fn 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
6998/// Secret-aware variant of [`parse_env_option_string`]: an empty (after
6999/// trimming) value clears the target, otherwise the trimmed value is wrapped in
7000/// a [`secrecy::SecretString`] so it is redacted from `Debug` and zeroized on
7001/// drop.
7002fn 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
7058// ── Default functions ──────────────────────────────────────────────
7059
7060const 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
7123/// Default `[server.tls]` cert/key reload poll interval, in seconds.
7124///
7125/// Kept in lockstep with `crate::tls::DEFAULT_RELOAD_INTERVAL_SECS` (the
7126/// serving path's constant); a literal is used here because this default must
7127/// compile even when the `tls` feature — and thus `crate::tls` — is off.
7128const fn default_tls_reload_interval_secs() -> u64 {
7129    60
7130}
7131
7132/// Default `[server.tls]` inbound-handshake timeout, in seconds.
7133///
7134/// Bounds a single TLS handshake so a client that opens TCP but never sends a
7135/// `ClientHello` cannot park the accept loop. 10s is generous for a real
7136/// handshake while still shedding a stalled connection promptly.
7137const fn default_tls_handshake_timeout_secs() -> u64 {
7138    10
7139}
7140
7141/// Default SSH user for `[deploy]`.
7142fn default_deploy_user() -> String {
7143    "root".to_owned()
7144}
7145
7146/// Default SSH port for `[deploy]`.
7147const fn default_deploy_ssh_port() -> u16 {
7148    22
7149}
7150
7151/// Default readiness window (seconds) before an `autumn deploy` rolls back.
7152const fn default_deploy_readiness_timeout_secs() -> u64 {
7153    60
7154}
7155
7156/// Default number of prior releases retained on the host for rollback.
7157const fn default_deploy_keep_releases() -> u32 {
7158    3
7159}
7160
7161/// Default profile the deployed app runs under. Defaults to the production
7162/// profile so an `autumn deploy` never silently boots under the `dev` profile.
7163fn default_deploy_profile() -> String {
7164    "prod".to_owned()
7165}
7166
7167/// Default directory for the ACME account key and issued certificates
7168/// (`[server.tls.acme] cache_dir`).
7169fn default_acme_cache_dir() -> PathBuf {
7170    PathBuf::from("config/acme")
7171}
7172
7173/// Default HTTP-01 challenge / redirect port (`[server.tls.acme]
7174/// http_challenge_port`). The ACME CA always validates HTTP-01 over port 80.
7175const fn default_acme_http_challenge_port() -> u16 {
7176    80
7177}
7178
7179/// Default renew-before window in days (`[server.tls.acme] renew_before_days`).
7180/// Let's Encrypt certificates are valid for 90 days; renewing with 30 days left
7181/// leaves ample slack for retries.
7182const 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
7202// ── Default trait impls ────────────────────────────────────────────
7203
7204impl 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
7286// ----------------------------------------------------------------------------
7287// ConfigLoader — tier-1 boot-time replaceable config loading
7288// ----------------------------------------------------------------------------
7289
7290/// Pluggable boot-time configuration loader.
7291///
7292/// Replace the default TOML + env loader with a custom strategy (e.g. AWS
7293/// Secrets Manager, Consul, a JSON file, an HTTP fetch) by implementing this
7294/// trait and installing it on the [`AppBuilder`](crate::app::AppBuilder) via
7295/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
7296///
7297/// The trait's return type uses `impl Future + Send` so implementations can
7298/// freely use `async fn` in their bodies while the framework can still spawn
7299/// the load on any executor.
7300///
7301/// # Example
7302///
7303/// ```rust,no_run
7304/// use autumn_web::config::{AutumnConfig, ConfigError, ConfigLoader};
7305///
7306/// pub struct JsonFileConfigLoader { path: std::path::PathBuf }
7307///
7308/// impl ConfigLoader for JsonFileConfigLoader {
7309///     async fn load(&self) -> Result<AutumnConfig, ConfigError> {
7310///         let bytes = std::fs::read(&self.path).map_err(ConfigError::Io)?;
7311///         serde_json::from_slice(&bytes)
7312///             .map_err(|e| ConfigError::Validation(e.to_string()))
7313///     }
7314/// }
7315/// ```
7316pub trait ConfigLoader: Send + Sync + 'static {
7317    /// Load and return a fully-resolved [`AutumnConfig`].
7318    ///
7319    /// Implementations are responsible for any layering, profile resolution,
7320    /// and validation they care to apply. The default implementation
7321    /// ([`TomlEnvConfigLoader`]) preserves Autumn's five-layer load
7322    /// (framework defaults → profile defaults → `autumn.toml` →
7323    /// `autumn-{profile}.toml` → `AUTUMN_*` env vars).
7324    fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
7325}
7326
7327/// Default [`ConfigLoader`] — Autumn's five-layer TOML + env load strategy.
7328///
7329/// Delegates to [`AutumnConfig::load_with_env`] using [`OsEnv`] for environment
7330/// variable reads. This is the loader used when no override is installed via
7331/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
7332#[derive(Debug, Default, Clone)]
7333pub struct TomlEnvConfigLoader {
7334    /// Top-level config roots declared by plugins via
7335    /// [`AppBuilder::config_section`](crate::app::AppBuilder::config_section).
7336    /// Each is treated as known-and-opaque under `server.strict_config`. Empty
7337    /// by default, so a bare `TomlEnvConfigLoader::new()` behaves exactly as
7338    /// before the plugin config-section seam.
7339    allowed_plugin_roots: BTreeSet<String>,
7340}
7341
7342impl TomlEnvConfigLoader {
7343    /// Construct a new default loader with no declared plugin config roots.
7344    #[must_use]
7345    pub const fn new() -> Self {
7346        Self {
7347            allowed_plugin_roots: BTreeSet::new(),
7348        }
7349    }
7350
7351    /// Declare the plugin-owned top-level config roots this loader should treat
7352    /// as known-and-opaque under `server.strict_config`.
7353    ///
7354    /// Wired by [`AppBuilder::run`](crate::app::AppBuilder::run) from the roots
7355    /// registered through
7356    /// [`config_section`](crate::app::AppBuilder::config_section), so a
7357    /// plugin-enabled app boots under strict config while genuinely-unknown
7358    /// roots still hard-fail. See
7359    /// [`load_with_env_and_plugin_roots`](AutumnConfig::load_with_env_and_plugin_roots).
7360    #[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        // Feed a project-root `.env` into the `AUTUMN_*` env layer before
7370        // resolving config from the real environment. Rather than mutating the
7371        // process environment (unsound on a live multi-threaded runtime), `.env`
7372        // values are layered *under* the real environment via an overlay `Env`,
7373        // so a real env var always wins. The sync file IO in `resolve_dotenv_vars`
7374        // is fine on the async path. A malformed `.env` fails loudly here rather
7375        // than silently skipping developer-provided values.
7376        let base = OsEnv;
7377        let profile = resolve_profile(&base);
7378        // Resolve `.env` from the same base directory config uses for
7379        // `autumn.toml` (AUTUMN_MANIFEST_DIR when set, else the process CWD),
7380        // so a binary launched from outside its crate root reads the `.env`
7381        // next to its config instead of the process working directory.
7382        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
7394/// Parses a duration string like "500ms", "5s", "2m", "1h",
7395/// or a plain integer representing milliseconds.
7396///
7397/// # Errors
7398/// Returns a `String` describing the parse failure when the input is empty,
7399/// has an unrecognised suffix, or contains a non-numeric value.
7400pub 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    // Check if it's a plain integer
7406    if let Ok(ms) = s.parse::<u64>() {
7407        return Ok(std::time::Duration::from_millis(ms));
7408    }
7409
7410    // Try parsing suffix
7411    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
7448/// Deserialises a TOML/JSON value into a [`std::time::Duration`].
7449///
7450/// Accepts either a string (`"500ms"`, `"5s"`, `"2m"`, `"1h"`) or a bare
7451/// integer (interpreted as milliseconds).
7452///
7453/// # Errors
7454/// Returns a deserialisation error if the value is not a valid duration.
7455pub 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
7474/// Deserialises an optional TOML/JSON value into <code>Option&lt;[std::time::Duration]&gt;</code>.
7475///
7476/// Accepts either a string (`"500ms"`, `"5s"`, `"2m"`, `"1h"`), a bare
7477/// integer (milliseconds), or `null`/absent to mean no timeout.
7478///
7479/// # Errors
7480/// Returns a deserialisation error if the value is present but invalid.
7481pub 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/// Row-level multi-tenancy configuration.
7496#[derive(Debug, Clone, Deserialize)]
7497pub struct TenancyConfig {
7498    /// Whether row-level multi-tenancy is enabled.
7499    #[serde(default)]
7500    pub enabled: bool,
7501
7502    /// Source configuration from which the tenant ID is extracted.
7503    /// Values can be "header" (default), "subdomain", "session", "jwt".
7504    #[serde(default = "default_tenancy_source")]
7505    pub source: String,
7506
7507    /// Header name to lookup if source is "header". Default: "x-tenant-id".
7508    #[serde(default = "default_tenancy_header_name")]
7509    pub header_name: String,
7510
7511    /// Session key to lookup if source is "session". Default: "`tenant_id`".
7512    #[serde(default = "default_tenancy_session_key")]
7513    pub session_key: String,
7514
7515    /// JWT claim to lookup if source is "jwt". Default: "`tenant_id`".
7516    #[serde(default = "default_tenancy_jwt_claim")]
7517    pub jwt_claim: String,
7518
7519    /// JWT secret key used to verify the JWT signature.
7520    ///
7521    /// Stored as a [`secrecy::SecretString`] so the raw value is redacted
7522    /// from `Debug` output and zeroized on drop. Call
7523    /// [`secrecy::ExposeSecret::expose_secret`] at the point of use.
7524    #[serde(default)]
7525    pub jwt_secret: Option<secrecy::SecretString>,
7526
7527    /// Expected JWT issuer to validate.
7528    #[serde(default)]
7529    pub jwt_issuer: Option<String>,
7530
7531    /// Expected JWT audience (`aud` claim) to validate.
7532    /// When set, audience checking is enabled; when `None`, audience checking
7533    /// is skipped for backward compatibility.
7534    #[serde(default)]
7535    pub jwt_audience: Option<String>,
7536
7537    /// Optional base domain for subdomain tenancy.
7538    #[serde(default)]
7539    pub base_domain: Option<String>,
7540
7541    /// Request paths that bypass tenant resolution entirely, so they remain
7542    /// reachable without a tenant (e.g. `/login`, `/signup`, static assets).
7543    ///
7544    /// Matching is exact or slash-delimited prefix: `/login` matches `/login`
7545    /// and `/login/sso` but not `/login-admin`. The configured health check
7546    /// path is always treated as public regardless of this list.
7547    #[serde(default)]
7548    pub public_paths: Vec<String>,
7549
7550    /// Where to redirect when a non-public request has no valid tenant.
7551    ///
7552    /// When set, a missing/unauthenticated tenant on a protected path returns a
7553    /// 302 redirect here instead of a raw 401 — friendlier for browser `SaaS`
7554    /// logins. When `None`, the underlying authorization error is returned.
7555    #[serde(default)]
7556    pub login_redirect: Option<String>,
7557
7558    /// Soft per-tenant memory quota, in bytes, for in-process tenant cells.
7559    /// `0` disables the quota (unlimited).
7560    #[serde(default)]
7561    pub quota_bytes: usize,
7562
7563    /// Maximum number of resident tenant cells; least-recently-used cells are
7564    /// evicted above this. `0` = unbounded.
7565    #[serde(default)]
7566    pub max_cells: usize,
7567
7568    /// Evict a tenant cell whose last access exceeds this many seconds.
7569    /// `0` = disabled.
7570    #[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// ── Resilience configuration ───────────────────────────────────────────────
7612
7613/// Resilience policy configurations.
7614#[derive(Debug, Clone, Default, Deserialize)]
7615pub struct ResilienceConfig {
7616    /// Circuit breaker configurations.
7617    #[serde(default)]
7618    pub circuit_breaker: CircuitBreakerConfig,
7619}
7620
7621/// Circuit breaker configuration structure.
7622#[derive(Debug, Clone, Default, Deserialize)]
7623pub struct CircuitBreakerConfig {
7624    /// Default circuit breaker policies.
7625    #[serde(default)]
7626    pub defaults: CircuitBreakerPolicyConfig,
7627    /// Per-host circuit breaker policy overrides.
7628    #[serde(default)]
7629    pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
7630}
7631
7632/// Configurable settings for a circuit breaker policy.
7633#[derive(Debug, Clone, Default, Deserialize)]
7634pub struct CircuitBreakerPolicyConfig {
7635    /// Failure ratio threshold (e.g. 0.5) to trip the breaker.
7636    pub failure_ratio_threshold: Option<f64>,
7637    /// Sample window duration in seconds.
7638    pub sample_window_secs: Option<u64>,
7639    /// Minimum samples required to evaluate failure ratio.
7640    pub minimum_sample_count: Option<u64>,
7641    /// Open state duration in seconds before entering half-open.
7642    pub open_duration_secs: Option<u64>,
7643    /// Number of successful trials required in half-open state to close the breaker.
7644    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    /// Per-path override for what `deserialize_any` feeds. Absent = `Str`.
7705    /// A path is escalated (Str→Map→Seq) across walk passes when its visitor
7706    /// rejects the current probe (e.g. `jobs.queues`'s seq/map-only visitor
7707    /// rejects the scalar `"0"`). See `get_schema_keys`.
7708    any_probe: Arc<Mutex<HashMap<String, AnyProbe>>>,
7709    /// Paths whose `deserialize_any` probe was rejected during the current pass.
7710    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        // `deserialize_any` is inherently ambiguous for a placeholder walker:
7748        // untagged SCALAR parsers (e.g. `deserialize_duration`) need a string,
7749        // while a visitor that accepts only seq/map (e.g. `JobQueuesConfig` at
7750        // `jobs.queues`) rejects a string and aborts the whole remaining walk
7751        // (#1890). We can't know which shape a given visitor wants, and serde
7752        // seeds can't be retried mid-walk, so we probe with a scalar by default
7753        // and let `get_schema_keys` re-run the walk, escalating any REJECTED
7754        // path to a map/seq probe on the next pass until none reject.
7755        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            // "0" is a valid non-empty string that also parses as an int/duration,
7765            // so untagged string- and number-shaped scalar parsers both accept it.
7766            AnyProbe::Str => visitor.visit_str("0"),
7767            // Empty map/seq: a seq/map-only visitor accepts it and yields an empty
7768            // value, so the walk records the field as a leaf and CONTINUES past it
7769            // (we intentionally do NOT descend — e.g. jobs.queues has dynamic keys).
7770            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        // Feed the FIRST declared variant name (not `""`) so serde's derived
8009        // variant-identifier visitor accepts it. An empty tag is an "unknown
8010        // variant" error that aborts the ENTIRE remaining schema traversal, so a
8011        // single enum field (e.g. `server.tls.acme.directory`) would drop every
8012        // sibling/subsequent section (`database`, …) from the derived schema —
8013        // silently disabling the strict unknown-key validator for them. The enum
8014        // is still treated as an opaque leaf: every `SchemaEnumAccess` variant
8015        // arm resolves to `visit_unit` without recursing.
8016        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    /// The variant name to report to serde's derived variant-identifier visitor.
8099    /// Must be a REAL variant name (the first declared one), never `""`, or
8100    /// serde returns an "unknown variant" error that aborts schema traversal.
8101    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    // Regression (#1608): `server.tls.acme.directory` is the `AcmeDirectory`
8182    // enum, declared under `server` — which precedes `database` in `AutumnConfig`.
8183    // The `SchemaDeserializer` must treat that enum as an opaque leaf and keep
8184    // walking; if it instead errors on the variant tag it aborts the whole
8185    // traversal at the enum, dropping `database` (and every later section) from
8186    // the derived schema. That silently disables the strict unknown-key validator
8187    // for `[database]`, so a typo like `primry_url` stops being flagged.
8188    #[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        // The unknown-key validator must still flag a typo in a section declared
8203        // after the enum, with the edit-distance suggestion.
8204        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    // #2063 helper: a `prod`, manifest-scoped env with `strict_config` sourced
8242    // from the on-disk `autumn.toml`. `prod` is pinned (not `dev`) so the
8243    // dev-only injected `[storage]` smart-default can't masquerade as an unknown
8244    // top-level root and skew these assertions — same reason the #1890 tests do.
8245    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    // #2063: the deploy CLI's lenient-unknown-roots load accepts a plugin-owned
8259    // top-level config table (`[media]`) under `strict_config` — the CLI cannot
8260    // know the app's plugin set — while the STRICT (app-boot) load still rejects
8261    // it, so app boot remains the authoritative strict gate for plugin roots.
8262    #[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        // App boot stays strict: an unknown `[media]` root is a hard error.
8273        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        // Deploy CLI accepts it as opaque (warn, not fail) so the project deploys.
8285        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    // #2063: any genuinely-unknown top-level root (not just `[media]`) is
8293    // warn-not-fail under the lenient CLI load, and still fatal under app boot.
8294    #[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    // #2063: leniency is scoped to top-level ROOTS only. A typo INSIDE a known
8315    // section (`[database] primry_url`) stays a hard error even under the lenient
8316    // CLI load — the CLI does not soften validation of sections it knows.
8317    #[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    // #2063: malformed TOML is fatal everywhere — the lenient policy never
8340    // softens a parse failure.
8341    #[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    // #2067: the lenient deploy-CLI load must NOT soften a PROFILE-PREFIXED
8358    // unknown root like `[profile.prod.media]`. Its schema parent is empty
8359    // (the profile prefix is stripped before root-schema validation), but its
8360    // actual path (`profile.prod.media`) is not a true top-level root — so it
8361    // stays strict and hard-fails, exactly as the deployed app rejects it at
8362    // boot (the `config_section` seam exempts ONLY the true top-level `[media]`
8363    // via `path.is_empty()`). Otherwise deploy would pass while remote boot
8364    // fails.
8365    #[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    // #2067: the profile-prefix strictness is not media-specific — a
8390    // profile-prefixed genuinely-unknown NON-plugin root
8391    // (`[profile.prod.definitely_unknown]`) also stays a hard error under the
8392    // lenient CLI load; only TRUE top-level roots are ever softened.
8393    #[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    // #2067: the lenient deploy-CLI demotion applies ONLY to a true top-level
8417    // root whose TOML value is a TABLE. A registered/unknown root written as a
8418    // SCALAR (`media = "enabled"`) or an ARRAY (`media = ["a", "b"]`) is a
8419    // malformed section nothing would deserialize, so it must HARD-FAIL under
8420    // the lenient CLI load too — exactly as the deployed app rejects it at boot
8421    // (the #2061 `config_section` seam exempts a plugin root only when
8422    // `val.is_table()`). Without the `is_table` gate deploy would accept a
8423    // non-table root that app boot rejects.
8424    #[test]
8425    fn deploy_cli_lenient_still_rejects_non_table_root() {
8426        // Scalar root: `media = "enabled"`.
8427        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        // Array root: `media = ["a", "b"]`.
8449        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    // #2067: a legitimately quoted-dotted TOP-LEVEL table root — the valid TOML
8472    // form of a plugin `config_section("my.plugin")`, whose top-level table is
8473    // `["my.plugin"]` (a single quoted key that happens to contain a dot) — must
8474    // be leniently ACCEPTED by the deploy CLI, because app boot ACCEPTS it too
8475    // (the #2061 exemption keys on the RAW table key with `path.is_empty()`). The
8476    // earlier `!path.contains('.')` heuristic wrongly HARD-FAILED it: the rendered
8477    // dotted string `my.plugin` is ambiguous between a quoted top-level key and a
8478    // 2-level path. Gating on the STRUCTURAL `is_top_level` (empty parent path)
8479    // fixes it. Regression against that string-heuristic bug.
8480    #[test]
8481    fn deploy_cli_lenient_accepts_quoted_dotted_top_level_root() {
8482        // `["my.plugin"]` is a quoted top-level key CONTAINING a dot (one
8483        // structural top-level table), NOT the nested `[my.plugin]` two-level
8484        // form — this is exactly what `config_section("my.plugin")` produces.
8485        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        // Inline-table form of the same quoted-dotted top-level root is
8503        // equivalent. It is written BEFORE the `[server]` header so it binds at
8504        // the document top level, not inside `[server]`.
8505        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    // 7a (#1890): a typo in a section that ONLY became strictly validated by the
8519    // schema-walk fix (here `[log]`, declared after `database`) must WARN, not
8520    // fail, during the one-release warn-first rollout.
8521    #[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                // Pin a non-dev profile: the `dev` smart-defaults inject a
8531                // feature-gated `[storage]` table which, with the `storage`
8532                // feature off, is flagged as a hard top-level unknown key and
8533                // would derail this test regardless of the `[log]` typo.
8534                ("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    // 7b (#1890): with `strict_config_enforce_all` set, the SAME post-database
8551    // typo is promoted to a hard error.
8552    #[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                // Non-dev profile so the `dev` smart-defaults' feature-gated
8565                // `[storage]` table isn't injected — otherwise (storage feature
8566                // off) the test would fail on `storage`, not the `[log]` typo it
8567                // is meant to exercise.
8568                ("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    // 7c (#1890 regression guard): sections that were strictly validated BEFORE
8590    // the fix (here `[server]`) must keep hard-failing on unknown keys.
8591    #[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                // Non-dev profile so the `dev` smart-defaults' feature-gated
8601                // `[storage]` table isn't injected; this test must fail on the
8602                // `[server]` typo, not on `storage` (storage feature off).
8603                ("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    // ── Plugin config-section seam (#1974 item 7) ─────────────────────────────
8625    //
8626    // A plugin owns a top-level `[media]` table core's closed schema knows
8627    // nothing about. `load_with_env_and_plugin_roots` exempts declared roots
8628    // from the strict unknown-key check as known-and-opaque, while every other
8629    // unknown root still hard-fails. All tests pin `AUTUMN_ENV=prod` so the dev
8630    // smart-defaults' feature-gated `[storage]` root isn't injected (storage
8631    // feature off), which would otherwise be flagged independently of `[media]`.
8632
8633    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    // A registered `[media]` root boots green under strict_config: a
8656    // media-enabled app no longer fails at boot with `unknown key "media"`.
8657    #[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    // #2067 boot/deploy parity: a registered QUOTED-DOTTED plugin root — the app
8675    // form of `config_section("my.plugin")`, whose top-level table is
8676    // `["my.plugin"]` — is exempted at app-boot strict too. The exemption keys on
8677    // the RAW table key (`plugin_config_roots.contains("my.plugin")`) with
8678    // `path.is_empty()`, so the dot in the key name is irrelevant. This documents
8679    // that deploy leniency (which now derives top-level-ness structurally) and
8680    // app boot agree on quoted-dotted top-level roots.
8681    #[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    // A registered plugin root written as a NON-TABLE (scalar or array) is a
8700    // malformed section, not the opaque `[media]` TABLE `config_section`
8701    // declares. It must NOT be exempted: nothing would deserialize it and the
8702    // app would boot silently on default plugin config, so it stays a strict
8703    // unknown-root HARD failure instead. Only a table-shaped `[media]` is opaque.
8704    #[test]
8705    fn strict_config_rejects_non_table_registered_plugin_root() {
8706        // Scalar misspelling of a registered root (`media = "enabled"` instead of
8707        // the `[media]` table) must hard-fail under strict_config.
8708        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        // Array-valued registered root (`media = ["a", "b"]`) is likewise
8724        // malformed and must hard-fail.
8725        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    // Without registration the same `[media]` root is still an unknown top-level
8747    // key and hard-fails — the seam is fail-closed, not a blanket allow.
8748    #[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    // Registering `[media]` does not weaken the check for OTHER unknown roots: a
8767    // genuinely-unknown top-level table still hard-fails.
8768    #[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    // A registered root is OPAQUE: even with `strict_config_enforce_all` set,
8791    // arbitrary nested children of `[media]` are never descended into and so are
8792    // never flagged — the plugin owns validation of its own subtree.
8793    #[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    // A registered plugin root under a PROFILE prefix (`[profile.prod.media]`)
8811    // stays STRICT and must be rejected — the exemption only ever covers the
8812    // TRUE top-level `[media]` table. Soundness rationale: the media plugin's
8813    // reader deserializes only the top-level `root.media` and does NOT apply
8814    // Autumn's profile merge, so a profile layer the plugin cannot consume must
8815    // not be exempted — otherwise a strict app with media settings only under
8816    // `[profile.prod.media]` would boot silently on default plugin config
8817    // instead of failing loudly. (Profile-aware plugin config is a separate,
8818    // larger enhancement.)
8819    #[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    // The profile-prefix opacity is NOT a blanket allow of profile subtrees: a
8845    // genuinely-unknown root under a profile prefix
8846    // (`[profile.prod.definitely_not_a_root]`) still hard-fails, because it is
8847    // validated against the root schema and is not a registered plugin root.
8848    #[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    // When strict_config is OFF, behavior is unchanged: `[media]` is tolerated
8872    // even with no roots registered (non-strict never ran the check).
8873    #[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    // 7c′ (#1890 regression guard): a MALFORMED top-level `[profile]` entry (e.g.
8900    // `[profile] dev = "prod"`, whose validation error path is `profile.dev`) is a
8901    // structural error that was always fatal under strict_config. It is NOT a
8902    // section newly revealed by #1890, so the warn-first classifier must keep it
8903    // hard-failing. A genuinely newly-covered section typo (`[resilience]`) with
8904    // the same strict_config (enforce_all OFF) must still only warn — proving the
8905    // fix is narrow and did not over-broaden into hard-failing new sections.
8906    #[test]
8907    fn malformed_profile_entry_still_hard_fails() {
8908        // Malformed profile block: `dev = "prod"` is a scalar where a nested
8909        // profile table is expected -> unknown-key error path `profile.dev`.
8910        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                // Non-dev profile so the `dev` smart-defaults' feature-gated
8918                // `[storage]` table isn't injected; this test must fail on the
8919                // malformed `[profile]` entry, not on `storage`.
8920                ("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        // Narrowness guard: a typo in a section that only became strictly
8941        // validated by #1890 (`[resilience]`) must still WARN (not fail) under the
8942        // same strict_config with enforce_all OFF.
8943        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                // Non-dev profile so the `dev` smart-defaults' feature-gated
8951                // `[storage]` table isn't injected: the `[resilience]` typo must
8952                // remain a warn-only (Ok) case, not be masked by a hard-failing
8953                // `storage` key when the storage feature is off.
8954                ("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    // 7c″ (#1890 P2 fix): a typo under a QUOTED DOTTED profile name (e.g.
8972    // `[profile."prod.eu".server]`) must classify by its real segment-derived
8973    // schema parent. The `"prod.eu"` key is ONE TOML key (a literal dot), so the
8974    // segmented path is `["profile", "prod.eu", "server"]` and the profile-stripped
8975    // schema parent is `server` — a pre-#1890 strict section that must keep
8976    // hard-failing, NOT be demoted to warn-only by string-splitting the joined
8977    // path. A `[profile."prod.eu".resilience]` typo (a newly-#1890-covered section)
8978    // with the same strict_config (enforce_all OFF) must still only warn — proving
8979    // the fix stays narrow even under dotted profile names.
8980    #[test]
8981    fn dotted_profile_name_preserves_strictness() {
8982        // Pre-#1890 strict section (`server`) under a quoted dotted profile name:
8983        // must hard-fail.
8984        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                // Non-dev profile so the `dev` smart-defaults' feature-gated
8996                // `[storage]` table isn't injected; this test must fail on the
8997                // `[server]` typo, not on `storage` (storage feature off).
8998                ("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        // Newly-#1890-covered section (`resilience`) under the same quoted dotted
9021        // profile name: must still only WARN (enforce_all off) — the fix is narrow.
9022        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    // 7d (#1890): the `database.statement_timeout` duration field — whose empty
9051    // probe used to abort the schema walk — still deserializes correctly at
9052    // runtime, in both string and integer (milliseconds) forms.
9053    #[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        // Integer form is interpreted as milliseconds.
9074        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        // At or above the threshold warns; below does not.
9086        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        // A zero threshold silences the warning regardless of the total.
9094        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    /// Mock loader for tests — returns a hand-built config without touching disk.
9107    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        // Regression: previously `validate()` called
9135        // `session.backend_plan(profile)` which returned an error for
9136        // `backend = "redis"` without `redis.url`, exiting the boot before
9137        // a `with_session_store(...)` override could apply. Session
9138        // backend validation now lives in `apply_session_layer`, which
9139        // short-circuits when a custom store is installed. `validate()`
9140        // is config-shape-only and must accept this combination.
9141        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        // No autumn.toml in the test runner's pwd; loader should fall back to
9154        // framework defaults rather than failing.
9155        let loader = TomlEnvConfigLoader::new();
9156        let resolved = loader.load().await.expect("default loader should succeed");
9157        // Default port is 3000 per ServerConfig::default — sanity check.
9158        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                // Ensure we just match the underlying variant correctly
9199                // as requested in the review.
9200                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        // Per-channel severity routing must be controllable via the documented
9350        // AUTUMN_ALERTS__*_SEVERITIES overrides, using the same `all`/`critical`
9351        // value parsing the TOML/file path uses.
9352        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        // Defaults are `All` for every channel.
9358        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        // Unset shard fields fall back to the [database] defaults.
9438        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            // Index 3 is unreachable because index 2 is absent: probing stops.
9513            .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        // NAME without PRIMARY_URL is not enough to create a shard.
9530        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        // slot * 3 / 16384 — contiguous, near-even thirds.
9588        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        // Overlap.
9648        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        // Gap — reported as compact ranges, not thousands of indices.
9661        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        // Out of range.
9675        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        // Session backend validation moved to `apply_session_layer` so a
9876        // custom store installed via `AppBuilder::with_session_store(...)`
9877        // can override an otherwise-invalid backend config without the boot
9878        // exiting first. `validate()` is config-shape-only now; runtime
9879        // session selection (and the backend error) lives in
9880        // `apply_session_layer`, which short-circuits when a custom store
9881        // is installed. `crate::session::tests::session_backend_plan_*`
9882        // still cover the underlying error cases directly on
9883        // `SessionConfig::backend_plan`.
9884        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    // ── TOML loading tests ───────────────────────────────────────────
9975
9976    #[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    // ── Environment variable override tests ──────────────────────
10111
10112    #[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        // Operators must be able to remove the scrape endpoint via the
10126        // documented AUTUMN_SECTION__FIELD convention, not just TOML.
10127        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    // ── auth.magic_link env overrides ─────────────────────────────────────────
10264
10265    #[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        // Simulate values loaded from autumn.toml.
10288        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        // Simulate values loaded from autumn.toml.
10300        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        // Simulate a value loaded from autumn.toml.
10312        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    // ── startup_wait_secs ─────────────────────────────────────────────────────
10318
10319    #[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        // Credentials are indirected: config names the env vars, never the values.
10432        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        // With no offsite env vars and no TOML section, nothing is materialized.
10494        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        // P2 #18: a lone false/opt-out toggle must NOT materialize an empty
10503        // [backup.offsite] (which would then fail validation / `doctor` with
10504        // "bucket is unset"). Offsite stays unconfigured.
10505        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        // P2 #18: AUTO_UPLOAD=true genuinely needs a validated destination, so it
10522        // DOES materialize the section (auto_upload set), as before.
10523        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        // A destination/credential key still materializes the section (with the
10536        // opt-out toggle applied to it), unchanged from before P2 #18.
10537        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        // #1791: optional-only keys (region, force_path_style, endpoint, prefix,
10553        // keep) must NOT materialize [backup.offsite] on their own — a bare
10554        // region with no bucket/credentials cannot upload, so offsite stays
10555        // UNCONFIGURED rather than producing an empty section that then fails
10556        // `doctor` with "bucket is unset".
10557        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        // #1791: the access/secret key-env names are REQUIRED signals, so either
10577        // one still materializes the section.
10578        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        // #1791: a lone bucket (required destination signal) still materializes.
10595        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        // #1791: region no longer TRIGGERS materialization, but it is still
10608        // APPLIED when a required key materializes the section.
10609        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        // Issue #1623: a queue value may be a bare weight OR a table with
10807        // per-queue `concurrency` (cap) and `reserved` (dedicated) slots.
10808        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        // Bare integer still works alongside the table form.
10837        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    // ── Server env override tests ────────────────────────────────
11001
11002    #[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); // Unchanged
11021    }
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    // ── server_timing_enabled resolver tests ────────────────────
11045
11046    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); // Unchanged
11124    }
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        // Unset: default stays 0 (unlimited).
11137        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        // Set via env: override is applied through the dispatcher.
11143        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        // Unset: default stays false.
11152        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        // Unset: defaults stay 0 (unbounded / disabled).
11200        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        // Unset: default stays None.
11220        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        // Set via env: wrapped as a SecretString, trimmed.
11226        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        // Empty value clears the secret.
11239        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    // ── server.tls (#1603) ────────────────────────────────────────
11405
11406    #[test]
11407    fn server_config_defaults_tls_none() {
11408        // Default must keep plain HTTP so existing apps are unaffected.
11409        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        // Reload interval and handshake timeout default when omitted.
11433        assert_eq!(tls.reload_interval_secs, 60);
11434        assert_eq!(tls.handshake_timeout_secs, 10);
11435        // No ACME section → static-cert mode.
11436        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        // A fully env-driven deployment can enable direct HTTPS with no
11471        // [server.tls] section in autumn.toml.
11472        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        // An env var overrides just the cert path of a TOML-configured section,
11493        // leaving the key path intact.
11494        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    // ── deploy (#1607) ────────────────────────────────────────────
11521
11522    #[test]
11523    fn deploy_absent_is_none() {
11524        // No [deploy] section → the field stays None so existing apps are
11525        // unaffected.
11526        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        // A bare [deploy] table materializes the section with every optional
11536        // field at its documented default.
11537        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        // Missing host: a bare table is valid at rest but validate() rejects it.
11581        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        // Present-but-blank host is also rejected.
11591        let blank = DeployConfig {
11592            host: Some("   ".to_owned()),
11593            ..DeployConfig::default()
11594        };
11595        assert!(blank.validate().is_err());
11596
11597        // A real host passes.
11598        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        // A CI/VPS deploy can keep the target host out of autumn.toml and drive
11608        // the whole [deploy] section through AUTUMN_DEPLOY__* env vars.
11609        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        // Opt-in TLS (#1969) can be driven entirely from the environment: setting
11638        // both keys materializes `[deploy]` with TLS on and the host — the exact
11639        // precondition under which the CLI resolves `tls_host == Some(host)`.
11640        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        // TOML configures a TLS host...
11654        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        // ...and the env var overrides it, matching every other deploy override.
11671        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        // A bare `[deploy]` table (or one omitting `profile`) resolves to the
11681        // production profile so a deploy never silently runs the `dev` profile.
11682        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        // The type default matches the serde default.
11692        assert_eq!(DeployConfig::default().profile, "prod");
11693    }
11694
11695    #[test]
11696    fn deploy_profile_honors_toml_and_env_override() {
11697        // TOML sets a non-prod profile...
11698        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        // ...and `AUTUMN_DEPLOY__PROFILE` wins over the TOML value.
11709        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        // Setting only AUTUMN_DEPLOY__HOST with no [deploy] in TOML seeds the
11717        // section with defaults and fills in the host.
11718        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        // Remaining fields fall back to their documented defaults.
11725        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        // An env var overrides just the host of a TOML-configured section,
11734        // leaving the other keys intact.
11735        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    // ── server.tls.acme (#1608) ───────────────────────────────────
11772
11773    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        // Staging is the default on purpose (rate-limit safety).
11809        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    // Regression (#1608, Codex P2): a blank/whitespace-only domain entry passes
11894    // `domains.is_empty()` (the list has length 1) but the runtime then orders a
11895    // cert for an empty DNS identifier, so `validate()` must reject it up front.
11896    #[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        // A whitespace-only entry is rejected the same way.
11904        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    // Regression (#1608, Codex P2): `http_challenge_port = 0` binds an ephemeral
11911    // OS port the HTTP-01 validator (always port 80) can never reach, so every
11912    // issuance fails while the process stays up — `validate()` must reject it.
11913    #[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    // Regression (#1608, Codex P2): a `renew_before_days` >= the issued cert's
11924    // lifetime (treated as ~90 days for a public CA) keeps `needs_renewal` true
11925    // immediately after every successful renewal, so the hourly loop re-orders a
11926    // fresh cert every tick until the CA rate-limits the account. `validate()`
11927    // must reject any value >= 90.
11928    #[test]
11929    fn validate_acme_renew_before_days_at_or_above_cert_lifetime_rejected() {
11930        // Well above the cert lifetime (the reviewer's example).
11931        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        // Exactly 90 (== the effective max cert lifetime) is also rejected: the
11940        // fresh cert would be due for renewal from the moment it is issued.
11941        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        // A sane sub-lifetime value passes.
11951        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        // The just-below-boundary value (89) is still accepted.
11958        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    // Companion: a valid domain list plus the default challenge port is unaffected
11966    // by the new blank-entry / zero-port rejections.
11967    #[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    // ── server.max_concurrent_requests (#1006) ────────────────────
11978
11979    #[test]
11980    fn server_config_defaults_max_concurrent_requests_none() {
11981        // Default must preserve today's unlimited behavior — no existing app
11982        // silently changes throughput.
11983        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        // parse_env_option's documented convention: empty string clears to None.
12018        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    // ── Log env override tests ───────────────────────────────────
12026
12027    #[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    // ── Health env override tests ────────────────────────────────
12060
12061    #[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    // ── Precedence test ──────────────────────────────────────────
12120
12121    #[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); // env wins
12130    }
12131
12132    // ── Validation tests ─────────────────────────────────────────
12133
12134    #[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        // The pool's TLS support parses libpq keyword/value strings, so
12177        // validation must let them through (issue #1585 review) — including
12178        // quoted values and whitespace around `=`.
12179        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        // primary_url and shard URLs accept the same forms.
12196        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    // ── DatabaseBackend detection tests (issue #1614) ──────────────
12247
12248    #[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        // libpq keyword/value strings are a Postgres shape (the pool accepts
12265        // them), so they must classify as Postgres, not fall through.
12266        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", // canonical sqlite:// (absolute path)
12276            "sqlite://./relative.db",
12277            "sqlite::memory:",
12278            "sqlite:app.db", // shorter sqlite: form
12279            "file:app.db",   // file: form
12280        ] {
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        // A bare filesystem path carries no scheme distinguishing it from a
12292        // typo'd URL, so it is deliberately NOT auto-detected as SQLite. Users
12293        // must spell an explicit sqlite:// (or sqlite:/file:) scheme.
12294        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    // ── SQLite config validation tests (issue #1614) ───────────────
12311
12312    #[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        // Postgres primary with a SQLite replica: a boot-time misconfiguration,
12379        // not a first-query surprise.
12380        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        // effective_primary_url() prefers primary_url; the legacy `url` role
12399        // must agree on the backend.
12400        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        // Regression guard: the existing Postgres primary + replica path is
12419        // unchanged and still validates cleanly.
12420        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    // ── Profile tests ──────────────────────────────────────────
12429
12430    #[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        // AC: HSTS auto-enabled in the production profile.
12558        assert!(
12559            config.security.headers.strict_transport_security,
12560            "prod profile must auto-enable Strict-Transport-Security"
12561        );
12562        // Defaults should still be secure-by-default in prod.
12563        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        // Overlay value wins
12612        assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
12613        // Base value preserved when not in overlay
12614        assert_eq!(
12615            base["server"]["host"],
12616            toml::Value::String("127.0.0.1".into())
12617        );
12618        // New key from overlay added
12619        assert_eq!(
12620            base["database"]["url"],
12621            toml::Value::String("postgres://localhost/test".into())
12622        );
12623        // Base key preserved
12624        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        // Load base
12654        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); // from base
12664        assert_eq!(config.database.pool_size, 10); // from base, preserved
12665        assert_eq!(
12666            config.database.url.as_deref(),
12667            Some("postgres://localhost/myapp_dev")
12668        ); // from profile
12669    }
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); // swap = 2 edits (del + ins)
12702        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    // ── Mutant-hunting tests ────────────────────────────────────
12725
12726    #[test]
12727    fn find_config_file_falls_back_to_cwd() {
12728        // Without AUTUMN_MANIFEST_DIR, should return just the filename
12729        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        // dir exists but the file doesn't
12749        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        // resolve_profile checks `--profile` in CLI args. We can't easily
12757        // inject args, but we can verify the env path doesn't match other args.
12758        // The `== "--profile"` guard is the key: if it were `!=`, every arg
12759        // would trigger the branch.
12760        let env = MockEnv::new();
12761        // With no env vars and no matching CLI args, should be None
12762        let profile = resolve_profile(&env);
12763        // This may or may not be None depending on test harness args,
12764        // but the important thing is it doesn't crash or return garbage.
12765        // The env-based tests above cover the positive cases.
12766        drop(profile);
12767    }
12768
12769    #[test]
12770    fn deep_merge_non_table_overlay_replaces_base() {
12771        // When overlay is not a table, it should replace (not merge into) base.
12772        // This kills the `&& → ||` mutant on line 162.
12773        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        // When base is table and overlay is NOT table, base should be unchanged
12777        // (the function only merges when BOTH are tables).
12778        deep_merge(&mut base, overlay);
12779        // base should still be the original table (overlay was ignored)
12780        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        // When base is not a table, overlay should not merge
12787        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        // base should be unchanged
12792        assert_eq!(base, toml::Value::String("original".into()));
12793    }
12794
12795    #[test]
12796    fn suggest_profile_close_match() {
12797        // "dve" is edit-distance 2 from "dev" → should suggest "dev"
12798        assert_eq!(suggest_profile("dve"), Some("dev"));
12799    }
12800
12801    #[test]
12802    fn suggest_profile_no_match_when_distant() {
12803        // "xyz" is far from both "dev" and "prod" → no suggestion
12804        assert_eq!(suggest_profile("xyz"), None);
12805    }
12806
12807    #[test]
12808    fn suggest_profile_exact_known_profile() {
12809        // Exact match has distance 0 → suggests itself
12810        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        // "prd" is distance 1 from "prod"
12817        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        // Test AutumnConfig::load_with_env() with a dev profile via env var.
12916        // This kills the "replace load → Ok(Default::default())" mutant.
12917        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
12918
12919        let config = AutumnConfig::load_with_env(&env).unwrap();
12920        // With dev profile, smart defaults should apply
12921        assert_eq!(config.profile.as_deref(), Some("dev"));
12922        assert_eq!(config.log.level, "debug"); // dev default
12923        assert_eq!(config.log.format, LogFormat::Pretty); // dev default
12924        assert!(config.health.detailed); // dev default
12925    }
12926
12927    #[test]
12928    fn load_custom_profile_without_toml_warns() {
12929        // Test the typo warning branch: profile != "dev" && profile != "prod"
12930        // without a corresponding autumn-{profile}.toml triggers warn_profile_typo.
12931        // This kills the match guard mutants on line 341.
12932        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        // staging has no smart defaults, so values should be framework defaults
12937        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        // dev/prod without their profile TOML should NOT trigger warn_profile_typo.
12944        // This tests the `None => {}` branch (line 342).
12945        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        // Malformed TOML should be ignored because `autumn-prod.toml` is chosen first.
13035        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        // Malformed TOML should be ignored because `autumn-production.toml` is chosen first.
13061        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        // load_from should return Err on non-NotFound IO errors.
13075        // On all platforms, trying to read a directory as a file triggers an error.
13076        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        // Reading a directory is an IO error, NOT NotFound.
13090        // This kills the "replace match guard NotFound with true" mutant:
13091        // if the guard were always true, this would return Ok(None) instead of Err.
13092        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        // Kills the "delete match arm Auto" mutant
13113        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
13114        let mut config = AutumnConfig::default();
13115        // Start with non-Auto to prove the override works
13116        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        // Kills the 'delete match arm "false" | "0"' mutant
13124        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
13125        let mut config = AutumnConfig::default();
13126        config.health.detailed = true; // start true, override to false
13127        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        // Prometheus metrics export is on by default and independent of
13163        // `sensitive`, so platform scraping works without exposing env/loggers.
13164        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        // Create a 10,000 deep nested table
13190        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        // Add a leaf value to test actual merging
13205        if let toml::Value::Table(t) = current_overlay {
13206            t.insert("y".to_owned(), toml::Value::Integer(42));
13207        }
13208
13209        // Trigger merge, expecting no panic/stack overflow
13210        // We run it on a thread with a large stack to avoid the stack overflow caused by Drop when base is dropped at the end of the function (since we created a 10,000 depth structure).
13211        std::thread::Builder::new()
13212            .stack_size(32 * 1024 * 1024)
13213            .spawn(move || {
13214                deep_merge(&mut base, overlay);
13215                // Let the OS clean up the memory instead of dropping deeply nested structure
13216                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        // Create structures nested exactly to MAX_MERGE_DEPTH + 1
13229        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        // Add a value deep in the overlay
13244        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        // Verify the value was NOT merged due to max depth limit
13251        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    // ── AUTUMN_SECURITY__FORBIDDEN_RESPONSE / __ALLOW_UNAUTHORIZED_REPOSITORY_API ──
13269
13270    #[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        // Pre-set to 403 to confirm env actually flips it back to 404.
13286        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        // Invalid value warns and leaves the existing setting alone.
13301        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        // Default is 2 MiB; the env override must raise it.
13333        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        // Invalid values are ignored, leaving the default intact.
13344        assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
13345    }
13346
13347    // ── [openapi] config section tests (RED phase) ─────────────────────────
13348
13349    #[test]
13350    fn openapi_runtime_config_defaults_enabled() {
13351        // The [openapi] section must default to enabled=true and path="/openapi.json".
13352        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        // In CI, FLY_MACHINE_ID and HOSTNAME may or may not be set,
13452        // so just verify we get a non-empty string back.
13453        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    // ── credentials integration ───────────────────────────────────────────
13517
13518    #[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        // Write a base configuration with an empty/blank github provider defined
13569        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        // Write an encrypted file but supply a wrong-length key so validation fails
13596        let bogus_key = "zz".repeat(32); // 64 chars but not valid hex
13597        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    // ── RequestTimeoutsConfig ──────────────────────────────────────────────
13664
13665    #[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    // ── Deprecation channel unit tests ────────────────────────────────────────
13826
13827    /// A tiny test-only registry so tests are independent of the real entries.
13828    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(); // AUTUMN_A__B__C not set
13849        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(); // no TOML key
13862        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        // Only the new replacement key is set; deprecated key is absent.
13880        let mut merged = toml::Table::new();
13881        let mut b = toml::Table::new();
13882        b.insert("d".to_owned(), toml::Value::Integer(1)); // new key, not deprecated
13883        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        // If a mid-segment is not a Table, must return false without panicking.
13915        let mut root = toml::Table::new();
13916        root.insert("a".to_owned(), toml::Value::Integer(42)); // "a" is a leaf, not a table
13917        assert!(!toml_path_present(&root, "a.b.c"));
13918    }
13919
13920    #[test]
13921    fn red_schema_leaf_paths_includes_known_paths() {
13922        // The SchemaDeserializer recurses into any derived-Deserialize struct it
13923        // reaches, regardless of module — external-module types (SecurityConfig,
13924        // AuthConfig, etc.) now descend too. They were root-only before the #1890
13925        // adaptive walk because the walk aborted before them (at the
13926        // `statement_timeout` duration / the `jobs.queues` seq-only visitor), not
13927        // because of their module. Each still also appears as a bare root leaf
13928        // (recorded as a field of the root struct).
13929        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        // Root-level sections also appear as single-segment leaves (recorded as
13943        // fields of the root struct), alongside their now-descended child keys.
13944        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    // ── ShardSlotAssignment / shards_auto_split / resolved_shard_assignments ──
13955
13956    #[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    // ── check_stored_slot_map ──────────────────────────────────────────────────
14024
14025    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        // Even with a wildly different stored map, explicit mode is never blocked.
14035        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        // Order-insensitive: stored in reverse order still matches.
14054        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    // ── Process role (#1613) ────────────────────────────────────────────────
14091
14092    #[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        // Serde alias also works.
14159        let aliased: AutumnConfig = toml::from_str("role = \"all\"\n").expect("all alias");
14160        assert_eq!(aliased.role, ProcessRole::Combined);
14161        // Absent → default.
14162        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        // Combined is always fine (enqueues and drains in one process), even on
14169        // the in-process local backend.
14170        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        // Split roles on any backend that falls through to the per-process local
14179        // runtime are invalid: the literal `local`, a typo like `postgresql`, a
14180        // blank backend, or any other unknown value.
14181        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        // The match is exact (mirroring `start_runtime`'s dispatch), so a
14199        // case-variant like `LOCAL` is likewise a non-durable fall-through.
14200        assert!(split_role_requires_durable_backend(
14201            ProcessRole::Web,
14202            "LOCAL"
14203        ));
14204        // Split roles on the recognized durable backends are fine.
14205        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        // Start from a non-default to prove invalid values do not reset it and
14232        // do not force it either — they leave the current value untouched.
14233        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        // And from the default, an invalid value keeps Combined.
14241        let mut config = AutumnConfig::default();
14242        config.apply_env_overrides_with_env(&env);
14243        assert_eq!(config.role, ProcessRole::Combined);
14244    }
14245}