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//! # Profiles
17//!
18//! Profiles are resolved in precedence order:
19//! 1. `AUTUMN_ENV` environment variable
20//! 2. `AUTUMN_PROFILE` environment variable (legacy alias)
21//! 3. `--profile` CLI flag
22//! 4. Auto-detect from debug/release build mode
23//!
24//! # Example
25//!
26//! ```rust
27//! use autumn_web::config::AutumnConfig;
28//!
29//! // All defaults -- no file needed
30//! let config = AutumnConfig::default();
31//! assert_eq!(config.server.port, 3000);
32//! assert_eq!(config.server.host, "127.0.0.1");
33//! assert!(config.database.url.is_none());
34//! ```
35//!
36//! # Environment variable reference
37//!
38//! | Variable | Config field | Type |
39//! |----------|-------------|------|
40//! | `AUTUMN_SERVER__PORT` | `server.port` | `u16` |
41//! | `AUTUMN_SERVER__HOST` | `server.host` | `String` |
42//! | `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` | `server.shutdown_timeout_secs` | `u64` |
43//! | `AUTUMN_SERVER__PRESTOP_GRACE_SECS` | `server.prestop_grace_secs` | `u64` |
44//! | `AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS` | `server.timeouts.request_timeout_ms` | `u64` |
45//! | `AUTUMN_DATABASE__URL` | `database.url` | `String` |
46//! | `AUTUMN_DATABASE__PRIMARY_URL` | `database.primary_url` | `String` |
47//! | `AUTUMN_DATABASE__REPLICA_URL` | `database.replica_url` | `String` |
48//! | `AUTUMN_DATABASE__POOL_SIZE` | `database.pool_size` | `usize` |
49//! | `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` | `database.primary_pool_size` | `usize` |
50//! | `AUTUMN_DATABASE__REPLICA_POOL_SIZE` | `database.replica_pool_size` | `usize` |
51//! | `AUTUMN_DATABASE__REPLICA_FALLBACK` | `database.replica_fallback` | `fail_readiness` / `primary` |
52//! | `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` | `database.connect_timeout_secs` | `u64` |
53//! | `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` | `database.auto_migrate_in_production` | `bool` |
54//! | `AUTUMN_LOG__LEVEL` | `log.level` | tracing filter directive |
55//! | `AUTUMN_LOG__FORMAT` | `log.format` | `Auto` / `Pretty` / `Json` |
56//! | `AUTUMN_TELEMETRY__ENABLED` | `telemetry.enabled` | `bool` |
57//! | `AUTUMN_TELEMETRY__SERVICE_NAME` | `telemetry.service_name` | `String` |
58//! | `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` | `telemetry.service_namespace` | `String` |
59//! | `AUTUMN_TELEMETRY__SERVICE_VERSION` | `telemetry.service_version` | `String` |
60//! | `AUTUMN_TELEMETRY__ENVIRONMENT` | `telemetry.environment` | `String` |
61//! | `AUTUMN_TELEMETRY__OTLP_ENDPOINT` | `telemetry.otlp_endpoint` | `String` |
62//! | `AUTUMN_TELEMETRY__PROTOCOL` | `telemetry.protocol` | `Grpc` / `HttpProtobuf` |
63//! | `AUTUMN_TELEMETRY__STRICT` | `telemetry.strict` | `bool` |
64//! | `AUTUMN_HEALTH__PATH` | `health.path` | `String` |
65//! | `AUTUMN_HEALTH__LIVE_PATH` | `health.live_path` | `String` |
66//! | `AUTUMN_HEALTH__READY_PATH` | `health.ready_path` | `String` |
67//! | `AUTUMN_HEALTH__STARTUP_PATH` | `health.startup_path` | `String` |
68//! | `AUTUMN_HEALTH__DETAILED` | `health.detailed` | `bool` |
69//! | `AUTUMN_CORS__ALLOWED_ORIGINS` | `cors.allowed_origins` | comma-separated `String` |
70//! | `AUTUMN_CORS__ALLOWED_METHODS` | `cors.allowed_methods` | comma-separated `String` |
71//! | `AUTUMN_CORS__ALLOWED_HEADERS` | `cors.allowed_headers` | comma-separated `String` |
72//! | `AUTUMN_CORS__ALLOW_CREDENTIALS` | `cors.allow_credentials` | `bool` |
73//! | `AUTUMN_CORS__MAX_AGE_SECS` | `cors.max_age_secs` | `u64` |
74//! | `AUTUMN_CACHE__BACKEND` | `cache.backend` | `memory` / `redis` |
75//! | `AUTUMN_CACHE__REDIS__URL` | `cache.redis.url` | `String` |
76//! | `AUTUMN_CACHE__REDIS__KEY_PREFIX` | `cache.redis.key_prefix` | `String` |
77//! | `AUTUMN_SESSION__BACKEND` | `session.backend` | `memory` / `redis` |
78//! | `AUTUMN_SESSION__COOKIE_NAME` | `session.cookie_name` | `String` |
79//! | `AUTUMN_SESSION__MAX_AGE_SECS` | `session.max_age_secs` | `u64` |
80//! | `AUTUMN_SESSION__SECURE` | `session.secure` | `bool` |
81//! | `AUTUMN_SESSION__SAME_SITE` | `session.same_site` | `String` |
82//! | `AUTUMN_SESSION__HTTP_ONLY` | `session.http_only` | `bool` |
83//! | `AUTUMN_SESSION__PATH` | `session.path` | `String` |
84//! | `AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION` | `session.allow_memory_in_production` | `bool` |
85//! | `AUTUMN_SESSION__REDIS__URL` | `session.redis.url` | `String` |
86//! | `AUTUMN_SESSION__REDIS__KEY_PREFIX` | `session.redis.key_prefix` | `String` |
87//! | `AUTUMN_CHANNELS__BACKEND` | `channels.backend` | `in_process` / `redis` |
88//! | `AUTUMN_CHANNELS__CAPACITY` | `channels.capacity` | `usize` |
89//! | `AUTUMN_CHANNELS__REDIS__URL` | `channels.redis.url` | `String` |
90//! | `AUTUMN_CHANNELS__REDIS__KEY_PREFIX` | `channels.redis.key_prefix` | `String` |
91//! | `AUTUMN_JOBS__BACKEND` | `jobs.backend` | `local` / `postgres` / `redis` |
92//! | `AUTUMN_JOBS__WORKERS` | `jobs.workers` | `usize` |
93//! | `AUTUMN_JOBS__MAX_ATTEMPTS` | `jobs.max_attempts` | `u32` |
94//! | `AUTUMN_JOBS__INITIAL_BACKOFF_MS` | `jobs.initial_backoff_ms` | `u64` |
95//! | `AUTUMN_JOBS__REDIS__URL` | `jobs.redis.url` | `String` |
96//! | `AUTUMN_JOBS__REDIS__KEY_PREFIX` | `jobs.redis.key_prefix` | `String` |
97//! | `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` | `jobs.redis.visibility_timeout_ms` | `u64` |
98//! | `AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS` | `jobs.postgres.visibility_timeout_ms` | `u64` |
99//! | `AUTUMN_SCHEDULER__BACKEND` | `scheduler.backend` | `in_process` / `postgres` |
100//! | `AUTUMN_SCHEDULER__LEASE_TTL_SECS` | `scheduler.lease_ttl_secs` | `u64` |
101//! | `AUTUMN_SCHEDULER__REPLICA_ID` | `scheduler.replica_id` | `String` |
102//! | `AUTUMN_SCHEDULER__KEY_PREFIX` | `scheduler.key_prefix` | `String` |
103//! | `AUTUMN_SECURITY__RATE_LIMIT__ENABLED` | `security.rate_limit.enabled` | `bool` |
104//! | `AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND` | `security.rate_limit.requests_per_second` | `f64` |
105//! | `AUTUMN_SECURITY__RATE_LIMIT__BURST` | `security.rate_limit.burst` | `u32` |
106//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS` | `security.rate_limit.trust_forwarded_headers` | `bool` |
107//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES` | `security.rate_limit.trusted_proxies` | comma-separated `String` |
108//! | `AUTUMN_ENV` | active profile | `String` |
109//! | `AUTUMN_PROFILE` | active profile (legacy alias) | `String` |
110//! | `AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES` | `security.upload.max_request_size_bytes` | `usize` |
111//! | `AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES` | `security.upload.max_file_size_bytes` | `usize` |
112//! | `AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES` | `security.upload.allowed_mime_types` | comma-separated `String` |
113//! | `AUTUMN_SECURITY__FORBIDDEN_RESPONSE` | `security.forbidden_response` | `"403"` or `"404"` |
114//! | `AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API` | `security.allow_unauthorized_repository_api` | `bool` |
115//! | `AUTUMN_SECURITY__SIGNING_SECRET` | `security.signing_secret.secret` | `String` |
116//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` | `security.webhooks.replay.backend` | `memory` / `redis` |
117//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` | `security.webhooks.replay.redis.url` | `String` |
118//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` | `security.webhooks.replay.redis.key_prefix` | `String` |
119//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` | `security.webhooks.replay.allow_memory_in_production` | `bool` |
120//! | `AUTUMN_DEV__INSPECTOR_PATH` | `dev.inspector_path` | `String` |
121//! | `AUTUMN_DEV__INSPECTOR_CAPACITY` | `dev.inspector_capacity` | `usize` |
122//! | `AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD` | `dev.inspector_n_plus_one_threshold` | `usize` |
123//! | `AUTUMN_COMPRESSION__ENABLED` | `compression.enabled` | `bool` |
124//! | `AUTUMN_AUTH__LOCKOUT__ENABLED` | `auth.lockout.enabled` | `bool` |
125//! | `AUTUMN_AUTH__LOCKOUT__THRESHOLD` | `auth.lockout.threshold` | `i32` |
126//! | `AUTUMN_AUTH__LOCKOUT__WINDOW_SECS` | `auth.lockout.window_secs` | `u64` |
127//! | `AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS` | `auth.lockout.cooloff_secs` | `u64` |
128
129use std::path::{Path, PathBuf};
130
131use serde::Deserialize;
132use thiserror::Error;
133
134/// Abstraction for reading environment variables, supporting dependency injection for testing.
135use std::sync::OnceLock;
136
137static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
138static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
139
140#[doc(hidden)]
141pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
142    let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
143    let _ = MACRO_IS_DEBUG.set(is_debug);
144}
145
146/// Trait for environment variable reading to allow testing overrides.
147///
148/// This abstracts the OS environment (`std::env::var`) so that
149/// configuration loading logic can be unit-tested deterministically
150/// by supplying a mock environment.
151pub trait Env {
152    /// Read an environment variable.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use autumn_web::config::{Env, OsEnv};
158    /// let env = OsEnv;
159    /// let val = env.var("NON_EXISTENT_VAR");
160    /// assert!(val.is_err());
161    /// ```
162    ///
163    /// # Errors
164    ///
165    /// Returns [`std::env::VarError`] if the variable is not present or is not valid Unicode.
166    fn var(&self, key: &str) -> Result<String, std::env::VarError>;
167}
168
169/// Production implementation of `Env` that reads from the OS environment.
170#[derive(Clone, Default)]
171pub struct OsEnv;
172
173impl Env for OsEnv {
174    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
175        if key == "AUTUMN_MANIFEST_DIR" {
176            if let Some(dir) = MACRO_MANIFEST_DIR.get() {
177                return Ok(dir.clone());
178            }
179        } else if key == "AUTUMN_IS_DEBUG"
180            && let Some(is_debug) = MACRO_IS_DEBUG.get()
181        {
182            return Ok(if *is_debug {
183                "1".to_string()
184            } else {
185                "0".to_string()
186            });
187        }
188        std::env::var(key)
189    }
190}
191
192/// Mock implementation of `Env` for testing.
193#[derive(Clone, Default)]
194pub struct MockEnv {
195    vars: std::collections::HashMap<String, String>,
196}
197
198impl MockEnv {
199    /// Create a new, empty `MockEnv`.
200    #[must_use]
201    pub fn new() -> Self {
202        Self {
203            vars: std::collections::HashMap::new(),
204        }
205    }
206
207    /// Set an environment variable in the mock.
208    #[must_use]
209    pub fn with(mut self, key: &str, value: &str) -> Self {
210        self.vars.insert(key.to_owned(), value.to_owned());
211        self
212    }
213
214    /// Remove an environment variable from the mock.
215    #[must_use]
216    pub fn without(mut self, key: &str) -> Self {
217        self.vars.remove(key);
218        self
219    }
220}
221
222impl Env for MockEnv {
223    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
224        self.vars
225            .get(key)
226            .cloned()
227            .ok_or(std::env::VarError::NotPresent)
228    }
229}
230
231/// Locate a config file by checking the app's crate directory first, then CWD.
232fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
233    if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
234        let candidate = PathBuf::from(manifest_dir).join(filename);
235        if candidate.exists() {
236            return candidate;
237        }
238    }
239    PathBuf::from(filename)
240}
241
242/// Load a TOML file as a raw `toml::Value` table.
243/// Returns `Ok(None)` if the file doesn't exist.
244fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
245    match std::fs::read_to_string(path) {
246        Ok(contents) => {
247            let table = toml::from_str::<toml::Table>(&contents)?;
248            Ok(Some(toml::Value::Table(table)))
249        }
250        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
251        Err(e) => Err(ConfigError::Io(e)),
252    }
253}
254
255/// Resolve the active profile using the precedence chain.
256///
257/// 1. `AUTUMN_ENV` env var (highest priority)
258/// 2. `AUTUMN_PROFILE` env var (legacy alias)
259/// 3. `--profile <name>` CLI flag
260/// 4. Auto-detect from build mode (`AUTUMN_IS_DEBUG` set by `#[autumn_web::main]`)
261/// 5. Fallback to `dev`
262pub(crate) fn resolve_profile(env: &dyn Env) -> String {
263    let selected_profile_input = resolve_profile_input(env);
264    normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
265}
266
267/// Resolve the raw profile selector value (before normalization).
268fn resolve_profile_input(env: &dyn Env) -> String {
269    // 1. Preferred env var
270    if let Ok(profile) = env.var("AUTUMN_ENV") {
271        let trimmed = profile.trim();
272        if !trimmed.is_empty() {
273            return trimmed.to_owned();
274        }
275    }
276
277    // 2. Legacy env var
278    if let Ok(profile) = env.var("AUTUMN_PROFILE") {
279        let trimmed = profile.trim();
280        if !trimmed.is_empty() {
281            return trimmed.to_owned();
282        }
283    }
284
285    // 3. CLI flag
286    let args: Vec<String> = std::env::args().collect();
287    for (i, arg) in args.iter().enumerate() {
288        if arg == "--profile"
289            && let Some(profile) = args.get(i + 1)
290        {
291            let trimmed = profile.trim();
292            if !trimmed.is_empty() {
293                return trimmed.to_owned();
294            }
295        }
296        if let Some(profile) = arg.strip_prefix("--profile=") {
297            let trimmed = profile.trim();
298            if !trimmed.is_empty() {
299                return trimmed.to_owned();
300            }
301        }
302    }
303
304    // 4. Auto-detect from build mode
305    if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
306        return "prod".to_owned();
307    }
308    "dev".to_owned()
309}
310
311/// Normalize profile aliases and trim whitespace.
312///
313/// Supported aliases:
314/// - `production` -> `prod`
315/// - `development` -> `dev`
316/// - `prod`/`PROD` -> `prod`
317/// - `dev`/`DEV` -> `dev`
318fn normalize_profile_name(profile: &str) -> Option<String> {
319    let trimmed = profile.trim();
320    if trimmed.is_empty() {
321        return None;
322    }
323
324    if trimmed.eq_ignore_ascii_case("production") {
325        return Some("prod".to_owned());
326    }
327    if trimmed.eq_ignore_ascii_case("development") {
328        return Some("dev".to_owned());
329    }
330    if trimmed.eq_ignore_ascii_case("prod") {
331        return Some("prod".to_owned());
332    }
333    if trimmed.eq_ignore_ascii_case("dev") {
334        return Some("dev".to_owned());
335    }
336
337    // Preserve user-specified case for custom profile names.
338    Some(trimmed.to_owned())
339}
340
341/// Profile names to check for inline/file overrides.
342///
343/// For canonical profiles, include legacy aliases for compatibility so
344/// `production` and `development` profile sources are still loaded.
345fn profile_lookup_names(profile: &str) -> Vec<&str> {
346    match profile {
347        "prod" => vec!["production", "prod"],
348        "dev" => vec!["development", "dev"],
349        other => vec![other],
350    }
351}
352
353/// Ordered file lookup names for profile override file compatibility.
354///
355/// Only one profile override file is loaded: the first existing file in this
356/// ordered list. The order prefers the explicitly-selected spelling.
357fn profile_override_file_lookup_names(profile: &str, selected_profile_input: &str) -> Vec<String> {
358    match profile {
359        "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
360            vec!["production".to_owned(), "prod".to_owned()]
361        }
362        "prod" => vec!["prod".to_owned(), "production".to_owned()],
363        "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
364            vec!["development".to_owned(), "dev".to_owned()]
365        }
366        "dev" => vec!["dev".to_owned(), "development".to_owned()],
367        other => vec![other.to_owned()],
368    }
369}
370
371/// Extract `[profile.<name>]` table from a parsed `autumn.toml`.
372fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
373    base.get("profile")
374        .and_then(toml::Value::as_table)
375        .and_then(|profiles| profiles.get(profile))
376        .and_then(toml::Value::as_table)
377        .map(|table| toml::Value::Table(table.clone()))
378}
379
380/// Profile-specific smart defaults as a TOML table.
381///
382/// Only `dev` and `prod` have smart defaults. Custom profiles
383/// (staging, test, etc.) get no smart defaults — they rely on
384/// their profile TOML file and env overrides.
385fn profile_defaults_as_toml(profile: &str) -> toml::Value {
386    let mut table = toml::map::Map::new();
387
388    match profile {
389        "dev" => {
390            let mut log = toml::map::Map::new();
391            log.insert("level".into(), "debug".into());
392            log.insert("format".into(), "Pretty".into());
393            table.insert("log".into(), toml::Value::Table(log));
394
395            let mut telemetry = toml::map::Map::new();
396            telemetry.insert("environment".into(), "development".into());
397            table.insert("telemetry".into(), toml::Value::Table(telemetry));
398
399            let mut server = toml::map::Map::new();
400            server.insert("host".into(), "127.0.0.1".into());
401            server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(1));
402            // Zero-out the prestop grace in dev: there is no load balancer to
403            // deregister, so the 5-second default would add unnecessary latency
404            // on every Ctrl-C.
405            server.insert("prestop_grace_secs".into(), toml::Value::Integer(0));
406            table.insert("server".into(), toml::Value::Table(server));
407
408            let mut health = toml::map::Map::new();
409            health.insert("detailed".into(), toml::Value::Boolean(true));
410            table.insert("health".into(), toml::Value::Table(health));
411
412            let mut actuator = toml::map::Map::new();
413            actuator.insert("sensitive".into(), toml::Value::Boolean(true));
414            table.insert("actuator".into(), toml::Value::Table(actuator));
415
416            let mut cors = toml::map::Map::new();
417            cors.insert(
418                "allowed_origins".into(),
419                toml::Value::Array(vec![toml::Value::String("*".to_owned())]),
420            );
421            table.insert("cors".into(), toml::Value::Table(cors));
422
423            // Dev: enable the local-disk blob store rooted at
424            // `target/blobs/` automatically when the `storage` feature
425            // is on. `prod` deliberately leaves `backend = "disabled"`
426            // so the operator has to opt into either `local` (with
427            // `allow_local_in_production = true`) or `s3`.
428            let mut storage = toml::map::Map::new();
429            storage.insert("backend".into(), "local".into());
430            table.insert("storage".into(), toml::Value::Table(storage));
431            // Dev: trust X-Forwarded-* from loopback only so local reverse
432            // proxies (nginx, caddy, etc. on 127.0.0.1/::1) work out of the box.
433            let mut trusted_proxies = toml::map::Map::new();
434            trusted_proxies.insert("trust_forwarded_headers".into(), toml::Value::Boolean(true));
435            trusted_proxies.insert(
436                "ranges".into(),
437                toml::Value::Array(vec![
438                    toml::Value::String("127.0.0.0/8".to_owned()),
439                    toml::Value::String("::1/128".to_owned()),
440                ]),
441            );
442            let mut security = toml::map::Map::new();
443            security.insert(
444                "trusted_proxies".into(),
445                toml::Value::Table(trusted_proxies),
446            );
447            table.insert("security".into(), toml::Value::Table(security));
448            // Dev: CSRF disabled (default), HSTS off (default)
449        }
450        "prod" => {
451            let mut log = toml::map::Map::new();
452            log.insert("level".into(), "info".into());
453            log.insert("format".into(), "Json".into());
454            table.insert("log".into(), toml::Value::Table(log));
455
456            let mut telemetry = toml::map::Map::new();
457            telemetry.insert("environment".into(), "production".into());
458            table.insert("telemetry".into(), toml::Value::Table(telemetry));
459
460            let mut server = toml::map::Map::new();
461            server.insert("host".into(), "0.0.0.0".into());
462            server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(30));
463            let mut timeouts = toml::map::Map::new();
464            timeouts.insert("request_timeout_ms".into(), toml::Value::Integer(30_000));
465            server.insert("timeouts".into(), toml::Value::Table(timeouts));
466            table.insert("server".into(), toml::Value::Table(server));
467
468            let mut health = toml::map::Map::new();
469            health.insert("detailed".into(), toml::Value::Boolean(false));
470            table.insert("health".into(), toml::Value::Table(health));
471
472            // Prod: strict security -- HSTS on, CSRF enabled, secure cookies
473            let mut security = toml::map::Map::new();
474            let mut headers = toml::map::Map::new();
475            headers.insert(
476                "strict_transport_security".into(),
477                toml::Value::Boolean(true),
478            );
479            security.insert("headers".into(), toml::Value::Table(headers));
480            let mut csrf = toml::map::Map::new();
481            csrf.insert("enabled".into(), toml::Value::Boolean(true));
482            security.insert("csrf".into(), toml::Value::Table(csrf));
483            table.insert("security".into(), toml::Value::Table(security));
484
485            let mut session = toml::map::Map::new();
486            session.insert("secure".into(), toml::Value::Boolean(true));
487            table.insert("session".into(), toml::Value::Table(session));
488        }
489        _ => {} // Custom profiles get no smart defaults
490    }
491
492    toml::Value::Table(table)
493}
494
495#[cfg(feature = "mail")]
496fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
497    merged
498        .get("mail")
499        .and_then(toml::Value::as_table)
500        .is_some_and(|mail| mail.contains_key("transport"))
501        || env
502            .var("AUTUMN_MAIL__TRANSPORT")
503            .ok()
504            .as_deref()
505            .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
506}
507
508/// Maximum recursion depth for merging TOML tables.
509const MAX_MERGE_DEPTH: usize = 16;
510
511/// Deep-merge two TOML values. Tables are merged recursively;
512/// non-table values in `overlay` replace those in `base`.
513fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
514    deep_merge_with_depth(base, overlay, 0);
515}
516
517fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
518    if depth > MAX_MERGE_DEPTH {
519        eprintln!(
520            "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
521        );
522        return;
523    }
524
525    let toml::Value::Table(overlay_table) = overlay else {
526        return;
527    };
528    let Some(base_table) = base.as_table_mut() else {
529        return;
530    };
531
532    for (key, overlay_val) in overlay_table {
533        let is_recursive_merge =
534            overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
535
536        if is_recursive_merge {
537            if let Some(base_val) = base_table.get_mut(&key) {
538                deep_merge_with_depth(base_val, overlay_val, depth + 1);
539            }
540        } else {
541            base_table.insert(key, overlay_val);
542        }
543    }
544}
545
546/// Suggest a close match for a custom profile name.
547///
548/// Returns `Some(name)` when a known profile is within edit distance 2.
549fn suggest_profile(profile: &str) -> Option<&'static str> {
550    let known = ["dev", "prod"];
551    let mut suggestions: Vec<(&str, usize)> = known
552        .iter()
553        .map(|k| (*k, levenshtein(profile, k)))
554        .filter(|(_, d)| *d <= 2)
555        .collect();
556    suggestions.sort_by_key(|(_, d)| *d);
557    suggestions.first().map(|(name, _)| *name)
558}
559
560/// Warn when a custom profile has no TOML file, suggesting close matches.
561fn warn_profile_typo(profile: &str) {
562    if let Some(suggestion) = suggest_profile(profile) {
563        eprintln!(
564            "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
565             and no smart defaults. Did you mean \"{suggestion}\"?"
566        );
567    }
568}
569
570fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
571    profile != "dev" && profile != "prod" && !has_inline_profile_section
572}
573
574/// Levenshtein edit distance between two strings.
575///
576/// ⚡ Bolt Optimization:
577/// Reduces memory allocations by using a single `Vec` instead of two and
578/// iterating directly over `Chars` to avoid `Vec<char>` allocations.
579fn levenshtein(a: &str, b: &str) -> usize {
580    let n = b.chars().count();
581    let mut prev: Vec<usize> = (0..=n).collect();
582    for (i, a_ch) in a.chars().enumerate() {
583        let mut prev_diag = prev[0];
584        prev[0] = i + 1;
585        for (j, b_ch) in b.chars().enumerate() {
586            let old_prev = prev[j + 1];
587            let cost = usize::from(a_ch != b_ch);
588            prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
589            prev_diag = old_prev;
590        }
591    }
592    prev[n]
593}
594
595/// Errors that can occur when loading or validating configuration.
596///
597/// Returned by [`AutumnConfig::load`], [`AutumnConfig::load_from`], and
598/// [`DatabaseConfig::validate`].
599///
600/// # Examples
601///
602/// ```rust
603/// use autumn_web::config::{AutumnConfig, ConfigError};
604/// use std::path::Path;
605///
606/// let result = AutumnConfig::load_from(Path::new("nonexistent.toml"));
607/// // Returns Ok(defaults) when file is missing -- not an error
608/// assert!(result.is_ok());
609/// ```
610#[derive(Debug, Error)]
611#[non_exhaustive]
612pub enum ConfigError {
613    /// The config file exists but could not be read.
614    #[error("failed to read autumn.toml: {0}")]
615    Io(#[from] std::io::Error),
616
617    /// The config file contains invalid TOML syntax.
618    #[error("invalid autumn.toml: {0}")]
619    Parse(#[from] toml::de::Error),
620
621    /// A configuration value failed semantic validation (e.g., invalid
622    /// database URL scheme).
623    #[error("configuration error: {0}")]
624    Validation(String),
625
626    /// The credentials file exists but could not be decrypted.
627    #[error("credentials error: {0}")]
628    Credentials(String),
629}
630
631/// Top-level framework configuration.
632///
633/// All sections are optional -- missing sections use their defaults.
634/// Deserialized from `autumn.toml` (TOML format).
635///
636/// # `autumn.toml` example
637///
638/// ```toml
639/// [server]
640/// port = 8080
641///
642/// [database]
643/// url = "postgres://user:pass@db:5432/myapp"
644/// pool_size = 20
645/// ```
646///
647/// # Examples
648///
649/// ```rust
650/// use autumn_web::config::AutumnConfig;
651///
652/// let config = AutumnConfig::default();
653/// assert_eq!(config.server.port, 3000);
654/// assert_eq!(config.database.pool_size, 10);
655/// assert_eq!(config.log.level, "info");
656/// assert_eq!(config.health.path, "/health");
657/// ```
658#[derive(Debug, Clone, Default, Deserialize)]
659pub struct AutumnConfig {
660    /// Active profile name (e.g., "dev", "prod", "staging").
661    /// Resolved at load time, not deserialized from TOML.
662    #[serde(skip)]
663    pub profile: Option<String>,
664
665    /// HTTP server settings (port, host, shutdown behavior).
666    #[serde(default)]
667    pub server: ServerConfig,
668
669    /// Database connection settings (URL, pool size, timeouts).
670    #[serde(default)]
671    pub database: DatabaseConfig,
672
673    /// Logging configuration (level, format).
674    #[serde(default)]
675    pub log: LogConfig,
676
677    /// Telemetry configuration (OTLP tracing and service metadata).
678    #[serde(default)]
679    pub telemetry: TelemetryConfig,
680
681    /// Health check endpoint settings.
682    #[serde(default)]
683    pub health: HealthConfig,
684
685    /// Actuator endpoint settings.
686    #[serde(default)]
687    pub actuator: ActuatorConfig,
688
689    /// CORS (Cross-Origin Resource Sharing) settings.
690    #[serde(default)]
691    pub cors: CorsConfig,
692
693    /// Session management settings.
694    #[serde(default)]
695    pub session: crate::session::SessionConfig,
696
697    /// Cache backend settings.
698    #[serde(default)]
699    pub cache: CacheConfig,
700
701    /// Row-level multi-tenancy settings.
702    #[serde(default)]
703    pub tenancy: TenancyConfig,
704
705    /// HTTP idempotency-key middleware settings.
706    #[serde(default)]
707    pub idempotency: IdempotencyConfig,
708
709    /// Real-time channel backend settings.
710    #[serde(default)]
711    pub channels: ChannelConfig,
712
713    /// Background job backend and runtime settings.
714    #[serde(default)]
715    pub jobs: JobConfig,
716
717    /// Scheduled task coordination backend settings.
718    #[serde(default)]
719    pub scheduler: SchedulerConfig,
720
721    /// Authentication settings.
722    #[serde(default)]
723    pub auth: crate::auth::AuthConfig,
724
725    /// Security settings (headers, CSRF).
726    #[serde(default)]
727    pub security: crate::security::config::SecurityConfig,
728
729    /// Internationalization settings (default locale, supported locales,
730    /// fallback chain). Populated from the `[i18n]` block in
731    /// `autumn.toml`.
732    #[cfg(feature = "i18n")]
733    #[serde(default)]
734    pub i18n: crate::i18n::I18nConfig,
735    /// Pluggable file storage configuration. Honored only when the
736    /// `storage` cargo feature is enabled.
737    #[cfg(feature = "storage")]
738    #[serde(default)]
739    pub storage: crate::storage::StorageConfig,
740    /// Transactional email settings.
741    #[cfg(feature = "mail")]
742    #[serde(default)]
743    pub mail: crate::mail::MailConfig,
744    /// `OpenAPI` spec runtime exposure settings.
745    ///
746    /// Controls whether the generated `OpenAPI` spec is served at runtime
747    /// and at which path. Use `[openapi] enabled = false` in `autumn.toml`
748    /// to suppress the spec endpoint in production.
749    #[serde(default, rename = "openapi")]
750    pub openapi_runtime: OpenApiRuntimeConfig,
751
752    /// Encrypted credentials store loaded from `config/credentials/<env>.toml.enc`.
753    ///
754    /// Empty when no credentials file exists (existing apps continue to boot unchanged).
755    /// Prefer using `config.credentials().get::<String>("stripe_key")` for type-safe access.
756    #[serde(skip)]
757    pub credentials: crate::credentials::CredentialsStore,
758
759    /// Outbound HTTP settings (`[http]` section in `autumn.toml`).
760    ///
761    /// The nested `[http.client]` sub-table configures the outbound client.
762    #[cfg(feature = "http-client")]
763    #[serde(default, rename = "http")]
764    pub http: HttpConfig,
765
766    /// Developer-experience settings (`[dev]` section in `autumn.toml`).
767    ///
768    /// Controls the request inspector and other dev-only features.
769    /// These settings have no effect outside the `dev` profile.
770    #[serde(default)]
771    pub dev: DevConfig,
772
773    /// Error-reporting settings (`[reporting]` section in `autumn.toml`).
774    ///
775    /// Controls delivery of panic + 5xx [`ErrorEvent`](crate::reporting::ErrorEvent)s
776    /// to registered reporters. Honored only when the `reporting` cargo
777    /// feature is enabled.
778    #[cfg(feature = "reporting")]
779    #[serde(default)]
780    pub reporting: ReportingConfig,
781
782    /// Response compression settings (`[compression]` section in `autumn.toml`).
783    ///
784    /// Compression is **off by default**. Enable with:
785    /// ```toml
786    /// [compression]
787    /// enabled = true
788    /// ```
789    /// or via `AUTUMN_COMPRESSION__ENABLED=true`.
790    #[serde(default)]
791    pub compression: CompressionConfig,
792
793    /// Bot protection / CAPTCHA settings (`[bot_protection]` section in `autumn.toml`).
794    ///
795    /// Requires a CAPTCHA token on mutating requests (POST/PUT/PATCH/DELETE) to
796    /// protect public-facing forms against automated abuse.
797    ///
798    /// # Example
799    ///
800    /// ```toml
801    /// [bot_protection]
802    /// enabled    = true
803    /// provider   = "turnstile"      # "turnstile" (default) or "hcaptcha"
804    /// site_key   = "0x4AAAA..."     # public key — safe to commit
805    /// secret_key = "..."            # private key — use env var!
806    /// dev_bypass = false
807    /// ```
808    #[serde(default)]
809    pub bot_protection: crate::security::captcha::BotProtectionConfig,
810
811    /// Resilience settings (circuit breakers, fallbacks).
812    #[serde(default)]
813    pub resilience: ResilienceConfig,
814
815    /// SEO settings (`[seo]` section in `autumn.toml`).
816    ///
817    /// Controls sitemap generation, robots.txt behavior, and canonical URL
818    /// computation. See [`crate::seo`] for the full surface.
819    ///
820    /// # Example `autumn.toml`
821    ///
822    /// ```toml
823    /// [seo]
824    /// base_url = "https://example.com"
825    ///
826    /// [seo.robots]
827    /// additional_rules = ["Disallow: /admin"]
828    /// ```
829    #[serde(default)]
830    pub seo: SeoConfig,
831}
832
833/// SEO configuration (`[seo]` section in `autumn.toml`).
834///
835/// # Example
836///
837/// ```toml
838/// [seo]
839/// base_url = "https://example.com"
840///
841/// [seo.robots]
842/// additional_rules = ["Disallow: /admin"]
843/// ```
844#[derive(Debug, Clone, Default, Deserialize)]
845pub struct SeoConfig {
846    /// Base URL used for canonical URL computation and sitemap auto-injection.
847    ///
848    /// E.g. `"https://example.com"`. When set, the `Sitemap:` directive is
849    /// automatically injected into `robots.txt`.
850    pub base_url: Option<String>,
851
852    /// Robots.txt overrides.
853    #[serde(default)]
854    pub robots: RobotsConfig,
855}
856
857/// Per-profile `robots.txt` overrides (`[seo.robots]` in `autumn.toml`).
858///
859/// The framework default behavior (dev/test → disallow all; prod → allow all)
860/// can be overridden here.
861#[derive(Debug, Clone, Default, Deserialize)]
862pub struct RobotsConfig {
863    /// Override the profile-driven allow/disallow default.
864    ///
865    /// `None` means: use the profile default (dev → disallow, prod → allow).
866    /// `Some(true)` forces `Allow: /`; `Some(false)` forces `Disallow: /`.
867    pub allow_all: Option<bool>,
868
869    /// Additional directives appended after the main `User-agent` block.
870    ///
871    /// Example: `["Disallow: /admin", "Crawl-delay: 5"]`
872    #[serde(default)]
873    pub additional_rules: Vec<String>,
874
875    /// Explicit `Sitemap:` URL.
876    ///
877    /// When `None`, the URL is auto-computed from `[seo] base_url` if set.
878    pub sitemap_url: Option<String>,
879}
880
881/// Error-reporting settings (`[reporting]` section in `autumn.toml`).
882///
883/// # Example `autumn.toml`
884///
885/// ```toml
886/// [reporting]
887/// enabled = true      # deliver events to reporters (default: true)
888/// sample_rate = 0.25  # report ~25% of events (default: 1.0 = all)
889/// ```
890///
891/// Note: `enabled = false` only suppresses *delivery* to reporters. Handler
892/// panics are still caught and converted to a clean 500 response regardless of
893/// this setting.
894#[cfg(feature = "reporting")]
895#[derive(Debug, Clone, Deserialize)]
896pub struct ReportingConfig {
897    /// Whether error events are delivered to registered reporters.
898    ///
899    /// Defaults to `true`. When `false`, panics are still caught and turned
900    /// into clean 500 responses, but no [`ErrorEvent`](crate::reporting::ErrorEvent)
901    /// is dispatched.
902    #[serde(default = "default_reporting_enabled")]
903    pub enabled: bool,
904    /// Fraction of events to deliver, in `[0.0, 1.0]`.
905    ///
906    /// `1.0` (the default) reports every event; `0.0` reports none. Values
907    /// outside the range are clamped at the extremes.
908    #[serde(default = "default_reporting_sample_rate")]
909    pub sample_rate: f64,
910}
911
912#[cfg(feature = "reporting")]
913impl Default for ReportingConfig {
914    fn default() -> Self {
915        Self {
916            enabled: default_reporting_enabled(),
917            sample_rate: default_reporting_sample_rate(),
918        }
919    }
920}
921
922#[cfg(feature = "reporting")]
923const fn default_reporting_enabled() -> bool {
924    true
925}
926
927#[cfg(feature = "reporting")]
928const fn default_reporting_sample_rate() -> f64 {
929    1.0
930}
931
932/// Developer-experience settings (`[dev]` section in `autumn.toml`).
933///
934/// All fields are ignored outside the `dev` profile.
935///
936/// # Example `autumn.toml`
937///
938/// ```toml
939/// [dev]
940/// inspector_path = "/_autumn/inspect"
941/// inspector_capacity = 200
942/// inspector_n_plus_one_threshold = 3
943/// ```
944#[derive(Debug, Clone, Deserialize)]
945pub struct DevConfig {
946    /// Mount path for the request inspector UI.
947    ///
948    /// Default: `"/_autumn/inspect"`. Only active in the `dev` profile;
949    /// ignored everywhere else.
950    #[serde(default = "default_inspector_path")]
951    pub inspector_path: String,
952
953    /// Maximum number of requests retained in the in-memory ring buffer.
954    ///
955    /// Default: `100`. Set to `0` to disable recording without removing
956    /// the middleware.
957    #[serde(default = "default_inspector_capacity")]
958    pub inspector_capacity: usize,
959
960    /// Minimum number of structurally identical SQL statements in a single
961    /// request before an N+1 warning is emitted.
962    ///
963    /// Default: `5`. Set to `0` to disable N+1 detection.
964    #[serde(default = "default_inspector_n_plus_one_threshold")]
965    pub inspector_n_plus_one_threshold: usize,
966}
967
968impl Default for DevConfig {
969    fn default() -> Self {
970        Self {
971            inspector_path: default_inspector_path(),
972            inspector_capacity: default_inspector_capacity(),
973            inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
974        }
975    }
976}
977
978fn default_inspector_path() -> String {
979    "/_autumn/inspect".to_owned()
980}
981
982const fn default_inspector_capacity() -> usize {
983    100
984}
985
986const fn default_inspector_n_plus_one_threshold() -> usize {
987    5
988}
989
990/// Top-level `[http]` configuration section.
991#[cfg(feature = "http-client")]
992#[derive(Debug, Clone, Default, Deserialize)]
993pub struct HttpConfig {
994    /// Outbound HTTP client settings (`[http.client]`).
995    #[serde(default)]
996    pub client: HttpClientConfig,
997}
998
999/// Configuration for the outbound HTTP client (`[http.client]` in `autumn.toml`).
1000///
1001/// # Example `autumn.toml`
1002///
1003/// ```toml
1004/// [http.client]
1005/// timeout_secs = 30
1006/// max_retries  = 3
1007///
1008/// [http.client.base_urls]
1009/// stripe   = "https://api.stripe.com"
1010/// sendgrid = "https://api.sendgrid.com"
1011/// ```
1012#[cfg(feature = "http-client")]
1013#[derive(Debug, Clone, Deserialize)]
1014pub struct HttpClientConfig {
1015    /// Per-request timeout in seconds. Default: 30.
1016    #[serde(default = "default_http_timeout_secs")]
1017    pub timeout_secs: u64,
1018
1019    /// Maximum retry attempts for transient failures on idempotent methods.
1020    /// Default: 3 (four total attempts).
1021    #[serde(default = "default_http_max_retries")]
1022    pub max_retries: u32,
1023
1024    /// Maximum Retry-After sleep duration in seconds to accept before clamping.
1025    /// Default: 10.
1026    #[serde(default = "default_http_max_retry_after_secs")]
1027    pub max_retry_after_secs: u64,
1028
1029    /// Named base URL aliases, e.g. `stripe = "https://api.stripe.com"`.
1030    ///
1031    /// A [`Client`](crate::http_client::Client) configured with `.named("stripe")` will
1032    /// prepend this URL to relative request paths and match against mocks
1033    /// registered for that alias via
1034    /// [`TestApp::http_mock`](crate::test::TestApp::http_mock).
1035    #[serde(default)]
1036    pub base_urls: std::collections::HashMap<String, String>,
1037}
1038
1039#[cfg(feature = "http-client")]
1040const fn default_http_timeout_secs() -> u64 {
1041    30
1042}
1043
1044#[cfg(feature = "http-client")]
1045const fn default_http_max_retries() -> u32 {
1046    3
1047}
1048
1049#[cfg(feature = "http-client")]
1050const fn default_http_max_retry_after_secs() -> u64 {
1051    10
1052}
1053
1054#[cfg(feature = "http-client")]
1055impl Default for HttpClientConfig {
1056    fn default() -> Self {
1057        Self {
1058            timeout_secs: default_http_timeout_secs(),
1059            max_retries: default_http_max_retries(),
1060            max_retry_after_secs: default_http_max_retry_after_secs(),
1061            base_urls: std::collections::HashMap::new(),
1062        }
1063    }
1064}
1065
1066impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
1067    type Rejection = crate::AutumnError;
1068
1069    async fn from_request_parts(
1070        _parts: &mut http::request::Parts,
1071        state: &crate::AppState,
1072    ) -> Result<Self, Self::Rejection> {
1073        state
1074            .extension::<Self>()
1075            .as_deref()
1076            .cloned()
1077            .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
1078    }
1079}
1080
1081/// Real-time channel backend selection.
1082#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1083#[serde(rename_all = "snake_case")]
1084pub enum ChannelBackend {
1085    /// In-process Tokio broadcast channels. Default, zero config.
1086    #[serde(alias = "local", alias = "memory")]
1087    #[default]
1088    InProcess,
1089    /// Redis pub/sub fan-out across application replicas.
1090    Redis,
1091}
1092
1093impl ChannelBackend {
1094    /// Parse an environment variable value for channel backend selection.
1095    #[must_use]
1096    pub fn from_env_value(value: &str) -> Option<Self> {
1097        match value.trim().to_ascii_lowercase().as_str() {
1098            "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
1099            "redis" => Some(Self::Redis),
1100            _ => None,
1101        }
1102    }
1103}
1104
1105/// Real-time channel runtime configuration.
1106#[derive(Debug, Clone, Deserialize)]
1107pub struct ChannelConfig {
1108    /// Runtime backend selection.
1109    #[serde(default)]
1110    pub backend: ChannelBackend,
1111    /// Per-topic broadcast ring buffer capacity.
1112    #[serde(default = "default_channel_capacity")]
1113    pub capacity: usize,
1114    /// Redis backend options.
1115    #[serde(default)]
1116    pub redis: ChannelRedisConfig,
1117}
1118
1119impl Default for ChannelConfig {
1120    fn default() -> Self {
1121        Self {
1122            backend: ChannelBackend::default(),
1123            capacity: default_channel_capacity(),
1124            redis: ChannelRedisConfig::default(),
1125        }
1126    }
1127}
1128
1129/// Redis channel backend configuration.
1130#[derive(Debug, Clone, Deserialize)]
1131pub struct ChannelRedisConfig {
1132    /// Redis URL used when `channels.backend = "redis"`.
1133    #[serde(default)]
1134    pub url: Option<String>,
1135    /// Redis pub/sub channel prefix.
1136    #[serde(default = "default_channels_redis_prefix")]
1137    pub key_prefix: String,
1138}
1139
1140impl Default for ChannelRedisConfig {
1141    fn default() -> Self {
1142        Self {
1143            url: None,
1144            key_prefix: default_channels_redis_prefix(),
1145        }
1146    }
1147}
1148
1149const fn default_channel_capacity() -> usize {
1150    32
1151}
1152
1153fn default_channels_redis_prefix() -> String {
1154    "autumn:channels".to_owned()
1155}
1156
1157// ── Cache configuration ──────────────────────────────────────────────────────
1158
1159/// Cache backend selection for `#[cached]` and `CacheResponseLayer`.
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
1161#[serde(rename_all = "lowercase")]
1162#[non_exhaustive]
1163pub enum CacheBackend {
1164    /// In-process Moka cache (default). Each replica has an independent store.
1165    #[default]
1166    Memory,
1167    /// Shared Redis cache. Invalidations propagate across all replicas.
1168    Redis,
1169}
1170
1171impl CacheBackend {
1172    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
1173        match value.trim().to_ascii_lowercase().as_str() {
1174            "memory" => Some(Self::Memory),
1175            "redis" => Some(Self::Redis),
1176            _ => None,
1177        }
1178    }
1179}
1180
1181/// Configuration for the shared application cache.
1182///
1183/// Placed in `autumn.toml` under `[cache]`.
1184///
1185/// # Examples
1186///
1187/// ```toml
1188/// [cache]
1189/// backend = "redis"
1190///
1191/// [cache.redis]
1192/// url = "redis://redis:6379"
1193/// key_prefix = "myapp:cache"
1194/// ```
1195#[derive(Debug, Clone, Default, serde::Deserialize)]
1196pub struct CacheConfig {
1197    /// Active cache backend.
1198    #[serde(default)]
1199    pub backend: CacheBackend,
1200
1201    /// Redis backend options.
1202    #[serde(default)]
1203    pub redis: CacheRedisConfig,
1204}
1205
1206impl CacheConfig {
1207    /// Returns `true` when the memory (Moka) backend is selected.
1208    #[must_use]
1209    pub fn is_memory(&self) -> bool {
1210        self.backend == CacheBackend::Memory
1211    }
1212
1213    /// Returns `true` when the Redis backend is selected.
1214    #[must_use]
1215    pub fn is_redis(&self) -> bool {
1216        self.backend == CacheBackend::Redis
1217    }
1218}
1219
1220/// Redis cache backend configuration.
1221#[derive(Debug, Clone, serde::Deserialize)]
1222pub struct CacheRedisConfig {
1223    /// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
1224    #[serde(default)]
1225    pub url: Option<String>,
1226
1227    /// Prefix for all cache keys stored in Redis.
1228    #[serde(default = "default_cache_redis_key_prefix")]
1229    pub key_prefix: String,
1230}
1231
1232impl Default for CacheRedisConfig {
1233    fn default() -> Self {
1234        Self {
1235            url: None,
1236            key_prefix: default_cache_redis_key_prefix(),
1237        }
1238    }
1239}
1240
1241fn default_cache_redis_key_prefix() -> String {
1242    "autumn:cache".to_owned()
1243}
1244
1245/// Scheduled task coordination backend selection.
1246#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1247#[serde(rename_all = "snake_case")]
1248pub enum SchedulerBackend {
1249    /// Per-process scheduler timers. This preserves existing single-replica behavior.
1250    #[serde(alias = "local", alias = "memory")]
1251    #[default]
1252    InProcess,
1253    /// Fleet coordination with Postgres advisory locks.
1254    Postgres,
1255}
1256
1257impl SchedulerBackend {
1258    /// Parse an environment variable value for scheduler backend selection.
1259    #[must_use]
1260    pub fn from_env_value(value: &str) -> Option<Self> {
1261        match value.trim().to_ascii_lowercase().as_str() {
1262            "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
1263            "postgres" | "postgresql" => Some(Self::Postgres),
1264            _ => None,
1265        }
1266    }
1267}
1268
1269/// Scheduled task coordination runtime configuration.
1270#[derive(Debug, Clone, Deserialize)]
1271pub struct SchedulerConfig {
1272    /// Runtime backend selection.
1273    #[serde(default)]
1274    pub backend: SchedulerBackend,
1275    /// Lease duration used by distributed backends for run visibility and timeout guidance.
1276    #[serde(default = "default_scheduler_lease_ttl_secs")]
1277    pub lease_ttl_secs: u64,
1278    /// Stable replica identifier surfaced in actuator metadata.
1279    #[serde(default)]
1280    pub replica_id: Option<String>,
1281    /// Prefix included when deriving Postgres advisory lock keys.
1282    #[serde(default = "default_scheduler_key_prefix")]
1283    pub key_prefix: String,
1284}
1285
1286impl SchedulerConfig {
1287    /// Resolve a stable-ish replica identifier for actuator metadata and lock ownership.
1288    #[must_use]
1289    pub fn resolved_replica_id(&self) -> String {
1290        self.replica_id
1291            .as_ref()
1292            .filter(|id| !id.trim().is_empty())
1293            .cloned()
1294            .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
1295            .or_else(|| std::env::var("HOSTNAME").ok())
1296            .unwrap_or_else(|| format!("pid-{}", std::process::id()))
1297    }
1298
1299    /// Validate scheduler-specific config shape.
1300    ///
1301    /// # Errors
1302    ///
1303    /// Returns [`ConfigError::Validation`] when values are syntactically valid TOML
1304    /// but cannot be used by the runtime.
1305    pub fn validate(&self) -> Result<(), ConfigError> {
1306        if self.lease_ttl_secs == 0 {
1307            return Err(ConfigError::Validation(
1308                "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
1309            ));
1310        }
1311        if self.key_prefix.trim().is_empty() {
1312            return Err(ConfigError::Validation(
1313                "scheduler.key_prefix must not be empty".to_owned(),
1314            ));
1315        }
1316        Ok(())
1317    }
1318}
1319
1320impl Default for SchedulerConfig {
1321    fn default() -> Self {
1322        Self {
1323            backend: SchedulerBackend::default(),
1324            lease_ttl_secs: default_scheduler_lease_ttl_secs(),
1325            replica_id: None,
1326            key_prefix: default_scheduler_key_prefix(),
1327        }
1328    }
1329}
1330
1331const fn default_scheduler_lease_ttl_secs() -> u64 {
1332    300
1333}
1334
1335fn default_scheduler_key_prefix() -> String {
1336    "autumn:scheduler".to_owned()
1337}
1338
1339/// Storage backend selection for HTTP idempotency keys.
1340#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
1341#[serde(rename_all = "lowercase")]
1342#[non_exhaustive]
1343pub enum IdempotencyBackend {
1344    #[default]
1345    Memory,
1346    Redis,
1347}
1348
1349impl IdempotencyBackend {
1350    /// Parse an environment variable value for idempotency backend selection.
1351    #[must_use]
1352    pub fn from_env_value(value: &str) -> Option<Self> {
1353        match value.trim().to_ascii_lowercase().as_str() {
1354            "memory" | "mem" => Some(Self::Memory),
1355            "redis" => Some(Self::Redis),
1356            _ => None,
1357        }
1358    }
1359}
1360
1361/// Redis connection settings for the idempotency backend.
1362#[derive(Debug, Clone, Deserialize)]
1363pub struct IdempotencyRedisConfig {
1364    /// Redis connection URL (e.g. `redis://localhost:6379`).
1365    pub url: Option<String>,
1366    /// Key prefix for all idempotency entries and locks stored in Redis.
1367    #[serde(default = "default_idempotency_redis_key_prefix")]
1368    pub key_prefix: String,
1369}
1370
1371impl Default for IdempotencyRedisConfig {
1372    fn default() -> Self {
1373        Self {
1374            url: None,
1375            key_prefix: default_idempotency_redis_key_prefix(),
1376        }
1377    }
1378}
1379
1380fn default_idempotency_redis_key_prefix() -> String {
1381    "autumn:idempotency".to_owned()
1382}
1383
1384/// HTTP idempotency-key middleware settings.
1385#[derive(Debug, Clone, Deserialize)]
1386pub struct IdempotencyConfig {
1387    /// Enable the idempotency-key middleware.
1388    ///
1389    /// When `true`, mutating requests that carry an `Idempotency-Key` header
1390    /// are deduplicated using the configured backend.
1391    ///
1392    /// `None` means the field was absent from the config file; the
1393    /// `AppBuilder::idempotent()` builder flag may still enable it.
1394    /// `Some(false)` is an explicit operator opt-out that overrides the builder.
1395    #[serde(default)]
1396    pub enabled: Option<bool>,
1397    /// Storage backend for idempotency records.
1398    #[serde(default)]
1399    pub backend: IdempotencyBackend,
1400    /// Time-to-live in seconds for stored idempotency records.
1401    #[serde(default = "default_idempotency_ttl_secs")]
1402    pub ttl_secs: u64,
1403    /// Maximum stale lifetime for distributed in-flight locks.
1404    ///
1405    /// The lock is released as soon as the handler finishes. This value is only
1406    /// the backend safety expiry for crashes or lost unlocks, so it should be
1407    /// comfortably longer than any supported mutating request duration.
1408    #[serde(default = "default_idempotency_in_flight_ttl_secs")]
1409    pub in_flight_ttl_secs: u64,
1410    /// Allow the in-memory backend in production environments.
1411    #[serde(default)]
1412    pub allow_memory_in_production: bool,
1413    /// Redis connection settings (used when `backend = "redis"`).
1414    #[serde(default)]
1415    pub redis: IdempotencyRedisConfig,
1416}
1417
1418impl Default for IdempotencyConfig {
1419    fn default() -> Self {
1420        Self {
1421            enabled: None,
1422            backend: IdempotencyBackend::default(),
1423            ttl_secs: default_idempotency_ttl_secs(),
1424            in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
1425            allow_memory_in_production: false,
1426            redis: IdempotencyRedisConfig::default(),
1427        }
1428    }
1429}
1430
1431const fn default_idempotency_ttl_secs() -> u64 {
1432    86_400
1433}
1434
1435const fn default_idempotency_in_flight_ttl_secs() -> u64 {
1436    86_400
1437}
1438
1439/// `OpenAPI` spec runtime exposure settings.
1440///
1441/// Populated from the `[openapi]` block in `autumn.toml`. When
1442/// `AppBuilder::openapi(...)` is called and `enabled = true`, the framework
1443/// mounts the spec at `path`. Set `enabled = false` in a production profile
1444/// to prevent exposing the spec publicly.
1445///
1446/// # `autumn.toml` example
1447///
1448/// ```toml
1449/// [openapi]
1450/// enabled = false   # disable in prod
1451/// path = "/openapi.json"
1452/// ```
1453#[derive(Debug, Clone, Deserialize)]
1454pub struct OpenApiRuntimeConfig {
1455    /// Whether the `OpenAPI` spec endpoint is served.
1456    ///
1457    /// Defaults to `true` so new projects get the spec immediately.
1458    /// Set to `false` in production profiles to suppress the endpoint.
1459    #[serde(default = "default_openapi_enabled")]
1460    pub enabled: bool,
1461    /// URL path at which `openapi.json` is served.
1462    ///
1463    /// Defaults to `/openapi.json`.
1464    #[serde(default = "default_openapi_path")]
1465    pub path: String,
1466}
1467
1468impl Default for OpenApiRuntimeConfig {
1469    fn default() -> Self {
1470        Self {
1471            enabled: default_openapi_enabled(),
1472            path: default_openapi_path(),
1473        }
1474    }
1475}
1476
1477const fn default_openapi_enabled() -> bool {
1478    true
1479}
1480
1481fn default_openapi_path() -> String {
1482    "/openapi.json".to_owned()
1483}
1484
1485/// Background job runtime configuration.
1486#[derive(Debug, Clone, Deserialize)]
1487pub struct JobConfig {
1488    /// Runtime backend selection.
1489    ///
1490    /// - `local` (default): in-process Tokio queue
1491    /// - `postgres`: Postgres-backed durable queue (requires `db` feature)
1492    /// - `redis`: Redis-backed durable queue (requires `redis` feature)
1493    #[serde(default = "default_job_backend")]
1494    pub backend: String,
1495    /// Number of concurrent worker loops to spawn.
1496    #[serde(default = "default_job_workers")]
1497    pub workers: usize,
1498    /// Default max attempts when `#[job(max_attempts = ...)]` is not set.
1499    #[serde(default = "default_job_max_attempts")]
1500    pub max_attempts: u32,
1501    /// Default initial retry backoff in milliseconds.
1502    #[serde(default = "default_job_backoff_ms")]
1503    pub initial_backoff_ms: u64,
1504    /// Redis backend options.
1505    #[serde(default)]
1506    pub redis: JobRedisConfig,
1507    /// Postgres backend options.
1508    #[serde(default)]
1509    pub postgres: JobPostgresConfig,
1510}
1511
1512impl Default for JobConfig {
1513    fn default() -> Self {
1514        Self {
1515            backend: default_job_backend(),
1516            workers: default_job_workers(),
1517            max_attempts: default_job_max_attempts(),
1518            initial_backoff_ms: default_job_backoff_ms(),
1519            redis: JobRedisConfig::default(),
1520            postgres: JobPostgresConfig::default(),
1521        }
1522    }
1523}
1524
1525/// Redis backend configuration options for the job runner.
1526#[derive(Debug, Clone, Deserialize)]
1527pub struct JobRedisConfig {
1528    /// Redis URL used when `jobs.backend = "redis"`.
1529    #[serde(default)]
1530    pub url: Option<String>,
1531    /// Key prefix for all queue keys.
1532    #[serde(default = "default_jobs_redis_prefix")]
1533    pub key_prefix: String,
1534    /// Duration before an in-flight job claim is considered stale.
1535    #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
1536    pub visibility_timeout_ms: u64,
1537}
1538
1539impl Default for JobRedisConfig {
1540    fn default() -> Self {
1541        Self {
1542            url: None,
1543            key_prefix: default_jobs_redis_prefix(),
1544            visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
1545        }
1546    }
1547}
1548
1549/// Postgres backend configuration options for the job runner.
1550#[derive(Debug, Clone, Deserialize)]
1551pub struct JobPostgresConfig {
1552    /// Duration before an in-flight job claim is considered stale and recovered.
1553    ///
1554    /// Workers that crash mid-job have their claim reclaimed by another worker
1555    /// within this bound. Default: 30 seconds.
1556    #[serde(default = "default_jobs_pg_visibility_timeout_ms")]
1557    pub visibility_timeout_ms: u64,
1558}
1559
1560impl Default for JobPostgresConfig {
1561    fn default() -> Self {
1562        Self {
1563            visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
1564        }
1565    }
1566}
1567
1568const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
1569    30_000
1570}
1571
1572fn default_job_backend() -> String {
1573    "local".to_owned()
1574}
1575
1576const fn default_job_workers() -> usize {
1577    1
1578}
1579
1580const fn default_job_max_attempts() -> u32 {
1581    5
1582}
1583
1584const fn default_job_backoff_ms() -> u64 {
1585    250
1586}
1587
1588fn default_jobs_redis_prefix() -> String {
1589    "autumn:jobs".to_owned()
1590}
1591
1592const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
1593    30_000
1594}
1595
1596impl AutumnConfig {
1597    /// Access the decrypted credentials store.
1598    ///
1599    /// Returns an empty store when no credentials file was found (the feature is opt-in).
1600    /// Use `config.credentials().get::<String>("stripe_key")` to access values.
1601    #[must_use]
1602    pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
1603        &self.credentials
1604    }
1605
1606    /// Load configuration with profile-aware layering.
1607    ///
1608    /// Applies the six-layer configuration system:
1609    /// 1. Framework defaults
1610    /// 2. Profile smart defaults (dev/prod)
1611    /// 3. `autumn.toml` (base config)
1612    /// 4. `[profile.{name}]` section in `autumn.toml`
1613    /// 5. `autumn-{profile}.toml` (legacy profile overrides)
1614    /// 6. `AUTUMN_*` environment variables
1615    ///
1616    /// # Errors
1617    ///
1618    /// Returns [`ConfigError::Io`] if a config file cannot be read,
1619    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
1620    /// [`ConfigError::Validation`] if a value is invalid.
1621    ///
1622    /// # Panics
1623    ///
1624    /// Panics if the internally-built TOML table fails to re-serialize
1625    /// (should never happen with well-formed profile defaults).
1626    pub fn load() -> Result<Self, ConfigError> {
1627        Self::load_with_env(&OsEnv)
1628    }
1629
1630    /// Load configuration with profile-aware layering, using a provided
1631    /// environment abstraction instead of the OS environment. Useful for testing.
1632    ///
1633    /// # Errors
1634    /// Returns [`ConfigError::Io`] if a config file cannot be read,
1635    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
1636    /// [`ConfigError::Validation`] if a value is invalid.
1637    ///
1638    /// # Panics
1639    /// Panics if the internally-built TOML table fails to re-serialize.
1640    pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
1641        let selected_profile_input = resolve_profile_input(env);
1642        let profile =
1643            normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
1644        let mut has_inline_profile_section = false;
1645
1646        // Build merged TOML:
1647        // profile smart defaults ← autumn.toml ← [profile.{name}] ← autumn-{profile}.toml
1648        let mut merged = profile_defaults_as_toml(&profile);
1649
1650        // Layer 3: base autumn.toml
1651        if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
1652            deep_merge(&mut merged, base.clone());
1653
1654            // Layer 4: [profile.{name}] in autumn.toml
1655            for profile_name in profile_lookup_names(&profile) {
1656                if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
1657                    deep_merge(&mut merged, inline_profile);
1658                    has_inline_profile_section = true;
1659                }
1660            }
1661        }
1662
1663        // Layer 5: autumn-{profile}.toml (legacy compatibility)
1664        let mut has_profile_file = false;
1665        for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
1666            let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
1667            if let Some(profile_toml) = load_raw_toml(&profile_path)? {
1668                deep_merge(&mut merged, profile_toml);
1669                has_profile_file = true;
1670                break;
1671            }
1672        }
1673        if !has_profile_file
1674            && should_warn_missing_profile_file(&profile, has_inline_profile_section)
1675        {
1676            warn_profile_typo(&profile);
1677        }
1678
1679        // Deserialize the merged TOML table into AutumnConfig
1680        let toml_str =
1681            toml::to_string(&merged).expect("internal error: failed to serialize merged config");
1682        let mut config: Self = toml::from_str(&toml_str)?;
1683        config.profile = Some(profile);
1684
1685        // Layer 6: env var overrides (highest priority)
1686        config.apply_env_overrides_with_env(env);
1687
1688        #[cfg(feature = "mail")]
1689        if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
1690            config.mail.transport = crate::mail::Transport::Log;
1691        }
1692
1693        config.validate()?;
1694
1695        let base_dir: PathBuf = env
1696            .var("AUTUMN_MANIFEST_DIR")
1697            .map_or_else(|_| PathBuf::from("."), PathBuf::from);
1698        let cred_profile = config.profile.as_deref().unwrap_or("dev");
1699        let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
1700        config.credentials = crate::credentials::load_credentials_with_key_override(
1701            cred_profile,
1702            &base_dir,
1703            master_key_override.as_deref(),
1704        )
1705        .map_err(|e| ConfigError::Credentials(e.to_string()))?;
1706
1707        #[cfg(feature = "oauth2")]
1708        {
1709            config.expand_oauth2_providers();
1710        }
1711
1712        Ok(config)
1713    }
1714
1715    /// Helper method to expand `OAuth2` preset configurations and resolve credentials-backed values.
1716    #[cfg(feature = "oauth2")]
1717    fn expand_oauth2_providers(&mut self) {
1718        let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
1719        for name in provider_names {
1720            // 1. Expand from preset if available
1721            if let (Some(preset), Some(p)) = (
1722                crate::auth::provider_preset(&name),
1723                self.auth.oauth2.providers.get_mut(&name),
1724            ) {
1725                if p.authorize_url.is_empty() {
1726                    p.authorize_url = preset.authorize_url;
1727                }
1728                if p.token_url.is_empty() {
1729                    p.token_url = preset.token_url;
1730                }
1731                if p.userinfo_url.is_none() {
1732                    p.userinfo_url = preset.userinfo_url;
1733                }
1734                if p.scope.is_empty() || p.scope == "default" {
1735                    p.scope = preset.scope;
1736                }
1737                if p.issuer.is_none() {
1738                    p.issuer = preset.issuer;
1739                }
1740                if p.jwks_url.is_none() {
1741                    p.jwks_url = preset.jwks_url;
1742                }
1743                if p.discovery_url.is_none() {
1744                    p.discovery_url = preset.discovery_url;
1745                }
1746            }
1747
1748            // 2. Resolve credentials-backed secrets/IDs
1749            if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
1750                let normalized_name = name
1751                    .chars()
1752                    .map(|c| if c.is_alphanumeric() { c } else { '_' })
1753                    .collect::<String>()
1754                    .to_lowercase();
1755
1756                let id_key = format!("oauth2_{normalized_name}_client_id");
1757                if p.client_id.is_empty() {
1758                    if let Some(id) = self.credentials.get::<String>(&id_key) {
1759                        p.client_id = id;
1760                    } else if let Some(id) = self
1761                        .credentials
1762                        .get::<String>(&format!("oauth2_{name}_client_id"))
1763                    {
1764                        p.client_id = id;
1765                    }
1766                }
1767                let secret_key = format!("oauth2_{normalized_name}_client_secret");
1768                if p.client_secret.is_empty() {
1769                    if let Some(secret) = self.credentials.get::<String>(&secret_key) {
1770                        p.client_secret = secret;
1771                    } else if let Some(secret) = self
1772                        .credentials
1773                        .get::<String>(&format!("oauth2_{name}_client_secret"))
1774                    {
1775                        p.client_secret = secret;
1776                    }
1777                }
1778            }
1779        }
1780    }
1781
1782    /// Load configuration from a specific TOML file path.
1783    ///
1784    /// Used internally and for testing. Does **not** apply profile
1785    /// layering or environment overrides. Prefer [`load()`](Self::load)
1786    /// in application code.
1787    ///
1788    /// # Errors
1789    ///
1790    /// Returns [`ConfigError::Io`] if the file cannot be read, or
1791    /// [`ConfigError::Parse`] if the file contains invalid TOML.
1792    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1793        match std::fs::read_to_string(path) {
1794            Ok(contents) => {
1795                let config: Self = toml::from_str(&contents)?;
1796                config.validate()?;
1797                Ok(config)
1798            }
1799            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1800            Err(e) => Err(ConfigError::Io(e)),
1801        }
1802    }
1803
1804    /// Validate the resolved configuration for semantic errors.
1805    ///
1806    /// # Errors
1807    /// Returns [`ConfigError::Validation`] when a field combination is
1808    /// syntactically well-formed TOML but semantically invalid.
1809    pub fn validate(&self) -> Result<(), ConfigError> {
1810        self.database.validate()?;
1811        self.cors.validate()?;
1812        self.scheduler.validate()?;
1813        let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
1814        self.security
1815            .webhooks
1816            .validate(is_production)
1817            .map_err(|error| ConfigError::Validation(error.to_string()))?;
1818        #[cfg(feature = "mail")]
1819        self.mail.validate(self.profile.as_deref())?;
1820        // Session backend validation deliberately lives in
1821        // `crate::session::apply_session_layer`, not here. That function
1822        // short-circuits when a custom `SessionStore` was installed via
1823        // `AppBuilder::with_session_store(...)`, so the (then-irrelevant)
1824        // `session.backend = "redis"` config without a redis URL doesn't
1825        // need to fail the boot. Validating the same thing here would
1826        // defeat the override and exit the app before the custom store
1827        // ever gets a chance to apply. The "prod profile + memory backend"
1828        // warning lives in `apply_session_layer` for the same reason.
1829        Ok(())
1830    }
1831
1832    /// Apply environment variable overrides to the loaded config.
1833    ///
1834    /// All fields can be overridden via `AUTUMN_SECTION__FIELD` environment
1835    /// variables. Double underscore `__` separates nested config sections.
1836    ///
1837    /// # Server
1838    /// - `AUTUMN_SERVER__PORT` → `server.port` (u16)
1839    /// - `AUTUMN_SERVER__HOST` → `server.host` (String)
1840    /// - `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` → `server.shutdown_timeout_secs` (u64)
1841    /// - `AUTUMN_SERVER__PRESTOP_GRACE_SECS` → `server.prestop_grace_secs` (u64)
1842    ///
1843    /// # Database
1844    /// - `AUTUMN_DATABASE__PRIMARY_URL` -> `database.primary_url` (String)
1845    /// - `AUTUMN_DATABASE__REPLICA_URL` -> `database.replica_url` (String)
1846    /// - `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` -> `database.primary_pool_size` (usize)
1847    /// - `AUTUMN_DATABASE__REPLICA_POOL_SIZE` -> `database.replica_pool_size` (usize)
1848    /// - `AUTUMN_DATABASE__REPLICA_FALLBACK` -> `database.replica_fallback` (`fail_readiness` | `primary`)
1849    /// - `AUTUMN_DATABASE__URL` → `database.url` (String)
1850    /// - `AUTUMN_DATABASE__POOL_SIZE` → `database.pool_size` (usize)
1851    /// - `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` → `database.connect_timeout_secs` (u64)
1852    /// - `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` -> `database.auto_migrate_in_production` (bool)
1853    ///
1854    /// # Log
1855    /// - `AUTUMN_LOG__LEVEL` → `log.level` (String, tracing filter directive)
1856    /// - `AUTUMN_LOG__FORMAT` → `log.format` (Auto | Pretty | Json)
1857    ///
1858    /// # Telemetry
1859    /// - `AUTUMN_TELEMETRY__ENABLED` -> `telemetry.enabled` (bool)
1860    /// - `AUTUMN_TELEMETRY__SERVICE_NAME` -> `telemetry.service_name` (String)
1861    /// - `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` -> `telemetry.service_namespace` (String)
1862    /// - `AUTUMN_TELEMETRY__SERVICE_VERSION` -> `telemetry.service_version` (String)
1863    /// - `AUTUMN_TELEMETRY__ENVIRONMENT` -> `telemetry.environment` (String)
1864    /// - `AUTUMN_TELEMETRY__OTLP_ENDPOINT` -> `telemetry.otlp_endpoint` (String)
1865    /// - `AUTUMN_TELEMETRY__PROTOCOL` -> `telemetry.protocol` (`Grpc` | `HttpProtobuf`)
1866    /// - `AUTUMN_TELEMETRY__STRICT` -> `telemetry.strict` (bool)
1867    ///
1868    /// # Health / Probes
1869    /// - `AUTUMN_HEALTH__PATH` → `health.path` (String)
1870    /// - `AUTUMN_HEALTH__LIVE_PATH` → `health.live_path` (String)
1871    /// - `AUTUMN_HEALTH__READY_PATH` → `health.ready_path` (String)
1872    /// - `AUTUMN_HEALTH__STARTUP_PATH` → `health.startup_path` (String)
1873    /// - `AUTUMN_HEALTH__DETAILED` → `health.detailed` (bool)
1874    ///
1875    /// # Jobs
1876    /// - `AUTUMN_JOBS__BACKEND` → `jobs.backend` (`local` / `redis`)
1877    /// - `AUTUMN_JOBS__WORKERS` → `jobs.workers` (`usize`)
1878    /// - `AUTUMN_JOBS__MAX_ATTEMPTS` → `jobs.max_attempts` (`u32`)
1879    /// - `AUTUMN_JOBS__INITIAL_BACKOFF_MS` → `jobs.initial_backoff_ms` (`u64`)
1880    /// - `AUTUMN_JOBS__REDIS__URL` → `jobs.redis.url` (`String`)
1881    /// - `AUTUMN_JOBS__REDIS__KEY_PREFIX` → `jobs.redis.key_prefix` (`String`)
1882    /// - `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` → `jobs.redis.visibility_timeout_ms` (`u64`)
1883    ///
1884    /// # Signed webhooks
1885    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` -> `security.webhooks.replay.backend` (`memory` / `redis`)
1886    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` -> `security.webhooks.replay.redis.url` (`String`)
1887    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` -> `security.webhooks.replay.redis.key_prefix` (`String`)
1888    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` -> `security.webhooks.replay.allow_memory_in_production` (`bool`)
1889    pub fn apply_env_overrides(&mut self) {
1890        self.apply_env_overrides_with_env(&OsEnv);
1891    }
1892
1893    /// Apply environment overrides using the provided env abstraction.
1894    pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
1895        self.apply_server_env_overrides_with_env(env);
1896        self.apply_database_env_overrides_with_env(env);
1897        self.apply_log_env_overrides_with_env(env);
1898        self.apply_telemetry_env_overrides_with_env(env);
1899        self.apply_health_env_overrides_with_env(env);
1900        self.apply_cors_env_overrides_with_env(env);
1901        self.apply_session_env_overrides_with_env(env);
1902        self.apply_cache_env_overrides_with_env(env);
1903        self.apply_channels_env_overrides_with_env(env);
1904        self.apply_jobs_env_overrides_with_env(env);
1905        self.apply_scheduler_env_overrides_with_env(env);
1906        self.apply_auth_env_overrides_with_env(env);
1907        self.apply_security_env_overrides_with_env(env);
1908        self.apply_bot_protection_env_overrides_with_env(env);
1909        self.apply_idempotency_env_overrides_with_env(env);
1910        self.apply_dev_env_overrides_with_env(env);
1911        self.apply_compression_env_overrides_with_env(env);
1912        self.apply_actuator_env_overrides_with_env(env);
1913        #[cfg(feature = "reporting")]
1914        self.apply_reporting_env_overrides_with_env(env);
1915        #[cfg(feature = "storage")]
1916        self.apply_storage_env_overrides_with_env(env);
1917        #[cfg(feature = "mail")]
1918        self.apply_mail_env_overrides_with_env(env);
1919        self.apply_resilience_env_overrides_with_env(env);
1920    }
1921
1922    #[cfg(feature = "reporting")]
1923    fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
1924        parse_env_bool(
1925            env,
1926            "AUTUMN_REPORTING__ENABLED",
1927            &mut self.reporting.enabled,
1928        );
1929        parse_env(
1930            env,
1931            "AUTUMN_REPORTING__SAMPLE_RATE",
1932            &mut self.reporting.sample_rate,
1933        );
1934    }
1935
1936    fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
1937        parse_env_string(
1938            env,
1939            "AUTUMN_DEV__INSPECTOR_PATH",
1940            &mut self.dev.inspector_path,
1941        );
1942        parse_env(
1943            env,
1944            "AUTUMN_DEV__INSPECTOR_CAPACITY",
1945            &mut self.dev.inspector_capacity,
1946        );
1947        parse_env(
1948            env,
1949            "AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
1950            &mut self.dev.inspector_n_plus_one_threshold,
1951        );
1952    }
1953
1954    fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
1955        parse_env_bool(
1956            env,
1957            "AUTUMN_COMPRESSION__ENABLED",
1958            &mut self.compression.enabled,
1959        );
1960    }
1961
1962    fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
1963        parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
1964        parse_env_bool(
1965            env,
1966            "AUTUMN_ACTUATOR__SENSITIVE",
1967            &mut self.actuator.sensitive,
1968        );
1969        // Security-sensitive: operators disable the Prometheus scrape endpoint
1970        // with AUTUMN_ACTUATOR__PROMETHEUS=false; the override must be honored
1971        // so the endpoint is not left exposed against the operator's intent.
1972        parse_env_bool(
1973            env,
1974            "AUTUMN_ACTUATOR__PROMETHEUS",
1975            &mut self.actuator.prometheus,
1976        );
1977    }
1978
1979    fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
1980        parse_env_option_bool(
1981            env,
1982            "AUTUMN_IDEMPOTENCY__ENABLED",
1983            &mut self.idempotency.enabled,
1984        );
1985        if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
1986            match IdempotencyBackend::from_env_value(&val) {
1987                Some(backend) => self.idempotency.backend = backend,
1988                None => eprintln!(
1989                    "Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
1990                ),
1991            }
1992        }
1993        parse_env(
1994            env,
1995            "AUTUMN_IDEMPOTENCY__TTL_SECS",
1996            &mut self.idempotency.ttl_secs,
1997        );
1998        parse_env(
1999            env,
2000            "AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
2001            &mut self.idempotency.in_flight_ttl_secs,
2002        );
2003        parse_env_bool(
2004            env,
2005            "AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
2006            &mut self.idempotency.allow_memory_in_production,
2007        );
2008        parse_env_string(
2009            env,
2010            "AUTUMN_IDEMPOTENCY__REDIS__URL",
2011            self.idempotency.redis.url.get_or_insert_with(String::new),
2012        );
2013        parse_env_string(
2014            env,
2015            "AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
2016            &mut self.idempotency.redis.key_prefix,
2017        );
2018    }
2019
2020    fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
2021        parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
2022        parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
2023        parse_env(
2024            env,
2025            "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
2026            &mut self.server.shutdown_timeout_secs,
2027        );
2028        parse_env(
2029            env,
2030            "AUTUMN_SERVER__PRESTOP_GRACE_SECS",
2031            &mut self.server.prestop_grace_secs,
2032        );
2033        parse_env_option(
2034            env,
2035            "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
2036            &mut self.server.timeouts.request_timeout_ms,
2037        );
2038    }
2039
2040    fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
2041        if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
2042            self.database.url = Some(val);
2043            self.database.primary_url = None;
2044        }
2045        parse_env_option_string(
2046            env,
2047            "AUTUMN_DATABASE__PRIMARY_URL",
2048            &mut self.database.primary_url,
2049        );
2050        parse_env_option_string(
2051            env,
2052            "AUTUMN_DATABASE__REPLICA_URL",
2053            &mut self.database.replica_url,
2054        );
2055        parse_env(
2056            env,
2057            "AUTUMN_DATABASE__POOL_SIZE",
2058            &mut self.database.pool_size,
2059        );
2060        parse_env_option(
2061            env,
2062            "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
2063            &mut self.database.primary_pool_size,
2064        );
2065        parse_env_option(
2066            env,
2067            "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
2068            &mut self.database.replica_pool_size,
2069        );
2070        parse_env(
2071            env,
2072            "AUTUMN_DATABASE__REPLICA_FALLBACK",
2073            &mut self.database.replica_fallback,
2074        );
2075        parse_env(
2076            env,
2077            "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
2078            &mut self.database.connect_timeout_secs,
2079        );
2080        parse_env_bool(
2081            env,
2082            "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
2083            &mut self.database.auto_migrate_in_production,
2084        );
2085    }
2086
2087    fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
2088        parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
2089        parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
2090        parse_env_csv(
2091            env,
2092            "AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
2093            &mut self.log.access_log_exclude,
2094        );
2095        if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
2096            match val.as_str() {
2097                "Auto" => self.log.format = LogFormat::Auto,
2098                "Pretty" => self.log.format = LogFormat::Pretty,
2099                "Json" => self.log.format = LogFormat::Json,
2100                _ => eprintln!(
2101                    "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
2102                     (expected Auto, Pretty, or Json), ignoring"
2103                ),
2104            }
2105        }
2106    }
2107
2108    fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
2109        // ── Health ──────────────────────────────────────────────
2110        parse_env_bool(
2111            env,
2112            "AUTUMN_TELEMETRY__ENABLED",
2113            &mut self.telemetry.enabled,
2114        );
2115        parse_env_string(
2116            env,
2117            "AUTUMN_TELEMETRY__SERVICE_NAME",
2118            &mut self.telemetry.service_name,
2119        );
2120        parse_env_option_string(
2121            env,
2122            "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
2123            &mut self.telemetry.service_namespace,
2124        );
2125        parse_env_string(
2126            env,
2127            "AUTUMN_TELEMETRY__SERVICE_VERSION",
2128            &mut self.telemetry.service_version,
2129        );
2130        parse_env_string(
2131            env,
2132            "AUTUMN_TELEMETRY__ENVIRONMENT",
2133            &mut self.telemetry.environment,
2134        );
2135        parse_env_option_string(
2136            env,
2137            "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
2138            &mut self.telemetry.otlp_endpoint,
2139        );
2140        if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
2141            match TelemetryProtocol::from_env_value(&val) {
2142                Some(protocol) => self.telemetry.protocol = protocol,
2143                None => eprintln!(
2144                    "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
2145                     (expected Grpc or HttpProtobuf), ignoring"
2146                ),
2147            }
2148        }
2149        parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
2150    }
2151
2152    fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
2153        parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
2154        parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
2155        parse_env_string(
2156            env,
2157            "AUTUMN_HEALTH__READY_PATH",
2158            &mut self.health.ready_path,
2159        );
2160        parse_env_string(
2161            env,
2162            "AUTUMN_HEALTH__STARTUP_PATH",
2163            &mut self.health.startup_path,
2164        );
2165        parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
2166    }
2167
2168    fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
2169        parse_env_csv(
2170            env,
2171            "AUTUMN_CORS__ALLOWED_ORIGINS",
2172            &mut self.cors.allowed_origins,
2173        );
2174        parse_env_csv(
2175            env,
2176            "AUTUMN_CORS__ALLOWED_METHODS",
2177            &mut self.cors.allowed_methods,
2178        );
2179        parse_env_csv(
2180            env,
2181            "AUTUMN_CORS__ALLOWED_HEADERS",
2182            &mut self.cors.allowed_headers,
2183        );
2184        parse_env_bool(
2185            env,
2186            "AUTUMN_CORS__ALLOW_CREDENTIALS",
2187            &mut self.cors.allow_credentials,
2188        );
2189        parse_env(
2190            env,
2191            "AUTUMN_CORS__MAX_AGE_SECS",
2192            &mut self.cors.max_age_secs,
2193        );
2194    }
2195
2196    fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
2197        parse_env_string(
2198            env,
2199            "AUTUMN_SESSION__COOKIE_NAME",
2200            &mut self.session.cookie_name,
2201        );
2202        if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
2203            match crate::session::SessionBackend::from_env_value(&val) {
2204                Some(backend) => self.session.backend = backend,
2205                None => eprintln!(
2206                    "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
2207                     (expected memory or redis), ignoring"
2208                ),
2209            }
2210        }
2211        parse_env(
2212            env,
2213            "AUTUMN_SESSION__MAX_AGE_SECS",
2214            &mut self.session.max_age_secs,
2215        );
2216        parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
2217        parse_env_string(
2218            env,
2219            "AUTUMN_SESSION__SAME_SITE",
2220            &mut self.session.same_site,
2221        );
2222        parse_env_bool(
2223            env,
2224            "AUTUMN_SESSION__HTTP_ONLY",
2225            &mut self.session.http_only,
2226        );
2227        parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
2228        parse_env_bool(
2229            env,
2230            "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
2231            &mut self.session.allow_memory_in_production,
2232        );
2233        parse_env_option_string(
2234            env,
2235            "AUTUMN_SESSION__REDIS__URL",
2236            &mut self.session.redis.url,
2237        );
2238        parse_env_string(
2239            env,
2240            "AUTUMN_SESSION__REDIS__KEY_PREFIX",
2241            &mut self.session.redis.key_prefix,
2242        );
2243    }
2244
2245    fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
2246        if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
2247            match CacheBackend::from_env_value(&val) {
2248                Some(backend) => self.cache.backend = backend,
2249                None => eprintln!(
2250                    "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
2251                     (expected memory or redis), ignoring"
2252                ),
2253            }
2254        }
2255        parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
2256        parse_env_string(
2257            env,
2258            "AUTUMN_CACHE__REDIS__KEY_PREFIX",
2259            &mut self.cache.redis.key_prefix,
2260        );
2261    }
2262
2263    fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
2264        if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
2265            match ChannelBackend::from_env_value(&val) {
2266                Some(backend) => self.channels.backend = backend,
2267                None => eprintln!(
2268                    "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
2269                     (expected in_process or redis), ignoring"
2270                ),
2271            }
2272        }
2273        parse_env(
2274            env,
2275            "AUTUMN_CHANNELS__CAPACITY",
2276            &mut self.channels.capacity,
2277        );
2278        parse_env_option_string(
2279            env,
2280            "AUTUMN_CHANNELS__REDIS__URL",
2281            &mut self.channels.redis.url,
2282        );
2283        parse_env_string(
2284            env,
2285            "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
2286            &mut self.channels.redis.key_prefix,
2287        );
2288    }
2289
2290    fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
2291        parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
2292        parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
2293        parse_env(
2294            env,
2295            "AUTUMN_JOBS__MAX_ATTEMPTS",
2296            &mut self.jobs.max_attempts,
2297        );
2298        parse_env(
2299            env,
2300            "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
2301            &mut self.jobs.initial_backoff_ms,
2302        );
2303        parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
2304        parse_env_string(
2305            env,
2306            "AUTUMN_JOBS__REDIS__KEY_PREFIX",
2307            &mut self.jobs.redis.key_prefix,
2308        );
2309        parse_env(
2310            env,
2311            "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
2312            &mut self.jobs.redis.visibility_timeout_ms,
2313        );
2314        parse_env(
2315            env,
2316            "AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
2317            &mut self.jobs.postgres.visibility_timeout_ms,
2318        );
2319    }
2320
2321    fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
2322        if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
2323            match SchedulerBackend::from_env_value(&val) {
2324                Some(backend) => self.scheduler.backend = backend,
2325                None => eprintln!(
2326                    "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
2327                     (expected in_process or postgres), ignoring"
2328                ),
2329            }
2330        }
2331        parse_env(
2332            env,
2333            "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
2334            &mut self.scheduler.lease_ttl_secs,
2335        );
2336        parse_env_option_string(
2337            env,
2338            "AUTUMN_SCHEDULER__REPLICA_ID",
2339            &mut self.scheduler.replica_id,
2340        );
2341        parse_env_string(
2342            env,
2343            "AUTUMN_SCHEDULER__KEY_PREFIX",
2344            &mut self.scheduler.key_prefix,
2345        );
2346    }
2347
2348    fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
2349        parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
2350        parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
2351        parse_env(
2352            env,
2353            "AUTUMN_AUTH__LOCKOUT__ENABLED",
2354            &mut self.auth.lockout.enabled,
2355        );
2356        parse_env(
2357            env,
2358            "AUTUMN_AUTH__LOCKOUT__THRESHOLD",
2359            &mut self.auth.lockout.threshold,
2360        );
2361        parse_env(
2362            env,
2363            "AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
2364            &mut self.auth.lockout.window_secs,
2365        );
2366        parse_env(
2367            env,
2368            "AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
2369            &mut self.auth.lockout.cooloff_secs,
2370        );
2371        #[cfg(feature = "oauth2")]
2372        {
2373            let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
2374            for name in provider_names {
2375                let upper = name
2376                    .chars()
2377                    .map(|c| if c.is_alphanumeric() { c } else { '_' })
2378                    .collect::<String>()
2379                    .to_uppercase();
2380
2381                let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
2382                if let Ok(id) = env.var(&client_id_var)
2383                    && !id.is_empty()
2384                    && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
2385                {
2386                    p.client_id = id;
2387                }
2388
2389                let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
2390                if let Ok(secret) = env.var(&client_secret_var)
2391                    && !secret.is_empty()
2392                    && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
2393                {
2394                    p.client_secret = secret;
2395                }
2396            }
2397        }
2398    }
2399
2400    /// Apply `AUTUMN_SECURITY__*` environment variable overrides.
2401    #[allow(clippy::too_many_lines)]
2402    fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
2403        parse_env_string(
2404            env,
2405            "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
2406            &mut self.security.headers.x_frame_options,
2407        );
2408        parse_env_bool(
2409            env,
2410            "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
2411            &mut self.security.headers.x_content_type_options,
2412        );
2413        parse_env_bool(
2414            env,
2415            "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
2416            &mut self.security.headers.strict_transport_security,
2417        );
2418        parse_env(
2419            env,
2420            "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
2421            &mut self.security.headers.hsts_max_age_secs,
2422        );
2423        parse_env_string(
2424            env,
2425            "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
2426            &mut self.security.headers.content_security_policy,
2427        );
2428        parse_env_string(
2429            env,
2430            "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
2431            &mut self.security.headers.referrer_policy,
2432        );
2433        parse_env_string(
2434            env,
2435            "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
2436            &mut self.security.headers.permissions_policy,
2437        );
2438
2439        // CSRF
2440        parse_env_bool(
2441            env,
2442            "AUTUMN_SECURITY__CSRF__ENABLED",
2443            &mut self.security.csrf.enabled,
2444        );
2445        parse_env_string(
2446            env,
2447            "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
2448            &mut self.security.csrf.token_header,
2449        );
2450        parse_env_string(
2451            env,
2452            "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
2453            &mut self.security.csrf.cookie_name,
2454        );
2455
2456        self.apply_rate_limit_env_overrides_with_env(env);
2457
2458        // Multipart uploads
2459        parse_env(
2460            env,
2461            "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
2462            &mut self.security.upload.max_request_size_bytes,
2463        );
2464        parse_env(
2465            env,
2466            "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
2467            &mut self.security.upload.max_file_size_bytes,
2468        );
2469        parse_env_csv(
2470            env,
2471            "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
2472            &mut self.security.upload.allowed_mime_types,
2473        );
2474
2475        // Authorization deny shape + repository-API escape hatch.
2476        if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
2477            match value.parse::<crate::authorization::ForbiddenResponse>() {
2478                Ok(parsed) => self.security.forbidden_response = parsed,
2479                Err(err) => tracing::warn!(
2480                    "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
2481                ),
2482            }
2483        }
2484        parse_env_bool(
2485            env,
2486            "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
2487            &mut self.security.allow_unauthorized_repository_api,
2488        );
2489
2490        // Signing secret (canonical env var documented in deployment guide)
2491        parse_env_option_string(
2492            env,
2493            "AUTUMN_SECURITY__SIGNING_SECRET",
2494            &mut self.security.signing_secret.secret,
2495        );
2496        parse_env_csv(
2497            env,
2498            "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
2499            &mut self.security.trusted_hosts.hosts,
2500        );
2501
2502        // Top-level trusted-proxy policy
2503        parse_env_csv(
2504            env,
2505            "AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
2506            &mut self.security.trusted_proxies.ranges,
2507        );
2508        parse_env_bool(
2509            env,
2510            "AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
2511            &mut self.security.trusted_proxies.trust_forwarded_headers,
2512        );
2513        if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
2514            if let Ok(hops) = val.trim().parse::<u32>() {
2515                self.security.trusted_proxies.trusted_hops = Some(hops);
2516            } else {
2517                tracing::warn!(
2518                    "ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
2519                     expected a non-negative integer"
2520                );
2521            }
2522        }
2523
2524        self.security.webhooks.apply_env_overrides_with_env(env);
2525    }
2526
2527    fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
2528        parse_env_bool(
2529            env,
2530            "AUTUMN_BOT_PROTECTION__ENABLED",
2531            &mut self.bot_protection.enabled,
2532        );
2533        parse_env_bool(
2534            env,
2535            "AUTUMN_BOT_PROTECTION__DEV_BYPASS",
2536            &mut self.bot_protection.dev_bypass,
2537        );
2538        if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
2539            match val.to_lowercase().as_str() {
2540                "turnstile" => {
2541                    self.bot_protection.provider =
2542                        crate::security::captcha::CaptchaProviderKind::Turnstile;
2543                }
2544                "hcaptcha" => {
2545                    self.bot_protection.provider =
2546                        crate::security::captcha::CaptchaProviderKind::HCaptcha;
2547                }
2548                _ => tracing::warn!(
2549                    "ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
2550                     expected \"turnstile\" or \"hcaptcha\""
2551                ),
2552            }
2553        }
2554        parse_env_option_string(
2555            env,
2556            "AUTUMN_BOT_PROTECTION__SITE_KEY",
2557            &mut self.bot_protection.site_key,
2558        );
2559        parse_env_option_string(
2560            env,
2561            "AUTUMN_BOT_PROTECTION__SECRET_KEY",
2562            &mut self.bot_protection.secret_key,
2563        );
2564        parse_env_option_string(
2565            env,
2566            "AUTUMN_BOT_PROTECTION__FORM_FIELD",
2567            &mut self.bot_protection.form_field,
2568        );
2569    }
2570
2571    fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
2572        parse_env_bool(
2573            env,
2574            "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
2575            &mut self.security.rate_limit.enabled,
2576        );
2577        parse_env(
2578            env,
2579            "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
2580            &mut self.security.rate_limit.requests_per_second,
2581        );
2582        parse_env(
2583            env,
2584            "AUTUMN_SECURITY__RATE_LIMIT__BURST",
2585            &mut self.security.rate_limit.burst,
2586        );
2587        parse_env_bool(
2588            env,
2589            "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
2590            &mut self.security.rate_limit.trust_forwarded_headers,
2591        );
2592        parse_env_csv(
2593            env,
2594            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
2595            &mut self.security.rate_limit.trusted_proxies,
2596        );
2597        if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
2598            match crate::security::config::KeyStrategy::from_env_value(&val) {
2599                Some(strategy) => self.security.rate_limit.key_strategy = strategy,
2600                None => eprintln!(
2601                    "Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
2602                     (expected ip, api_token, or authenticated_principal), ignoring"
2603                ),
2604            }
2605        }
2606        // BACKEND is always parsed so misconfiguration is surfaced even without
2607        // the redis feature (build_backend will warn and fall back to memory).
2608        if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
2609            match crate::security::config::RateLimitBackend::from_env_value(&val) {
2610                Some(backend) => self.security.rate_limit.backend = backend,
2611                None => eprintln!(
2612                    "Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
2613                     (expected memory or redis), ignoring"
2614                ),
2615            }
2616        }
2617        #[cfg(feature = "redis")]
2618        {
2619            use crate::security::config::RateLimitBackendFailure;
2620            if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
2621                match RateLimitBackendFailure::from_env_value(&val) {
2622                    Some(mode) => self.security.rate_limit.on_backend_failure = mode,
2623                    None => eprintln!(
2624                        "Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
2625                         valid (expected fail_open or fail_closed), ignoring"
2626                    ),
2627                }
2628            }
2629            parse_env_option_string(
2630                env,
2631                "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
2632                &mut self.security.rate_limit.redis.url,
2633            );
2634            parse_env_string(
2635                env,
2636                "AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
2637                &mut self.security.rate_limit.redis.key_prefix,
2638            );
2639        }
2640    }
2641
2642    #[cfg(feature = "storage")]
2643    fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
2644        if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
2645            match crate::storage::StorageBackend::from_env_value(&val) {
2646                Some(backend) => self.storage.backend = backend,
2647                None => eprintln!(
2648                    "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
2649                     (expected disabled, local, or s3), ignoring"
2650                ),
2651            }
2652        }
2653        parse_env_string(
2654            env,
2655            "AUTUMN_STORAGE__DEFAULT_PROVIDER",
2656            &mut self.storage.default_provider,
2657        );
2658        parse_env_bool(
2659            env,
2660            "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
2661            &mut self.storage.allow_local_in_production,
2662        );
2663        if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
2664            self.storage.local.root = PathBuf::from(val);
2665        }
2666        parse_env_string(
2667            env,
2668            "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
2669            &mut self.storage.local.mount_path,
2670        );
2671        parse_env(
2672            env,
2673            "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
2674            &mut self.storage.local.default_url_expiry_secs,
2675        );
2676        parse_env_option_string(
2677            env,
2678            "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
2679            &mut self.storage.local.signing_key,
2680        );
2681        parse_env_option_string(
2682            env,
2683            "AUTUMN_STORAGE__S3__BUCKET",
2684            &mut self.storage.s3.bucket,
2685        );
2686        parse_env_option_string(
2687            env,
2688            "AUTUMN_STORAGE__S3__REGION",
2689            &mut self.storage.s3.region,
2690        );
2691        parse_env_option_string(
2692            env,
2693            "AUTUMN_STORAGE__S3__ENDPOINT",
2694            &mut self.storage.s3.endpoint,
2695        );
2696        parse_env_option_string(
2697            env,
2698            "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
2699            &mut self.storage.s3.public_base_url,
2700        );
2701        parse_env_option_string(
2702            env,
2703            "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
2704            &mut self.storage.s3.access_key_id_env,
2705        );
2706        parse_env_option_string(
2707            env,
2708            "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
2709            &mut self.storage.s3.secret_access_key_env,
2710        );
2711        parse_env_bool(
2712            env,
2713            "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
2714            &mut self.storage.s3.force_path_style,
2715        );
2716        parse_env(
2717            env,
2718            "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
2719            &mut self.storage.s3.default_url_expiry_secs,
2720        );
2721        parse_env(
2722            env,
2723            "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
2724            &mut self.storage.variants.max_source_bytes,
2725        );
2726        parse_env(
2727            env,
2728            "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
2729            &mut self.storage.variants.max_source_width,
2730        );
2731        parse_env(
2732            env,
2733            "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
2734            &mut self.storage.variants.max_source_height,
2735        );
2736    }
2737
2738    #[cfg(feature = "mail")]
2739    fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
2740        if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
2741            match crate::mail::Transport::from_env_value(&val) {
2742                Some(transport) => self.mail.transport = transport,
2743                None => eprintln!(
2744                    "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
2745                     (expected log, file, smtp, or disabled), ignoring"
2746                ),
2747            }
2748        }
2749        parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
2750        parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
2751        parse_env_bool(
2752            env,
2753            "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
2754            &mut self.mail.allow_log_in_production,
2755        );
2756        parse_env_bool(
2757            env,
2758            "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
2759            &mut self.mail.allow_in_process_deliver_later_in_production,
2760        );
2761        parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
2762        if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
2763            self.mail.file_dir = PathBuf::from(val);
2764        }
2765        parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
2766        if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
2767            match val.parse::<u16>() {
2768                Ok(port) => self.mail.smtp.port = Some(port),
2769                Err(_) => {
2770                    eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
2771                }
2772            }
2773        }
2774        parse_env_option_string(
2775            env,
2776            "AUTUMN_MAIL__SMTP__USERNAME",
2777            &mut self.mail.smtp.username,
2778        );
2779        parse_env_option_string(
2780            env,
2781            "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
2782            &mut self.mail.smtp.password_env,
2783        );
2784        if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
2785            match crate::mail::TlsMode::from_env_value(&val) {
2786                Some(tls) => self.mail.smtp.tls = tls,
2787                None => eprintln!(
2788                    "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
2789                     (expected disabled, starttls, or tls), ignoring"
2790                ),
2791            }
2792        }
2793    }
2794
2795    /// Returns the active profile name, if any.
2796    #[must_use]
2797    pub fn profile_name(&self) -> Option<&str> {
2798        self.profile.as_deref()
2799    }
2800}
2801
2802/// HTTP server configuration.
2803///
2804/// Controls which address the server binds to and how graceful shutdown
2805/// behaves.
2806///
2807/// # Defaults
2808///
2809/// | Field | Default |
2810/// |-------|---------|
2811/// | `port` | `3000` |
2812/// | `host` | `"127.0.0.1"` |
2813/// | `shutdown_timeout_secs` | `30` |
2814///
2815/// # Examples
2816///
2817/// ```rust
2818/// use autumn_web::config::ServerConfig;
2819///
2820/// let server = ServerConfig::default();
2821/// assert_eq!(server.port, 3000);
2822/// assert_eq!(server.host, "127.0.0.1");
2823/// ```
2824/// Per-request timeout configuration.
2825///
2826/// Controls how long the server waits for a complete request-response cycle
2827/// before returning `408 Request Timeout`. A value of `None` or `0` disables
2828/// the timeout (the default, so existing applications are unaffected).
2829///
2830/// # `autumn.toml` example
2831///
2832/// ```toml
2833/// [server.timeouts]
2834/// request_timeout_ms = 30000  # 30 seconds
2835/// ```
2836#[derive(Debug, Clone, Default, Deserialize)]
2837pub struct RequestTimeoutsConfig {
2838    /// Maximum time in milliseconds allowed for a complete request-response
2839    /// cycle. When exceeded the framework returns `408 Request Timeout` with
2840    /// a Problem Details body. `None` (default) or `0` disables the timeout.
2841    ///
2842    /// Configured via `AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS`.
2843    #[serde(default)]
2844    pub request_timeout_ms: Option<u64>,
2845}
2846
2847#[derive(Debug, Clone, Deserialize)]
2848pub struct ServerConfig {
2849    /// Port to listen on. Default: `3000`.
2850    #[serde(default = "default_port")]
2851    pub port: u16,
2852
2853    /// Host/IP to bind to. Default: `"127.0.0.1"`.
2854    ///
2855    /// Set to `"0.0.0.0"` to accept connections from all interfaces
2856    /// (typical for containerized deployments).
2857    #[serde(default = "default_host")]
2858    pub host: String,
2859
2860    /// Seconds to wait for in-flight requests during graceful shutdown.
2861    /// Default: `30`.
2862    ///
2863    /// When the server receives a shutdown signal, it stops accepting
2864    /// new connections and waits up to this many seconds for in-flight
2865    /// requests to complete before forcibly terminating.
2866    #[serde(default = "default_shutdown_timeout")]
2867    pub shutdown_timeout_secs: u64,
2868
2869    /// Seconds between `/ready` returning 503 and the TCP listener
2870    /// closing to new connections. Default: `5`.
2871    ///
2872    /// This gap gives upstream load balancers time to deregister the
2873    /// replica before it stops accepting new connections, preventing
2874    /// connection resets on in-flight requests from the LB tier.
2875    /// Must be tuned to match the LB's health-check interval + deregistration
2876    /// propagation time. Set to `0` to disable the grace period.
2877    #[serde(default = "default_prestop_grace")]
2878    pub prestop_grace_secs: u64,
2879
2880    /// Per-request timeout configuration.
2881    ///
2882    /// Controls request-cycle timeouts for `DoS` protection. By default
2883    /// all timeouts are disabled so existing applications are unaffected.
2884    /// Set `request_timeout_ms` in `[server.timeouts]` to enable.
2885    #[serde(default)]
2886    pub timeouts: RequestTimeoutsConfig,
2887}
2888
2889/// Behavior when a configured read replica is unavailable or stale.
2890#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
2891#[serde(rename_all = "snake_case")]
2892#[non_exhaustive]
2893pub enum ReplicaFallback {
2894    /// Readiness should fail when the configured replica cannot safely serve reads.
2895    #[default]
2896    FailReadiness,
2897    /// Read paths may use the primary when the replica is unavailable or stale.
2898    Primary,
2899}
2900
2901impl std::str::FromStr for ReplicaFallback {
2902    type Err = ();
2903
2904    fn from_str(value: &str) -> Result<Self, Self::Err> {
2905        match value.trim().to_ascii_lowercase().as_str() {
2906            "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
2907            "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
2908            _ => Err(()),
2909        }
2910    }
2911}
2912
2913/// Database connection configuration.
2914///
2915/// When `url` is `None` (the default), the application runs without a
2916/// database -- useful for static-site or API-gateway use cases. Set a
2917/// Postgres URL to enable the connection pool and the [`Db`](crate::Db)
2918/// extractor.
2919///
2920/// # Defaults
2921///
2922/// | Field | Default |
2923/// |-------|---------|
2924/// | `url` | `None` |
2925/// | `primary_url` | `None` |
2926/// | `replica_url` | `None` |
2927/// | `pool_size` | `10` |
2928/// | `primary_pool_size` | `None` |
2929/// | `replica_pool_size` | `None` |
2930/// | `replica_fallback` | `fail_readiness` |
2931/// | `connect_timeout_secs` | `5` |
2932/// | `auto_migrate_in_production` | `false` |
2933///
2934/// # Examples
2935///
2936/// ```rust
2937/// use autumn_web::config::DatabaseConfig;
2938///
2939/// let db = DatabaseConfig::default();
2940/// assert!(db.url.is_none());
2941/// assert_eq!(db.pool_size, 10);
2942/// ```
2943#[derive(Debug, Clone, Deserialize)]
2944pub struct DatabaseConfig {
2945    /// Postgres connection URL. `None` means no database is configured.
2946    ///
2947    /// Compatibility alias for the primary/write role. New multi-role
2948    /// deployments should prefer [`primary_url`](Self::primary_url).
2949    ///
2950    /// Must start with `postgres://` or `postgresql://` when present.
2951    #[serde(default)]
2952    pub url: Option<String>,
2953
2954    /// Postgres URL for the primary/write role.
2955    ///
2956    /// All writes, transactions, advisory locks, and migrations use this role.
2957    /// When unset, [`url`](Self::url) remains the single-primary fallback.
2958    #[serde(default)]
2959    pub primary_url: Option<String>,
2960
2961    /// Optional Postgres URL for the read/replica role.
2962    ///
2963    /// Read-only paths may use this pool when configured. If omitted, read
2964    /// paths use the primary role.
2965    #[serde(default)]
2966    pub replica_url: Option<String>,
2967
2968    /// Maximum number of connections in the pool. Default: `10`.
2969    ///
2970    /// Compatibility/default pool size used for both roles unless a
2971    /// role-specific size is set.
2972    #[serde(default = "default_pool_size")]
2973    pub pool_size: usize,
2974
2975    /// Optional primary/write role pool size.
2976    #[serde(default)]
2977    pub primary_pool_size: Option<usize>,
2978
2979    /// Optional read/replica role pool size.
2980    #[serde(default)]
2981    pub replica_pool_size: Option<usize>,
2982
2983    /// Deterministic behavior for configured replicas that cannot safely serve
2984    /// reads. Default: fail readiness.
2985    #[serde(default)]
2986    pub replica_fallback: ReplicaFallback,
2987
2988    /// Seconds to wait while acquiring a pooled connection, including
2989    /// creating a new connection when the pool grows.
2990    /// Default: `5`.
2991    #[serde(default = "default_connect_timeout")]
2992    pub connect_timeout_secs: u64,
2993
2994    /// When true, permits automatic migration application while running with
2995    /// `prod`/`production` profile. Default: `false`.
2996    ///
2997    /// Keep this disabled for multi-replica production fleets and use an
2998    /// explicit migration job (`autumn migrate`) instead.
2999    #[serde(default)]
3000    pub auto_migrate_in_production: bool,
3001
3002    /// Optional database statement timeout.
3003    #[serde(deserialize_with = "deserialize_option_duration", default)]
3004    pub statement_timeout: Option<std::time::Duration>,
3005
3006    /// Slow query threshold. Default: `500ms`.
3007    #[serde(
3008        deserialize_with = "deserialize_duration",
3009        default = "default_slow_query_threshold"
3010    )]
3011    pub slow_query_threshold: std::time::Duration,
3012}
3013
3014impl DatabaseConfig {
3015    /// Resolved primary/write database URL.
3016    #[must_use]
3017    pub fn effective_primary_url(&self) -> Option<&str> {
3018        self.primary_url.as_deref().or(self.url.as_deref())
3019    }
3020
3021    /// Resolved primary/write role pool size.
3022    #[must_use]
3023    pub fn effective_primary_pool_size(&self) -> usize {
3024        self.primary_pool_size.unwrap_or(self.pool_size)
3025    }
3026
3027    /// Resolved read/replica role pool size.
3028    #[must_use]
3029    pub fn effective_replica_pool_size(&self) -> usize {
3030        self.replica_pool_size.unwrap_or(self.pool_size)
3031    }
3032
3033    /// Validate database configuration.
3034    ///
3035    /// # Errors
3036    ///
3037    /// Returns a validation error if the URL has an invalid scheme.
3038    pub fn validate(&self) -> Result<(), ConfigError> {
3039        for (field, url) in [
3040            ("database.url", self.url.as_deref()),
3041            ("database.primary_url", self.primary_url.as_deref()),
3042            ("database.replica_url", self.replica_url.as_deref()),
3043        ] {
3044            if let Some(url) = url
3045                && !url.starts_with("postgres://")
3046                && !url.starts_with("postgresql://")
3047            {
3048                let label = if field == "database.url" {
3049                    "database URL"
3050                } else {
3051                    field
3052                };
3053                return Err(ConfigError::Validation(format!(
3054                    "Invalid {label}: must start with postgres:// or postgresql://, got {url:?}"
3055                )));
3056            }
3057        }
3058
3059        if self.replica_url.is_some() && self.effective_primary_url().is_none() {
3060            return Err(ConfigError::Validation(
3061                "database.replica_url requires database.primary_url or database.url".to_owned(),
3062            ));
3063        }
3064        Ok(())
3065    }
3066}
3067
3068/// Logging configuration.
3069///
3070/// Controls the tracing subscriber's filter level and output format.
3071/// See [`LogFormat`] for output format options.
3072///
3073/// # Examples
3074///
3075/// ```rust
3076/// use autumn_web::config::{LogConfig, LogFormat};
3077///
3078/// let log = LogConfig::default();
3079/// assert_eq!(log.level, "info");
3080/// assert_eq!(log.format, LogFormat::Auto);
3081/// assert!(log.access_log);
3082/// ```
3083#[derive(Debug, Clone, Deserialize)]
3084pub struct LogConfig {
3085    /// Tracing filter directive. Default: `"info"`.
3086    ///
3087    /// Supports the full `tracing` filter syntax, e.g.
3088    /// `"autumn=debug,tower_http=trace"`.
3089    #[serde(default = "default_log_level")]
3090    pub level: String,
3091
3092    /// Log output format. Default: [`LogFormat::Auto`].
3093    #[serde(default)]
3094    pub format: LogFormat,
3095
3096    /// Additional sensitive parameter keys to scrub from logs/traces.
3097    #[serde(default)]
3098    pub filter_parameters: Vec<String>,
3099
3100    /// Explicitly remove default sensitive keys from the built-in deny-list.
3101    #[serde(default)]
3102    pub unfilter_parameters: Vec<String>,
3103
3104    /// Emit one structured access-log event per served HTTP request.
3105    /// Default: `true`.
3106    ///
3107    /// The event (target `autumn::access`, level `INFO`) carries `method`,
3108    /// `route` (the matched low-cardinality template), `status`,
3109    /// `duration_ms`, and `request_id`, and is rendered by the standard
3110    /// subscriber according to [`format`](Self::format). It requires no
3111    /// telemetry feature or collector.
3112    #[serde(default = "default_access_log")]
3113    pub access_log: bool,
3114
3115    /// Path prefixes excluded from access logging so steady-state probe and
3116    /// asset traffic does not drown application signal. Default:
3117    /// `["/health", "/live", "/ready", "/startup", "/actuator", "/static"]`
3118    /// (the built-in probe, actuator, and static-asset mounts).
3119    ///
3120    /// Prefixes match whole path segments: `"/actuator"` excludes
3121    /// `/actuator/health` but not `/actuators`. Setting this replaces the
3122    /// default set entirely — and if you move the probe endpoints
3123    /// (`health.path` etc.), mirror the new paths here.
3124    #[serde(default = "default_access_log_exclude")]
3125    pub access_log_exclude: Vec<String>,
3126
3127    /// In-memory log capture buffer for `/actuator/logfile`.
3128    ///
3129    /// When enabled, recent structured log entries are visible over HTTP
3130    /// through the sensitive actuator endpoint without SSH access or an
3131    /// external log aggregator.  The buffer is bounded and never grows
3132    /// unbounded.
3133    #[serde(default)]
3134    pub capture: crate::log::capture::LogCaptureConfig,
3135}
3136
3137/// Log output format.
3138///
3139/// Controls how tracing events are rendered. The default ([`Auto`](Self::Auto))
3140/// auto-detects based on the `AUTUMN_ENV` environment variable.
3141///
3142/// | Variant | Behaviour |
3143/// |---------|-----------|
3144/// | [`Auto`](Self::Auto) | Pretty in dev, JSON when `AUTUMN_ENV=production` |
3145/// | [`Pretty`](Self::Pretty) | Always human-readable, colorized |
3146/// | [`Json`](Self::Json) | Always structured JSON (for log aggregators) |
3147///
3148/// # Examples
3149///
3150/// ```rust
3151/// use autumn_web::config::LogFormat;
3152///
3153/// assert_eq!(LogFormat::default(), LogFormat::Auto);
3154/// ```
3155#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
3156#[non_exhaustive]
3157pub enum LogFormat {
3158    /// Pretty in dev, JSON in production (based on `AUTUMN_ENV`).
3159    #[default]
3160    Auto,
3161    /// Human-readable, colorized output.
3162    Pretty,
3163    /// Structured JSON output suitable for log aggregation pipelines.
3164    Json,
3165}
3166
3167/// Telemetry configuration.
3168///
3169/// Controls whether Autumn enables OTLP trace export and how the process
3170/// identifies itself in resource metadata.
3171#[derive(Debug, Clone, Deserialize)]
3172pub struct TelemetryConfig {
3173    /// Enable framework-managed telemetry. Default: `false`.
3174    #[serde(default)]
3175    pub enabled: bool,
3176
3177    /// Logical service name. Default: `"autumn-app"`.
3178    #[serde(default = "default_telemetry_service_name")]
3179    pub service_name: String,
3180
3181    /// Optional service namespace (e.g. team, domain, or product family).
3182    #[serde(default)]
3183    pub service_namespace: Option<String>,
3184
3185    /// Service version string advertised in resource metadata.
3186    #[serde(default = "default_telemetry_service_version")]
3187    pub service_version: String,
3188
3189    /// Deployment environment label for trace resource metadata.
3190    #[serde(default = "default_telemetry_environment")]
3191    pub environment: String,
3192
3193    /// OTLP collector endpoint. Required when telemetry is enabled.
3194    #[serde(default)]
3195    pub otlp_endpoint: Option<String>,
3196
3197    /// OTLP transport protocol. Default: [`TelemetryProtocol::Grpc`].
3198    #[serde(default)]
3199    pub protocol: TelemetryProtocol,
3200
3201    /// When `true`, telemetry initialization failures abort startup.
3202    #[serde(default)]
3203    pub strict: bool,
3204}
3205
3206/// OTLP transport protocol selection.
3207#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
3208#[non_exhaustive]
3209pub enum TelemetryProtocol {
3210    /// OTLP over gRPC.
3211    #[serde(alias = "grpc", alias = "GRPC")]
3212    #[default]
3213    Grpc,
3214    /// OTLP over HTTP/protobuf.
3215    #[serde(
3216        alias = "http-protobuf",
3217        alias = "http_protobuf",
3218        alias = "HTTP_PROTOBUF"
3219    )]
3220    HttpProtobuf,
3221}
3222
3223impl TelemetryProtocol {
3224    fn from_env_value(value: &str) -> Option<Self> {
3225        match value {
3226            "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
3227            "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
3228            | "httpprotobuf" => Some(Self::HttpProtobuf),
3229            _ => None,
3230        }
3231    }
3232}
3233
3234/// Health check endpoint configuration.
3235///
3236/// The health check is automatically mounted by [`AppBuilder::run`](crate::app::AppBuilder::run).
3237/// See the [`health`](crate::health) module for response format details.
3238///
3239/// # Examples
3240///
3241/// ```rust
3242/// use autumn_web::config::HealthConfig;
3243///
3244/// let health = HealthConfig::default();
3245/// assert_eq!(health.path, "/health");
3246/// assert_eq!(health.live_path, "/live");
3247/// assert_eq!(health.ready_path, "/ready");
3248/// assert_eq!(health.startup_path, "/startup");
3249/// assert!(!health.detailed);
3250/// ```
3251#[derive(Debug, Clone, Deserialize)]
3252pub struct HealthConfig {
3253    /// Compatibility alias path for readiness. Default: `"/health"`.
3254    ///
3255    /// Common alternatives: `"/healthz"`, `"/_health"`.
3256    #[serde(default = "default_health_path")]
3257    pub path: String,
3258
3259    /// URL path for the liveness probe. Default: `"/live"`.
3260    #[serde(default = "default_live_path")]
3261    pub live_path: String,
3262
3263    /// URL path for the readiness probe. Default: `"/ready"`.
3264    #[serde(default = "default_ready_path")]
3265    pub ready_path: String,
3266
3267    /// URL path for the startup probe. Default: `"/startup"`.
3268    #[serde(default = "default_startup_path")]
3269    pub startup_path: String,
3270
3271    /// When `true`, the health endpoint includes detailed info (profile,
3272    /// uptime, pool stats). Default: `false` (overridden to `true` for
3273    /// `dev` profile via smart defaults).
3274    #[serde(default)]
3275    pub detailed: bool,
3276}
3277
3278/// Actuator endpoint configuration.
3279///
3280/// Controls which operational endpoints are exposed. The `sensitive` flag
3281/// determines whether sensitive endpoints (env, configprops, loggers,
3282/// tasks) are available. Defaults to `true` for `dev`, `false` for `prod`.
3283#[derive(Debug, Clone, Deserialize)]
3284pub struct ActuatorConfig {
3285    /// URL prefix for actuator endpoints. Default: `"/actuator"`.
3286    #[serde(default = "default_actuator_prefix")]
3287    pub prefix: String,
3288
3289    /// When `true`, expose sensitive endpoints (env, loggers, tasks).
3290    /// Defaults vary by profile: `true` for dev, `false` for prod.
3291    #[serde(default)]
3292    pub sensitive: bool,
3293
3294    /// When `true`, mount the `/actuator/prometheus` scrape endpoint.
3295    ///
3296    /// This is **independent of [`Self::sensitive`]**: a production app can
3297    /// expose Prometheus metrics for platform scraping (e.g. Fly.io `[metrics]`)
3298    /// while keeping `sensitive = false` so env/configprops/loggers/tasks/jobs
3299    /// stay off the public surface. Set to `false` to remove the scrape
3300    /// endpoint entirely (it then returns `404`). Default: `true`.
3301    #[serde(default = "default_actuator_prometheus")]
3302    pub prometheus: bool,
3303}
3304
3305impl Default for ActuatorConfig {
3306    fn default() -> Self {
3307        Self {
3308            prefix: default_actuator_prefix(),
3309            sensitive: false,
3310            prometheus: default_actuator_prometheus(),
3311        }
3312    }
3313}
3314
3315fn default_actuator_prefix() -> String {
3316    "/actuator".to_owned()
3317}
3318
3319const fn default_actuator_prometheus() -> bool {
3320    true
3321}
3322
3323/// CORS (Cross-Origin Resource Sharing) configuration.
3324///
3325/// Controls which origins, methods, and headers are allowed for
3326/// cross-origin requests. Disabled by default -- enable by setting
3327/// `allowed_origins` in `autumn.toml` or via environment variables.
3328///
3329/// # Defaults
3330///
3331/// | Field | Default |
3332/// |-------|---------|
3333/// | `allowed_origins` | `[]` (CORS disabled) |
3334/// | `allowed_methods` | `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]` |
3335/// | `allowed_headers` | `["Content-Type", "Authorization"]` |
3336/// | `allow_credentials` | `false` |
3337/// | `max_age_secs` | `86400` (24 hours) |
3338///
3339/// # Profile smart defaults
3340///
3341/// The `dev` profile enables permissive CORS (`allowed_origins = ["*"]`)
3342/// so local front-end development works out of the box.
3343///
3344/// # Examples
3345///
3346/// ```toml
3347/// [cors]
3348/// allowed_origins = ["https://example.com", "https://app.example.com"]
3349/// allow_credentials = true
3350/// ```
3351///
3352/// ```rust
3353/// use autumn_web::config::CorsConfig;
3354///
3355/// let cors = CorsConfig::default();
3356/// assert!(cors.allowed_origins.is_empty());
3357/// assert!(!cors.allow_credentials);
3358/// ```
3359#[derive(Debug, Clone, Deserialize)]
3360pub struct CorsConfig {
3361    /// Origins allowed to make cross-origin requests.
3362    ///
3363    /// Use `["*"]` to allow any origin (not recommended for production
3364    /// with credentials). When empty, CORS middleware is not applied.
3365    #[serde(default)]
3366    pub allowed_origins: Vec<String>,
3367
3368    /// HTTP methods allowed for cross-origin requests.
3369    /// Default: `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]`.
3370    #[serde(default = "default_cors_methods")]
3371    pub allowed_methods: Vec<String>,
3372
3373    /// Headers allowed in cross-origin requests.
3374    /// Default: `["Content-Type", "Authorization"]`.
3375    #[serde(default = "default_cors_headers")]
3376    pub allowed_headers: Vec<String>,
3377
3378    /// Whether to include `Access-Control-Allow-Credentials: true`.
3379    /// Default: `false`.
3380    #[serde(default)]
3381    pub allow_credentials: bool,
3382
3383    /// How long (in seconds) browsers may cache preflight responses.
3384    /// Default: `86400` (24 hours).
3385    #[serde(default = "default_cors_max_age")]
3386    pub max_age_secs: u64,
3387}
3388
3389impl Default for CorsConfig {
3390    fn default() -> Self {
3391        Self {
3392            allowed_origins: Vec::new(),
3393            allowed_methods: default_cors_methods(),
3394            allowed_headers: default_cors_headers(),
3395            allow_credentials: false,
3396            max_age_secs: default_cors_max_age(),
3397        }
3398    }
3399}
3400
3401impl CorsConfig {
3402    /// Validate CORS configuration for combinations rejected by browsers.
3403    ///
3404    /// # Errors
3405    ///
3406    /// Returns a validation error when `allow_credentials = true` is combined
3407    /// with a wildcard `"*"` origin. Browsers refuse this combination per the
3408    /// Fetch spec, and `tower-http`'s `CorsLayer` panics when asked to build
3409    /// it, so we fail fast at config load with an actionable message.
3410    pub fn validate(&self) -> Result<(), ConfigError> {
3411        if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
3412            return Err(ConfigError::Validation(
3413                "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
3414                 list explicit origins instead (browsers reject the wildcard+credentials combo)"
3415                    .to_owned(),
3416            ));
3417        }
3418        Ok(())
3419    }
3420}
3421
3422fn default_cors_methods() -> Vec<String> {
3423    vec![
3424        "GET".to_owned(),
3425        "POST".to_owned(),
3426        "PUT".to_owned(),
3427        "DELETE".to_owned(),
3428        "PATCH".to_owned(),
3429        "OPTIONS".to_owned(),
3430    ]
3431}
3432
3433fn default_cors_headers() -> Vec<String> {
3434    vec!["Content-Type".to_owned(), "Authorization".to_owned()]
3435}
3436
3437const fn default_cors_max_age() -> u64 {
3438    86400
3439}
3440
3441// ── CompressionConfig ──────────────────────────────────────────────────────
3442
3443/// Response compression settings (`[compression]` section in `autumn.toml`).
3444///
3445/// Compression is **off by default** to avoid the [BREACH/CRIME] class of
3446/// compression side-channel attacks, where an attacker can infer secret
3447/// content (e.g. CSRF tokens) by observing how the compressed size changes as
3448/// they inject attacker-controlled bytes alongside the secret. Enable only when
3449/// you understand the tradeoff — or when a CDN / reverse-proxy handles TLS and
3450/// terminates there.
3451///
3452/// [BREACH/CRIME]: https://breachattack.com/
3453///
3454/// # One-liner opt-in
3455///
3456/// ```toml
3457/// [compression]
3458/// enabled = true
3459/// ```
3460///
3461/// # Environment variable override
3462///
3463/// | Variable | Field | Type |
3464/// |----------|-------|------|
3465/// | `AUTUMN_COMPRESSION__ENABLED` | `enabled` | `bool` |
3466///
3467/// # `ETag` compatibility
3468///
3469/// Autumn's framework-managed compression layer is applied **outside** any
3470/// user-registered `EtagLayer`, so `ETags` are computed on the uncompressed body.
3471/// Because `CompressionLayer` sets `Vary: Accept-Encoding`, caches correctly
3472/// store separate entries per encoding. Using weak `ETags` (`W/`) when
3473/// compression is enabled is safe per RFC 7232 §2.1 (weak comparison allows
3474/// encoding variations).
3475///
3476/// # Example
3477///
3478/// ```rust
3479/// use autumn_web::config::CompressionConfig;
3480///
3481/// let cfg = CompressionConfig::default();
3482/// assert!(!cfg.enabled);
3483/// ```
3484#[derive(Debug, Clone, Deserialize, Default)]
3485pub struct CompressionConfig {
3486    /// Enable response compression. Default: `false`.
3487    ///
3488    /// When `true`, the framework inserts a `CompressionLayer` that honors the
3489    /// client's `Accept-Encoding` header (gzip and brotli supported) and sets
3490    /// `Vary: Accept-Encoding` on all compressible responses.
3491    /// Non-compressible content types (images, archives) and responses that
3492    /// already carry `Content-Encoding` are passed through unchanged.
3493    #[serde(default)]
3494    pub enabled: bool,
3495}
3496
3497/// Parse an environment variable into a typed target, logging a warning on failure.
3498fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
3499    if let Ok(val) = env.var(key) {
3500        match val.parse::<T>() {
3501            Ok(v) => *target = v,
3502            Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
3503        }
3504    }
3505}
3506
3507fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
3508    if let Ok(val) = env.var(key) {
3509        *target = if val.is_empty() { None } else { Some(val) };
3510    }
3511}
3512
3513fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
3514    if let Ok(val) = env.var(key) {
3515        if val.is_empty() {
3516            *target = None;
3517        } else {
3518            match val.parse::<T>() {
3519                Ok(v) => *target = Some(v),
3520                Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
3521            }
3522        }
3523    }
3524}
3525
3526fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
3527    if let Ok(val) = env.var(key) {
3528        *target = val;
3529    }
3530}
3531
3532fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
3533    if let Ok(val) = env.var(key) {
3534        match val.as_str() {
3535            "true" | "1" => *target = true,
3536            "false" | "0" => *target = false,
3537            _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
3538        }
3539    }
3540}
3541
3542fn parse_env_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
3543    if let Ok(val) = env.var(key) {
3544        match val.as_str() {
3545            "true" | "1" => *target = Some(true),
3546            "false" | "0" => *target = Some(false),
3547            _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
3548        }
3549    }
3550}
3551
3552fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
3553    if let Ok(val) = env.var(key) {
3554        *target = val.split(',').map(|s| s.trim().to_owned()).collect();
3555    }
3556}
3557
3558// ── Default functions ──────────────────────────────────────────────
3559
3560const fn default_port() -> u16 {
3561    3000
3562}
3563
3564fn default_host() -> String {
3565    "127.0.0.1".to_owned()
3566}
3567
3568const fn default_shutdown_timeout() -> u64 {
3569    30
3570}
3571
3572const fn default_prestop_grace() -> u64 {
3573    5
3574}
3575
3576const fn default_pool_size() -> usize {
3577    10
3578}
3579
3580const fn default_connect_timeout() -> u64 {
3581    5
3582}
3583
3584fn default_log_level() -> String {
3585    "info".to_owned()
3586}
3587
3588const fn default_access_log() -> bool {
3589    true
3590}
3591
3592fn default_access_log_exclude() -> Vec<String> {
3593    vec![
3594        "/health".to_owned(),
3595        "/live".to_owned(),
3596        "/ready".to_owned(),
3597        "/startup".to_owned(),
3598        "/actuator".to_owned(),
3599        "/static".to_owned(),
3600    ]
3601}
3602
3603fn default_telemetry_service_name() -> String {
3604    "autumn-app".to_owned()
3605}
3606
3607fn default_telemetry_service_version() -> String {
3608    "unknown".to_owned()
3609}
3610
3611fn default_telemetry_environment() -> String {
3612    "development".to_owned()
3613}
3614
3615fn default_health_path() -> String {
3616    "/health".to_owned()
3617}
3618
3619fn default_live_path() -> String {
3620    "/live".to_owned()
3621}
3622
3623fn default_ready_path() -> String {
3624    "/ready".to_owned()
3625}
3626
3627fn default_startup_path() -> String {
3628    "/startup".to_owned()
3629}
3630
3631// ── Default trait impls ────────────────────────────────────────────
3632
3633impl Default for ServerConfig {
3634    fn default() -> Self {
3635        Self {
3636            port: default_port(),
3637            host: default_host(),
3638            shutdown_timeout_secs: default_shutdown_timeout(),
3639            prestop_grace_secs: default_prestop_grace(),
3640            timeouts: RequestTimeoutsConfig::default(),
3641        }
3642    }
3643}
3644
3645impl Default for DatabaseConfig {
3646    fn default() -> Self {
3647        Self {
3648            url: None,
3649            primary_url: None,
3650            replica_url: None,
3651            pool_size: default_pool_size(),
3652            primary_pool_size: None,
3653            replica_pool_size: None,
3654            replica_fallback: ReplicaFallback::default(),
3655            connect_timeout_secs: default_connect_timeout(),
3656            auto_migrate_in_production: false,
3657            statement_timeout: None,
3658            slow_query_threshold: default_slow_query_threshold(),
3659        }
3660    }
3661}
3662
3663impl Default for LogConfig {
3664    fn default() -> Self {
3665        Self {
3666            level: default_log_level(),
3667            format: LogFormat::default(),
3668            filter_parameters: Vec::new(),
3669            unfilter_parameters: Vec::new(),
3670            access_log: default_access_log(),
3671            access_log_exclude: default_access_log_exclude(),
3672            capture: crate::log::capture::LogCaptureConfig::default(),
3673        }
3674    }
3675}
3676
3677impl Default for TelemetryConfig {
3678    fn default() -> Self {
3679        Self {
3680            enabled: false,
3681            service_name: default_telemetry_service_name(),
3682            service_namespace: None,
3683            service_version: default_telemetry_service_version(),
3684            environment: default_telemetry_environment(),
3685            otlp_endpoint: None,
3686            protocol: TelemetryProtocol::default(),
3687            strict: false,
3688        }
3689    }
3690}
3691
3692impl Default for HealthConfig {
3693    fn default() -> Self {
3694        Self {
3695            path: default_health_path(),
3696            live_path: default_live_path(),
3697            ready_path: default_ready_path(),
3698            startup_path: default_startup_path(),
3699            detailed: false,
3700        }
3701    }
3702}
3703
3704// ----------------------------------------------------------------------------
3705// ConfigLoader — tier-1 boot-time replaceable config loading
3706// ----------------------------------------------------------------------------
3707
3708/// Pluggable boot-time configuration loader.
3709///
3710/// Replace the default TOML + env loader with a custom strategy (e.g. AWS
3711/// Secrets Manager, Consul, a JSON file, an HTTP fetch) by implementing this
3712/// trait and installing it on the [`AppBuilder`](crate::app::AppBuilder) via
3713/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
3714///
3715/// The trait's return type uses `impl Future + Send` so implementations can
3716/// freely use `async fn` in their bodies while the framework can still spawn
3717/// the load on any executor.
3718///
3719/// # Example
3720///
3721/// ```rust,no_run
3722/// use autumn_web::config::{AutumnConfig, ConfigError, ConfigLoader};
3723///
3724/// pub struct JsonFileConfigLoader { path: std::path::PathBuf }
3725///
3726/// impl ConfigLoader for JsonFileConfigLoader {
3727///     async fn load(&self) -> Result<AutumnConfig, ConfigError> {
3728///         let bytes = std::fs::read(&self.path).map_err(ConfigError::Io)?;
3729///         serde_json::from_slice(&bytes)
3730///             .map_err(|e| ConfigError::Validation(e.to_string()))
3731///     }
3732/// }
3733/// ```
3734pub trait ConfigLoader: Send + Sync + 'static {
3735    /// Load and return a fully-resolved [`AutumnConfig`].
3736    ///
3737    /// Implementations are responsible for any layering, profile resolution,
3738    /// and validation they care to apply. The default implementation
3739    /// ([`TomlEnvConfigLoader`]) preserves Autumn's five-layer load
3740    /// (framework defaults → profile defaults → `autumn.toml` →
3741    /// `autumn-{profile}.toml` → `AUTUMN_*` env vars).
3742    fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
3743}
3744
3745/// Default [`ConfigLoader`] — Autumn's five-layer TOML + env load strategy.
3746///
3747/// Delegates to [`AutumnConfig::load_with_env`] using [`OsEnv`] for environment
3748/// variable reads. This is the loader used when no override is installed via
3749/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
3750#[derive(Debug, Default, Clone, Copy)]
3751pub struct TomlEnvConfigLoader;
3752
3753impl TomlEnvConfigLoader {
3754    /// Construct a new default loader.
3755    #[must_use]
3756    pub const fn new() -> Self {
3757        Self
3758    }
3759}
3760
3761impl ConfigLoader for TomlEnvConfigLoader {
3762    async fn load(&self) -> Result<AutumnConfig, ConfigError> {
3763        AutumnConfig::load_with_env(&OsEnv)
3764    }
3765}
3766
3767const fn default_slow_query_threshold() -> std::time::Duration {
3768    std::time::Duration::from_millis(500)
3769}
3770
3771/// Parses a duration string like "500ms", "5s", "2m", "1h",
3772/// or a plain integer representing milliseconds.
3773///
3774/// # Errors
3775/// Returns a `String` describing the parse failure when the input is empty,
3776/// has an unrecognised suffix, or contains a non-numeric value.
3777pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
3778    if s.is_empty() {
3779        return Err("duration string is empty".to_owned());
3780    }
3781
3782    // Check if it's a plain integer
3783    if let Ok(ms) = s.parse::<u64>() {
3784        return Ok(std::time::Duration::from_millis(ms));
3785    }
3786
3787    // Try parsing suffix
3788    if let Some(val_str) = s.strip_suffix("ms") {
3789        let val = val_str
3790            .parse::<u64>()
3791            .map_err(|e| format!("invalid duration integer: {e}"))?;
3792        return Ok(std::time::Duration::from_millis(val));
3793    }
3794
3795    if let Some(val_str) = s.strip_suffix('s') {
3796        let val = val_str
3797            .parse::<u64>()
3798            .map_err(|e| format!("invalid duration integer: {e}"))?;
3799        return Ok(std::time::Duration::from_secs(val));
3800    }
3801
3802    if let Some(val_str) = s.strip_suffix('m') {
3803        let val = val_str
3804            .parse::<u64>()
3805            .map_err(|e| format!("invalid duration integer: {e}"))?;
3806        let secs = val.checked_mul(60).ok_or_else(|| {
3807            format!("duration overflow: '{s}' exceeds maximum representable value")
3808        })?;
3809        return Ok(std::time::Duration::from_secs(secs));
3810    }
3811
3812    if let Some(val_str) = s.strip_suffix('h') {
3813        let val = val_str
3814            .parse::<u64>()
3815            .map_err(|e| format!("invalid duration integer: {e}"))?;
3816        let secs = val.checked_mul(3600).ok_or_else(|| {
3817            format!("duration overflow: '{s}' exceeds maximum representable value")
3818        })?;
3819        return Ok(std::time::Duration::from_secs(secs));
3820    }
3821
3822    Err(format!("invalid duration format: '{s}'"))
3823}
3824
3825/// Deserialises a TOML/JSON value into a [`std::time::Duration`].
3826///
3827/// Accepts either a string (`"500ms"`, `"5s"`, `"2m"`, `"1h"`) or a bare
3828/// integer (interpreted as milliseconds).
3829///
3830/// # Errors
3831/// Returns a deserialisation error if the value is not a valid duration.
3832pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
3833where
3834    D: serde::Deserializer<'de>,
3835{
3836    use serde::Deserialize;
3837
3838    #[derive(Deserialize)]
3839    #[serde(untagged)]
3840    enum DurationOrStr {
3841        String(String),
3842        Integer(u64),
3843    }
3844
3845    match DurationOrStr::deserialize(deserializer)? {
3846        DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
3847        DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
3848    }
3849}
3850
3851/// Deserialises an optional TOML/JSON value into <code>Option&lt;[std::time::Duration]&gt;</code>.
3852///
3853/// Accepts either a string (`"500ms"`, `"5s"`, `"2m"`, `"1h"`), a bare
3854/// integer (milliseconds), or `null`/absent to mean no timeout.
3855///
3856/// # Errors
3857/// Returns a deserialisation error if the value is present but invalid.
3858pub fn deserialize_option_duration<'de, D>(
3859    deserializer: D,
3860) -> Result<Option<std::time::Duration>, D::Error>
3861where
3862    D: serde::Deserializer<'de>,
3863{
3864    use serde::Deserialize;
3865
3866    #[derive(Deserialize)]
3867    struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
3868
3869    Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
3870}
3871
3872/// Row-level multi-tenancy configuration.
3873#[derive(Debug, Clone, Deserialize)]
3874pub struct TenancyConfig {
3875    /// Whether row-level multi-tenancy is enabled.
3876    #[serde(default)]
3877    pub enabled: bool,
3878
3879    /// Source configuration from which the tenant ID is extracted.
3880    /// Values can be "header" (default), "subdomain", "session", "jwt".
3881    #[serde(default = "default_tenancy_source")]
3882    pub source: String,
3883
3884    /// Header name to lookup if source is "header". Default: "x-tenant-id".
3885    #[serde(default = "default_tenancy_header_name")]
3886    pub header_name: String,
3887
3888    /// Session key to lookup if source is "session". Default: "`tenant_id`".
3889    #[serde(default = "default_tenancy_session_key")]
3890    pub session_key: String,
3891
3892    /// JWT claim to lookup if source is "jwt". Default: "`tenant_id`".
3893    #[serde(default = "default_tenancy_jwt_claim")]
3894    pub jwt_claim: String,
3895
3896    /// JWT secret key used to verify the JWT signature.
3897    #[serde(default)]
3898    pub jwt_secret: Option<String>,
3899
3900    /// Expected JWT issuer to validate.
3901    #[serde(default)]
3902    pub jwt_issuer: Option<String>,
3903
3904    /// Expected JWT audience (`aud` claim) to validate.
3905    /// When set, audience checking is enabled; when `None`, audience checking
3906    /// is skipped for backward compatibility.
3907    #[serde(default)]
3908    pub jwt_audience: Option<String>,
3909
3910    /// Optional base domain for subdomain tenancy.
3911    #[serde(default)]
3912    pub base_domain: Option<String>,
3913}
3914
3915fn default_tenancy_source() -> String {
3916    "header".to_string()
3917}
3918
3919fn default_tenancy_header_name() -> String {
3920    "x-tenant-id".to_string()
3921}
3922
3923fn default_tenancy_session_key() -> String {
3924    "tenant_id".to_string()
3925}
3926
3927fn default_tenancy_jwt_claim() -> String {
3928    "tenant_id".to_string()
3929}
3930
3931impl Default for TenancyConfig {
3932    fn default() -> Self {
3933        Self {
3934            enabled: false,
3935            source: default_tenancy_source(),
3936            header_name: default_tenancy_header_name(),
3937            session_key: default_tenancy_session_key(),
3938            jwt_claim: default_tenancy_jwt_claim(),
3939            jwt_secret: None,
3940            jwt_issuer: None,
3941            jwt_audience: None,
3942            base_domain: None,
3943        }
3944    }
3945}
3946
3947// ── Resilience configuration ───────────────────────────────────────────────
3948
3949/// Resilience policy configurations.
3950#[derive(Debug, Clone, Default, Deserialize)]
3951pub struct ResilienceConfig {
3952    /// Circuit breaker configurations.
3953    #[serde(default)]
3954    pub circuit_breaker: CircuitBreakerConfig,
3955}
3956
3957/// Circuit breaker configuration structure.
3958#[derive(Debug, Clone, Default, Deserialize)]
3959pub struct CircuitBreakerConfig {
3960    /// Default circuit breaker policies.
3961    #[serde(default)]
3962    pub defaults: CircuitBreakerPolicyConfig,
3963    /// Per-host circuit breaker policy overrides.
3964    #[serde(default)]
3965    pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
3966}
3967
3968/// Configurable settings for a circuit breaker policy.
3969#[derive(Debug, Clone, Default, Deserialize)]
3970pub struct CircuitBreakerPolicyConfig {
3971    /// Failure ratio threshold (e.g. 0.5) to trip the breaker.
3972    pub failure_ratio_threshold: Option<f64>,
3973    /// Sample window duration in seconds.
3974    pub sample_window_secs: Option<u64>,
3975    /// Minimum samples required to evaluate failure ratio.
3976    pub minimum_sample_count: Option<u64>,
3977    /// Open state duration in seconds before entering half-open.
3978    pub open_duration_secs: Option<u64>,
3979    /// Number of successful trials required in half-open state to close the breaker.
3980    pub half_open_trial_count: Option<u64>,
3981}
3982
3983impl AutumnConfig {
3984    fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
3985        parse_env_option(
3986            env,
3987            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
3988            &mut self
3989                .resilience
3990                .circuit_breaker
3991                .defaults
3992                .failure_ratio_threshold,
3993        );
3994        parse_env_option(
3995            env,
3996            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
3997            &mut self.resilience.circuit_breaker.defaults.sample_window_secs,
3998        );
3999        parse_env_option(
4000            env,
4001            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
4002            &mut self
4003                .resilience
4004                .circuit_breaker
4005                .defaults
4006                .minimum_sample_count,
4007        );
4008        parse_env_option(
4009            env,
4010            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
4011            &mut self.resilience.circuit_breaker.defaults.open_duration_secs,
4012        );
4013        parse_env_option(
4014            env,
4015            "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
4016            &mut self
4017                .resilience
4018                .circuit_breaker
4019                .defaults
4020                .half_open_trial_count,
4021        );
4022    }
4023}
4024
4025#[cfg(test)]
4026mod tests {
4027
4028    use super::*;
4029
4030    /// Mock loader for tests — returns a hand-built config without touching disk.
4031    struct MockConfigLoader {
4032        config: AutumnConfig,
4033    }
4034
4035    impl ConfigLoader for MockConfigLoader {
4036        async fn load(&self) -> Result<AutumnConfig, ConfigError> {
4037            Ok(self.config.clone())
4038        }
4039    }
4040
4041    #[tokio::test]
4042    async fn config_loader_trait_returns_supplied_config() {
4043        let mut custom = AutumnConfig::default();
4044        custom.server.port = 9999;
4045        custom.profile = Some("integration-test".to_owned());
4046
4047        let loader = MockConfigLoader {
4048            config: custom.clone(),
4049        };
4050        let resolved = loader.load().await.expect("mock loader should succeed");
4051
4052        assert_eq!(resolved.server.port, 9999);
4053        assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
4054    }
4055
4056    #[test]
4057    fn validate_does_not_error_on_redis_backend_without_url() {
4058        // Regression: previously `validate()` called
4059        // `session.backend_plan(profile)` which returned an error for
4060        // `backend = "redis"` without `redis.url`, exiting the boot before
4061        // a `with_session_store(...)` override could apply. Session
4062        // backend validation now lives in `apply_session_layer`, which
4063        // short-circuits when a custom store is installed. `validate()`
4064        // is config-shape-only and must accept this combination.
4065        let mut config = AutumnConfig::default();
4066        config.session.backend = crate::session::SessionBackend::Redis;
4067        config.session.redis.url = None;
4068
4069        config.validate().expect(
4070            "validate() must accept redis-backend-without-url so custom \
4071             session store overrides aren't blocked at boot",
4072        );
4073    }
4074
4075    #[tokio::test]
4076    async fn default_toml_env_loader_succeeds_without_files() {
4077        // No autumn.toml in the test runner's pwd; loader should fall back to
4078        // framework defaults rather than failing.
4079        let loader = TomlEnvConfigLoader::new();
4080        let resolved = loader.load().await.expect("default loader should succeed");
4081        // Default port is 3000 per ServerConfig::default — sanity check.
4082        assert_eq!(resolved.server.port, 3000);
4083    }
4084
4085    #[test]
4086    fn database_config_validate_none() {
4087        let config = DatabaseConfig {
4088            url: None,
4089            ..Default::default()
4090        };
4091        assert!(config.validate().is_ok());
4092    }
4093
4094    #[test]
4095    fn database_config_validate_valid_postgres() {
4096        let config = DatabaseConfig {
4097            url: Some("postgres://user:pass@localhost:5432/db".to_string()),
4098            ..Default::default()
4099        };
4100        assert!(config.validate().is_ok());
4101    }
4102
4103    #[test]
4104    fn database_config_validate_valid_postgresql() {
4105        let config = DatabaseConfig {
4106            url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
4107            ..Default::default()
4108        };
4109        assert!(config.validate().is_ok());
4110    }
4111
4112    #[test]
4113    fn database_config_validate_invalid_scheme() {
4114        let config = DatabaseConfig {
4115            url: Some("mysql://user:pass@localhost:3306/db".to_string()),
4116            ..Default::default()
4117        };
4118        let result = config.validate();
4119        assert!(result.is_err());
4120        match result {
4121            Err(ConfigError::Validation(msg)) => {
4122                // Ensure we just match the underlying variant correctly
4123                // as requested in the review.
4124                assert!(msg.contains("must start with postgres:// or postgresql://"));
4125            }
4126            _ => panic!("Expected ConfigError::Validation"),
4127        }
4128    }
4129
4130    #[test]
4131    fn server_defaults() {
4132        let config = ServerConfig::default();
4133        assert_eq!(config.port, 3000);
4134        assert_eq!(config.host, "127.0.0.1");
4135        assert_eq!(config.shutdown_timeout_secs, 30);
4136    }
4137
4138    #[test]
4139    fn database_defaults() {
4140        let config = DatabaseConfig::default();
4141        assert!(config.url.is_none());
4142        assert_eq!(config.pool_size, 10);
4143        assert_eq!(config.connect_timeout_secs, 5);
4144    }
4145
4146    #[test]
4147    fn database_validate_none_url_is_ok() {
4148        let config = DatabaseConfig {
4149            url: None,
4150            ..Default::default()
4151        };
4152        assert!(config.validate().is_ok());
4153    }
4154
4155    #[test]
4156    fn database_validate_postgres_url_is_ok() {
4157        let config = DatabaseConfig {
4158            url: Some("postgres://user:pass@localhost/db".to_string()),
4159            ..Default::default()
4160        };
4161        assert!(config.validate().is_ok());
4162    }
4163
4164    #[test]
4165    fn database_validate_postgresql_url_is_ok() {
4166        let config = DatabaseConfig {
4167            url: Some("postgresql://user:pass@localhost/db".to_string()),
4168            ..Default::default()
4169        };
4170        assert!(config.validate().is_ok());
4171    }
4172
4173    #[test]
4174    fn database_validate_invalid_url_is_err() {
4175        let config = DatabaseConfig {
4176            url: Some("mysql://user:pass@localhost/db".to_string()),
4177            ..Default::default()
4178        };
4179        let result = config.validate();
4180        assert!(result.is_err());
4181        if let Err(ConfigError::Validation(msg)) = result {
4182            assert!(msg.contains("Invalid database URL"));
4183            assert!(msg.contains("must start with postgres:// or postgresql://"));
4184        } else {
4185            panic!("Expected ConfigError::Validation");
4186        }
4187    }
4188
4189    #[test]
4190    fn database_topology_deserializes_primary_and_replica_urls() {
4191        let config: AutumnConfig = toml::from_str(
4192            r#"
4193[database]
4194primary_url = "postgres://primary.example/app"
4195replica_url = "postgres://replica.example/app"
4196primary_pool_size = 12
4197replica_pool_size = 4
4198replica_fallback = "primary"
4199"#,
4200        )
4201        .expect("database topology config should parse");
4202
4203        assert_eq!(
4204            config.database.primary_url.as_deref(),
4205            Some("postgres://primary.example/app")
4206        );
4207        assert_eq!(
4208            config.database.replica_url.as_deref(),
4209            Some("postgres://replica.example/app")
4210        );
4211        assert_eq!(config.database.primary_pool_size, Some(12));
4212        assert_eq!(config.database.replica_pool_size, Some(4));
4213        assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
4214        assert_eq!(
4215            config.database.effective_primary_url(),
4216            Some("postgres://primary.example/app")
4217        );
4218        assert_eq!(config.database.effective_primary_pool_size(), 12);
4219        assert_eq!(config.database.effective_replica_pool_size(), 4);
4220    }
4221
4222    #[test]
4223    fn database_topology_keeps_url_as_single_primary_compatibility_path() {
4224        let config: AutumnConfig = toml::from_str(
4225            r#"
4226[database]
4227url = "postgres://single.example/app"
4228pool_size = 7
4229"#,
4230        )
4231        .expect("legacy database.url config should parse");
4232
4233        assert_eq!(
4234            config.database.effective_primary_url(),
4235            Some("postgres://single.example/app")
4236        );
4237        assert_eq!(config.database.effective_primary_pool_size(), 7);
4238        assert_eq!(config.database.effective_replica_pool_size(), 7);
4239        assert!(config.database.replica_url.is_none());
4240    }
4241
4242    #[test]
4243    fn database_topology_rejects_replica_without_primary() {
4244        let config = DatabaseConfig {
4245            replica_url: Some("postgres://replica.example/app".to_owned()),
4246            ..Default::default()
4247        };
4248
4249        let result = config.validate();
4250
4251        assert!(result.is_err());
4252        let Err(ConfigError::Validation(message)) = result else {
4253            panic!("expected database topology validation error");
4254        };
4255        assert!(message.contains("database.replica_url"));
4256        assert!(message.contains("database.primary_url"));
4257    }
4258
4259    #[test]
4260    fn database_topology_env_overrides_role_fields() {
4261        let env = MockEnv::new()
4262            .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
4263            .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
4264            .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
4265            .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
4266            .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
4267        let mut config = AutumnConfig::default();
4268
4269        config.apply_env_overrides_with_env(&env);
4270
4271        assert_eq!(
4272            config.database.primary_url.as_deref(),
4273            Some("postgres://primary.env/app")
4274        );
4275        assert_eq!(
4276            config.database.replica_url.as_deref(),
4277            Some("postgres://replica.env/app")
4278        );
4279        assert_eq!(config.database.primary_pool_size, Some(9));
4280        assert_eq!(config.database.replica_pool_size, Some(3));
4281        assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
4282    }
4283
4284    #[test]
4285    fn database_validate_url_edge_cases() {
4286        let invalid_urls = vec![
4287            "POSTGRES://localhost/db",
4288            "postgres:/localhost/db",
4289            "postgres:localhost/db",
4290            "http://postgres",
4291            "   postgres://localhost/db",
4292            "",
4293        ];
4294
4295        for invalid_url in invalid_urls {
4296            let config = DatabaseConfig {
4297                url: Some(invalid_url.to_string()),
4298                ..Default::default()
4299            };
4300            assert!(
4301                config.validate().is_err(),
4302                "URL should be invalid: {invalid_url}"
4303            );
4304        }
4305    }
4306
4307    #[test]
4308    fn autumn_config_validate_ok() {
4309        let config = AutumnConfig::default();
4310        assert!(config.validate().is_ok());
4311    }
4312
4313    #[test]
4314    fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
4315        // Session backend validation moved to `apply_session_layer` so a
4316        // custom store installed via `AppBuilder::with_session_store(...)`
4317        // can override an otherwise-invalid backend config without the boot
4318        // exiting first. `validate()` is config-shape-only now; runtime
4319        // session selection (and the backend error) lives in
4320        // `apply_session_layer`, which short-circuits when a custom store
4321        // is installed. `crate::session::tests::session_backend_plan_*`
4322        // still cover the underlying error cases directly on
4323        // `SessionConfig::backend_plan`.
4324        let mut config = AutumnConfig::default();
4325        config.session.backend = crate::session::SessionBackend::Redis;
4326        config.session.redis.url = None;
4327
4328        config
4329            .validate()
4330            .expect("validate() must accept invalid session backend so custom store can override");
4331    }
4332
4333    #[test]
4334    fn autumn_config_validate_database_err() {
4335        let mut config = AutumnConfig::default();
4336        config.database.url = Some("mysql://localhost/test".to_string());
4337        assert!(config.validate().is_err());
4338    }
4339
4340    #[test]
4341    fn log_defaults() {
4342        let config = LogConfig::default();
4343        assert_eq!(config.level, "info");
4344        assert_eq!(config.format, LogFormat::Auto);
4345    }
4346
4347    #[test]
4348    fn telemetry_defaults() {
4349        let config = TelemetryConfig::default();
4350        assert!(!config.enabled);
4351        assert_eq!(config.service_name, "autumn-app");
4352        assert!(config.service_namespace.is_none());
4353        assert_eq!(config.service_version, "unknown");
4354        assert_eq!(config.environment, "development");
4355        assert!(config.otlp_endpoint.is_none());
4356        assert_eq!(config.protocol, TelemetryProtocol::Grpc);
4357        assert!(!config.strict);
4358    }
4359
4360    #[test]
4361    fn health_defaults() {
4362        let config = HealthConfig::default();
4363        assert_eq!(config.path, "/health");
4364        assert_eq!(config.live_path, "/live");
4365        assert_eq!(config.ready_path, "/ready");
4366        assert_eq!(config.startup_path, "/startup");
4367        assert!(!config.detailed);
4368    }
4369
4370    #[test]
4371    fn top_level_default_populates_all_sections() {
4372        let config = AutumnConfig::default();
4373        assert_eq!(config.server.port, 3000);
4374        assert!(config.database.url.is_none());
4375        assert_eq!(config.log.level, "info");
4376        assert_eq!(config.health.path, "/health");
4377    }
4378
4379    #[test]
4380    fn deserialize_empty_object_uses_all_defaults() {
4381        let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
4382        assert_eq!(config.server.port, 3000);
4383        assert_eq!(config.server.host, "127.0.0.1");
4384        assert_eq!(config.server.shutdown_timeout_secs, 30);
4385        assert!(config.database.url.is_none());
4386        assert_eq!(config.database.pool_size, 10);
4387        assert_eq!(config.database.connect_timeout_secs, 5);
4388        assert!(!config.database.auto_migrate_in_production);
4389        assert_eq!(config.log.level, "info");
4390        assert_eq!(config.log.format, LogFormat::Auto);
4391        assert_eq!(config.health.path, "/health");
4392    }
4393
4394    #[test]
4395    fn deserialize_partial_config_merges_with_defaults() {
4396        let json = r#"{"server": {"port": 8080}}"#;
4397        let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
4398        assert_eq!(config.server.port, 8080);
4399        assert_eq!(config.server.host, "127.0.0.1");
4400        assert_eq!(config.database.pool_size, 10);
4401        assert_eq!(config.log.level, "info");
4402    }
4403
4404    #[test]
4405    fn log_format_variants_deserialize() {
4406        let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
4407        let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
4408        let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
4409        assert_eq!(auto, LogFormat::Auto);
4410        assert_eq!(pretty, LogFormat::Pretty);
4411        assert_eq!(json, LogFormat::Json);
4412    }
4413
4414    // ── TOML loading tests ───────────────────────────────────────────
4415
4416    #[test]
4417    fn load_missing_file_returns_defaults() {
4418        let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
4419        assert_eq!(config.server.port, 3000);
4420        assert!(config.database.url.is_none());
4421    }
4422
4423    #[test]
4424    fn load_valid_full_config() {
4425        let dir = tempfile::tempdir().unwrap();
4426        let path = dir.path().join("autumn.toml");
4427        std::fs::write(
4428            &path,
4429            r#"
4430[server]
4431port = 8080
4432host = "0.0.0.0"
4433shutdown_timeout_secs = 60
4434
4435[database]
4436url = "postgres://user:pass@db:5432/myapp"
4437pool_size = 20
4438connect_timeout_secs = 10
4439auto_migrate_in_production = true
4440
4441[log]
4442level = "debug"
4443format = "Json"
4444
4445[health]
4446path = "/healthz"
4447"#,
4448        )
4449        .unwrap();
4450
4451        let config = AutumnConfig::load_from(&path).unwrap();
4452        assert_eq!(config.server.port, 8080);
4453        assert_eq!(config.server.host, "0.0.0.0");
4454        assert_eq!(config.server.shutdown_timeout_secs, 60);
4455        assert_eq!(
4456            config.database.url.as_deref(),
4457            Some("postgres://user:pass@db:5432/myapp")
4458        );
4459        assert_eq!(config.database.pool_size, 20);
4460        assert_eq!(config.database.connect_timeout_secs, 10);
4461        assert!(config.database.auto_migrate_in_production);
4462        assert_eq!(config.log.level, "debug");
4463        assert_eq!(config.log.format, LogFormat::Json);
4464        assert_eq!(config.health.path, "/healthz");
4465    }
4466
4467    #[test]
4468    fn load_partial_config_merges_with_defaults() {
4469        let dir = tempfile::tempdir().unwrap();
4470        let path = dir.path().join("autumn.toml");
4471        std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
4472
4473        let config = AutumnConfig::load_from(&path).unwrap();
4474        assert_eq!(config.server.port, 9090);
4475        assert_eq!(config.server.host, "127.0.0.1");
4476        assert_eq!(config.database.pool_size, 10);
4477        assert_eq!(config.log.level, "info");
4478    }
4479
4480    #[test]
4481    fn access_log_defaults_on_with_probe_and_asset_exclusions() {
4482        let log = LogConfig::default();
4483        assert!(log.access_log);
4484        assert_eq!(
4485            log.access_log_exclude,
4486            vec![
4487                "/health",
4488                "/live",
4489                "/ready",
4490                "/startup",
4491                "/actuator",
4492                "/static"
4493            ]
4494        );
4495    }
4496
4497    #[test]
4498    fn env_override_access_log_off() {
4499        let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
4500        let mut config = AutumnConfig::default();
4501        config.apply_env_overrides_with_env(&env);
4502        assert!(!config.log.access_log);
4503    }
4504
4505    #[test]
4506    fn env_override_access_log_exclude_csv() {
4507        let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
4508        let mut config = AutumnConfig::default();
4509        config.apply_env_overrides_with_env(&env);
4510        assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
4511    }
4512
4513    #[test]
4514    fn access_log_is_configurable_from_toml() {
4515        let dir = tempfile::tempdir().unwrap();
4516        let path = dir.path().join("autumn.toml");
4517        std::fs::write(
4518            &path,
4519            "[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
4520        )
4521        .unwrap();
4522
4523        let config = AutumnConfig::load_from(&path).unwrap();
4524        assert!(!config.log.access_log);
4525        assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
4526    }
4527
4528    #[test]
4529    fn load_invalid_toml_returns_error() {
4530        let dir = tempfile::tempdir().unwrap();
4531        let path = dir.path().join("autumn.toml");
4532        std::fs::write(&path, "not valid [[[toml").unwrap();
4533
4534        let result = AutumnConfig::load_from(&path);
4535        assert!(result.is_err());
4536        let err = result.unwrap_err();
4537        assert!(err.to_string().contains("invalid autumn.toml"));
4538    }
4539
4540    #[test]
4541    fn load_empty_file_returns_defaults() {
4542        let dir = tempfile::tempdir().unwrap();
4543        let path = dir.path().join("autumn.toml");
4544        std::fs::write(&path, "").unwrap();
4545
4546        let config = AutumnConfig::load_from(&path).unwrap();
4547        assert_eq!(config.server.port, 3000);
4548    }
4549
4550    // ── Environment variable override tests ──────────────────────
4551
4552    #[test]
4553    fn env_override_database_url() {
4554        let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
4555        let mut config = AutumnConfig::default();
4556        config.apply_env_overrides_with_env(&env);
4557        assert_eq!(
4558            config.database.url.as_deref(),
4559            Some("postgres://override:5432/test")
4560        );
4561    }
4562
4563    #[test]
4564    fn env_override_actuator_prometheus_disables() {
4565        // Operators must be able to remove the scrape endpoint via the
4566        // documented AUTUMN_SECTION__FIELD convention, not just TOML.
4567        let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
4568        let mut config = AutumnConfig::default();
4569        assert!(config.actuator.prometheus, "default should be enabled");
4570        config.apply_env_overrides_with_env(&env);
4571        assert!(
4572            !config.actuator.prometheus,
4573            "AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
4574        );
4575    }
4576
4577    #[test]
4578    fn env_override_actuator_sensitive() {
4579        let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
4580        let mut config = AutumnConfig::default();
4581        assert!(!config.actuator.sensitive);
4582        config.apply_env_overrides_with_env(&env);
4583        assert!(config.actuator.sensitive);
4584    }
4585
4586    #[test]
4587    fn env_override_actuator_prefix() {
4588        let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
4589        let mut config = AutumnConfig::default();
4590        config.apply_env_overrides_with_env(&env);
4591        assert_eq!(config.actuator.prefix, "/ops");
4592    }
4593
4594    #[test]
4595    fn env_override_database_url_wins_over_file_primary_url() {
4596        let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
4597        let mut config = AutumnConfig::default();
4598        config.database.primary_url = Some("postgres://file.example/app".to_owned());
4599
4600        config.apply_env_overrides_with_env(&env);
4601
4602        assert_eq!(
4603            config.database.effective_primary_url(),
4604            Some("postgres://env.example/app")
4605        );
4606        assert!(config.database.primary_url.is_none());
4607    }
4608
4609    #[test]
4610    fn env_override_database_primary_url_wins_over_legacy_database_url() {
4611        let env = MockEnv::new()
4612            .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
4613            .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
4614        let mut config = AutumnConfig::default();
4615        config.database.primary_url = Some("postgres://file.example/app".to_owned());
4616
4617        config.apply_env_overrides_with_env(&env);
4618
4619        assert_eq!(
4620            config.database.effective_primary_url(),
4621            Some("postgres://primary.env/app")
4622        );
4623        assert_eq!(
4624            config.database.url.as_deref(),
4625            Some("postgres://legacy.env/app")
4626        );
4627    }
4628
4629    #[test]
4630    fn env_override_pool_size() {
4631        let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
4632        let mut config = AutumnConfig::default();
4633        config.apply_env_overrides_with_env(&env);
4634        assert_eq!(config.database.pool_size, 25);
4635    }
4636
4637    #[cfg(feature = "reporting")]
4638    #[test]
4639    fn env_override_reporting() {
4640        let env = MockEnv::new()
4641            .with("AUTUMN_REPORTING__ENABLED", "false")
4642            .with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
4643        let mut config = AutumnConfig::default();
4644        assert!(config.reporting.enabled);
4645        assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
4646        config.apply_env_overrides_with_env(&env);
4647        assert!(!config.reporting.enabled);
4648        assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
4649    }
4650
4651    #[test]
4652    fn env_override_connect_timeout() {
4653        let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
4654        let mut config = AutumnConfig::default();
4655        config.apply_env_overrides_with_env(&env);
4656        assert_eq!(config.database.connect_timeout_secs, 15);
4657    }
4658
4659    #[test]
4660    fn env_override_invalid_pool_size_ignored() {
4661        let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
4662        let mut config = AutumnConfig::default();
4663        config.apply_env_overrides_with_env(&env);
4664        assert_eq!(config.database.pool_size, 10);
4665    }
4666
4667    #[cfg(feature = "storage")]
4668    #[test]
4669    fn env_override_storage_fields() {
4670        let env = MockEnv::new()
4671            .with("AUTUMN_STORAGE__BACKEND", "s3")
4672            .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
4673            .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
4674            .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
4675            .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
4676            .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
4677            .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
4678            .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
4679            .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
4680            .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
4681            .with(
4682                "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
4683                "https://cdn.example.test",
4684            )
4685            .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
4686            .with(
4687                "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
4688                "AWS_SECRET_ACCESS_KEY",
4689            )
4690            .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
4691            .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
4692            .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
4693            .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
4694            .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
4695        let mut config = AutumnConfig::default();
4696
4697        config.apply_env_overrides_with_env(&env);
4698
4699        assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
4700        assert_eq!(config.storage.default_provider, "media");
4701        assert!(config.storage.allow_local_in_production);
4702        assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
4703        assert_eq!(config.storage.local.mount_path, "/files");
4704        assert_eq!(config.storage.local.default_url_expiry_secs, 42);
4705        assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
4706        assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
4707        assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
4708        assert_eq!(
4709            config.storage.s3.endpoint.as_deref(),
4710            Some("https://s3.example.test")
4711        );
4712        assert_eq!(
4713            config.storage.s3.public_base_url.as_deref(),
4714            Some("https://cdn.example.test")
4715        );
4716        assert_eq!(
4717            config.storage.s3.access_key_id_env.as_deref(),
4718            Some("AWS_ACCESS_KEY_ID")
4719        );
4720        assert_eq!(
4721            config.storage.s3.secret_access_key_env.as_deref(),
4722            Some("AWS_SECRET_ACCESS_KEY")
4723        );
4724        assert!(config.storage.s3.force_path_style);
4725        assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
4726        assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
4727        assert_eq!(config.storage.variants.max_source_width, 2_000);
4728        assert_eq!(config.storage.variants.max_source_height, 1_500);
4729    }
4730
4731    #[test]
4732    fn env_override_database_auto_migrate_in_production() {
4733        let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
4734        let mut config = AutumnConfig::default();
4735        config.apply_env_overrides_with_env(&env);
4736        assert!(config.database.auto_migrate_in_production);
4737    }
4738
4739    #[test]
4740    fn env_override_jobs_fields() {
4741        let env = MockEnv::new()
4742            .with("AUTUMN_JOBS__BACKEND", "redis")
4743            .with("AUTUMN_JOBS__WORKERS", "8")
4744            .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
4745            .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
4746            .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
4747            .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
4748            .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
4749        let mut config = AutumnConfig::default();
4750        config.apply_env_overrides_with_env(&env);
4751
4752        assert_eq!(config.jobs.backend, "redis");
4753        assert_eq!(config.jobs.workers, 8);
4754        assert_eq!(config.jobs.max_attempts, 12);
4755        assert_eq!(config.jobs.initial_backoff_ms, 750);
4756        assert_eq!(
4757            config.jobs.redis.url.as_deref(),
4758            Some("redis://jobs:6379/2")
4759        );
4760        assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
4761        assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
4762    }
4763
4764    #[test]
4765    fn jobs_toml_deserializes_redis_visibility_timeout() {
4766        let config: AutumnConfig = toml::from_str(
4767            r#"
4768            [jobs]
4769            backend = "redis"
4770
4771            [jobs.redis]
4772            url = "redis://localhost:6379/5"
4773            key_prefix = "demo:jobs"
4774            visibility_timeout_ms = 15000
4775            "#,
4776        )
4777        .unwrap();
4778
4779        assert_eq!(config.jobs.backend, "redis");
4780        assert_eq!(
4781            config.jobs.redis.url.as_deref(),
4782            Some("redis://localhost:6379/5")
4783        );
4784        assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
4785        assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
4786    }
4787
4788    #[test]
4789    fn channels_defaults_to_in_process_backend() {
4790        let config = AutumnConfig::default();
4791
4792        assert_eq!(config.channels.backend, ChannelBackend::InProcess);
4793        assert_eq!(config.channels.capacity, 32);
4794        assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
4795        assert!(config.channels.redis.url.is_none());
4796    }
4797
4798    #[test]
4799    fn channels_env_overrides_fields() {
4800        let env = MockEnv::new()
4801            .with("AUTUMN_CHANNELS__BACKEND", "redis")
4802            .with("AUTUMN_CHANNELS__CAPACITY", "128")
4803            .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
4804            .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
4805        let mut config = AutumnConfig::default();
4806
4807        config.apply_env_overrides_with_env(&env);
4808
4809        assert_eq!(config.channels.backend, ChannelBackend::Redis);
4810        assert_eq!(config.channels.capacity, 128);
4811        assert_eq!(
4812            config.channels.redis.url.as_deref(),
4813            Some("redis://channels:6379/4")
4814        );
4815        assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
4816    }
4817
4818    #[test]
4819    fn channels_toml_deserializes_redis_backend() {
4820        let config: AutumnConfig = toml::from_str(
4821            r#"
4822            [channels]
4823            backend = "redis"
4824            capacity = 64
4825
4826            [channels.redis]
4827            url = "redis://localhost:6379/5"
4828            key_prefix = "demo:channels"
4829            "#,
4830        )
4831        .unwrap();
4832
4833        assert_eq!(config.channels.backend, ChannelBackend::Redis);
4834        assert_eq!(config.channels.capacity, 64);
4835        assert_eq!(
4836            config.channels.redis.url.as_deref(),
4837            Some("redis://localhost:6379/5")
4838        );
4839        assert_eq!(config.channels.redis.key_prefix, "demo:channels");
4840    }
4841
4842    #[test]
4843    fn env_override_invalid_jobs_numeric_values_ignored() {
4844        let env = MockEnv::new()
4845            .with("AUTUMN_JOBS__WORKERS", "many")
4846            .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
4847            .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
4848        let mut config = AutumnConfig::default();
4849        config.apply_env_overrides_with_env(&env);
4850
4851        assert_eq!(config.jobs.workers, 1);
4852        assert_eq!(config.jobs.max_attempts, 5);
4853        assert_eq!(config.jobs.initial_backoff_ms, 250);
4854    }
4855
4856    // ── Server env override tests ────────────────────────────────
4857
4858    #[test]
4859    fn env_override_server_port() {
4860        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
4861        let mut config = AutumnConfig::default();
4862        config.apply_env_overrides_with_env(&env);
4863        assert_eq!(config.server.port, 8080);
4864    }
4865
4866    #[test]
4867    fn parse_env_works() {
4868        let env = MockEnv::new().with("SOME_NUM", "123");
4869        let mut target: u32 = 0;
4870        parse_env(&env, "SOME_NUM", &mut target);
4871        assert_eq!(target, 123);
4872
4873        let env_err = MockEnv::new().with("SOME_NUM", "abc");
4874        let mut target_err: u32 = 0;
4875        parse_env(&env_err, "SOME_NUM", &mut target_err);
4876        assert_eq!(target_err, 0); // Unchanged
4877    }
4878
4879    #[test]
4880    fn parse_env_option_string_works() {
4881        let env = MockEnv::new().with("SOME_OPT", "val");
4882        let mut target = None;
4883        parse_env_option_string(&env, "SOME_OPT", &mut target);
4884        assert_eq!(target, Some("val".to_string()));
4885
4886        let env_empty = MockEnv::new().with("SOME_OPT", "");
4887        let mut target_empty = Some("old".to_string());
4888        parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
4889        assert_eq!(target_empty, None);
4890    }
4891
4892    #[test]
4893    fn parse_env_string_works() {
4894        let env = MockEnv::new().with("SOME_STR", "val");
4895        let mut target = "old".to_string();
4896        parse_env_string(&env, "SOME_STR", &mut target);
4897        assert_eq!(target, "val");
4898    }
4899
4900    #[test]
4901    fn parse_env_bool_works() {
4902        let env = MockEnv::new().with("SOME_BOOL", "true");
4903        let mut target = false;
4904        parse_env_bool(&env, "SOME_BOOL", &mut target);
4905        assert!(target);
4906
4907        let env2 = MockEnv::new().with("SOME_BOOL", "1");
4908        let mut target2 = false;
4909        parse_env_bool(&env2, "SOME_BOOL", &mut target2);
4910        assert!(target2);
4911
4912        let env3 = MockEnv::new().with("SOME_BOOL", "0");
4913        let mut target3 = true;
4914        parse_env_bool(&env3, "SOME_BOOL", &mut target3);
4915        assert!(!target3);
4916
4917        let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
4918        let mut target_err = true;
4919        parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
4920        assert!(target_err); // Unchanged
4921    }
4922
4923    #[test]
4924    fn parse_env_csv_works() {
4925        let env = MockEnv::new().with("SOME_CSV", "a, b,c");
4926        let mut target = vec![];
4927        parse_env_csv(&env, "SOME_CSV", &mut target);
4928        assert_eq!(target, vec!["a", "b", "c"]);
4929    }
4930
4931    #[test]
4932    fn env_override_rate_limit_trusted_proxies() {
4933        let env = MockEnv::new().with(
4934            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
4935            "10.0.0.10, 203.0.113.0/24",
4936        );
4937        let mut config = AutumnConfig::default();
4938        config.apply_env_overrides_with_env(&env);
4939        assert_eq!(
4940            config.security.rate_limit.trusted_proxies,
4941            vec!["10.0.0.10", "203.0.113.0/24"]
4942        );
4943    }
4944
4945    #[test]
4946    fn env_override_rate_limit_backend_redis() {
4947        use crate::security::config::RateLimitBackend;
4948        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
4949        let mut config = AutumnConfig::default();
4950        config.apply_env_overrides_with_env(&env);
4951        assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
4952    }
4953
4954    #[test]
4955    fn env_override_rate_limit_backend_memory() {
4956        use crate::security::config::RateLimitBackend;
4957        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
4958        let mut config = AutumnConfig::default();
4959        config.apply_env_overrides_with_env(&env);
4960        assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
4961    }
4962
4963    #[test]
4964    fn env_override_rate_limit_backend_invalid_ignored() {
4965        use crate::security::config::RateLimitBackend;
4966        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
4967        let mut config = AutumnConfig::default();
4968        config.apply_env_overrides_with_env(&env);
4969        assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
4970    }
4971
4972    #[cfg(feature = "redis")]
4973    #[test]
4974    fn env_override_rate_limit_on_backend_failure_fail_closed() {
4975        use crate::security::config::RateLimitBackendFailure;
4976        let env = MockEnv::new().with(
4977            "AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
4978            "fail_closed",
4979        );
4980        let mut config = AutumnConfig::default();
4981        config.apply_env_overrides_with_env(&env);
4982        assert_eq!(
4983            config.security.rate_limit.on_backend_failure,
4984            RateLimitBackendFailure::FailClosed
4985        );
4986    }
4987
4988    #[cfg(feature = "redis")]
4989    #[test]
4990    fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
4991        use crate::security::config::RateLimitBackendFailure;
4992        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
4993        let mut config = AutumnConfig::default();
4994        config.apply_env_overrides_with_env(&env);
4995        assert_eq!(
4996            config.security.rate_limit.on_backend_failure,
4997            RateLimitBackendFailure::FailOpen
4998        );
4999    }
5000
5001    #[cfg(feature = "redis")]
5002    #[test]
5003    fn env_override_rate_limit_redis_url() {
5004        let env = MockEnv::new().with(
5005            "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
5006            "redis://myhost:6379",
5007        );
5008        let mut config = AutumnConfig::default();
5009        config.apply_env_overrides_with_env(&env);
5010        assert_eq!(
5011            config.security.rate_limit.redis.url.as_deref(),
5012            Some("redis://myhost:6379")
5013        );
5014    }
5015
5016    #[cfg(feature = "redis")]
5017    #[test]
5018    fn env_override_rate_limit_redis_key_prefix() {
5019        let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
5020        let mut config = AutumnConfig::default();
5021        config.apply_env_overrides_with_env(&env);
5022        assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
5023    }
5024
5025    #[test]
5026    fn env_override_server_host() {
5027        let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
5028        let mut config = AutumnConfig::default();
5029        config.apply_env_overrides_with_env(&env);
5030        assert_eq!(config.server.host, "0.0.0.0");
5031    }
5032
5033    #[test]
5034    fn env_override_server_shutdown_timeout() {
5035        let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
5036        let mut config = AutumnConfig::default();
5037        config.apply_env_overrides_with_env(&env);
5038        assert_eq!(config.server.shutdown_timeout_secs, 60);
5039    }
5040
5041    #[test]
5042    fn env_override_invalid_server_port_ignored() {
5043        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
5044        let mut config = AutumnConfig::default();
5045        config.apply_env_overrides_with_env(&env);
5046        assert_eq!(config.server.port, 3000);
5047    }
5048
5049    #[test]
5050    fn env_override_invalid_shutdown_timeout_ignored() {
5051        let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
5052        let mut config = AutumnConfig::default();
5053        config.apply_env_overrides_with_env(&env);
5054        assert_eq!(config.server.shutdown_timeout_secs, 30);
5055    }
5056
5057    // ── Log env override tests ───────────────────────────────────
5058
5059    #[test]
5060    fn env_override_log_level() {
5061        let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
5062        let mut config = AutumnConfig::default();
5063        config.apply_env_overrides_with_env(&env);
5064        assert_eq!(config.log.level, "debug");
5065    }
5066
5067    #[test]
5068    fn env_override_log_format_json() {
5069        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
5070        let mut config = AutumnConfig::default();
5071        config.apply_env_overrides_with_env(&env);
5072        assert_eq!(config.log.format, LogFormat::Json);
5073    }
5074
5075    #[test]
5076    fn env_override_log_format_pretty() {
5077        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
5078        let mut config = AutumnConfig::default();
5079        config.apply_env_overrides_with_env(&env);
5080        assert_eq!(config.log.format, LogFormat::Pretty);
5081    }
5082
5083    #[test]
5084    fn env_override_invalid_log_format_ignored() {
5085        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
5086        let mut config = AutumnConfig::default();
5087        config.apply_env_overrides_with_env(&env);
5088        assert_eq!(config.log.format, LogFormat::Auto);
5089    }
5090
5091    // ── Health env override tests ────────────────────────────────
5092
5093    #[test]
5094    fn env_override_telemetry_fields() {
5095        let env = MockEnv::new()
5096            .with("AUTUMN_TELEMETRY__ENABLED", "true")
5097            .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
5098            .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
5099            .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
5100            .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
5101            .with(
5102                "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
5103                "http://otel-collector:4317",
5104            )
5105            .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
5106            .with("AUTUMN_TELEMETRY__STRICT", "true");
5107        let mut config = AutumnConfig::default();
5108        config.apply_env_overrides_with_env(&env);
5109        assert!(config.telemetry.enabled);
5110        assert_eq!(config.telemetry.service_name, "orders-api");
5111        assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
5112        assert_eq!(config.telemetry.service_version, "1.2.3");
5113        assert_eq!(config.telemetry.environment, "production");
5114        assert_eq!(
5115            config.telemetry.otlp_endpoint.as_deref(),
5116            Some("http://otel-collector:4317")
5117        );
5118        assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
5119        assert!(config.telemetry.strict);
5120    }
5121
5122    #[test]
5123    fn env_override_invalid_telemetry_protocol_ignored() {
5124        let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
5125        let mut config = AutumnConfig::default();
5126        config.apply_env_overrides_with_env(&env);
5127        assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
5128    }
5129
5130    #[test]
5131    fn env_override_health_path() {
5132        let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
5133        let mut config = AutumnConfig::default();
5134        config.apply_env_overrides_with_env(&env);
5135        assert_eq!(config.health.path, "/healthz");
5136    }
5137
5138    #[test]
5139    fn env_override_probe_paths() {
5140        let env = MockEnv::new()
5141            .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
5142            .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
5143            .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
5144        let mut config = AutumnConfig::default();
5145        config.apply_env_overrides_with_env(&env);
5146        assert_eq!(config.health.live_path, "/livez");
5147        assert_eq!(config.health.ready_path, "/readyz");
5148        assert_eq!(config.health.startup_path, "/startupz");
5149    }
5150
5151    // ── Precedence test ──────────────────────────────────────────
5152
5153    #[test]
5154    fn env_overrides_toml_values() {
5155        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
5156        let dir = tempfile::tempdir().unwrap();
5157        let path = dir.path().join("autumn.toml");
5158        std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
5159        let mut config = AutumnConfig::load_from(&path).unwrap();
5160        config.apply_env_overrides_with_env(&env);
5161        assert_eq!(config.server.port, 9999); // env wins
5162    }
5163
5164    // ── Validation tests ─────────────────────────────────────────
5165
5166    #[test]
5167    fn validate_rejects_invalid_url_scheme() {
5168        let config = DatabaseConfig {
5169            url: Some("mysql://localhost/test".to_owned()),
5170            ..Default::default()
5171        };
5172        let result = config.validate();
5173        assert!(result.is_err());
5174        assert!(
5175            result
5176                .unwrap_err()
5177                .to_string()
5178                .contains("must start with postgres://")
5179        );
5180    }
5181
5182    #[test]
5183    fn validate_accepts_postgres_url() {
5184        let config = DatabaseConfig {
5185            url: Some("postgres://localhost/test".to_owned()),
5186            ..Default::default()
5187        };
5188        assert!(config.validate().is_ok());
5189    }
5190
5191    #[test]
5192    fn validate_accepts_postgresql_url() {
5193        let config = DatabaseConfig {
5194            url: Some("postgresql://localhost/test".to_owned()),
5195            ..Default::default()
5196        };
5197        assert!(config.validate().is_ok());
5198    }
5199
5200    #[test]
5201    fn validate_accepts_no_url() {
5202        let config = DatabaseConfig::default();
5203        assert!(config.validate().is_ok());
5204    }
5205
5206    // ── Profile tests ──────────────────────────────────────────
5207
5208    #[test]
5209    fn resolve_profile_from_autumn_env() {
5210        let env = MockEnv::new().with("AUTUMN_ENV", "prod");
5211        let profile = resolve_profile(&env);
5212        assert_eq!(profile, "prod");
5213    }
5214
5215    #[test]
5216    fn resolve_profile_from_legacy_env() {
5217        let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
5218        let profile = resolve_profile(&env);
5219        assert_eq!(profile, "staging");
5220    }
5221
5222    #[test]
5223    fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
5224        let env = MockEnv::new()
5225            .with("AUTUMN_ENV", "dev")
5226            .with("AUTUMN_PROFILE", "prod");
5227        let profile = resolve_profile(&env);
5228        assert_eq!(profile, "dev");
5229    }
5230
5231    #[test]
5232    fn resolve_profile_normalizes_production_alias() {
5233        let env = MockEnv::new().with("AUTUMN_ENV", "production");
5234        let profile = resolve_profile(&env);
5235        assert_eq!(profile, "prod");
5236    }
5237
5238    #[test]
5239    fn resolve_profile_normalizes_development_alias_with_whitespace() {
5240        let env = MockEnv::new().with("AUTUMN_ENV", "  development  ");
5241        let profile = resolve_profile(&env);
5242        assert_eq!(profile, "dev");
5243    }
5244
5245    #[test]
5246    fn resolve_profile_normalizes_uppercase_dev_and_prod() {
5247        let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
5248        let prod = resolve_profile(&prod_env);
5249        assert_eq!(prod, "prod");
5250
5251        let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
5252        let dev = resolve_profile(&dev_env);
5253        assert_eq!(dev, "dev");
5254    }
5255
5256    #[test]
5257    fn resolve_profile_preserves_case_for_custom_profiles() {
5258        let env = MockEnv::new().with("AUTUMN_ENV", "QA");
5259        let profile = resolve_profile(&env);
5260        assert_eq!(profile, "QA");
5261    }
5262
5263    #[test]
5264    fn resolve_profile_auto_detect_debug() {
5265        let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
5266        let profile = resolve_profile(&env);
5267        assert_eq!(profile, "dev");
5268    }
5269
5270    #[test]
5271    fn resolve_profile_auto_detect_release() {
5272        let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
5273        let profile = resolve_profile(&env);
5274        assert_eq!(profile, "prod");
5275    }
5276
5277    #[test]
5278    fn resolve_profile_defaults_to_dev_when_no_signal_present() {
5279        let env = MockEnv::new();
5280        let profile = resolve_profile(&env);
5281        assert_eq!(profile, "dev");
5282    }
5283
5284    #[test]
5285    fn dev_profile_smart_defaults() {
5286        let defaults = profile_defaults_as_toml("dev");
5287        let toml_str = toml::to_string(&defaults).unwrap();
5288        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5289
5290        assert_eq!(config.log.level, "debug");
5291        assert_eq!(config.log.format, LogFormat::Pretty);
5292        assert_eq!(config.server.host, "127.0.0.1");
5293        assert_eq!(config.server.shutdown_timeout_secs, 1);
5294        assert_eq!(
5295            config.server.prestop_grace_secs, 0,
5296            "dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
5297        );
5298        assert_eq!(config.telemetry.environment, "development");
5299        assert!(config.health.detailed);
5300        assert_eq!(config.cors.allowed_origins, vec!["*"]);
5301        assert!(
5302            config.security.trusted_proxies.trust_forwarded_headers,
5303            "dev profile must trust forwarded headers from loopback"
5304        );
5305        assert!(
5306            config
5307                .security
5308                .trusted_proxies
5309                .ranges
5310                .contains(&"127.0.0.0/8".to_owned()),
5311            "dev profile must include 127.0.0.0/8 as trusted proxy range"
5312        );
5313        assert!(
5314            config
5315                .security
5316                .trusted_proxies
5317                .ranges
5318                .contains(&"::1/128".to_owned()),
5319            "dev profile must include ::1/128 as trusted proxy range"
5320        );
5321    }
5322
5323    #[test]
5324    fn prod_profile_smart_defaults() {
5325        let defaults = profile_defaults_as_toml("prod");
5326        let toml_str = toml::to_string(&defaults).unwrap();
5327        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5328
5329        assert_eq!(config.log.level, "info");
5330        assert_eq!(config.log.format, LogFormat::Json);
5331        assert_eq!(config.server.host, "0.0.0.0");
5332        assert_eq!(config.server.shutdown_timeout_secs, 30);
5333        assert_eq!(config.telemetry.environment, "production");
5334        assert!(!config.health.detailed);
5335        // AC: HSTS auto-enabled in the production profile.
5336        assert!(
5337            config.security.headers.strict_transport_security,
5338            "prod profile must auto-enable Strict-Transport-Security"
5339        );
5340        // Defaults should still be secure-by-default in prod.
5341        assert_eq!(config.security.headers.x_frame_options, "DENY");
5342        assert!(config.security.headers.x_content_type_options);
5343        assert!(!config.security.headers.content_security_policy.is_empty());
5344    }
5345
5346    #[test]
5347    fn dev_profile_does_not_auto_enable_hsts() {
5348        let defaults = profile_defaults_as_toml("dev");
5349        let toml_str = toml::to_string(&defaults).unwrap();
5350        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5351
5352        assert!(
5353            !config.security.headers.strict_transport_security,
5354            "dev profile must not force HSTS on (local http development)"
5355        );
5356    }
5357
5358    #[test]
5359    fn custom_profile_no_smart_defaults() {
5360        let defaults = profile_defaults_as_toml("staging");
5361        assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
5362    }
5363
5364    #[test]
5365    fn deep_merge_tables() {
5366        let mut base: toml::Value = toml::from_str(
5367            r#"
5368            [server]
5369            port = 3000
5370            host = "127.0.0.1"
5371            [database]
5372            pool_size = 10
5373            "#,
5374        )
5375        .unwrap();
5376
5377        let overlay: toml::Value = toml::from_str(
5378            r#"
5379            [server]
5380            port = 8080
5381            [database]
5382            url = "postgres://localhost/test"
5383            "#,
5384        )
5385        .unwrap();
5386
5387        deep_merge(&mut base, overlay);
5388
5389        // Overlay value wins
5390        assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
5391        // Base value preserved when not in overlay
5392        assert_eq!(
5393            base["server"]["host"],
5394            toml::Value::String("127.0.0.1".into())
5395        );
5396        // New key from overlay added
5397        assert_eq!(
5398            base["database"]["url"],
5399            toml::Value::String("postgres://localhost/test".into())
5400        );
5401        // Base key preserved
5402        assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
5403    }
5404
5405    #[test]
5406    fn profile_toml_overrides_base_toml() {
5407        let dir = tempfile::tempdir().unwrap();
5408        let base_path = dir.path().join("autumn.toml");
5409        let dev_path = dir.path().join("autumn-dev.toml");
5410
5411        std::fs::write(
5412            &base_path,
5413            r"
5414            [server]
5415            port = 3000
5416            [database]
5417            pool_size = 10
5418            ",
5419        )
5420        .unwrap();
5421
5422        std::fs::write(
5423            &dev_path,
5424            r#"
5425            [database]
5426            url = "postgres://localhost/myapp_dev"
5427            "#,
5428        )
5429        .unwrap();
5430
5431        // Load base
5432        let mut merged = toml::Value::Table(toml::map::Map::new());
5433        let base = load_raw_toml(&base_path).unwrap().unwrap();
5434        deep_merge(&mut merged, base);
5435        let profile = load_raw_toml(&dev_path).unwrap().unwrap();
5436        deep_merge(&mut merged, profile);
5437
5438        let toml_str = toml::to_string(&merged).unwrap();
5439        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5440
5441        assert_eq!(config.server.port, 3000); // from base
5442        assert_eq!(config.database.pool_size, 10); // from base, preserved
5443        assert_eq!(
5444            config.database.url.as_deref(),
5445            Some("postgres://localhost/myapp_dev")
5446        ); // from profile
5447    }
5448
5449    #[test]
5450    fn inline_profile_section_overrides_base_toml() {
5451        let mut merged = toml::Value::Table(toml::map::Map::new());
5452        let base: toml::Value = toml::from_str(
5453            r#"
5454            [server]
5455            port = 3000
5456
5457            [log]
5458            level = "info"
5459
5460            [profile.dev.log]
5461            level = "debug"
5462            "#,
5463        )
5464        .unwrap();
5465
5466        deep_merge(&mut merged, base.clone());
5467        let inline = profile_section_from_base_toml(&base, "dev").unwrap();
5468        deep_merge(&mut merged, inline);
5469
5470        let toml_str = toml::to_string(&merged).unwrap();
5471        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
5472        assert_eq!(config.server.port, 3000);
5473        assert_eq!(config.log.level, "debug");
5474    }
5475
5476    #[test]
5477    fn levenshtein_basic() {
5478        assert_eq!(levenshtein("dev", "dev"), 0);
5479        assert_eq!(levenshtein("dev", "dve"), 2); // swap = 2 edits (del + ins)
5480        assert_eq!(levenshtein("prod", "prodd"), 1);
5481        assert_eq!(levenshtein("prod", "prd"), 1);
5482        assert_eq!(levenshtein("staging", "dev"), 7);
5483    }
5484
5485    #[test]
5486    fn env_override_health_detailed() {
5487        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
5488        let mut config = AutumnConfig::default();
5489        config.apply_env_overrides_with_env(&env);
5490        assert!(config.health.detailed);
5491    }
5492
5493    #[test]
5494    fn profile_name_accessor() {
5495        let mut config = AutumnConfig::default();
5496        assert!(config.profile_name().is_none());
5497
5498        config.profile = Some("dev".to_owned());
5499        assert_eq!(config.profile_name(), Some("dev"));
5500    }
5501
5502    // ── Mutant-hunting tests ────────────────────────────────────
5503
5504    #[test]
5505    fn find_config_file_falls_back_to_cwd() {
5506        // Without AUTUMN_MANIFEST_DIR, should return just the filename
5507        let env = MockEnv::new();
5508        let path = find_config_file_named("autumn.toml", &env);
5509        assert_eq!(path, PathBuf::from("autumn.toml"));
5510    }
5511
5512    #[test]
5513    fn find_config_file_uses_manifest_dir_when_file_exists() {
5514        let dir = tempfile::tempdir().unwrap();
5515        let config_path = dir.path().join("autumn.toml");
5516        std::fs::write(&config_path, "").unwrap();
5517
5518        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5519        let path = find_config_file_named("autumn.toml", &env);
5520        assert_eq!(path, config_path);
5521    }
5522
5523    #[test]
5524    fn find_config_file_falls_back_when_manifest_dir_missing_file() {
5525        let dir = tempfile::tempdir().unwrap();
5526        // dir exists but the file doesn't
5527        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5528        let path = find_config_file_named("nonexistent.toml", &env);
5529        assert_eq!(path, PathBuf::from("nonexistent.toml"));
5530    }
5531
5532    #[test]
5533    fn resolve_profile_cli_flag_exact_match() {
5534        // resolve_profile checks `--profile` in CLI args. We can't easily
5535        // inject args, but we can verify the env path doesn't match other args.
5536        // The `== "--profile"` guard is the key: if it were `!=`, every arg
5537        // would trigger the branch.
5538        let env = MockEnv::new();
5539        // With no env vars and no matching CLI args, should be None
5540        let profile = resolve_profile(&env);
5541        // This may or may not be None depending on test harness args,
5542        // but the important thing is it doesn't crash or return garbage.
5543        // The env-based tests above cover the positive cases.
5544        drop(profile);
5545    }
5546
5547    #[test]
5548    fn deep_merge_non_table_overlay_replaces_base() {
5549        // When overlay is not a table, it should replace (not merge into) base.
5550        // This kills the `&& → ||` mutant on line 162.
5551        let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
5552        let overlay = toml::Value::String("not_a_table".into());
5553
5554        // When base is table and overlay is NOT table, base should be unchanged
5555        // (the function only merges when BOTH are tables).
5556        deep_merge(&mut base, overlay);
5557        // base should still be the original table (overlay was ignored)
5558        assert!(base.is_table());
5559        assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
5560    }
5561
5562    #[test]
5563    fn deep_merge_when_base_not_table() {
5564        // When base is not a table, overlay should not merge
5565        let mut base = toml::Value::String("original".into());
5566        let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
5567
5568        deep_merge(&mut base, overlay);
5569        // base should be unchanged
5570        assert_eq!(base, toml::Value::String("original".into()));
5571    }
5572
5573    #[test]
5574    fn suggest_profile_close_match() {
5575        // "dve" is edit-distance 2 from "dev" → should suggest "dev"
5576        assert_eq!(suggest_profile("dve"), Some("dev"));
5577    }
5578
5579    #[test]
5580    fn suggest_profile_no_match_when_distant() {
5581        // "xyz" is far from both "dev" and "prod" → no suggestion
5582        assert_eq!(suggest_profile("xyz"), None);
5583    }
5584
5585    #[test]
5586    fn suggest_profile_exact_known_profile() {
5587        // Exact match has distance 0 → suggests itself
5588        assert_eq!(suggest_profile("dev"), Some("dev"));
5589        assert_eq!(suggest_profile("prod"), Some("prod"));
5590    }
5591
5592    #[test]
5593    fn suggest_profile_prd() {
5594        // "prd" is distance 1 from "prod"
5595        assert_eq!(suggest_profile("prd"), Some("prod"));
5596    }
5597
5598    #[test]
5599    fn warn_profile_typo_runs_without_panic() {
5600        warn_profile_typo("dve");
5601        warn_profile_typo("xyz");
5602    }
5603
5604    #[test]
5605    fn should_warn_missing_profile_file_custom_without_inline() {
5606        assert!(should_warn_missing_profile_file("staging", false));
5607    }
5608
5609    #[test]
5610    fn should_not_warn_missing_profile_file_custom_with_inline() {
5611        assert!(!should_warn_missing_profile_file("staging", true));
5612    }
5613
5614    #[test]
5615    fn should_not_warn_missing_profile_file_dev_or_prod() {
5616        assert!(!should_warn_missing_profile_file("dev", false));
5617        assert!(!should_warn_missing_profile_file("prod", false));
5618    }
5619
5620    #[test]
5621    fn levenshtein_threshold_in_warn_profile_typo() {
5622        assert!(levenshtein("dve", "dev") <= 2);
5623        assert!(levenshtein("xyz", "dev") > 2);
5624        assert!(levenshtein("xyz", "prod") > 2);
5625    }
5626
5627    #[test]
5628    fn env_override_cors_allowed_origins() {
5629        let env = MockEnv::new().with(
5630            "AUTUMN_CORS__ALLOWED_ORIGINS",
5631            "https://a.com, https://b.com",
5632        );
5633        let mut config = AutumnConfig::default();
5634        config.apply_env_overrides_with_env(&env);
5635        assert_eq!(
5636            config.cors.allowed_origins,
5637            vec!["https://a.com", "https://b.com"]
5638        );
5639    }
5640
5641    #[test]
5642    fn env_override_cors_allow_credentials() {
5643        let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
5644        let mut config = AutumnConfig::default();
5645        config.apply_env_overrides_with_env(&env);
5646        assert!(config.cors.allow_credentials);
5647    }
5648
5649    #[test]
5650    fn env_override_cors_max_age() {
5651        let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
5652        let mut config = AutumnConfig::default();
5653        config.apply_env_overrides_with_env(&env);
5654        assert_eq!(config.cors.max_age_secs, 3600);
5655    }
5656
5657    #[test]
5658    fn cors_validate_rejects_wildcard_with_credentials() {
5659        let mut config = AutumnConfig::default();
5660        config.cors.allowed_origins = vec!["*".to_owned()];
5661        config.cors.allow_credentials = true;
5662
5663        let result = config.validate();
5664        match result {
5665            Err(ConfigError::Validation(msg)) => {
5666                assert!(
5667                    msg.contains("allow_credentials") && msg.contains('*'),
5668                    "message should mention credentials and wildcard, got: {msg}"
5669                );
5670            }
5671            other => panic!("expected ConfigError::Validation, got {other:?}"),
5672        }
5673    }
5674
5675    #[test]
5676    fn cors_validate_accepts_wildcard_without_credentials() {
5677        let mut config = AutumnConfig::default();
5678        config.cors.allowed_origins = vec!["*".to_owned()];
5679        config.cors.allow_credentials = false;
5680        assert!(config.validate().is_ok());
5681    }
5682
5683    #[test]
5684    fn cors_validate_accepts_explicit_origins_with_credentials() {
5685        let mut config = AutumnConfig::default();
5686        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
5687        config.cors.allow_credentials = true;
5688        assert!(config.validate().is_ok());
5689    }
5690
5691    #[test]
5692    fn load_uses_profile_layering() {
5693        // Test AutumnConfig::load_with_env() with a dev profile via env var.
5694        // This kills the "replace load → Ok(Default::default())" mutant.
5695        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
5696
5697        let config = AutumnConfig::load_with_env(&env).unwrap();
5698        // With dev profile, smart defaults should apply
5699        assert_eq!(config.profile.as_deref(), Some("dev"));
5700        assert_eq!(config.log.level, "debug"); // dev default
5701        assert_eq!(config.log.format, LogFormat::Pretty); // dev default
5702        assert!(config.health.detailed); // dev default
5703    }
5704
5705    #[test]
5706    fn load_custom_profile_without_toml_warns() {
5707        // Test the typo warning branch: profile != "dev" && profile != "prod"
5708        // without a corresponding autumn-{profile}.toml triggers warn_profile_typo.
5709        // This kills the match guard mutants on line 341.
5710        let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
5711
5712        let config = AutumnConfig::load_with_env(&env).unwrap();
5713        assert_eq!(config.profile.as_deref(), Some("staging"));
5714        // staging has no smart defaults, so values should be framework defaults
5715        assert_eq!(config.server.port, 3000);
5716        assert_eq!(config.log.level, "info");
5717    }
5718
5719    #[test]
5720    fn load_dev_profile_no_profile_toml_no_warn() {
5721        // dev/prod without their profile TOML should NOT trigger warn_profile_typo.
5722        // This tests the `None => {}` branch (line 342).
5723        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
5724
5725        let config = AutumnConfig::load_with_env(&env).unwrap();
5726        assert_eq!(config.profile.as_deref(), Some("dev"));
5727    }
5728
5729    #[test]
5730    fn load_custom_profile_uses_inline_profile_without_legacy_file() {
5731        let dir = tempfile::tempdir().unwrap();
5732        let base_path = dir.path().join("autumn.toml");
5733        std::fs::write(
5734            &base_path,
5735            r"
5736            [server]
5737            port = 3000
5738
5739            [profile.staging.server]
5740            port = 4100
5741            ",
5742        )
5743        .unwrap();
5744
5745        let env = MockEnv::new()
5746            .with("AUTUMN_ENV", "staging")
5747            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5748
5749        let config = AutumnConfig::load_with_env(&env).unwrap();
5750        assert_eq!(config.profile.as_deref(), Some("staging"));
5751        assert_eq!(config.server.port, 4100);
5752    }
5753
5754    #[test]
5755    fn load_production_profile_reads_inline_profile_production_section() {
5756        let dir = tempfile::tempdir().unwrap();
5757        let base_path = dir.path().join("autumn.toml");
5758        std::fs::write(
5759            &base_path,
5760            r"
5761            [profile.production.server]
5762            port = 4200
5763            ",
5764        )
5765        .unwrap();
5766
5767        let env = MockEnv::new()
5768            .with("AUTUMN_ENV", "production")
5769            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5770
5771        let config = AutumnConfig::load_with_env(&env).unwrap();
5772        assert_eq!(config.profile.as_deref(), Some("prod"));
5773        assert_eq!(config.server.port, 4200);
5774    }
5775
5776    #[test]
5777    fn load_production_profile_reads_legacy_autumn_production_toml() {
5778        let dir = tempfile::tempdir().unwrap();
5779        let production_path = dir.path().join("autumn-production.toml");
5780        std::fs::write(
5781            &production_path,
5782            r"
5783            [server]
5784            port = 4300
5785            ",
5786        )
5787        .unwrap();
5788
5789        let env = MockEnv::new()
5790            .with("AUTUMN_ENV", "production")
5791            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5792
5793        let config = AutumnConfig::load_with_env(&env).unwrap();
5794        assert_eq!(config.profile.as_deref(), Some("prod"));
5795        assert_eq!(config.server.port, 4300);
5796    }
5797
5798    #[test]
5799    fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
5800        let dir = tempfile::tempdir().unwrap();
5801        let prod_path = dir.path().join("autumn-prod.toml");
5802        let production_path = dir.path().join("autumn-production.toml");
5803
5804        std::fs::write(
5805            &prod_path,
5806            r"
5807            [server]
5808            port = 4400
5809            ",
5810        )
5811        .unwrap();
5812        // Malformed TOML should be ignored because `autumn-prod.toml` is chosen first.
5813        std::fs::write(&production_path, "[server\nport = 4500").unwrap();
5814
5815        let env = MockEnv::new()
5816            .with("AUTUMN_ENV", "prod")
5817            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5818
5819        let config = AutumnConfig::load_with_env(&env).unwrap();
5820        assert_eq!(config.profile.as_deref(), Some("prod"));
5821        assert_eq!(config.server.port, 4400);
5822    }
5823
5824    #[test]
5825    fn load_production_prefers_autumn_production_toml_before_prod_alias() {
5826        let dir = tempfile::tempdir().unwrap();
5827        let prod_path = dir.path().join("autumn-prod.toml");
5828        let production_path = dir.path().join("autumn-production.toml");
5829
5830        std::fs::write(
5831            &production_path,
5832            r"
5833            [server]
5834            port = 4500
5835            ",
5836        )
5837        .unwrap();
5838        // Malformed TOML should be ignored because `autumn-production.toml` is chosen first.
5839        std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
5840
5841        let env = MockEnv::new()
5842            .with("AUTUMN_ENV", "production")
5843            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
5844
5845        let config = AutumnConfig::load_with_env(&env).unwrap();
5846        assert_eq!(config.profile.as_deref(), Some("prod"));
5847        assert_eq!(config.server.port, 4500);
5848    }
5849
5850    #[test]
5851    fn load_from_io_error_is_not_swallowed() {
5852        // load_from should return Err on non-NotFound IO errors.
5853        // On all platforms, trying to read a directory as a file triggers an error.
5854        let dir = tempfile::tempdir().unwrap();
5855        let result = AutumnConfig::load_from(dir.path());
5856        assert!(result.is_err());
5857    }
5858
5859    #[test]
5860    fn load_raw_toml_missing_file_returns_none() {
5861        let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
5862        assert!(result.is_none());
5863    }
5864
5865    #[test]
5866    fn load_raw_toml_directory_returns_io_error() {
5867        // Reading a directory is an IO error, NOT NotFound.
5868        // This kills the "replace match guard NotFound with true" mutant:
5869        // if the guard were always true, this would return Ok(None) instead of Err.
5870        let dir = tempfile::tempdir().unwrap();
5871        let result = load_raw_toml(dir.path());
5872        assert!(result.is_err());
5873    }
5874
5875    #[test]
5876    fn load_raw_toml_valid_file_returns_some() {
5877        let dir = tempfile::tempdir().unwrap();
5878        let path = dir.path().join("test.toml");
5879        std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
5880        let result = load_raw_toml(&path).unwrap();
5881        assert!(result.is_some());
5882        assert_eq!(
5883            result.unwrap()["server"]["port"],
5884            toml::Value::Integer(3000)
5885        );
5886    }
5887
5888    #[test]
5889    fn env_override_log_format_auto() {
5890        // Kills the "delete match arm Auto" mutant
5891        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
5892        let mut config = AutumnConfig::default();
5893        // Start with non-Auto to prove the override works
5894        config.log.format = LogFormat::Json;
5895        config.apply_env_overrides_with_env(&env);
5896        assert_eq!(config.log.format, LogFormat::Auto);
5897    }
5898
5899    #[test]
5900    fn env_override_health_detailed_false() {
5901        // Kills the 'delete match arm "false" | "0"' mutant
5902        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
5903        let mut config = AutumnConfig::default();
5904        config.health.detailed = true; // start true, override to false
5905        config.apply_env_overrides_with_env(&env);
5906        assert!(!config.health.detailed);
5907    }
5908
5909    #[test]
5910    fn env_override_health_detailed_zero() {
5911        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
5912        let mut config = AutumnConfig::default();
5913        config.health.detailed = true;
5914        config.apply_env_overrides_with_env(&env);
5915        assert!(!config.health.detailed);
5916    }
5917
5918    #[test]
5919    fn cors_defaults() {
5920        let cors = CorsConfig::default();
5921        assert!(cors.allowed_origins.is_empty());
5922        assert_eq!(cors.allowed_methods.len(), 6);
5923        assert!(cors.allowed_methods.contains(&"GET".to_owned()));
5924        assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
5925        assert!(!cors.allow_credentials);
5926        assert_eq!(cors.max_age_secs, 86400);
5927    }
5928
5929    #[test]
5930    fn cors_in_full_config_defaults() {
5931        let config = AutumnConfig::default();
5932        assert!(config.cors.allowed_origins.is_empty());
5933    }
5934
5935    #[test]
5936    fn actuator_defaults() {
5937        let config = ActuatorConfig::default();
5938        assert_eq!(config.prefix, "/actuator");
5939        assert!(!config.sensitive);
5940        // Prometheus metrics export is on by default and independent of
5941        // `sensitive`, so platform scraping works without exposing env/loggers.
5942        assert!(config.prometheus);
5943    }
5944
5945    #[test]
5946    fn actuator_prometheus_can_be_disabled_via_toml() {
5947        let toml = r"
5948            sensitive = false
5949            prometheus = false
5950        ";
5951        let config: ActuatorConfig = toml::from_str(toml).unwrap();
5952        assert!(!config.sensitive);
5953        assert!(!config.prometheus);
5954    }
5955
5956    #[test]
5957    fn actuator_prefix_in_full_config() {
5958        let config = AutumnConfig::default();
5959        assert_eq!(config.actuator.prefix, "/actuator");
5960    }
5961
5962    #[test]
5963    fn deep_merge_handles_deep_nesting() {
5964        let mut base = toml::Value::Table(toml::map::Map::new());
5965        let mut overlay = toml::Value::Table(toml::map::Map::new());
5966
5967        // Create a 10,000 deep nested table
5968        let mut current_base = &mut base;
5969        let mut current_overlay = &mut overlay;
5970
5971        for _ in 0..10_000 {
5972            if let toml::Value::Table(t) = current_base {
5973                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
5974                current_base = t.get_mut("x").unwrap();
5975            }
5976            if let toml::Value::Table(t) = current_overlay {
5977                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
5978                current_overlay = t.get_mut("x").unwrap();
5979            }
5980        }
5981
5982        // Add a leaf value to test actual merging
5983        if let toml::Value::Table(t) = current_overlay {
5984            t.insert("y".to_owned(), toml::Value::Integer(42));
5985        }
5986
5987        // Trigger merge, expecting no panic/stack overflow
5988        // 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).
5989        std::thread::Builder::new()
5990            .stack_size(32 * 1024 * 1024)
5991            .spawn(move || {
5992                deep_merge(&mut base, overlay);
5993                // Let the OS clean up the memory instead of dropping deeply nested structure
5994                std::mem::forget(base);
5995            })
5996            .unwrap()
5997            .join()
5998            .unwrap();
5999    }
6000
6001    #[test]
6002    fn deep_merge_stops_at_max_depth() {
6003        let mut base = toml::Value::Table(toml::map::Map::new());
6004        let mut overlay = toml::Value::Table(toml::map::Map::new());
6005
6006        // Create structures nested exactly to MAX_MERGE_DEPTH + 1
6007        let mut current_base = &mut base;
6008        let mut current_overlay = &mut overlay;
6009
6010        for _ in 0..=MAX_MERGE_DEPTH {
6011            if let toml::Value::Table(t) = current_base {
6012                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
6013                current_base = t.get_mut("x").unwrap();
6014            }
6015            if let toml::Value::Table(t) = current_overlay {
6016                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
6017                current_overlay = t.get_mut("x").unwrap();
6018            }
6019        }
6020
6021        // Add a value deep in the overlay
6022        if let toml::Value::Table(t) = current_overlay {
6023            t.insert("deep_value".to_owned(), toml::Value::Integer(123));
6024        }
6025
6026        deep_merge(&mut base, overlay);
6027
6028        // Verify the value was NOT merged due to max depth limit
6029        let mut current_base_check = &base;
6030        for _ in 0..=MAX_MERGE_DEPTH {
6031            if let toml::Value::Table(t) = current_base_check {
6032                current_base_check = t.get("x").unwrap();
6033            }
6034        }
6035
6036        if let toml::Value::Table(t) = current_base_check {
6037            assert!(
6038                !t.contains_key("deep_value"),
6039                "Value beyond MAX_MERGE_DEPTH should not be merged"
6040            );
6041        } else {
6042            panic!("Expected a table");
6043        }
6044    }
6045
6046    // ── AUTUMN_SECURITY__FORBIDDEN_RESPONSE / __ALLOW_UNAUTHORIZED_REPOSITORY_API ──
6047
6048    #[test]
6049    fn env_override_forbidden_response_403() {
6050        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
6051        let mut config = AutumnConfig::default();
6052        config.apply_env_overrides_with_env(&env);
6053        assert_eq!(
6054            config.security.forbidden_response,
6055            crate::authorization::ForbiddenResponse::Forbidden403
6056        );
6057    }
6058
6059    #[test]
6060    fn env_override_forbidden_response_404() {
6061        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
6062        let mut config = AutumnConfig::default();
6063        // Pre-set to 403 to confirm env actually flips it back to 404.
6064        config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
6065        config.apply_env_overrides_with_env(&env);
6066        assert_eq!(
6067            config.security.forbidden_response,
6068            crate::authorization::ForbiddenResponse::NotFound404
6069        );
6070    }
6071
6072    #[test]
6073    fn env_override_forbidden_response_invalid_keeps_existing() {
6074        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
6075        let mut config = AutumnConfig::default();
6076        config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
6077        config.apply_env_overrides_with_env(&env);
6078        // Invalid value warns and leaves the existing setting alone.
6079        assert_eq!(
6080            config.security.forbidden_response,
6081            crate::authorization::ForbiddenResponse::Forbidden403
6082        );
6083    }
6084
6085    #[test]
6086    fn env_override_allow_unauthorized_repository_api() {
6087        let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
6088        let mut config = AutumnConfig::default();
6089        assert!(!config.security.allow_unauthorized_repository_api);
6090        config.apply_env_overrides_with_env(&env);
6091        assert!(config.security.allow_unauthorized_repository_api);
6092    }
6093
6094    #[test]
6095    fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
6096        let env = MockEnv::new().with(
6097            "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
6098            "false",
6099        );
6100        let mut config = AutumnConfig::default();
6101        config.security.allow_unauthorized_repository_api = true;
6102        config.apply_env_overrides_with_env(&env);
6103        assert!(!config.security.allow_unauthorized_repository_api);
6104    }
6105
6106    // ── [openapi] config section tests (RED phase) ─────────────────────────
6107
6108    #[test]
6109    fn openapi_runtime_config_defaults_enabled() {
6110        // The [openapi] section must default to enabled=true and path="/openapi.json".
6111        let config = AutumnConfig::default();
6112        assert!(
6113            config.openapi_runtime.enabled,
6114            "[openapi] must default to enabled = true"
6115        );
6116        assert_eq!(
6117            config.openapi_runtime.path, "/openapi.json",
6118            "[openapi] must default to path = \"/openapi.json\""
6119        );
6120    }
6121
6122    #[test]
6123    fn openapi_runtime_config_can_be_disabled_via_toml() {
6124        let toml_str = "
6125[openapi]
6126enabled = false
6127";
6128        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
6129        assert!(
6130            !config.openapi_runtime.enabled,
6131            "[openapi] enabled = false must deserialize correctly"
6132        );
6133    }
6134
6135    #[test]
6136    fn openapi_runtime_config_path_can_be_customized() {
6137        let toml_str = r#"
6138[openapi]
6139path = "/api-spec.json"
6140"#;
6141        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
6142        assert_eq!(
6143            config.openapi_runtime.path, "/api-spec.json",
6144            "[openapi] path must deserialize correctly"
6145        );
6146    }
6147
6148    #[test]
6149    fn cache_env_overrides_fields() {
6150        let env = MockEnv::new()
6151            .with("AUTUMN_CACHE__BACKEND", "redis")
6152            .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
6153            .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
6154        let mut config = AutumnConfig::default();
6155
6156        config.apply_env_overrides_with_env(&env);
6157
6158        assert!(config.cache.is_redis(), "backend should be redis");
6159        assert_eq!(
6160            config.cache.redis.url.as_deref(),
6161            Some("redis://cache:6379/1")
6162        );
6163        assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
6164    }
6165
6166    #[test]
6167    fn cache_backend_from_env_value_invalid_is_none() {
6168        assert!(CacheBackend::from_env_value("postgres").is_none());
6169        assert!(CacheBackend::from_env_value("").is_none());
6170    }
6171
6172    #[test]
6173    fn scheduler_validate_rejects_zero_lease_ttl() {
6174        let cfg = SchedulerConfig {
6175            lease_ttl_secs: 0,
6176            ..SchedulerConfig::default()
6177        };
6178        assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
6179    }
6180
6181    #[test]
6182    fn scheduler_validate_rejects_empty_key_prefix() {
6183        let cfg = SchedulerConfig {
6184            key_prefix: "   ".to_owned(),
6185            ..SchedulerConfig::default()
6186        };
6187        assert!(cfg.validate().is_err(), "blank key_prefix must fail");
6188    }
6189
6190    #[test]
6191    fn scheduler_validate_ok_with_defaults() {
6192        assert!(SchedulerConfig::default().validate().is_ok());
6193    }
6194
6195    #[test]
6196    fn scheduler_resolved_replica_id_uses_explicit_value() {
6197        let cfg = SchedulerConfig {
6198            replica_id: Some("my-pod".to_owned()),
6199            ..SchedulerConfig::default()
6200        };
6201        assert_eq!(cfg.resolved_replica_id(), "my-pod");
6202    }
6203
6204    #[test]
6205    fn scheduler_resolved_replica_id_falls_back_to_pid() {
6206        let cfg = SchedulerConfig {
6207            replica_id: None,
6208            ..SchedulerConfig::default()
6209        };
6210        // In CI, FLY_MACHINE_ID and HOSTNAME may or may not be set,
6211        // so just verify we get a non-empty string back.
6212        assert!(!cfg.resolved_replica_id().is_empty());
6213    }
6214
6215    #[cfg(feature = "mail")]
6216    #[test]
6217    fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
6218        let env = MockEnv::new()
6219            .with(
6220                "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
6221                "true",
6222            )
6223            .with("AUTUMN_MAIL__TRANSPORT", "smtp")
6224            .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
6225
6226        let mut config = AutumnConfig::default();
6227        config.apply_mail_env_overrides_with_env(&env);
6228
6229        assert!(
6230            config.mail.allow_in_process_deliver_later_in_production,
6231            "env var should set allow_in_process_deliver_later_in_production"
6232        );
6233    }
6234
6235    #[cfg(feature = "mail")]
6236    #[test]
6237    fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
6238        let env = MockEnv::new();
6239        let mut config = AutumnConfig::default();
6240        config.apply_mail_env_overrides_with_env(&env);
6241
6242        assert!(
6243            !config.mail.allow_in_process_deliver_later_in_production,
6244            "flag should default to false when env var is not set"
6245        );
6246    }
6247
6248    // ── credentials integration ───────────────────────────────────────────
6249
6250    #[test]
6251    fn config_credentials_empty_when_no_directory() {
6252        let env = MockEnv::new();
6253        let config = AutumnConfig::load_with_env(&env).unwrap();
6254        assert!(
6255            config.credentials().is_empty(),
6256            "existing apps without config/credentials/ must boot with an empty credentials store"
6257        );
6258    }
6259
6260    #[test]
6261    fn config_has_credentials_accessor() {
6262        let config = AutumnConfig::default();
6263        let _store = config.credentials();
6264    }
6265
6266    #[test]
6267    fn config_credentials_loaded_when_file_present() {
6268        use crate::credentials::{MasterKey, encrypt};
6269        use tempfile::TempDir;
6270
6271        let tmp = TempDir::new().unwrap();
6272        let key = MasterKey::generate();
6273        let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
6274        std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
6275        std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
6276
6277        let env = MockEnv::new()
6278            .with("AUTUMN_MASTER_KEY", &key.to_hex())
6279            .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
6280        let config = AutumnConfig::load_with_env(&env).unwrap();
6281        let val: Option<String> = config.credentials().get("stripe_key");
6282        assert_eq!(val.as_deref(), Some("sk_test_xyz"));
6283    }
6284
6285    #[cfg(feature = "oauth2")]
6286    #[test]
6287    fn config_resolves_oauth_credentials_by_convention() {
6288        use crate::credentials::{MasterKey, encrypt};
6289        use tempfile::TempDir;
6290
6291        let tmp = TempDir::new().unwrap();
6292        let key = MasterKey::generate();
6293        let ct = encrypt(
6294            &key,
6295            b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
6296        );
6297        std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
6298        std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
6299
6300        // Write a base configuration with an empty/blank github provider defined
6301        std::fs::create_dir_all(tmp.path().join("config")).unwrap();
6302        let config_toml = r#"
6303[auth.oauth2.github]
6304client_id = ""
6305client_secret = ""
6306authorize_url = "https://github.com/login/oauth/authorize"
6307token_url = "https://github.com/login/oauth/access_token"
6308redirect_uri = "http://localhost:3000/auth/github/callback"
6309"#;
6310        std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
6311
6312        let env = MockEnv::new()
6313            .with("AUTUMN_MASTER_KEY", &key.to_hex())
6314            .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
6315        let config = AutumnConfig::load_with_env(&env).unwrap();
6316        let github = config.auth.oauth2.providers.get("github").unwrap();
6317        assert_eq!(github.client_id, "git-id-123");
6318        assert_eq!(github.client_secret, "git-secret-456");
6319    }
6320
6321    #[test]
6322    fn config_fails_with_credentials_error_when_key_is_invalid() {
6323        use crate::credentials::encrypt;
6324        use tempfile::TempDir;
6325
6326        let tmp = TempDir::new().unwrap();
6327        // Write an encrypted file but supply a wrong-length key so validation fails
6328        let bogus_key = "zz".repeat(32); // 64 chars but not valid hex
6329        let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
6330        std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
6331        std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
6332
6333        let env = MockEnv::new()
6334            .with("AUTUMN_MASTER_KEY", &bogus_key)
6335            .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
6336        let err = AutumnConfig::load_with_env(&env).unwrap_err();
6337        assert!(
6338            matches!(err, ConfigError::Credentials(_)),
6339            "bad master key should produce ConfigError::Credentials, got {err:?}"
6340        );
6341    }
6342
6343    #[test]
6344    fn test_parse_duration_str() {
6345        assert_eq!(
6346            parse_duration_str("500ms").unwrap(),
6347            std::time::Duration::from_millis(500)
6348        );
6349        assert_eq!(
6350            parse_duration_str("5s").unwrap(),
6351            std::time::Duration::from_secs(5)
6352        );
6353        assert_eq!(
6354            parse_duration_str("2m").unwrap(),
6355            std::time::Duration::from_secs(120)
6356        );
6357        assert_eq!(
6358            parse_duration_str("1h").unwrap(),
6359            std::time::Duration::from_secs(3600)
6360        );
6361        assert_eq!(
6362            parse_duration_str("1000").unwrap(),
6363            std::time::Duration::from_secs(1)
6364        );
6365        assert!(parse_duration_str("abc").is_err());
6366        assert!(parse_duration_str("").is_err());
6367    }
6368
6369    #[test]
6370    fn test_database_config_duration_deserialization() {
6371        #[derive(Debug, Deserialize)]
6372        struct TestConfig {
6373            #[serde(deserialize_with = "deserialize_option_duration", default)]
6374            timeout: Option<std::time::Duration>,
6375            #[serde(deserialize_with = "deserialize_duration")]
6376            threshold: std::time::Duration,
6377        }
6378
6379        let toml_str = r#"
6380            timeout = "2s"
6381            threshold = "100ms"
6382        "#;
6383        let parsed: TestConfig = toml::from_str(toml_str).unwrap();
6384        assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
6385        assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
6386
6387        let toml_str_null = r#"
6388            threshold = "500"
6389        "#;
6390        let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
6391        assert_eq!(parsed_null.timeout, None);
6392        assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
6393    }
6394
6395    // ── RequestTimeoutsConfig ──────────────────────────────────────────────
6396
6397    #[test]
6398    fn request_timeouts_config_defaults_to_none() {
6399        let config = RequestTimeoutsConfig::default();
6400        assert!(config.request_timeout_ms.is_none());
6401    }
6402
6403    #[test]
6404    fn server_config_timeouts_defaults_to_disabled() {
6405        let config = ServerConfig::default();
6406        assert!(config.timeouts.request_timeout_ms.is_none());
6407    }
6408
6409    #[test]
6410    fn request_timeouts_config_can_be_set_via_toml() {
6411        let toml_str = "request_timeout_ms = 5000";
6412        let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
6413        assert_eq!(config.request_timeout_ms, Some(5000));
6414    }
6415
6416    #[test]
6417    fn server_config_timeouts_deserialize_nested() {
6418        let toml_str = r#"
6419            port = 3000
6420            host = "127.0.0.1"
6421            shutdown_timeout_secs = 30
6422            prestop_grace_secs = 5
6423
6424            [timeouts]
6425            request_timeout_ms = 15000
6426        "#;
6427        let config: ServerConfig = toml::from_str(toml_str).unwrap();
6428        assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
6429    }
6430
6431    #[test]
6432    fn autumn_config_server_timeouts_roundtrip() {
6433        let mut config = AutumnConfig::default();
6434        config.server.timeouts.request_timeout_ms = Some(20_000);
6435        assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
6436    }
6437
6438    #[test]
6439    fn server_timeouts_env_var_override() {
6440        struct FakeEnv(std::collections::HashMap<String, String>);
6441        impl Env for FakeEnv {
6442            fn var(&self, key: &str) -> Result<String, std::env::VarError> {
6443                self.0
6444                    .get(key)
6445                    .cloned()
6446                    .ok_or(std::env::VarError::NotPresent)
6447            }
6448        }
6449
6450        let mut config = AutumnConfig::default();
6451        let env = FakeEnv(
6452            [(
6453                "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
6454                "8000".to_owned(),
6455            )]
6456            .into(),
6457        );
6458        config.apply_server_env_overrides_with_env(&env);
6459        assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
6460    }
6461
6462    #[test]
6463    fn prod_profile_sets_request_timeout_30s() {
6464        let defaults = profile_defaults_as_toml("prod");
6465        let toml_str = toml::to_string(&defaults).unwrap();
6466        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
6467        assert_eq!(
6468            config.server.timeouts.request_timeout_ms,
6469            Some(30_000),
6470            "prod profile must enable the 30-second request timeout by default"
6471        );
6472    }
6473
6474    #[test]
6475    fn dev_profile_leaves_request_timeout_disabled() {
6476        let defaults = profile_defaults_as_toml("dev");
6477        let toml_str = toml::to_string(&defaults).unwrap();
6478        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
6479        assert!(
6480            config.server.timeouts.request_timeout_ms.is_none(),
6481            "dev profile must not enable a request timeout by default"
6482        );
6483    }
6484
6485    #[test]
6486    fn test_resilience_config_defaults() {
6487        let config = AutumnConfig::default();
6488        assert!(
6489            config
6490                .resilience
6491                .circuit_breaker
6492                .defaults
6493                .failure_ratio_threshold
6494                .is_none()
6495        );
6496    }
6497
6498    #[test]
6499    fn test_resilience_config_parsing() {
6500        let toml_str = r#"
6501            [resilience.circuit_breaker.defaults]
6502            failure_ratio_threshold = 0.6
6503            sample_window_secs = 20
6504            minimum_sample_count = 15
6505            open_duration_secs = 30
6506            half_open_trial_count = 5
6507
6508            [resilience.circuit_breaker.hosts."api.github.com"]
6509            failure_ratio_threshold = 0.3
6510            open_duration_secs = 10
6511        "#;
6512        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
6513        let cb = &config.resilience.circuit_breaker;
6514        assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
6515        assert_eq!(cb.defaults.sample_window_secs, Some(20));
6516        assert_eq!(cb.defaults.minimum_sample_count, Some(15));
6517        assert_eq!(cb.defaults.open_duration_secs, Some(30));
6518        assert_eq!(cb.defaults.half_open_trial_count, Some(5));
6519
6520        let host_cb = cb.hosts.get("api.github.com").unwrap();
6521        assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
6522        assert_eq!(host_cb.open_duration_secs, Some(10));
6523        assert!(host_cb.sample_window_secs.is_none());
6524    }
6525
6526    #[test]
6527    fn test_resilience_config_env_overrides() {
6528        struct FakeEnv(std::collections::HashMap<String, String>);
6529        impl Env for FakeEnv {
6530            fn var(&self, key: &str) -> Result<String, std::env::VarError> {
6531                self.0
6532                    .get(key)
6533                    .cloned()
6534                    .ok_or(std::env::VarError::NotPresent)
6535            }
6536        }
6537
6538        let mut config = AutumnConfig::default();
6539        let env = FakeEnv(
6540            [(
6541                "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
6542                "0.7".to_owned(),
6543            )]
6544            .into(),
6545        );
6546        config.apply_resilience_env_overrides_with_env(&env);
6547        assert_eq!(
6548            config
6549                .resilience
6550                .circuit_breaker
6551                .defaults
6552                .failure_ratio_threshold,
6553            Some(0.7)
6554        );
6555    }
6556}