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_DATABASE__URL` | `database.url` | `String` |
44//! | `AUTUMN_DATABASE__PRIMARY_URL` | `database.primary_url` | `String` |
45//! | `AUTUMN_DATABASE__REPLICA_URL` | `database.replica_url` | `String` |
46//! | `AUTUMN_DATABASE__POOL_SIZE` | `database.pool_size` | `usize` |
47//! | `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` | `database.primary_pool_size` | `usize` |
48//! | `AUTUMN_DATABASE__REPLICA_POOL_SIZE` | `database.replica_pool_size` | `usize` |
49//! | `AUTUMN_DATABASE__REPLICA_FALLBACK` | `database.replica_fallback` | `fail_readiness` / `primary` |
50//! | `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` | `database.connect_timeout_secs` | `u64` |
51//! | `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` | `database.auto_migrate_in_production` | `bool` |
52//! | `AUTUMN_LOG__LEVEL` | `log.level` | tracing filter directive |
53//! | `AUTUMN_LOG__FORMAT` | `log.format` | `Auto` / `Pretty` / `Json` |
54//! | `AUTUMN_TELEMETRY__ENABLED` | `telemetry.enabled` | `bool` |
55//! | `AUTUMN_TELEMETRY__SERVICE_NAME` | `telemetry.service_name` | `String` |
56//! | `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` | `telemetry.service_namespace` | `String` |
57//! | `AUTUMN_TELEMETRY__SERVICE_VERSION` | `telemetry.service_version` | `String` |
58//! | `AUTUMN_TELEMETRY__ENVIRONMENT` | `telemetry.environment` | `String` |
59//! | `AUTUMN_TELEMETRY__OTLP_ENDPOINT` | `telemetry.otlp_endpoint` | `String` |
60//! | `AUTUMN_TELEMETRY__PROTOCOL` | `telemetry.protocol` | `Grpc` / `HttpProtobuf` |
61//! | `AUTUMN_TELEMETRY__STRICT` | `telemetry.strict` | `bool` |
62//! | `AUTUMN_HEALTH__PATH` | `health.path` | `String` |
63//! | `AUTUMN_HEALTH__LIVE_PATH` | `health.live_path` | `String` |
64//! | `AUTUMN_HEALTH__READY_PATH` | `health.ready_path` | `String` |
65//! | `AUTUMN_HEALTH__STARTUP_PATH` | `health.startup_path` | `String` |
66//! | `AUTUMN_HEALTH__DETAILED` | `health.detailed` | `bool` |
67//! | `AUTUMN_CORS__ALLOWED_ORIGINS` | `cors.allowed_origins` | comma-separated `String` |
68//! | `AUTUMN_CORS__ALLOWED_METHODS` | `cors.allowed_methods` | comma-separated `String` |
69//! | `AUTUMN_CORS__ALLOWED_HEADERS` | `cors.allowed_headers` | comma-separated `String` |
70//! | `AUTUMN_CORS__ALLOW_CREDENTIALS` | `cors.allow_credentials` | `bool` |
71//! | `AUTUMN_CORS__MAX_AGE_SECS` | `cors.max_age_secs` | `u64` |
72//! | `AUTUMN_CACHE__BACKEND` | `cache.backend` | `memory` / `redis` |
73//! | `AUTUMN_CACHE__REDIS__URL` | `cache.redis.url` | `String` |
74//! | `AUTUMN_CACHE__REDIS__KEY_PREFIX` | `cache.redis.key_prefix` | `String` |
75//! | `AUTUMN_SESSION__BACKEND` | `session.backend` | `memory` / `redis` |
76//! | `AUTUMN_SESSION__COOKIE_NAME` | `session.cookie_name` | `String` |
77//! | `AUTUMN_SESSION__MAX_AGE_SECS` | `session.max_age_secs` | `u64` |
78//! | `AUTUMN_SESSION__SECURE` | `session.secure` | `bool` |
79//! | `AUTUMN_SESSION__SAME_SITE` | `session.same_site` | `String` |
80//! | `AUTUMN_SESSION__HTTP_ONLY` | `session.http_only` | `bool` |
81//! | `AUTUMN_SESSION__PATH` | `session.path` | `String` |
82//! | `AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION` | `session.allow_memory_in_production` | `bool` |
83//! | `AUTUMN_SESSION__REDIS__URL` | `session.redis.url` | `String` |
84//! | `AUTUMN_SESSION__REDIS__KEY_PREFIX` | `session.redis.key_prefix` | `String` |
85//! | `AUTUMN_CHANNELS__BACKEND` | `channels.backend` | `in_process` / `redis` |
86//! | `AUTUMN_CHANNELS__CAPACITY` | `channels.capacity` | `usize` |
87//! | `AUTUMN_CHANNELS__REDIS__URL` | `channels.redis.url` | `String` |
88//! | `AUTUMN_CHANNELS__REDIS__KEY_PREFIX` | `channels.redis.key_prefix` | `String` |
89//! | `AUTUMN_JOBS__BACKEND` | `jobs.backend` | `local` / `redis` |
90//! | `AUTUMN_JOBS__WORKERS` | `jobs.workers` | `usize` |
91//! | `AUTUMN_JOBS__MAX_ATTEMPTS` | `jobs.max_attempts` | `u32` |
92//! | `AUTUMN_JOBS__INITIAL_BACKOFF_MS` | `jobs.initial_backoff_ms` | `u64` |
93//! | `AUTUMN_JOBS__REDIS__URL` | `jobs.redis.url` | `String` |
94//! | `AUTUMN_JOBS__REDIS__KEY_PREFIX` | `jobs.redis.key_prefix` | `String` |
95//! | `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` | `jobs.redis.visibility_timeout_ms` | `u64` |
96//! | `AUTUMN_SCHEDULER__BACKEND` | `scheduler.backend` | `in_process` / `postgres` |
97//! | `AUTUMN_SCHEDULER__LEASE_TTL_SECS` | `scheduler.lease_ttl_secs` | `u64` |
98//! | `AUTUMN_SCHEDULER__REPLICA_ID` | `scheduler.replica_id` | `String` |
99//! | `AUTUMN_SCHEDULER__KEY_PREFIX` | `scheduler.key_prefix` | `String` |
100//! | `AUTUMN_SECURITY__RATE_LIMIT__ENABLED` | `security.rate_limit.enabled` | `bool` |
101//! | `AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND` | `security.rate_limit.requests_per_second` | `f64` |
102//! | `AUTUMN_SECURITY__RATE_LIMIT__BURST` | `security.rate_limit.burst` | `u32` |
103//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS` | `security.rate_limit.trust_forwarded_headers` | `bool` |
104//! | `AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES` | `security.rate_limit.trusted_proxies` | comma-separated `String` |
105//! | `AUTUMN_ENV` | active profile | `String` |
106//! | `AUTUMN_PROFILE` | active profile (legacy alias) | `String` |
107//! | `AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES` | `security.upload.max_request_size_bytes` | `usize` |
108//! | `AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES` | `security.upload.max_file_size_bytes` | `usize` |
109//! | `AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES` | `security.upload.allowed_mime_types` | comma-separated `String` |
110//! | `AUTUMN_SECURITY__FORBIDDEN_RESPONSE` | `security.forbidden_response` | `"403"` or `"404"` |
111//! | `AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API` | `security.allow_unauthorized_repository_api` | `bool` |
112//! | `AUTUMN_SECURITY__SIGNING_SECRET` | `security.signing_secret.secret` | `String` |
113//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` | `security.webhooks.replay.backend` | `memory` / `redis` |
114//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` | `security.webhooks.replay.redis.url` | `String` |
115//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` | `security.webhooks.replay.redis.key_prefix` | `String` |
116//! | `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` | `security.webhooks.replay.allow_memory_in_production` | `bool` |
117
118use std::path::{Path, PathBuf};
119
120use serde::Deserialize;
121use thiserror::Error;
122
123/// Abstraction for reading environment variables, supporting dependency injection for testing.
124use std::sync::OnceLock;
125
126static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
127static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
128
129#[doc(hidden)]
130pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
131    let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
132    let _ = MACRO_IS_DEBUG.set(is_debug);
133}
134
135/// Trait for environment variable reading to allow testing overrides.
136///
137/// This abstracts the OS environment (`std::env::var`) so that
138/// configuration loading logic can be unit-tested deterministically
139/// by supplying a mock environment.
140pub trait Env {
141    /// Read an environment variable.
142    ///
143    /// # Examples
144    ///
145    /// ```
146    /// use autumn_web::config::{Env, OsEnv};
147    /// let env = OsEnv;
148    /// let val = env.var("NON_EXISTENT_VAR");
149    /// assert!(val.is_err());
150    /// ```
151    ///
152    /// # Errors
153    ///
154    /// Returns [`std::env::VarError`] if the variable is not present or is not valid Unicode.
155    fn var(&self, key: &str) -> Result<String, std::env::VarError>;
156}
157
158/// Production implementation of `Env` that reads from the OS environment.
159#[derive(Clone, Default)]
160pub struct OsEnv;
161
162impl Env for OsEnv {
163    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
164        if key == "AUTUMN_MANIFEST_DIR" {
165            if let Some(dir) = MACRO_MANIFEST_DIR.get() {
166                return Ok(dir.clone());
167            }
168        } else if key == "AUTUMN_IS_DEBUG"
169            && let Some(is_debug) = MACRO_IS_DEBUG.get()
170        {
171            return Ok(if *is_debug {
172                "1".to_string()
173            } else {
174                "0".to_string()
175            });
176        }
177        std::env::var(key)
178    }
179}
180
181/// Mock implementation of `Env` for testing.
182#[derive(Clone, Default)]
183pub struct MockEnv {
184    vars: std::collections::HashMap<String, String>,
185}
186
187impl MockEnv {
188    /// Create a new, empty `MockEnv`.
189    #[must_use]
190    pub fn new() -> Self {
191        Self {
192            vars: std::collections::HashMap::new(),
193        }
194    }
195
196    /// Set an environment variable in the mock.
197    #[must_use]
198    pub fn with(mut self, key: &str, value: &str) -> Self {
199        self.vars.insert(key.to_owned(), value.to_owned());
200        self
201    }
202
203    /// Remove an environment variable from the mock.
204    #[must_use]
205    pub fn without(mut self, key: &str) -> Self {
206        self.vars.remove(key);
207        self
208    }
209}
210
211impl Env for MockEnv {
212    fn var(&self, key: &str) -> Result<String, std::env::VarError> {
213        self.vars
214            .get(key)
215            .cloned()
216            .ok_or(std::env::VarError::NotPresent)
217    }
218}
219
220/// Locate a config file by checking the app's crate directory first, then CWD.
221fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
222    if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
223        let candidate = PathBuf::from(manifest_dir).join(filename);
224        if candidate.exists() {
225            return candidate;
226        }
227    }
228    PathBuf::from(filename)
229}
230
231/// Load a TOML file as a raw `toml::Value` table.
232/// Returns `Ok(None)` if the file doesn't exist.
233fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
234    match std::fs::read_to_string(path) {
235        Ok(contents) => {
236            let table = toml::from_str::<toml::Table>(&contents)?;
237            Ok(Some(toml::Value::Table(table)))
238        }
239        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
240        Err(e) => Err(ConfigError::Io(e)),
241    }
242}
243
244/// Resolve the active profile using the precedence chain.
245///
246/// 1. `AUTUMN_ENV` env var (highest priority)
247/// 2. `AUTUMN_PROFILE` env var (legacy alias)
248/// 3. `--profile <name>` CLI flag
249/// 4. Auto-detect from build mode (`AUTUMN_IS_DEBUG` set by `#[autumn_web::main]`)
250/// 5. Fallback to `dev`
251pub(crate) fn resolve_profile(env: &dyn Env) -> String {
252    let selected_profile_input = resolve_profile_input(env);
253    normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
254}
255
256/// Resolve the raw profile selector value (before normalization).
257fn resolve_profile_input(env: &dyn Env) -> String {
258    // 1. Preferred env var
259    if let Ok(profile) = env.var("AUTUMN_ENV") {
260        let trimmed = profile.trim();
261        if !trimmed.is_empty() {
262            return trimmed.to_owned();
263        }
264    }
265
266    // 2. Legacy env var
267    if let Ok(profile) = env.var("AUTUMN_PROFILE") {
268        let trimmed = profile.trim();
269        if !trimmed.is_empty() {
270            return trimmed.to_owned();
271        }
272    }
273
274    // 3. CLI flag
275    let args: Vec<String> = std::env::args().collect();
276    for (i, arg) in args.iter().enumerate() {
277        if arg == "--profile"
278            && let Some(profile) = args.get(i + 1)
279        {
280            let trimmed = profile.trim();
281            if !trimmed.is_empty() {
282                return trimmed.to_owned();
283            }
284        }
285        if let Some(profile) = arg.strip_prefix("--profile=") {
286            let trimmed = profile.trim();
287            if !trimmed.is_empty() {
288                return trimmed.to_owned();
289            }
290        }
291    }
292
293    // 4. Auto-detect from build mode
294    if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
295        return "prod".to_owned();
296    }
297    "dev".to_owned()
298}
299
300/// Normalize profile aliases and trim whitespace.
301///
302/// Supported aliases:
303/// - `production` -> `prod`
304/// - `development` -> `dev`
305/// - `prod`/`PROD` -> `prod`
306/// - `dev`/`DEV` -> `dev`
307fn normalize_profile_name(profile: &str) -> Option<String> {
308    let trimmed = profile.trim();
309    if trimmed.is_empty() {
310        return None;
311    }
312
313    if trimmed.eq_ignore_ascii_case("production") {
314        return Some("prod".to_owned());
315    }
316    if trimmed.eq_ignore_ascii_case("development") {
317        return Some("dev".to_owned());
318    }
319    if trimmed.eq_ignore_ascii_case("prod") {
320        return Some("prod".to_owned());
321    }
322    if trimmed.eq_ignore_ascii_case("dev") {
323        return Some("dev".to_owned());
324    }
325
326    // Preserve user-specified case for custom profile names.
327    Some(trimmed.to_owned())
328}
329
330/// Profile names to check for inline/file overrides.
331///
332/// For canonical profiles, include legacy aliases for compatibility so
333/// `production` and `development` profile sources are still loaded.
334fn profile_lookup_names(profile: &str) -> Vec<&str> {
335    match profile {
336        "prod" => vec!["production", "prod"],
337        "dev" => vec!["development", "dev"],
338        other => vec![other],
339    }
340}
341
342/// Ordered file lookup names for profile override file compatibility.
343///
344/// Only one profile override file is loaded: the first existing file in this
345/// ordered list. The order prefers the explicitly-selected spelling.
346fn profile_override_file_lookup_names(profile: &str, selected_profile_input: &str) -> Vec<String> {
347    match profile {
348        "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
349            vec!["production".to_owned(), "prod".to_owned()]
350        }
351        "prod" => vec!["prod".to_owned(), "production".to_owned()],
352        "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
353            vec!["development".to_owned(), "dev".to_owned()]
354        }
355        "dev" => vec!["dev".to_owned(), "development".to_owned()],
356        other => vec![other.to_owned()],
357    }
358}
359
360/// Extract `[profile.<name>]` table from a parsed `autumn.toml`.
361fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
362    base.get("profile")
363        .and_then(toml::Value::as_table)
364        .and_then(|profiles| profiles.get(profile))
365        .and_then(toml::Value::as_table)
366        .map(|table| toml::Value::Table(table.clone()))
367}
368
369/// Profile-specific smart defaults as a TOML table.
370///
371/// Only `dev` and `prod` have smart defaults. Custom profiles
372/// (staging, test, etc.) get no smart defaults — they rely on
373/// their profile TOML file and env overrides.
374fn profile_defaults_as_toml(profile: &str) -> toml::Value {
375    let mut table = toml::map::Map::new();
376
377    match profile {
378        "dev" => {
379            let mut log = toml::map::Map::new();
380            log.insert("level".into(), "debug".into());
381            log.insert("format".into(), "Pretty".into());
382            table.insert("log".into(), toml::Value::Table(log));
383
384            let mut telemetry = toml::map::Map::new();
385            telemetry.insert("environment".into(), "development".into());
386            table.insert("telemetry".into(), toml::Value::Table(telemetry));
387
388            let mut server = toml::map::Map::new();
389            server.insert("host".into(), "127.0.0.1".into());
390            server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(1));
391            table.insert("server".into(), toml::Value::Table(server));
392
393            let mut health = toml::map::Map::new();
394            health.insert("detailed".into(), toml::Value::Boolean(true));
395            table.insert("health".into(), toml::Value::Table(health));
396
397            let mut actuator = toml::map::Map::new();
398            actuator.insert("sensitive".into(), toml::Value::Boolean(true));
399            table.insert("actuator".into(), toml::Value::Table(actuator));
400
401            let mut cors = toml::map::Map::new();
402            cors.insert(
403                "allowed_origins".into(),
404                toml::Value::Array(vec![toml::Value::String("*".to_owned())]),
405            );
406            table.insert("cors".into(), toml::Value::Table(cors));
407
408            // Dev: enable the local-disk blob store rooted at
409            // `target/blobs/` automatically when the `storage` feature
410            // is on. `prod` deliberately leaves `backend = "disabled"`
411            // so the operator has to opt into either `local` (with
412            // `allow_local_in_production = true`) or `s3`.
413            let mut storage = toml::map::Map::new();
414            storage.insert("backend".into(), "local".into());
415            table.insert("storage".into(), toml::Value::Table(storage));
416            // Dev: CSRF disabled (default), HSTS off (default)
417        }
418        "prod" => {
419            let mut log = toml::map::Map::new();
420            log.insert("level".into(), "info".into());
421            log.insert("format".into(), "Json".into());
422            table.insert("log".into(), toml::Value::Table(log));
423
424            let mut telemetry = toml::map::Map::new();
425            telemetry.insert("environment".into(), "production".into());
426            table.insert("telemetry".into(), toml::Value::Table(telemetry));
427
428            let mut server = toml::map::Map::new();
429            server.insert("host".into(), "0.0.0.0".into());
430            server.insert("shutdown_timeout_secs".into(), toml::Value::Integer(30));
431            table.insert("server".into(), toml::Value::Table(server));
432
433            let mut health = toml::map::Map::new();
434            health.insert("detailed".into(), toml::Value::Boolean(false));
435            table.insert("health".into(), toml::Value::Table(health));
436
437            // Prod: strict security -- HSTS on, CSRF enabled, secure cookies
438            let mut security = toml::map::Map::new();
439            let mut headers = toml::map::Map::new();
440            headers.insert(
441                "strict_transport_security".into(),
442                toml::Value::Boolean(true),
443            );
444            security.insert("headers".into(), toml::Value::Table(headers));
445            let mut csrf = toml::map::Map::new();
446            csrf.insert("enabled".into(), toml::Value::Boolean(true));
447            security.insert("csrf".into(), toml::Value::Table(csrf));
448            table.insert("security".into(), toml::Value::Table(security));
449
450            let mut session = toml::map::Map::new();
451            session.insert("secure".into(), toml::Value::Boolean(true));
452            table.insert("session".into(), toml::Value::Table(session));
453        }
454        _ => {} // Custom profiles get no smart defaults
455    }
456
457    toml::Value::Table(table)
458}
459
460#[cfg(feature = "mail")]
461fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
462    merged
463        .get("mail")
464        .and_then(toml::Value::as_table)
465        .is_some_and(|mail| mail.contains_key("transport"))
466        || env
467            .var("AUTUMN_MAIL__TRANSPORT")
468            .ok()
469            .as_deref()
470            .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
471}
472
473/// Maximum recursion depth for merging TOML tables.
474const MAX_MERGE_DEPTH: usize = 16;
475
476/// Deep-merge two TOML values. Tables are merged recursively;
477/// non-table values in `overlay` replace those in `base`.
478fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
479    deep_merge_with_depth(base, overlay, 0);
480}
481
482fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
483    if depth > MAX_MERGE_DEPTH {
484        eprintln!(
485            "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
486        );
487        return;
488    }
489
490    let toml::Value::Table(overlay_table) = overlay else {
491        return;
492    };
493    let Some(base_table) = base.as_table_mut() else {
494        return;
495    };
496
497    for (key, overlay_val) in overlay_table {
498        let is_recursive_merge =
499            overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
500
501        if is_recursive_merge {
502            if let Some(base_val) = base_table.get_mut(&key) {
503                deep_merge_with_depth(base_val, overlay_val, depth + 1);
504            }
505        } else {
506            base_table.insert(key, overlay_val);
507        }
508    }
509}
510
511/// Suggest a close match for a custom profile name.
512///
513/// Returns `Some(name)` when a known profile is within edit distance 2.
514fn suggest_profile(profile: &str) -> Option<&'static str> {
515    let known = ["dev", "prod"];
516    let mut suggestions: Vec<(&str, usize)> = known
517        .iter()
518        .map(|k| (*k, levenshtein(profile, k)))
519        .filter(|(_, d)| *d <= 2)
520        .collect();
521    suggestions.sort_by_key(|(_, d)| *d);
522    suggestions.first().map(|(name, _)| *name)
523}
524
525/// Warn when a custom profile has no TOML file, suggesting close matches.
526fn warn_profile_typo(profile: &str) {
527    if let Some(suggestion) = suggest_profile(profile) {
528        eprintln!(
529            "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
530             and no smart defaults. Did you mean \"{suggestion}\"?"
531        );
532    }
533}
534
535fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
536    profile != "dev" && profile != "prod" && !has_inline_profile_section
537}
538
539/// Levenshtein edit distance between two strings.
540///
541/// ⚡ Bolt Optimization:
542/// Reduces memory allocations by using a single `Vec` instead of two and
543/// iterating directly over `Chars` to avoid `Vec<char>` allocations.
544fn levenshtein(a: &str, b: &str) -> usize {
545    let n = b.chars().count();
546    let mut prev: Vec<usize> = (0..=n).collect();
547    for (i, a_ch) in a.chars().enumerate() {
548        let mut prev_diag = prev[0];
549        prev[0] = i + 1;
550        for (j, b_ch) in b.chars().enumerate() {
551            let old_prev = prev[j + 1];
552            let cost = usize::from(a_ch != b_ch);
553            prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
554            prev_diag = old_prev;
555        }
556    }
557    prev[n]
558}
559
560/// Errors that can occur when loading or validating configuration.
561///
562/// Returned by [`AutumnConfig::load`], [`AutumnConfig::load_from`], and
563/// [`DatabaseConfig::validate`].
564///
565/// # Examples
566///
567/// ```rust
568/// use autumn_web::config::{AutumnConfig, ConfigError};
569/// use std::path::Path;
570///
571/// let result = AutumnConfig::load_from(Path::new("nonexistent.toml"));
572/// // Returns Ok(defaults) when file is missing -- not an error
573/// assert!(result.is_ok());
574/// ```
575#[derive(Debug, Error)]
576#[non_exhaustive]
577pub enum ConfigError {
578    /// The config file exists but could not be read.
579    #[error("failed to read autumn.toml: {0}")]
580    Io(#[from] std::io::Error),
581
582    /// The config file contains invalid TOML syntax.
583    #[error("invalid autumn.toml: {0}")]
584    Parse(#[from] toml::de::Error),
585
586    /// A configuration value failed semantic validation (e.g., invalid
587    /// database URL scheme).
588    #[error("configuration error: {0}")]
589    Validation(String),
590}
591
592/// Top-level framework configuration.
593///
594/// All sections are optional -- missing sections use their defaults.
595/// Deserialized from `autumn.toml` (TOML format).
596///
597/// # `autumn.toml` example
598///
599/// ```toml
600/// [server]
601/// port = 8080
602///
603/// [database]
604/// url = "postgres://user:pass@db:5432/myapp"
605/// pool_size = 20
606/// ```
607///
608/// # Examples
609///
610/// ```rust
611/// use autumn_web::config::AutumnConfig;
612///
613/// let config = AutumnConfig::default();
614/// assert_eq!(config.server.port, 3000);
615/// assert_eq!(config.database.pool_size, 10);
616/// assert_eq!(config.log.level, "info");
617/// assert_eq!(config.health.path, "/health");
618/// ```
619#[derive(Debug, Clone, Default, Deserialize)]
620pub struct AutumnConfig {
621    /// Active profile name (e.g., "dev", "prod", "staging").
622    /// Resolved at load time, not deserialized from TOML.
623    #[serde(skip)]
624    pub profile: Option<String>,
625
626    /// HTTP server settings (port, host, shutdown behavior).
627    #[serde(default)]
628    pub server: ServerConfig,
629
630    /// Database connection settings (URL, pool size, timeouts).
631    #[serde(default)]
632    pub database: DatabaseConfig,
633
634    /// Logging configuration (level, format).
635    #[serde(default)]
636    pub log: LogConfig,
637
638    /// Telemetry configuration (OTLP tracing and service metadata).
639    #[serde(default)]
640    pub telemetry: TelemetryConfig,
641
642    /// Health check endpoint settings.
643    #[serde(default)]
644    pub health: HealthConfig,
645
646    /// Actuator endpoint settings.
647    #[serde(default)]
648    pub actuator: ActuatorConfig,
649
650    /// CORS (Cross-Origin Resource Sharing) settings.
651    #[serde(default)]
652    pub cors: CorsConfig,
653
654    /// Session management settings.
655    #[serde(default)]
656    pub session: crate::session::SessionConfig,
657
658    /// Cache backend settings.
659    #[serde(default)]
660    pub cache: CacheConfig,
661
662    /// Real-time channel backend settings.
663    #[serde(default)]
664    pub channels: ChannelConfig,
665
666    /// Background job backend and runtime settings.
667    #[serde(default)]
668    pub jobs: JobConfig,
669
670    /// Scheduled task coordination backend settings.
671    #[serde(default)]
672    pub scheduler: SchedulerConfig,
673
674    /// Authentication settings.
675    #[serde(default)]
676    pub auth: crate::auth::AuthConfig,
677
678    /// Security settings (headers, CSRF).
679    #[serde(default)]
680    pub security: crate::security::config::SecurityConfig,
681
682    /// Internationalization settings (default locale, supported locales,
683    /// fallback chain). Populated from the `[i18n]` block in
684    /// `autumn.toml`.
685    #[cfg(feature = "i18n")]
686    #[serde(default)]
687    pub i18n: crate::i18n::I18nConfig,
688    /// Pluggable file storage configuration. Honored only when the
689    /// `storage` cargo feature is enabled.
690    #[cfg(feature = "storage")]
691    #[serde(default)]
692    pub storage: crate::storage::StorageConfig,
693    /// Transactional email settings.
694    #[cfg(feature = "mail")]
695    #[serde(default)]
696    pub mail: crate::mail::MailConfig,
697    /// `OpenAPI` spec runtime exposure settings.
698    ///
699    /// Controls whether the generated `OpenAPI` spec is served at runtime
700    /// and at which path. Use `[openapi] enabled = false` in `autumn.toml`
701    /// to suppress the spec endpoint in production.
702    #[serde(default, rename = "openapi")]
703    pub openapi_runtime: OpenApiRuntimeConfig,
704}
705
706impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
707    type Rejection = crate::AutumnError;
708
709    async fn from_request_parts(
710        _parts: &mut http::request::Parts,
711        state: &crate::AppState,
712    ) -> Result<Self, Self::Rejection> {
713        state
714            .extension::<Self>()
715            .as_deref()
716            .cloned()
717            .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
718    }
719}
720
721/// Real-time channel backend selection.
722#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
723#[serde(rename_all = "snake_case")]
724pub enum ChannelBackend {
725    /// In-process Tokio broadcast channels. Default, zero config.
726    #[serde(alias = "local", alias = "memory")]
727    #[default]
728    InProcess,
729    /// Redis pub/sub fan-out across application replicas.
730    Redis,
731}
732
733impl ChannelBackend {
734    /// Parse an environment variable value for channel backend selection.
735    #[must_use]
736    pub fn from_env_value(value: &str) -> Option<Self> {
737        match value.trim().to_ascii_lowercase().as_str() {
738            "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
739            "redis" => Some(Self::Redis),
740            _ => None,
741        }
742    }
743}
744
745/// Real-time channel runtime configuration.
746#[derive(Debug, Clone, Deserialize)]
747pub struct ChannelConfig {
748    /// Runtime backend selection.
749    #[serde(default)]
750    pub backend: ChannelBackend,
751    /// Per-topic broadcast ring buffer capacity.
752    #[serde(default = "default_channel_capacity")]
753    pub capacity: usize,
754    /// Redis backend options.
755    #[serde(default)]
756    pub redis: ChannelRedisConfig,
757}
758
759impl Default for ChannelConfig {
760    fn default() -> Self {
761        Self {
762            backend: ChannelBackend::default(),
763            capacity: default_channel_capacity(),
764            redis: ChannelRedisConfig::default(),
765        }
766    }
767}
768
769/// Redis channel backend configuration.
770#[derive(Debug, Clone, Deserialize)]
771pub struct ChannelRedisConfig {
772    /// Redis URL used when `channels.backend = "redis"`.
773    #[serde(default)]
774    pub url: Option<String>,
775    /// Redis pub/sub channel prefix.
776    #[serde(default = "default_channels_redis_prefix")]
777    pub key_prefix: String,
778}
779
780impl Default for ChannelRedisConfig {
781    fn default() -> Self {
782        Self {
783            url: None,
784            key_prefix: default_channels_redis_prefix(),
785        }
786    }
787}
788
789const fn default_channel_capacity() -> usize {
790    32
791}
792
793fn default_channels_redis_prefix() -> String {
794    "autumn:channels".to_owned()
795}
796
797// ── Cache configuration ──────────────────────────────────────────────────────
798
799/// Cache backend selection for `#[cached]` and `CacheResponseLayer`.
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
801#[serde(rename_all = "lowercase")]
802#[non_exhaustive]
803pub enum CacheBackend {
804    /// In-process Moka cache (default). Each replica has an independent store.
805    #[default]
806    Memory,
807    /// Shared Redis cache. Invalidations propagate across all replicas.
808    Redis,
809}
810
811impl CacheBackend {
812    pub(crate) fn from_env_value(value: &str) -> Option<Self> {
813        match value.trim().to_ascii_lowercase().as_str() {
814            "memory" => Some(Self::Memory),
815            "redis" => Some(Self::Redis),
816            _ => None,
817        }
818    }
819}
820
821/// Configuration for the shared application cache.
822///
823/// Placed in `autumn.toml` under `[cache]`.
824///
825/// # Examples
826///
827/// ```toml
828/// [cache]
829/// backend = "redis"
830///
831/// [cache.redis]
832/// url = "redis://redis:6379"
833/// key_prefix = "myapp:cache"
834/// ```
835#[derive(Debug, Clone, Default, serde::Deserialize)]
836pub struct CacheConfig {
837    /// Active cache backend.
838    #[serde(default)]
839    pub backend: CacheBackend,
840
841    /// Redis backend options.
842    #[serde(default)]
843    pub redis: CacheRedisConfig,
844}
845
846impl CacheConfig {
847    /// Returns `true` when the memory (Moka) backend is selected.
848    #[must_use]
849    pub fn is_memory(&self) -> bool {
850        self.backend == CacheBackend::Memory
851    }
852
853    /// Returns `true` when the Redis backend is selected.
854    #[must_use]
855    pub fn is_redis(&self) -> bool {
856        self.backend == CacheBackend::Redis
857    }
858}
859
860/// Redis cache backend configuration.
861#[derive(Debug, Clone, serde::Deserialize)]
862pub struct CacheRedisConfig {
863    /// Redis connection URL (e.g. `redis://127.0.0.1:6379`).
864    #[serde(default)]
865    pub url: Option<String>,
866
867    /// Prefix for all cache keys stored in Redis.
868    #[serde(default = "default_cache_redis_key_prefix")]
869    pub key_prefix: String,
870}
871
872impl Default for CacheRedisConfig {
873    fn default() -> Self {
874        Self {
875            url: None,
876            key_prefix: default_cache_redis_key_prefix(),
877        }
878    }
879}
880
881fn default_cache_redis_key_prefix() -> String {
882    "autumn:cache".to_owned()
883}
884
885/// Scheduled task coordination backend selection.
886#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
887#[serde(rename_all = "snake_case")]
888pub enum SchedulerBackend {
889    /// Per-process scheduler timers. This preserves existing single-replica behavior.
890    #[serde(alias = "local", alias = "memory")]
891    #[default]
892    InProcess,
893    /// Fleet coordination with Postgres advisory locks.
894    Postgres,
895}
896
897impl SchedulerBackend {
898    /// Parse an environment variable value for scheduler backend selection.
899    #[must_use]
900    pub fn from_env_value(value: &str) -> Option<Self> {
901        match value.trim().to_ascii_lowercase().as_str() {
902            "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
903            "postgres" | "postgresql" => Some(Self::Postgres),
904            _ => None,
905        }
906    }
907}
908
909/// Scheduled task coordination runtime configuration.
910#[derive(Debug, Clone, Deserialize)]
911pub struct SchedulerConfig {
912    /// Runtime backend selection.
913    #[serde(default)]
914    pub backend: SchedulerBackend,
915    /// Lease duration used by distributed backends for run visibility and timeout guidance.
916    #[serde(default = "default_scheduler_lease_ttl_secs")]
917    pub lease_ttl_secs: u64,
918    /// Stable replica identifier surfaced in actuator metadata.
919    #[serde(default)]
920    pub replica_id: Option<String>,
921    /// Prefix included when deriving Postgres advisory lock keys.
922    #[serde(default = "default_scheduler_key_prefix")]
923    pub key_prefix: String,
924}
925
926impl SchedulerConfig {
927    /// Resolve a stable-ish replica identifier for actuator metadata and lock ownership.
928    #[must_use]
929    pub fn resolved_replica_id(&self) -> String {
930        self.replica_id
931            .as_ref()
932            .filter(|id| !id.trim().is_empty())
933            .cloned()
934            .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
935            .or_else(|| std::env::var("HOSTNAME").ok())
936            .unwrap_or_else(|| format!("pid-{}", std::process::id()))
937    }
938
939    /// Validate scheduler-specific config shape.
940    ///
941    /// # Errors
942    ///
943    /// Returns [`ConfigError::Validation`] when values are syntactically valid TOML
944    /// but cannot be used by the runtime.
945    pub fn validate(&self) -> Result<(), ConfigError> {
946        if self.lease_ttl_secs == 0 {
947            return Err(ConfigError::Validation(
948                "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
949            ));
950        }
951        if self.key_prefix.trim().is_empty() {
952            return Err(ConfigError::Validation(
953                "scheduler.key_prefix must not be empty".to_owned(),
954            ));
955        }
956        Ok(())
957    }
958}
959
960impl Default for SchedulerConfig {
961    fn default() -> Self {
962        Self {
963            backend: SchedulerBackend::default(),
964            lease_ttl_secs: default_scheduler_lease_ttl_secs(),
965            replica_id: None,
966            key_prefix: default_scheduler_key_prefix(),
967        }
968    }
969}
970
971const fn default_scheduler_lease_ttl_secs() -> u64 {
972    300
973}
974
975fn default_scheduler_key_prefix() -> String {
976    "autumn:scheduler".to_owned()
977}
978
979/// `OpenAPI` spec runtime exposure settings.
980///
981/// Populated from the `[openapi]` block in `autumn.toml`. When
982/// `AppBuilder::openapi(...)` is called and `enabled = true`, the framework
983/// mounts the spec at `path`. Set `enabled = false` in a production profile
984/// to prevent exposing the spec publicly.
985///
986/// # `autumn.toml` example
987///
988/// ```toml
989/// [openapi]
990/// enabled = false   # disable in prod
991/// path = "/openapi.json"
992/// ```
993#[derive(Debug, Clone, Deserialize)]
994pub struct OpenApiRuntimeConfig {
995    /// Whether the `OpenAPI` spec endpoint is served.
996    ///
997    /// Defaults to `true` so new projects get the spec immediately.
998    /// Set to `false` in production profiles to suppress the endpoint.
999    #[serde(default = "default_openapi_enabled")]
1000    pub enabled: bool,
1001    /// URL path at which `openapi.json` is served.
1002    ///
1003    /// Defaults to `/openapi.json`.
1004    #[serde(default = "default_openapi_path")]
1005    pub path: String,
1006}
1007
1008impl Default for OpenApiRuntimeConfig {
1009    fn default() -> Self {
1010        Self {
1011            enabled: default_openapi_enabled(),
1012            path: default_openapi_path(),
1013        }
1014    }
1015}
1016
1017const fn default_openapi_enabled() -> bool {
1018    true
1019}
1020
1021fn default_openapi_path() -> String {
1022    "/openapi.json".to_owned()
1023}
1024
1025/// Background job runtime configuration.
1026#[derive(Debug, Clone, Deserialize)]
1027pub struct JobConfig {
1028    /// Runtime backend selection.
1029    ///
1030    /// - `local` (default): in-process Tokio queue
1031    /// - `redis`: Redis-backed durable queue (requires `redis` feature)
1032    #[serde(default = "default_job_backend")]
1033    pub backend: String,
1034    /// Number of concurrent worker loops to spawn.
1035    #[serde(default = "default_job_workers")]
1036    pub workers: usize,
1037    /// Default max attempts when `#[job(max_attempts = ...)]` is not set.
1038    #[serde(default = "default_job_max_attempts")]
1039    pub max_attempts: u32,
1040    /// Default initial retry backoff in milliseconds.
1041    #[serde(default = "default_job_backoff_ms")]
1042    pub initial_backoff_ms: u64,
1043    /// Redis backend options.
1044    #[serde(default)]
1045    pub redis: JobRedisConfig,
1046}
1047
1048impl Default for JobConfig {
1049    fn default() -> Self {
1050        Self {
1051            backend: default_job_backend(),
1052            workers: default_job_workers(),
1053            max_attempts: default_job_max_attempts(),
1054            initial_backoff_ms: default_job_backoff_ms(),
1055            redis: JobRedisConfig::default(),
1056        }
1057    }
1058}
1059
1060/// Redis backend configuration options for the job runner.
1061#[derive(Debug, Clone, Deserialize)]
1062pub struct JobRedisConfig {
1063    /// Redis URL used when `jobs.backend = "redis"`.
1064    #[serde(default)]
1065    pub url: Option<String>,
1066    /// Key prefix for all queue keys.
1067    #[serde(default = "default_jobs_redis_prefix")]
1068    pub key_prefix: String,
1069    /// Duration before an in-flight job claim is considered stale.
1070    #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
1071    pub visibility_timeout_ms: u64,
1072}
1073
1074impl Default for JobRedisConfig {
1075    fn default() -> Self {
1076        Self {
1077            url: None,
1078            key_prefix: default_jobs_redis_prefix(),
1079            visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
1080        }
1081    }
1082}
1083
1084fn default_job_backend() -> String {
1085    "local".to_owned()
1086}
1087
1088const fn default_job_workers() -> usize {
1089    1
1090}
1091
1092const fn default_job_max_attempts() -> u32 {
1093    5
1094}
1095
1096const fn default_job_backoff_ms() -> u64 {
1097    250
1098}
1099
1100fn default_jobs_redis_prefix() -> String {
1101    "autumn:jobs".to_owned()
1102}
1103
1104const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
1105    30_000
1106}
1107
1108impl AutumnConfig {
1109    /// Load configuration with profile-aware layering.
1110    ///
1111    /// Applies the six-layer configuration system:
1112    /// 1. Framework defaults
1113    /// 2. Profile smart defaults (dev/prod)
1114    /// 3. `autumn.toml` (base config)
1115    /// 4. `[profile.{name}]` section in `autumn.toml`
1116    /// 5. `autumn-{profile}.toml` (legacy profile overrides)
1117    /// 6. `AUTUMN_*` environment variables
1118    ///
1119    /// # Errors
1120    ///
1121    /// Returns [`ConfigError::Io`] if a config file cannot be read,
1122    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
1123    /// [`ConfigError::Validation`] if a value is invalid.
1124    ///
1125    /// # Panics
1126    ///
1127    /// Panics if the internally-built TOML table fails to re-serialize
1128    /// (should never happen with well-formed profile defaults).
1129    pub fn load() -> Result<Self, ConfigError> {
1130        Self::load_with_env(&OsEnv)
1131    }
1132
1133    /// Load configuration with profile-aware layering, using a provided
1134    /// environment abstraction instead of the OS environment. Useful for testing.
1135    ///
1136    /// # Errors
1137    /// Returns [`ConfigError::Io`] if a config file cannot be read,
1138    /// [`ConfigError::Parse`] if a file contains invalid TOML, or
1139    /// [`ConfigError::Validation`] if a value is invalid.
1140    ///
1141    /// # Panics
1142    /// Panics if the internally-built TOML table fails to re-serialize.
1143    pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
1144        let selected_profile_input = resolve_profile_input(env);
1145        let profile =
1146            normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
1147        let mut has_inline_profile_section = false;
1148
1149        // Build merged TOML:
1150        // profile smart defaults ← autumn.toml ← [profile.{name}] ← autumn-{profile}.toml
1151        let mut merged = profile_defaults_as_toml(&profile);
1152
1153        // Layer 3: base autumn.toml
1154        if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
1155            deep_merge(&mut merged, base.clone());
1156
1157            // Layer 4: [profile.{name}] in autumn.toml
1158            for profile_name in profile_lookup_names(&profile) {
1159                if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
1160                    deep_merge(&mut merged, inline_profile);
1161                    has_inline_profile_section = true;
1162                }
1163            }
1164        }
1165
1166        // Layer 5: autumn-{profile}.toml (legacy compatibility)
1167        let mut has_profile_file = false;
1168        for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
1169            let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
1170            if let Some(profile_toml) = load_raw_toml(&profile_path)? {
1171                deep_merge(&mut merged, profile_toml);
1172                has_profile_file = true;
1173                break;
1174            }
1175        }
1176        if !has_profile_file
1177            && should_warn_missing_profile_file(&profile, has_inline_profile_section)
1178        {
1179            warn_profile_typo(&profile);
1180        }
1181
1182        // Deserialize the merged TOML table into AutumnConfig
1183        let toml_str =
1184            toml::to_string(&merged).expect("internal error: failed to serialize merged config");
1185        let mut config: Self = toml::from_str(&toml_str)?;
1186        config.profile = Some(profile);
1187
1188        // Layer 6: env var overrides (highest priority)
1189        config.apply_env_overrides_with_env(env);
1190
1191        #[cfg(feature = "mail")]
1192        if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
1193            config.mail.transport = crate::mail::Transport::Log;
1194        }
1195
1196        config.validate()?;
1197        Ok(config)
1198    }
1199
1200    /// Load configuration from a specific TOML file path.
1201    ///
1202    /// Used internally and for testing. Does **not** apply profile
1203    /// layering or environment overrides. Prefer [`load()`](Self::load)
1204    /// in application code.
1205    ///
1206    /// # Errors
1207    ///
1208    /// Returns [`ConfigError::Io`] if the file cannot be read, or
1209    /// [`ConfigError::Parse`] if the file contains invalid TOML.
1210    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
1211        match std::fs::read_to_string(path) {
1212            Ok(contents) => {
1213                let config: Self = toml::from_str(&contents)?;
1214                config.validate()?;
1215                Ok(config)
1216            }
1217            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
1218            Err(e) => Err(ConfigError::Io(e)),
1219        }
1220    }
1221
1222    /// Validate the resolved configuration for semantic errors.
1223    ///
1224    /// # Errors
1225    /// Returns [`ConfigError::Validation`] when a field combination is
1226    /// syntactically well-formed TOML but semantically invalid.
1227    pub fn validate(&self) -> Result<(), ConfigError> {
1228        self.database.validate()?;
1229        self.cors.validate()?;
1230        self.scheduler.validate()?;
1231        let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
1232        self.security
1233            .webhooks
1234            .validate(is_production)
1235            .map_err(|error| ConfigError::Validation(error.to_string()))?;
1236        #[cfg(feature = "mail")]
1237        self.mail.validate(self.profile.as_deref())?;
1238        // Session backend validation deliberately lives in
1239        // `crate::session::apply_session_layer`, not here. That function
1240        // short-circuits when a custom `SessionStore` was installed via
1241        // `AppBuilder::with_session_store(...)`, so the (then-irrelevant)
1242        // `session.backend = "redis"` config without a redis URL doesn't
1243        // need to fail the boot. Validating the same thing here would
1244        // defeat the override and exit the app before the custom store
1245        // ever gets a chance to apply. The "prod profile + memory backend"
1246        // warning lives in `apply_session_layer` for the same reason.
1247        Ok(())
1248    }
1249
1250    /// Apply environment variable overrides to the loaded config.
1251    ///
1252    /// All fields can be overridden via `AUTUMN_SECTION__FIELD` environment
1253    /// variables. Double underscore `__` separates nested config sections.
1254    ///
1255    /// # Server
1256    /// - `AUTUMN_SERVER__PORT` → `server.port` (u16)
1257    /// - `AUTUMN_SERVER__HOST` → `server.host` (String)
1258    /// - `AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS` → `server.shutdown_timeout_secs` (u64)
1259    ///
1260    /// # Database
1261    /// - `AUTUMN_DATABASE__PRIMARY_URL` -> `database.primary_url` (String)
1262    /// - `AUTUMN_DATABASE__REPLICA_URL` -> `database.replica_url` (String)
1263    /// - `AUTUMN_DATABASE__PRIMARY_POOL_SIZE` -> `database.primary_pool_size` (usize)
1264    /// - `AUTUMN_DATABASE__REPLICA_POOL_SIZE` -> `database.replica_pool_size` (usize)
1265    /// - `AUTUMN_DATABASE__REPLICA_FALLBACK` -> `database.replica_fallback` (`fail_readiness` | `primary`)
1266    /// - `AUTUMN_DATABASE__URL` → `database.url` (String)
1267    /// - `AUTUMN_DATABASE__POOL_SIZE` → `database.pool_size` (usize)
1268    /// - `AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS` → `database.connect_timeout_secs` (u64)
1269    /// - `AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION` -> `database.auto_migrate_in_production` (bool)
1270    ///
1271    /// # Log
1272    /// - `AUTUMN_LOG__LEVEL` → `log.level` (String, tracing filter directive)
1273    /// - `AUTUMN_LOG__FORMAT` → `log.format` (Auto | Pretty | Json)
1274    ///
1275    /// # Telemetry
1276    /// - `AUTUMN_TELEMETRY__ENABLED` -> `telemetry.enabled` (bool)
1277    /// - `AUTUMN_TELEMETRY__SERVICE_NAME` -> `telemetry.service_name` (String)
1278    /// - `AUTUMN_TELEMETRY__SERVICE_NAMESPACE` -> `telemetry.service_namespace` (String)
1279    /// - `AUTUMN_TELEMETRY__SERVICE_VERSION` -> `telemetry.service_version` (String)
1280    /// - `AUTUMN_TELEMETRY__ENVIRONMENT` -> `telemetry.environment` (String)
1281    /// - `AUTUMN_TELEMETRY__OTLP_ENDPOINT` -> `telemetry.otlp_endpoint` (String)
1282    /// - `AUTUMN_TELEMETRY__PROTOCOL` -> `telemetry.protocol` (`Grpc` | `HttpProtobuf`)
1283    /// - `AUTUMN_TELEMETRY__STRICT` -> `telemetry.strict` (bool)
1284    ///
1285    /// # Health / Probes
1286    /// - `AUTUMN_HEALTH__PATH` → `health.path` (String)
1287    /// - `AUTUMN_HEALTH__LIVE_PATH` → `health.live_path` (String)
1288    /// - `AUTUMN_HEALTH__READY_PATH` → `health.ready_path` (String)
1289    /// - `AUTUMN_HEALTH__STARTUP_PATH` → `health.startup_path` (String)
1290    /// - `AUTUMN_HEALTH__DETAILED` → `health.detailed` (bool)
1291    ///
1292    /// # Jobs
1293    /// - `AUTUMN_JOBS__BACKEND` → `jobs.backend` (`local` / `redis`)
1294    /// - `AUTUMN_JOBS__WORKERS` → `jobs.workers` (`usize`)
1295    /// - `AUTUMN_JOBS__MAX_ATTEMPTS` → `jobs.max_attempts` (`u32`)
1296    /// - `AUTUMN_JOBS__INITIAL_BACKOFF_MS` → `jobs.initial_backoff_ms` (`u64`)
1297    /// - `AUTUMN_JOBS__REDIS__URL` → `jobs.redis.url` (`String`)
1298    /// - `AUTUMN_JOBS__REDIS__KEY_PREFIX` → `jobs.redis.key_prefix` (`String`)
1299    /// - `AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS` → `jobs.redis.visibility_timeout_ms` (`u64`)
1300    ///
1301    /// # Signed webhooks
1302    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__BACKEND` -> `security.webhooks.replay.backend` (`memory` / `redis`)
1303    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__URL` -> `security.webhooks.replay.redis.url` (`String`)
1304    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__REDIS__KEY_PREFIX` -> `security.webhooks.replay.redis.key_prefix` (`String`)
1305    /// - `AUTUMN_SECURITY__WEBHOOKS__REPLAY__ALLOW_MEMORY_IN_PRODUCTION` -> `security.webhooks.replay.allow_memory_in_production` (`bool`)
1306    pub fn apply_env_overrides(&mut self) {
1307        self.apply_env_overrides_with_env(&OsEnv);
1308    }
1309
1310    /// Apply environment overrides using the provided env abstraction.
1311    pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
1312        self.apply_server_env_overrides_with_env(env);
1313        self.apply_database_env_overrides_with_env(env);
1314        self.apply_log_env_overrides_with_env(env);
1315        self.apply_telemetry_env_overrides_with_env(env);
1316        self.apply_health_env_overrides_with_env(env);
1317        self.apply_cors_env_overrides_with_env(env);
1318        self.apply_session_env_overrides_with_env(env);
1319        self.apply_cache_env_overrides_with_env(env);
1320        self.apply_channels_env_overrides_with_env(env);
1321        self.apply_jobs_env_overrides_with_env(env);
1322        self.apply_scheduler_env_overrides_with_env(env);
1323        self.apply_auth_env_overrides_with_env(env);
1324        self.apply_security_env_overrides_with_env(env);
1325        #[cfg(feature = "storage")]
1326        self.apply_storage_env_overrides_with_env(env);
1327        #[cfg(feature = "mail")]
1328        self.apply_mail_env_overrides_with_env(env);
1329    }
1330
1331    fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
1332        parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
1333        parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
1334        parse_env(
1335            env,
1336            "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
1337            &mut self.server.shutdown_timeout_secs,
1338        );
1339    }
1340
1341    fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
1342        if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
1343            self.database.url = Some(val);
1344            self.database.primary_url = None;
1345        }
1346        parse_env_option_string(
1347            env,
1348            "AUTUMN_DATABASE__PRIMARY_URL",
1349            &mut self.database.primary_url,
1350        );
1351        parse_env_option_string(
1352            env,
1353            "AUTUMN_DATABASE__REPLICA_URL",
1354            &mut self.database.replica_url,
1355        );
1356        parse_env(
1357            env,
1358            "AUTUMN_DATABASE__POOL_SIZE",
1359            &mut self.database.pool_size,
1360        );
1361        parse_env_option(
1362            env,
1363            "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
1364            &mut self.database.primary_pool_size,
1365        );
1366        parse_env_option(
1367            env,
1368            "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
1369            &mut self.database.replica_pool_size,
1370        );
1371        parse_env(
1372            env,
1373            "AUTUMN_DATABASE__REPLICA_FALLBACK",
1374            &mut self.database.replica_fallback,
1375        );
1376        parse_env(
1377            env,
1378            "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
1379            &mut self.database.connect_timeout_secs,
1380        );
1381        parse_env_bool(
1382            env,
1383            "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
1384            &mut self.database.auto_migrate_in_production,
1385        );
1386    }
1387
1388    fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
1389        parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
1390        if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
1391            match val.as_str() {
1392                "Auto" => self.log.format = LogFormat::Auto,
1393                "Pretty" => self.log.format = LogFormat::Pretty,
1394                "Json" => self.log.format = LogFormat::Json,
1395                _ => eprintln!(
1396                    "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
1397                     (expected Auto, Pretty, or Json), ignoring"
1398                ),
1399            }
1400        }
1401    }
1402
1403    fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
1404        // ── Health ──────────────────────────────────────────────
1405        parse_env_bool(
1406            env,
1407            "AUTUMN_TELEMETRY__ENABLED",
1408            &mut self.telemetry.enabled,
1409        );
1410        parse_env_string(
1411            env,
1412            "AUTUMN_TELEMETRY__SERVICE_NAME",
1413            &mut self.telemetry.service_name,
1414        );
1415        parse_env_option_string(
1416            env,
1417            "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
1418            &mut self.telemetry.service_namespace,
1419        );
1420        parse_env_string(
1421            env,
1422            "AUTUMN_TELEMETRY__SERVICE_VERSION",
1423            &mut self.telemetry.service_version,
1424        );
1425        parse_env_string(
1426            env,
1427            "AUTUMN_TELEMETRY__ENVIRONMENT",
1428            &mut self.telemetry.environment,
1429        );
1430        parse_env_option_string(
1431            env,
1432            "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
1433            &mut self.telemetry.otlp_endpoint,
1434        );
1435        if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
1436            match TelemetryProtocol::from_env_value(&val) {
1437                Some(protocol) => self.telemetry.protocol = protocol,
1438                None => eprintln!(
1439                    "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
1440                     (expected Grpc or HttpProtobuf), ignoring"
1441                ),
1442            }
1443        }
1444        parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
1445    }
1446
1447    fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
1448        parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
1449        parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
1450        parse_env_string(
1451            env,
1452            "AUTUMN_HEALTH__READY_PATH",
1453            &mut self.health.ready_path,
1454        );
1455        parse_env_string(
1456            env,
1457            "AUTUMN_HEALTH__STARTUP_PATH",
1458            &mut self.health.startup_path,
1459        );
1460        parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
1461    }
1462
1463    fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
1464        parse_env_csv(
1465            env,
1466            "AUTUMN_CORS__ALLOWED_ORIGINS",
1467            &mut self.cors.allowed_origins,
1468        );
1469        parse_env_csv(
1470            env,
1471            "AUTUMN_CORS__ALLOWED_METHODS",
1472            &mut self.cors.allowed_methods,
1473        );
1474        parse_env_csv(
1475            env,
1476            "AUTUMN_CORS__ALLOWED_HEADERS",
1477            &mut self.cors.allowed_headers,
1478        );
1479        parse_env_bool(
1480            env,
1481            "AUTUMN_CORS__ALLOW_CREDENTIALS",
1482            &mut self.cors.allow_credentials,
1483        );
1484        parse_env(
1485            env,
1486            "AUTUMN_CORS__MAX_AGE_SECS",
1487            &mut self.cors.max_age_secs,
1488        );
1489    }
1490
1491    fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
1492        parse_env_string(
1493            env,
1494            "AUTUMN_SESSION__COOKIE_NAME",
1495            &mut self.session.cookie_name,
1496        );
1497        if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
1498            match crate::session::SessionBackend::from_env_value(&val) {
1499                Some(backend) => self.session.backend = backend,
1500                None => eprintln!(
1501                    "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
1502                     (expected memory or redis), ignoring"
1503                ),
1504            }
1505        }
1506        parse_env(
1507            env,
1508            "AUTUMN_SESSION__MAX_AGE_SECS",
1509            &mut self.session.max_age_secs,
1510        );
1511        parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
1512        parse_env_string(
1513            env,
1514            "AUTUMN_SESSION__SAME_SITE",
1515            &mut self.session.same_site,
1516        );
1517        parse_env_bool(
1518            env,
1519            "AUTUMN_SESSION__HTTP_ONLY",
1520            &mut self.session.http_only,
1521        );
1522        parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
1523        parse_env_bool(
1524            env,
1525            "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
1526            &mut self.session.allow_memory_in_production,
1527        );
1528        parse_env_option_string(
1529            env,
1530            "AUTUMN_SESSION__REDIS__URL",
1531            &mut self.session.redis.url,
1532        );
1533        parse_env_string(
1534            env,
1535            "AUTUMN_SESSION__REDIS__KEY_PREFIX",
1536            &mut self.session.redis.key_prefix,
1537        );
1538    }
1539
1540    fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
1541        if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
1542            match CacheBackend::from_env_value(&val) {
1543                Some(backend) => self.cache.backend = backend,
1544                None => eprintln!(
1545                    "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
1546                     (expected memory or redis), ignoring"
1547                ),
1548            }
1549        }
1550        parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
1551        parse_env_string(
1552            env,
1553            "AUTUMN_CACHE__REDIS__KEY_PREFIX",
1554            &mut self.cache.redis.key_prefix,
1555        );
1556    }
1557
1558    fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
1559        if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
1560            match ChannelBackend::from_env_value(&val) {
1561                Some(backend) => self.channels.backend = backend,
1562                None => eprintln!(
1563                    "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
1564                     (expected in_process or redis), ignoring"
1565                ),
1566            }
1567        }
1568        parse_env(
1569            env,
1570            "AUTUMN_CHANNELS__CAPACITY",
1571            &mut self.channels.capacity,
1572        );
1573        parse_env_option_string(
1574            env,
1575            "AUTUMN_CHANNELS__REDIS__URL",
1576            &mut self.channels.redis.url,
1577        );
1578        parse_env_string(
1579            env,
1580            "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
1581            &mut self.channels.redis.key_prefix,
1582        );
1583    }
1584
1585    fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
1586        parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
1587        parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
1588        parse_env(
1589            env,
1590            "AUTUMN_JOBS__MAX_ATTEMPTS",
1591            &mut self.jobs.max_attempts,
1592        );
1593        parse_env(
1594            env,
1595            "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
1596            &mut self.jobs.initial_backoff_ms,
1597        );
1598        parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
1599        parse_env_string(
1600            env,
1601            "AUTUMN_JOBS__REDIS__KEY_PREFIX",
1602            &mut self.jobs.redis.key_prefix,
1603        );
1604        parse_env(
1605            env,
1606            "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
1607            &mut self.jobs.redis.visibility_timeout_ms,
1608        );
1609    }
1610
1611    fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
1612        if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
1613            match SchedulerBackend::from_env_value(&val) {
1614                Some(backend) => self.scheduler.backend = backend,
1615                None => eprintln!(
1616                    "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
1617                     (expected in_process or postgres), ignoring"
1618                ),
1619            }
1620        }
1621        parse_env(
1622            env,
1623            "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
1624            &mut self.scheduler.lease_ttl_secs,
1625        );
1626        parse_env_option_string(
1627            env,
1628            "AUTUMN_SCHEDULER__REPLICA_ID",
1629            &mut self.scheduler.replica_id,
1630        );
1631        parse_env_string(
1632            env,
1633            "AUTUMN_SCHEDULER__KEY_PREFIX",
1634            &mut self.scheduler.key_prefix,
1635        );
1636    }
1637
1638    fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
1639        parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
1640        parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
1641    }
1642
1643    /// Apply `AUTUMN_SECURITY__*` environment variable overrides.
1644    #[allow(clippy::too_many_lines)]
1645    fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
1646        parse_env_string(
1647            env,
1648            "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
1649            &mut self.security.headers.x_frame_options,
1650        );
1651        parse_env_bool(
1652            env,
1653            "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
1654            &mut self.security.headers.x_content_type_options,
1655        );
1656        parse_env_bool(
1657            env,
1658            "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
1659            &mut self.security.headers.strict_transport_security,
1660        );
1661        parse_env(
1662            env,
1663            "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
1664            &mut self.security.headers.hsts_max_age_secs,
1665        );
1666        parse_env_string(
1667            env,
1668            "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
1669            &mut self.security.headers.content_security_policy,
1670        );
1671        parse_env_string(
1672            env,
1673            "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
1674            &mut self.security.headers.referrer_policy,
1675        );
1676        parse_env_string(
1677            env,
1678            "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
1679            &mut self.security.headers.permissions_policy,
1680        );
1681
1682        // CSRF
1683        parse_env_bool(
1684            env,
1685            "AUTUMN_SECURITY__CSRF__ENABLED",
1686            &mut self.security.csrf.enabled,
1687        );
1688        parse_env_string(
1689            env,
1690            "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
1691            &mut self.security.csrf.token_header,
1692        );
1693        parse_env_string(
1694            env,
1695            "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
1696            &mut self.security.csrf.cookie_name,
1697        );
1698
1699        self.apply_rate_limit_env_overrides_with_env(env);
1700
1701        // Multipart uploads
1702        parse_env(
1703            env,
1704            "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
1705            &mut self.security.upload.max_request_size_bytes,
1706        );
1707        parse_env(
1708            env,
1709            "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
1710            &mut self.security.upload.max_file_size_bytes,
1711        );
1712        parse_env_csv(
1713            env,
1714            "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
1715            &mut self.security.upload.allowed_mime_types,
1716        );
1717
1718        // Authorization deny shape + repository-API escape hatch.
1719        if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
1720            match value.parse::<crate::authorization::ForbiddenResponse>() {
1721                Ok(parsed) => self.security.forbidden_response = parsed,
1722                Err(err) => tracing::warn!(
1723                    "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
1724                ),
1725            }
1726        }
1727        parse_env_bool(
1728            env,
1729            "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
1730            &mut self.security.allow_unauthorized_repository_api,
1731        );
1732
1733        // Signing secret (canonical env var documented in deployment guide)
1734        parse_env_option_string(
1735            env,
1736            "AUTUMN_SECURITY__SIGNING_SECRET",
1737            &mut self.security.signing_secret.secret,
1738        );
1739
1740        self.security.webhooks.apply_env_overrides_with_env(env);
1741    }
1742
1743    fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
1744        parse_env_bool(
1745            env,
1746            "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
1747            &mut self.security.rate_limit.enabled,
1748        );
1749        parse_env(
1750            env,
1751            "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
1752            &mut self.security.rate_limit.requests_per_second,
1753        );
1754        parse_env(
1755            env,
1756            "AUTUMN_SECURITY__RATE_LIMIT__BURST",
1757            &mut self.security.rate_limit.burst,
1758        );
1759        parse_env_bool(
1760            env,
1761            "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
1762            &mut self.security.rate_limit.trust_forwarded_headers,
1763        );
1764        parse_env_csv(
1765            env,
1766            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
1767            &mut self.security.rate_limit.trusted_proxies,
1768        );
1769    }
1770
1771    #[cfg(feature = "storage")]
1772    fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
1773        if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
1774            match crate::storage::StorageBackend::from_env_value(&val) {
1775                Some(backend) => self.storage.backend = backend,
1776                None => eprintln!(
1777                    "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
1778                     (expected disabled, local, or s3), ignoring"
1779                ),
1780            }
1781        }
1782        parse_env_string(
1783            env,
1784            "AUTUMN_STORAGE__DEFAULT_PROVIDER",
1785            &mut self.storage.default_provider,
1786        );
1787        parse_env_bool(
1788            env,
1789            "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
1790            &mut self.storage.allow_local_in_production,
1791        );
1792        if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
1793            self.storage.local.root = PathBuf::from(val);
1794        }
1795        parse_env_string(
1796            env,
1797            "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
1798            &mut self.storage.local.mount_path,
1799        );
1800        parse_env(
1801            env,
1802            "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
1803            &mut self.storage.local.default_url_expiry_secs,
1804        );
1805        parse_env_option_string(
1806            env,
1807            "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
1808            &mut self.storage.local.signing_key,
1809        );
1810        parse_env_option_string(
1811            env,
1812            "AUTUMN_STORAGE__S3__BUCKET",
1813            &mut self.storage.s3.bucket,
1814        );
1815        parse_env_option_string(
1816            env,
1817            "AUTUMN_STORAGE__S3__REGION",
1818            &mut self.storage.s3.region,
1819        );
1820        parse_env_option_string(
1821            env,
1822            "AUTUMN_STORAGE__S3__ENDPOINT",
1823            &mut self.storage.s3.endpoint,
1824        );
1825        parse_env_option_string(
1826            env,
1827            "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
1828            &mut self.storage.s3.public_base_url,
1829        );
1830        parse_env_option_string(
1831            env,
1832            "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
1833            &mut self.storage.s3.access_key_id_env,
1834        );
1835        parse_env_option_string(
1836            env,
1837            "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
1838            &mut self.storage.s3.secret_access_key_env,
1839        );
1840        parse_env_bool(
1841            env,
1842            "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
1843            &mut self.storage.s3.force_path_style,
1844        );
1845        parse_env(
1846            env,
1847            "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
1848            &mut self.storage.s3.default_url_expiry_secs,
1849        );
1850    }
1851
1852    #[cfg(feature = "mail")]
1853    fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
1854        if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
1855            match crate::mail::Transport::from_env_value(&val) {
1856                Some(transport) => self.mail.transport = transport,
1857                None => eprintln!(
1858                    "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
1859                     (expected log, file, smtp, or disabled), ignoring"
1860                ),
1861            }
1862        }
1863        parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
1864        parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
1865        parse_env_bool(
1866            env,
1867            "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
1868            &mut self.mail.allow_log_in_production,
1869        );
1870        parse_env_bool(
1871            env,
1872            "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
1873            &mut self.mail.allow_in_process_deliver_later_in_production,
1874        );
1875        parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
1876        if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
1877            self.mail.file_dir = PathBuf::from(val);
1878        }
1879        parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
1880        if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
1881            match val.parse::<u16>() {
1882                Ok(port) => self.mail.smtp.port = Some(port),
1883                Err(_) => {
1884                    eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
1885                }
1886            }
1887        }
1888        parse_env_option_string(
1889            env,
1890            "AUTUMN_MAIL__SMTP__USERNAME",
1891            &mut self.mail.smtp.username,
1892        );
1893        parse_env_option_string(
1894            env,
1895            "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
1896            &mut self.mail.smtp.password_env,
1897        );
1898        if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
1899            match crate::mail::TlsMode::from_env_value(&val) {
1900                Some(tls) => self.mail.smtp.tls = tls,
1901                None => eprintln!(
1902                    "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
1903                     (expected disabled, starttls, or tls), ignoring"
1904                ),
1905            }
1906        }
1907    }
1908
1909    /// Returns the active profile name, if any.
1910    #[must_use]
1911    pub fn profile_name(&self) -> Option<&str> {
1912        self.profile.as_deref()
1913    }
1914}
1915
1916/// HTTP server configuration.
1917///
1918/// Controls which address the server binds to and how graceful shutdown
1919/// behaves.
1920///
1921/// # Defaults
1922///
1923/// | Field | Default |
1924/// |-------|---------|
1925/// | `port` | `3000` |
1926/// | `host` | `"127.0.0.1"` |
1927/// | `shutdown_timeout_secs` | `30` |
1928///
1929/// # Examples
1930///
1931/// ```rust
1932/// use autumn_web::config::ServerConfig;
1933///
1934/// let server = ServerConfig::default();
1935/// assert_eq!(server.port, 3000);
1936/// assert_eq!(server.host, "127.0.0.1");
1937/// ```
1938#[derive(Debug, Clone, Deserialize)]
1939pub struct ServerConfig {
1940    /// Port to listen on. Default: `3000`.
1941    #[serde(default = "default_port")]
1942    pub port: u16,
1943
1944    /// Host/IP to bind to. Default: `"127.0.0.1"`.
1945    ///
1946    /// Set to `"0.0.0.0"` to accept connections from all interfaces
1947    /// (typical for containerized deployments).
1948    #[serde(default = "default_host")]
1949    pub host: String,
1950
1951    /// Seconds to wait for in-flight requests during graceful shutdown.
1952    /// Default: `30`.
1953    ///
1954    /// When the server receives a shutdown signal, it stops accepting
1955    /// new connections and waits up to this many seconds for in-flight
1956    /// requests to complete before forcibly terminating.
1957    #[serde(default = "default_shutdown_timeout")]
1958    pub shutdown_timeout_secs: u64,
1959}
1960
1961/// Behavior when a configured read replica is unavailable or stale.
1962#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
1963#[serde(rename_all = "snake_case")]
1964#[non_exhaustive]
1965pub enum ReplicaFallback {
1966    /// Readiness should fail when the configured replica cannot safely serve reads.
1967    #[default]
1968    FailReadiness,
1969    /// Read paths may use the primary when the replica is unavailable or stale.
1970    Primary,
1971}
1972
1973impl std::str::FromStr for ReplicaFallback {
1974    type Err = ();
1975
1976    fn from_str(value: &str) -> Result<Self, Self::Err> {
1977        match value.trim().to_ascii_lowercase().as_str() {
1978            "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
1979            "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
1980            _ => Err(()),
1981        }
1982    }
1983}
1984
1985/// Database connection configuration.
1986///
1987/// When `url` is `None` (the default), the application runs without a
1988/// database -- useful for static-site or API-gateway use cases. Set a
1989/// Postgres URL to enable the connection pool and the [`Db`](crate::Db)
1990/// extractor.
1991///
1992/// # Defaults
1993///
1994/// | Field | Default |
1995/// |-------|---------|
1996/// | `url` | `None` |
1997/// | `primary_url` | `None` |
1998/// | `replica_url` | `None` |
1999/// | `pool_size` | `10` |
2000/// | `primary_pool_size` | `None` |
2001/// | `replica_pool_size` | `None` |
2002/// | `replica_fallback` | `fail_readiness` |
2003/// | `connect_timeout_secs` | `5` |
2004/// | `auto_migrate_in_production` | `false` |
2005///
2006/// # Examples
2007///
2008/// ```rust
2009/// use autumn_web::config::DatabaseConfig;
2010///
2011/// let db = DatabaseConfig::default();
2012/// assert!(db.url.is_none());
2013/// assert_eq!(db.pool_size, 10);
2014/// ```
2015#[derive(Debug, Clone, Deserialize)]
2016pub struct DatabaseConfig {
2017    /// Postgres connection URL. `None` means no database is configured.
2018    ///
2019    /// Compatibility alias for the primary/write role. New multi-role
2020    /// deployments should prefer [`primary_url`](Self::primary_url).
2021    ///
2022    /// Must start with `postgres://` or `postgresql://` when present.
2023    #[serde(default)]
2024    pub url: Option<String>,
2025
2026    /// Postgres URL for the primary/write role.
2027    ///
2028    /// All writes, transactions, advisory locks, and migrations use this role.
2029    /// When unset, [`url`](Self::url) remains the single-primary fallback.
2030    #[serde(default)]
2031    pub primary_url: Option<String>,
2032
2033    /// Optional Postgres URL for the read/replica role.
2034    ///
2035    /// Read-only paths may use this pool when configured. If omitted, read
2036    /// paths use the primary role.
2037    #[serde(default)]
2038    pub replica_url: Option<String>,
2039
2040    /// Maximum number of connections in the pool. Default: `10`.
2041    ///
2042    /// Compatibility/default pool size used for both roles unless a
2043    /// role-specific size is set.
2044    #[serde(default = "default_pool_size")]
2045    pub pool_size: usize,
2046
2047    /// Optional primary/write role pool size.
2048    #[serde(default)]
2049    pub primary_pool_size: Option<usize>,
2050
2051    /// Optional read/replica role pool size.
2052    #[serde(default)]
2053    pub replica_pool_size: Option<usize>,
2054
2055    /// Deterministic behavior for configured replicas that cannot safely serve
2056    /// reads. Default: fail readiness.
2057    #[serde(default)]
2058    pub replica_fallback: ReplicaFallback,
2059
2060    /// Seconds to wait while acquiring a pooled connection, including
2061    /// creating a new connection when the pool grows.
2062    /// Default: `5`.
2063    #[serde(default = "default_connect_timeout")]
2064    pub connect_timeout_secs: u64,
2065
2066    /// When true, permits automatic migration application while running with
2067    /// `prod`/`production` profile. Default: `false`.
2068    ///
2069    /// Keep this disabled for multi-replica production fleets and use an
2070    /// explicit migration job (`autumn migrate`) instead.
2071    #[serde(default)]
2072    pub auto_migrate_in_production: bool,
2073}
2074
2075impl DatabaseConfig {
2076    /// Resolved primary/write database URL.
2077    #[must_use]
2078    pub fn effective_primary_url(&self) -> Option<&str> {
2079        self.primary_url.as_deref().or(self.url.as_deref())
2080    }
2081
2082    /// Resolved primary/write role pool size.
2083    #[must_use]
2084    pub fn effective_primary_pool_size(&self) -> usize {
2085        self.primary_pool_size.unwrap_or(self.pool_size)
2086    }
2087
2088    /// Resolved read/replica role pool size.
2089    #[must_use]
2090    pub fn effective_replica_pool_size(&self) -> usize {
2091        self.replica_pool_size.unwrap_or(self.pool_size)
2092    }
2093
2094    /// Validate database configuration.
2095    ///
2096    /// # Errors
2097    ///
2098    /// Returns a validation error if the URL has an invalid scheme.
2099    pub fn validate(&self) -> Result<(), ConfigError> {
2100        for (field, url) in [
2101            ("database.url", self.url.as_deref()),
2102            ("database.primary_url", self.primary_url.as_deref()),
2103            ("database.replica_url", self.replica_url.as_deref()),
2104        ] {
2105            if let Some(url) = url
2106                && !url.starts_with("postgres://")
2107                && !url.starts_with("postgresql://")
2108            {
2109                let label = if field == "database.url" {
2110                    "database URL"
2111                } else {
2112                    field
2113                };
2114                return Err(ConfigError::Validation(format!(
2115                    "Invalid {label}: must start with postgres:// or postgresql://, got {url:?}"
2116                )));
2117            }
2118        }
2119
2120        if self.replica_url.is_some() && self.effective_primary_url().is_none() {
2121            return Err(ConfigError::Validation(
2122                "database.replica_url requires database.primary_url or database.url".to_owned(),
2123            ));
2124        }
2125        Ok(())
2126    }
2127}
2128
2129/// Logging configuration.
2130///
2131/// Controls the tracing subscriber's filter level and output format.
2132/// See [`LogFormat`] for output format options.
2133///
2134/// # Examples
2135///
2136/// ```rust
2137/// use autumn_web::config::{LogConfig, LogFormat};
2138///
2139/// let log = LogConfig::default();
2140/// assert_eq!(log.level, "info");
2141/// assert_eq!(log.format, LogFormat::Auto);
2142/// ```
2143#[derive(Debug, Clone, Deserialize)]
2144pub struct LogConfig {
2145    /// Tracing filter directive. Default: `"info"`.
2146    ///
2147    /// Supports the full `tracing` filter syntax, e.g.
2148    /// `"autumn=debug,tower_http=trace"`.
2149    #[serde(default = "default_log_level")]
2150    pub level: String,
2151
2152    /// Log output format. Default: [`LogFormat::Auto`].
2153    #[serde(default)]
2154    pub format: LogFormat,
2155}
2156
2157/// Log output format.
2158///
2159/// Controls how tracing events are rendered. The default ([`Auto`](Self::Auto))
2160/// auto-detects based on the `AUTUMN_ENV` environment variable.
2161///
2162/// | Variant | Behaviour |
2163/// |---------|-----------|
2164/// | [`Auto`](Self::Auto) | Pretty in dev, JSON when `AUTUMN_ENV=production` |
2165/// | [`Pretty`](Self::Pretty) | Always human-readable, colorized |
2166/// | [`Json`](Self::Json) | Always structured JSON (for log aggregators) |
2167///
2168/// # Examples
2169///
2170/// ```rust
2171/// use autumn_web::config::LogFormat;
2172///
2173/// assert_eq!(LogFormat::default(), LogFormat::Auto);
2174/// ```
2175#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
2176#[non_exhaustive]
2177pub enum LogFormat {
2178    /// Pretty in dev, JSON in production (based on `AUTUMN_ENV`).
2179    #[default]
2180    Auto,
2181    /// Human-readable, colorized output.
2182    Pretty,
2183    /// Structured JSON output suitable for log aggregation pipelines.
2184    Json,
2185}
2186
2187/// Telemetry configuration.
2188///
2189/// Controls whether Autumn enables OTLP trace export and how the process
2190/// identifies itself in resource metadata.
2191#[derive(Debug, Clone, Deserialize)]
2192pub struct TelemetryConfig {
2193    /// Enable framework-managed telemetry. Default: `false`.
2194    #[serde(default)]
2195    pub enabled: bool,
2196
2197    /// Logical service name. Default: `"autumn-app"`.
2198    #[serde(default = "default_telemetry_service_name")]
2199    pub service_name: String,
2200
2201    /// Optional service namespace (e.g. team, domain, or product family).
2202    #[serde(default)]
2203    pub service_namespace: Option<String>,
2204
2205    /// Service version string advertised in resource metadata.
2206    #[serde(default = "default_telemetry_service_version")]
2207    pub service_version: String,
2208
2209    /// Deployment environment label for trace resource metadata.
2210    #[serde(default = "default_telemetry_environment")]
2211    pub environment: String,
2212
2213    /// OTLP collector endpoint. Required when telemetry is enabled.
2214    #[serde(default)]
2215    pub otlp_endpoint: Option<String>,
2216
2217    /// OTLP transport protocol. Default: [`TelemetryProtocol::Grpc`].
2218    #[serde(default)]
2219    pub protocol: TelemetryProtocol,
2220
2221    /// When `true`, telemetry initialization failures abort startup.
2222    #[serde(default)]
2223    pub strict: bool,
2224}
2225
2226/// OTLP transport protocol selection.
2227#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
2228#[non_exhaustive]
2229pub enum TelemetryProtocol {
2230    /// OTLP over gRPC.
2231    #[serde(alias = "grpc", alias = "GRPC")]
2232    #[default]
2233    Grpc,
2234    /// OTLP over HTTP/protobuf.
2235    #[serde(
2236        alias = "http-protobuf",
2237        alias = "http_protobuf",
2238        alias = "HTTP_PROTOBUF"
2239    )]
2240    HttpProtobuf,
2241}
2242
2243impl TelemetryProtocol {
2244    fn from_env_value(value: &str) -> Option<Self> {
2245        match value {
2246            "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
2247            "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
2248            | "httpprotobuf" => Some(Self::HttpProtobuf),
2249            _ => None,
2250        }
2251    }
2252}
2253
2254/// Health check endpoint configuration.
2255///
2256/// The health check is automatically mounted by [`AppBuilder::run`](crate::app::AppBuilder::run).
2257/// See the [`health`](crate::health) module for response format details.
2258///
2259/// # Examples
2260///
2261/// ```rust
2262/// use autumn_web::config::HealthConfig;
2263///
2264/// let health = HealthConfig::default();
2265/// assert_eq!(health.path, "/health");
2266/// assert_eq!(health.live_path, "/live");
2267/// assert_eq!(health.ready_path, "/ready");
2268/// assert_eq!(health.startup_path, "/startup");
2269/// assert!(!health.detailed);
2270/// ```
2271#[derive(Debug, Clone, Deserialize)]
2272pub struct HealthConfig {
2273    /// Compatibility alias path for readiness. Default: `"/health"`.
2274    ///
2275    /// Common alternatives: `"/healthz"`, `"/_health"`.
2276    #[serde(default = "default_health_path")]
2277    pub path: String,
2278
2279    /// URL path for the liveness probe. Default: `"/live"`.
2280    #[serde(default = "default_live_path")]
2281    pub live_path: String,
2282
2283    /// URL path for the readiness probe. Default: `"/ready"`.
2284    #[serde(default = "default_ready_path")]
2285    pub ready_path: String,
2286
2287    /// URL path for the startup probe. Default: `"/startup"`.
2288    #[serde(default = "default_startup_path")]
2289    pub startup_path: String,
2290
2291    /// When `true`, the health endpoint includes detailed info (profile,
2292    /// uptime, pool stats). Default: `false` (overridden to `true` for
2293    /// `dev` profile via smart defaults).
2294    #[serde(default)]
2295    pub detailed: bool,
2296}
2297
2298/// Actuator endpoint configuration.
2299///
2300/// Controls which operational endpoints are exposed. The `sensitive` flag
2301/// determines whether sensitive endpoints (env, configprops, loggers,
2302/// tasks) are available. Defaults to `true` for `dev`, `false` for `prod`.
2303#[derive(Debug, Clone, Deserialize)]
2304pub struct ActuatorConfig {
2305    /// URL prefix for actuator endpoints. Default: `"/actuator"`.
2306    #[serde(default = "default_actuator_prefix")]
2307    pub prefix: String,
2308
2309    /// When `true`, expose sensitive endpoints (env, loggers, tasks).
2310    /// Defaults vary by profile: `true` for dev, `false` for prod.
2311    #[serde(default)]
2312    pub sensitive: bool,
2313}
2314
2315impl Default for ActuatorConfig {
2316    fn default() -> Self {
2317        Self {
2318            prefix: default_actuator_prefix(),
2319            sensitive: false,
2320        }
2321    }
2322}
2323
2324fn default_actuator_prefix() -> String {
2325    "/actuator".to_owned()
2326}
2327
2328/// CORS (Cross-Origin Resource Sharing) configuration.
2329///
2330/// Controls which origins, methods, and headers are allowed for
2331/// cross-origin requests. Disabled by default -- enable by setting
2332/// `allowed_origins` in `autumn.toml` or via environment variables.
2333///
2334/// # Defaults
2335///
2336/// | Field | Default |
2337/// |-------|---------|
2338/// | `allowed_origins` | `[]` (CORS disabled) |
2339/// | `allowed_methods` | `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]` |
2340/// | `allowed_headers` | `["Content-Type", "Authorization"]` |
2341/// | `allow_credentials` | `false` |
2342/// | `max_age_secs` | `86400` (24 hours) |
2343///
2344/// # Profile smart defaults
2345///
2346/// The `dev` profile enables permissive CORS (`allowed_origins = ["*"]`)
2347/// so local front-end development works out of the box.
2348///
2349/// # Examples
2350///
2351/// ```toml
2352/// [cors]
2353/// allowed_origins = ["https://example.com", "https://app.example.com"]
2354/// allow_credentials = true
2355/// ```
2356///
2357/// ```rust
2358/// use autumn_web::config::CorsConfig;
2359///
2360/// let cors = CorsConfig::default();
2361/// assert!(cors.allowed_origins.is_empty());
2362/// assert!(!cors.allow_credentials);
2363/// ```
2364#[derive(Debug, Clone, Deserialize)]
2365pub struct CorsConfig {
2366    /// Origins allowed to make cross-origin requests.
2367    ///
2368    /// Use `["*"]` to allow any origin (not recommended for production
2369    /// with credentials). When empty, CORS middleware is not applied.
2370    #[serde(default)]
2371    pub allowed_origins: Vec<String>,
2372
2373    /// HTTP methods allowed for cross-origin requests.
2374    /// Default: `["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]`.
2375    #[serde(default = "default_cors_methods")]
2376    pub allowed_methods: Vec<String>,
2377
2378    /// Headers allowed in cross-origin requests.
2379    /// Default: `["Content-Type", "Authorization"]`.
2380    #[serde(default = "default_cors_headers")]
2381    pub allowed_headers: Vec<String>,
2382
2383    /// Whether to include `Access-Control-Allow-Credentials: true`.
2384    /// Default: `false`.
2385    #[serde(default)]
2386    pub allow_credentials: bool,
2387
2388    /// How long (in seconds) browsers may cache preflight responses.
2389    /// Default: `86400` (24 hours).
2390    #[serde(default = "default_cors_max_age")]
2391    pub max_age_secs: u64,
2392}
2393
2394impl Default for CorsConfig {
2395    fn default() -> Self {
2396        Self {
2397            allowed_origins: Vec::new(),
2398            allowed_methods: default_cors_methods(),
2399            allowed_headers: default_cors_headers(),
2400            allow_credentials: false,
2401            max_age_secs: default_cors_max_age(),
2402        }
2403    }
2404}
2405
2406impl CorsConfig {
2407    /// Validate CORS configuration for combinations rejected by browsers.
2408    ///
2409    /// # Errors
2410    ///
2411    /// Returns a validation error when `allow_credentials = true` is combined
2412    /// with a wildcard `"*"` origin. Browsers refuse this combination per the
2413    /// Fetch spec, and `tower-http`'s `CorsLayer` panics when asked to build
2414    /// it, so we fail fast at config load with an actionable message.
2415    pub fn validate(&self) -> Result<(), ConfigError> {
2416        if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
2417            return Err(ConfigError::Validation(
2418                "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
2419                 list explicit origins instead (browsers reject the wildcard+credentials combo)"
2420                    .to_owned(),
2421            ));
2422        }
2423        Ok(())
2424    }
2425}
2426
2427fn default_cors_methods() -> Vec<String> {
2428    vec![
2429        "GET".to_owned(),
2430        "POST".to_owned(),
2431        "PUT".to_owned(),
2432        "DELETE".to_owned(),
2433        "PATCH".to_owned(),
2434        "OPTIONS".to_owned(),
2435    ]
2436}
2437
2438fn default_cors_headers() -> Vec<String> {
2439    vec!["Content-Type".to_owned(), "Authorization".to_owned()]
2440}
2441
2442const fn default_cors_max_age() -> u64 {
2443    86400
2444}
2445
2446/// Parse an environment variable into a typed target, logging a warning on failure.
2447fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
2448    if let Ok(val) = env.var(key) {
2449        match val.parse::<T>() {
2450            Ok(v) => *target = v,
2451            Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
2452        }
2453    }
2454}
2455
2456fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
2457    if let Ok(val) = env.var(key) {
2458        *target = if val.is_empty() { None } else { Some(val) };
2459    }
2460}
2461
2462fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
2463    if let Ok(val) = env.var(key) {
2464        if val.is_empty() {
2465            *target = None;
2466        } else {
2467            match val.parse::<T>() {
2468                Ok(v) => *target = Some(v),
2469                Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
2470            }
2471        }
2472    }
2473}
2474
2475fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
2476    if let Ok(val) = env.var(key) {
2477        *target = val;
2478    }
2479}
2480
2481fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
2482    if let Ok(val) = env.var(key) {
2483        match val.as_str() {
2484            "true" | "1" => *target = true,
2485            "false" | "0" => *target = false,
2486            _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
2487        }
2488    }
2489}
2490
2491fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
2492    if let Ok(val) = env.var(key) {
2493        *target = val.split(',').map(|s| s.trim().to_owned()).collect();
2494    }
2495}
2496
2497// ── Default functions ──────────────────────────────────────────────
2498
2499const fn default_port() -> u16 {
2500    3000
2501}
2502
2503fn default_host() -> String {
2504    "127.0.0.1".to_owned()
2505}
2506
2507const fn default_shutdown_timeout() -> u64 {
2508    30
2509}
2510
2511const fn default_pool_size() -> usize {
2512    10
2513}
2514
2515const fn default_connect_timeout() -> u64 {
2516    5
2517}
2518
2519fn default_log_level() -> String {
2520    "info".to_owned()
2521}
2522
2523fn default_telemetry_service_name() -> String {
2524    "autumn-app".to_owned()
2525}
2526
2527fn default_telemetry_service_version() -> String {
2528    "unknown".to_owned()
2529}
2530
2531fn default_telemetry_environment() -> String {
2532    "development".to_owned()
2533}
2534
2535fn default_health_path() -> String {
2536    "/health".to_owned()
2537}
2538
2539fn default_live_path() -> String {
2540    "/live".to_owned()
2541}
2542
2543fn default_ready_path() -> String {
2544    "/ready".to_owned()
2545}
2546
2547fn default_startup_path() -> String {
2548    "/startup".to_owned()
2549}
2550
2551// ── Default trait impls ────────────────────────────────────────────
2552
2553impl Default for ServerConfig {
2554    fn default() -> Self {
2555        Self {
2556            port: default_port(),
2557            host: default_host(),
2558            shutdown_timeout_secs: default_shutdown_timeout(),
2559        }
2560    }
2561}
2562
2563impl Default for DatabaseConfig {
2564    fn default() -> Self {
2565        Self {
2566            url: None,
2567            primary_url: None,
2568            replica_url: None,
2569            pool_size: default_pool_size(),
2570            primary_pool_size: None,
2571            replica_pool_size: None,
2572            replica_fallback: ReplicaFallback::default(),
2573            connect_timeout_secs: default_connect_timeout(),
2574            auto_migrate_in_production: false,
2575        }
2576    }
2577}
2578
2579impl Default for LogConfig {
2580    fn default() -> Self {
2581        Self {
2582            level: default_log_level(),
2583            format: LogFormat::default(),
2584        }
2585    }
2586}
2587
2588impl Default for TelemetryConfig {
2589    fn default() -> Self {
2590        Self {
2591            enabled: false,
2592            service_name: default_telemetry_service_name(),
2593            service_namespace: None,
2594            service_version: default_telemetry_service_version(),
2595            environment: default_telemetry_environment(),
2596            otlp_endpoint: None,
2597            protocol: TelemetryProtocol::default(),
2598            strict: false,
2599        }
2600    }
2601}
2602
2603impl Default for HealthConfig {
2604    fn default() -> Self {
2605        Self {
2606            path: default_health_path(),
2607            live_path: default_live_path(),
2608            ready_path: default_ready_path(),
2609            startup_path: default_startup_path(),
2610            detailed: false,
2611        }
2612    }
2613}
2614
2615// ----------------------------------------------------------------------------
2616// ConfigLoader — tier-1 boot-time replaceable config loading
2617// ----------------------------------------------------------------------------
2618
2619/// Pluggable boot-time configuration loader.
2620///
2621/// Replace the default TOML + env loader with a custom strategy (e.g. AWS
2622/// Secrets Manager, Consul, a JSON file, an HTTP fetch) by implementing this
2623/// trait and installing it on the [`AppBuilder`](crate::app::AppBuilder) via
2624/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
2625///
2626/// The trait's return type uses `impl Future + Send` so implementations can
2627/// freely use `async fn` in their bodies while the framework can still spawn
2628/// the load on any executor.
2629///
2630/// # Example
2631///
2632/// ```rust,no_run
2633/// use autumn_web::config::{AutumnConfig, ConfigError, ConfigLoader};
2634///
2635/// pub struct JsonFileConfigLoader { path: std::path::PathBuf }
2636///
2637/// impl ConfigLoader for JsonFileConfigLoader {
2638///     async fn load(&self) -> Result<AutumnConfig, ConfigError> {
2639///         let bytes = std::fs::read(&self.path).map_err(ConfigError::Io)?;
2640///         serde_json::from_slice(&bytes)
2641///             .map_err(|e| ConfigError::Validation(e.to_string()))
2642///     }
2643/// }
2644/// ```
2645pub trait ConfigLoader: Send + Sync + 'static {
2646    /// Load and return a fully-resolved [`AutumnConfig`].
2647    ///
2648    /// Implementations are responsible for any layering, profile resolution,
2649    /// and validation they care to apply. The default implementation
2650    /// ([`TomlEnvConfigLoader`]) preserves Autumn's five-layer load
2651    /// (framework defaults → profile defaults → `autumn.toml` →
2652    /// `autumn-{profile}.toml` → `AUTUMN_*` env vars).
2653    fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
2654}
2655
2656/// Default [`ConfigLoader`] — Autumn's five-layer TOML + env load strategy.
2657///
2658/// Delegates to [`AutumnConfig::load_with_env`] using [`OsEnv`] for environment
2659/// variable reads. This is the loader used when no override is installed via
2660/// [`with_config_loader`](crate::app::AppBuilder::with_config_loader).
2661#[derive(Debug, Default, Clone, Copy)]
2662pub struct TomlEnvConfigLoader;
2663
2664impl TomlEnvConfigLoader {
2665    /// Construct a new default loader.
2666    #[must_use]
2667    pub const fn new() -> Self {
2668        Self
2669    }
2670}
2671
2672impl ConfigLoader for TomlEnvConfigLoader {
2673    async fn load(&self) -> Result<AutumnConfig, ConfigError> {
2674        AutumnConfig::load_with_env(&OsEnv)
2675    }
2676}
2677
2678#[cfg(test)]
2679mod tests {
2680
2681    use super::*;
2682
2683    /// Mock loader for tests — returns a hand-built config without touching disk.
2684    struct MockConfigLoader {
2685        config: AutumnConfig,
2686    }
2687
2688    impl ConfigLoader for MockConfigLoader {
2689        async fn load(&self) -> Result<AutumnConfig, ConfigError> {
2690            Ok(self.config.clone())
2691        }
2692    }
2693
2694    #[tokio::test]
2695    async fn config_loader_trait_returns_supplied_config() {
2696        let mut custom = AutumnConfig::default();
2697        custom.server.port = 9999;
2698        custom.profile = Some("integration-test".to_owned());
2699
2700        let loader = MockConfigLoader {
2701            config: custom.clone(),
2702        };
2703        let resolved = loader.load().await.expect("mock loader should succeed");
2704
2705        assert_eq!(resolved.server.port, 9999);
2706        assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
2707    }
2708
2709    #[test]
2710    fn validate_does_not_error_on_redis_backend_without_url() {
2711        // Regression: previously `validate()` called
2712        // `session.backend_plan(profile)` which returned an error for
2713        // `backend = "redis"` without `redis.url`, exiting the boot before
2714        // a `with_session_store(...)` override could apply. Session
2715        // backend validation now lives in `apply_session_layer`, which
2716        // short-circuits when a custom store is installed. `validate()`
2717        // is config-shape-only and must accept this combination.
2718        let mut config = AutumnConfig::default();
2719        config.session.backend = crate::session::SessionBackend::Redis;
2720        config.session.redis.url = None;
2721
2722        config.validate().expect(
2723            "validate() must accept redis-backend-without-url so custom \
2724             session store overrides aren't blocked at boot",
2725        );
2726    }
2727
2728    #[tokio::test]
2729    async fn default_toml_env_loader_succeeds_without_files() {
2730        // No autumn.toml in the test runner's pwd; loader should fall back to
2731        // framework defaults rather than failing.
2732        let loader = TomlEnvConfigLoader::new();
2733        let resolved = loader.load().await.expect("default loader should succeed");
2734        // Default port is 3000 per ServerConfig::default — sanity check.
2735        assert_eq!(resolved.server.port, 3000);
2736    }
2737
2738    #[test]
2739    fn database_config_validate_none() {
2740        let config = DatabaseConfig {
2741            url: None,
2742            ..Default::default()
2743        };
2744        assert!(config.validate().is_ok());
2745    }
2746
2747    #[test]
2748    fn database_config_validate_valid_postgres() {
2749        let config = DatabaseConfig {
2750            url: Some("postgres://user:pass@localhost:5432/db".to_string()),
2751            ..Default::default()
2752        };
2753        assert!(config.validate().is_ok());
2754    }
2755
2756    #[test]
2757    fn database_config_validate_valid_postgresql() {
2758        let config = DatabaseConfig {
2759            url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
2760            ..Default::default()
2761        };
2762        assert!(config.validate().is_ok());
2763    }
2764
2765    #[test]
2766    fn database_config_validate_invalid_scheme() {
2767        let config = DatabaseConfig {
2768            url: Some("mysql://user:pass@localhost:3306/db".to_string()),
2769            ..Default::default()
2770        };
2771        let result = config.validate();
2772        assert!(result.is_err());
2773        match result {
2774            Err(ConfigError::Validation(msg)) => {
2775                // Ensure we just match the underlying variant correctly
2776                // as requested in the review.
2777                assert!(msg.contains("must start with postgres:// or postgresql://"));
2778            }
2779            _ => panic!("Expected ConfigError::Validation"),
2780        }
2781    }
2782
2783    #[test]
2784    fn server_defaults() {
2785        let config = ServerConfig::default();
2786        assert_eq!(config.port, 3000);
2787        assert_eq!(config.host, "127.0.0.1");
2788        assert_eq!(config.shutdown_timeout_secs, 30);
2789    }
2790
2791    #[test]
2792    fn database_defaults() {
2793        let config = DatabaseConfig::default();
2794        assert!(config.url.is_none());
2795        assert_eq!(config.pool_size, 10);
2796        assert_eq!(config.connect_timeout_secs, 5);
2797    }
2798
2799    #[test]
2800    fn database_validate_none_url_is_ok() {
2801        let config = DatabaseConfig {
2802            url: None,
2803            ..Default::default()
2804        };
2805        assert!(config.validate().is_ok());
2806    }
2807
2808    #[test]
2809    fn database_validate_postgres_url_is_ok() {
2810        let config = DatabaseConfig {
2811            url: Some("postgres://user:pass@localhost/db".to_string()),
2812            ..Default::default()
2813        };
2814        assert!(config.validate().is_ok());
2815    }
2816
2817    #[test]
2818    fn database_validate_postgresql_url_is_ok() {
2819        let config = DatabaseConfig {
2820            url: Some("postgresql://user:pass@localhost/db".to_string()),
2821            ..Default::default()
2822        };
2823        assert!(config.validate().is_ok());
2824    }
2825
2826    #[test]
2827    fn database_validate_invalid_url_is_err() {
2828        let config = DatabaseConfig {
2829            url: Some("mysql://user:pass@localhost/db".to_string()),
2830            ..Default::default()
2831        };
2832        let result = config.validate();
2833        assert!(result.is_err());
2834        if let Err(ConfigError::Validation(msg)) = result {
2835            assert!(msg.contains("Invalid database URL"));
2836            assert!(msg.contains("must start with postgres:// or postgresql://"));
2837        } else {
2838            panic!("Expected ConfigError::Validation");
2839        }
2840    }
2841
2842    #[test]
2843    fn database_topology_deserializes_primary_and_replica_urls() {
2844        let config: AutumnConfig = toml::from_str(
2845            r#"
2846[database]
2847primary_url = "postgres://primary.example/app"
2848replica_url = "postgres://replica.example/app"
2849primary_pool_size = 12
2850replica_pool_size = 4
2851replica_fallback = "primary"
2852"#,
2853        )
2854        .expect("database topology config should parse");
2855
2856        assert_eq!(
2857            config.database.primary_url.as_deref(),
2858            Some("postgres://primary.example/app")
2859        );
2860        assert_eq!(
2861            config.database.replica_url.as_deref(),
2862            Some("postgres://replica.example/app")
2863        );
2864        assert_eq!(config.database.primary_pool_size, Some(12));
2865        assert_eq!(config.database.replica_pool_size, Some(4));
2866        assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
2867        assert_eq!(
2868            config.database.effective_primary_url(),
2869            Some("postgres://primary.example/app")
2870        );
2871        assert_eq!(config.database.effective_primary_pool_size(), 12);
2872        assert_eq!(config.database.effective_replica_pool_size(), 4);
2873    }
2874
2875    #[test]
2876    fn database_topology_keeps_url_as_single_primary_compatibility_path() {
2877        let config: AutumnConfig = toml::from_str(
2878            r#"
2879[database]
2880url = "postgres://single.example/app"
2881pool_size = 7
2882"#,
2883        )
2884        .expect("legacy database.url config should parse");
2885
2886        assert_eq!(
2887            config.database.effective_primary_url(),
2888            Some("postgres://single.example/app")
2889        );
2890        assert_eq!(config.database.effective_primary_pool_size(), 7);
2891        assert_eq!(config.database.effective_replica_pool_size(), 7);
2892        assert!(config.database.replica_url.is_none());
2893    }
2894
2895    #[test]
2896    fn database_topology_rejects_replica_without_primary() {
2897        let config = DatabaseConfig {
2898            replica_url: Some("postgres://replica.example/app".to_owned()),
2899            ..Default::default()
2900        };
2901
2902        let result = config.validate();
2903
2904        assert!(result.is_err());
2905        let Err(ConfigError::Validation(message)) = result else {
2906            panic!("expected database topology validation error");
2907        };
2908        assert!(message.contains("database.replica_url"));
2909        assert!(message.contains("database.primary_url"));
2910    }
2911
2912    #[test]
2913    fn database_topology_env_overrides_role_fields() {
2914        let env = MockEnv::new()
2915            .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
2916            .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
2917            .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
2918            .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
2919            .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
2920        let mut config = AutumnConfig::default();
2921
2922        config.apply_env_overrides_with_env(&env);
2923
2924        assert_eq!(
2925            config.database.primary_url.as_deref(),
2926            Some("postgres://primary.env/app")
2927        );
2928        assert_eq!(
2929            config.database.replica_url.as_deref(),
2930            Some("postgres://replica.env/app")
2931        );
2932        assert_eq!(config.database.primary_pool_size, Some(9));
2933        assert_eq!(config.database.replica_pool_size, Some(3));
2934        assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
2935    }
2936
2937    #[test]
2938    fn database_validate_url_edge_cases() {
2939        let invalid_urls = vec![
2940            "POSTGRES://localhost/db",
2941            "postgres:/localhost/db",
2942            "postgres:localhost/db",
2943            "http://postgres",
2944            "   postgres://localhost/db",
2945            "",
2946        ];
2947
2948        for invalid_url in invalid_urls {
2949            let config = DatabaseConfig {
2950                url: Some(invalid_url.to_string()),
2951                ..Default::default()
2952            };
2953            assert!(
2954                config.validate().is_err(),
2955                "URL should be invalid: {invalid_url}"
2956            );
2957        }
2958    }
2959
2960    #[test]
2961    fn autumn_config_validate_ok() {
2962        let config = AutumnConfig::default();
2963        assert!(config.validate().is_ok());
2964    }
2965
2966    #[test]
2967    fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
2968        // Session backend validation moved to `apply_session_layer` so a
2969        // custom store installed via `AppBuilder::with_session_store(...)`
2970        // can override an otherwise-invalid backend config without the boot
2971        // exiting first. `validate()` is config-shape-only now; runtime
2972        // session selection (and the backend error) lives in
2973        // `apply_session_layer`, which short-circuits when a custom store
2974        // is installed. `crate::session::tests::session_backend_plan_*`
2975        // still cover the underlying error cases directly on
2976        // `SessionConfig::backend_plan`.
2977        let mut config = AutumnConfig::default();
2978        config.session.backend = crate::session::SessionBackend::Redis;
2979        config.session.redis.url = None;
2980
2981        config
2982            .validate()
2983            .expect("validate() must accept invalid session backend so custom store can override");
2984    }
2985
2986    #[test]
2987    fn autumn_config_validate_database_err() {
2988        let mut config = AutumnConfig::default();
2989        config.database.url = Some("mysql://localhost/test".to_string());
2990        assert!(config.validate().is_err());
2991    }
2992
2993    #[test]
2994    fn log_defaults() {
2995        let config = LogConfig::default();
2996        assert_eq!(config.level, "info");
2997        assert_eq!(config.format, LogFormat::Auto);
2998    }
2999
3000    #[test]
3001    fn telemetry_defaults() {
3002        let config = TelemetryConfig::default();
3003        assert!(!config.enabled);
3004        assert_eq!(config.service_name, "autumn-app");
3005        assert!(config.service_namespace.is_none());
3006        assert_eq!(config.service_version, "unknown");
3007        assert_eq!(config.environment, "development");
3008        assert!(config.otlp_endpoint.is_none());
3009        assert_eq!(config.protocol, TelemetryProtocol::Grpc);
3010        assert!(!config.strict);
3011    }
3012
3013    #[test]
3014    fn health_defaults() {
3015        let config = HealthConfig::default();
3016        assert_eq!(config.path, "/health");
3017        assert_eq!(config.live_path, "/live");
3018        assert_eq!(config.ready_path, "/ready");
3019        assert_eq!(config.startup_path, "/startup");
3020        assert!(!config.detailed);
3021    }
3022
3023    #[test]
3024    fn top_level_default_populates_all_sections() {
3025        let config = AutumnConfig::default();
3026        assert_eq!(config.server.port, 3000);
3027        assert!(config.database.url.is_none());
3028        assert_eq!(config.log.level, "info");
3029        assert_eq!(config.health.path, "/health");
3030    }
3031
3032    #[test]
3033    fn deserialize_empty_object_uses_all_defaults() {
3034        let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
3035        assert_eq!(config.server.port, 3000);
3036        assert_eq!(config.server.host, "127.0.0.1");
3037        assert_eq!(config.server.shutdown_timeout_secs, 30);
3038        assert!(config.database.url.is_none());
3039        assert_eq!(config.database.pool_size, 10);
3040        assert_eq!(config.database.connect_timeout_secs, 5);
3041        assert!(!config.database.auto_migrate_in_production);
3042        assert_eq!(config.log.level, "info");
3043        assert_eq!(config.log.format, LogFormat::Auto);
3044        assert_eq!(config.health.path, "/health");
3045    }
3046
3047    #[test]
3048    fn deserialize_partial_config_merges_with_defaults() {
3049        let json = r#"{"server": {"port": 8080}}"#;
3050        let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
3051        assert_eq!(config.server.port, 8080);
3052        assert_eq!(config.server.host, "127.0.0.1");
3053        assert_eq!(config.database.pool_size, 10);
3054        assert_eq!(config.log.level, "info");
3055    }
3056
3057    #[test]
3058    fn log_format_variants_deserialize() {
3059        let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
3060        let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
3061        let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
3062        assert_eq!(auto, LogFormat::Auto);
3063        assert_eq!(pretty, LogFormat::Pretty);
3064        assert_eq!(json, LogFormat::Json);
3065    }
3066
3067    // ── TOML loading tests ───────────────────────────────────────────
3068
3069    #[test]
3070    fn load_missing_file_returns_defaults() {
3071        let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
3072        assert_eq!(config.server.port, 3000);
3073        assert!(config.database.url.is_none());
3074    }
3075
3076    #[test]
3077    fn load_valid_full_config() {
3078        let dir = tempfile::tempdir().unwrap();
3079        let path = dir.path().join("autumn.toml");
3080        std::fs::write(
3081            &path,
3082            r#"
3083[server]
3084port = 8080
3085host = "0.0.0.0"
3086shutdown_timeout_secs = 60
3087
3088[database]
3089url = "postgres://user:pass@db:5432/myapp"
3090pool_size = 20
3091connect_timeout_secs = 10
3092auto_migrate_in_production = true
3093
3094[log]
3095level = "debug"
3096format = "Json"
3097
3098[health]
3099path = "/healthz"
3100"#,
3101        )
3102        .unwrap();
3103
3104        let config = AutumnConfig::load_from(&path).unwrap();
3105        assert_eq!(config.server.port, 8080);
3106        assert_eq!(config.server.host, "0.0.0.0");
3107        assert_eq!(config.server.shutdown_timeout_secs, 60);
3108        assert_eq!(
3109            config.database.url.as_deref(),
3110            Some("postgres://user:pass@db:5432/myapp")
3111        );
3112        assert_eq!(config.database.pool_size, 20);
3113        assert_eq!(config.database.connect_timeout_secs, 10);
3114        assert!(config.database.auto_migrate_in_production);
3115        assert_eq!(config.log.level, "debug");
3116        assert_eq!(config.log.format, LogFormat::Json);
3117        assert_eq!(config.health.path, "/healthz");
3118    }
3119
3120    #[test]
3121    fn load_partial_config_merges_with_defaults() {
3122        let dir = tempfile::tempdir().unwrap();
3123        let path = dir.path().join("autumn.toml");
3124        std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
3125
3126        let config = AutumnConfig::load_from(&path).unwrap();
3127        assert_eq!(config.server.port, 9090);
3128        assert_eq!(config.server.host, "127.0.0.1");
3129        assert_eq!(config.database.pool_size, 10);
3130        assert_eq!(config.log.level, "info");
3131    }
3132
3133    #[test]
3134    fn load_invalid_toml_returns_error() {
3135        let dir = tempfile::tempdir().unwrap();
3136        let path = dir.path().join("autumn.toml");
3137        std::fs::write(&path, "not valid [[[toml").unwrap();
3138
3139        let result = AutumnConfig::load_from(&path);
3140        assert!(result.is_err());
3141        let err = result.unwrap_err();
3142        assert!(err.to_string().contains("invalid autumn.toml"));
3143    }
3144
3145    #[test]
3146    fn load_empty_file_returns_defaults() {
3147        let dir = tempfile::tempdir().unwrap();
3148        let path = dir.path().join("autumn.toml");
3149        std::fs::write(&path, "").unwrap();
3150
3151        let config = AutumnConfig::load_from(&path).unwrap();
3152        assert_eq!(config.server.port, 3000);
3153    }
3154
3155    // ── Environment variable override tests ──────────────────────
3156
3157    #[test]
3158    fn env_override_database_url() {
3159        let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
3160        let mut config = AutumnConfig::default();
3161        config.apply_env_overrides_with_env(&env);
3162        assert_eq!(
3163            config.database.url.as_deref(),
3164            Some("postgres://override:5432/test")
3165        );
3166    }
3167
3168    #[test]
3169    fn env_override_database_url_wins_over_file_primary_url() {
3170        let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
3171        let mut config = AutumnConfig::default();
3172        config.database.primary_url = Some("postgres://file.example/app".to_owned());
3173
3174        config.apply_env_overrides_with_env(&env);
3175
3176        assert_eq!(
3177            config.database.effective_primary_url(),
3178            Some("postgres://env.example/app")
3179        );
3180        assert!(config.database.primary_url.is_none());
3181    }
3182
3183    #[test]
3184    fn env_override_database_primary_url_wins_over_legacy_database_url() {
3185        let env = MockEnv::new()
3186            .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
3187            .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
3188        let mut config = AutumnConfig::default();
3189        config.database.primary_url = Some("postgres://file.example/app".to_owned());
3190
3191        config.apply_env_overrides_with_env(&env);
3192
3193        assert_eq!(
3194            config.database.effective_primary_url(),
3195            Some("postgres://primary.env/app")
3196        );
3197        assert_eq!(
3198            config.database.url.as_deref(),
3199            Some("postgres://legacy.env/app")
3200        );
3201    }
3202
3203    #[test]
3204    fn env_override_pool_size() {
3205        let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
3206        let mut config = AutumnConfig::default();
3207        config.apply_env_overrides_with_env(&env);
3208        assert_eq!(config.database.pool_size, 25);
3209    }
3210
3211    #[test]
3212    fn env_override_connect_timeout() {
3213        let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
3214        let mut config = AutumnConfig::default();
3215        config.apply_env_overrides_with_env(&env);
3216        assert_eq!(config.database.connect_timeout_secs, 15);
3217    }
3218
3219    #[test]
3220    fn env_override_invalid_pool_size_ignored() {
3221        let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
3222        let mut config = AutumnConfig::default();
3223        config.apply_env_overrides_with_env(&env);
3224        assert_eq!(config.database.pool_size, 10);
3225    }
3226
3227    #[cfg(feature = "storage")]
3228    #[test]
3229    fn env_override_storage_fields() {
3230        let env = MockEnv::new()
3231            .with("AUTUMN_STORAGE__BACKEND", "s3")
3232            .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
3233            .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
3234            .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
3235            .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
3236            .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
3237            .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
3238            .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
3239            .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
3240            .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
3241            .with(
3242                "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
3243                "https://cdn.example.test",
3244            )
3245            .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
3246            .with(
3247                "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
3248                "AWS_SECRET_ACCESS_KEY",
3249            )
3250            .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
3251            .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99");
3252        let mut config = AutumnConfig::default();
3253
3254        config.apply_env_overrides_with_env(&env);
3255
3256        assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
3257        assert_eq!(config.storage.default_provider, "media");
3258        assert!(config.storage.allow_local_in_production);
3259        assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
3260        assert_eq!(config.storage.local.mount_path, "/files");
3261        assert_eq!(config.storage.local.default_url_expiry_secs, 42);
3262        assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
3263        assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
3264        assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
3265        assert_eq!(
3266            config.storage.s3.endpoint.as_deref(),
3267            Some("https://s3.example.test")
3268        );
3269        assert_eq!(
3270            config.storage.s3.public_base_url.as_deref(),
3271            Some("https://cdn.example.test")
3272        );
3273        assert_eq!(
3274            config.storage.s3.access_key_id_env.as_deref(),
3275            Some("AWS_ACCESS_KEY_ID")
3276        );
3277        assert_eq!(
3278            config.storage.s3.secret_access_key_env.as_deref(),
3279            Some("AWS_SECRET_ACCESS_KEY")
3280        );
3281        assert!(config.storage.s3.force_path_style);
3282        assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
3283    }
3284
3285    #[test]
3286    fn env_override_database_auto_migrate_in_production() {
3287        let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
3288        let mut config = AutumnConfig::default();
3289        config.apply_env_overrides_with_env(&env);
3290        assert!(config.database.auto_migrate_in_production);
3291    }
3292
3293    #[test]
3294    fn env_override_jobs_fields() {
3295        let env = MockEnv::new()
3296            .with("AUTUMN_JOBS__BACKEND", "redis")
3297            .with("AUTUMN_JOBS__WORKERS", "8")
3298            .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
3299            .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
3300            .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
3301            .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
3302            .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
3303        let mut config = AutumnConfig::default();
3304        config.apply_env_overrides_with_env(&env);
3305
3306        assert_eq!(config.jobs.backend, "redis");
3307        assert_eq!(config.jobs.workers, 8);
3308        assert_eq!(config.jobs.max_attempts, 12);
3309        assert_eq!(config.jobs.initial_backoff_ms, 750);
3310        assert_eq!(
3311            config.jobs.redis.url.as_deref(),
3312            Some("redis://jobs:6379/2")
3313        );
3314        assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
3315        assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
3316    }
3317
3318    #[test]
3319    fn jobs_toml_deserializes_redis_visibility_timeout() {
3320        let config: AutumnConfig = toml::from_str(
3321            r#"
3322            [jobs]
3323            backend = "redis"
3324
3325            [jobs.redis]
3326            url = "redis://localhost:6379/5"
3327            key_prefix = "demo:jobs"
3328            visibility_timeout_ms = 15000
3329            "#,
3330        )
3331        .unwrap();
3332
3333        assert_eq!(config.jobs.backend, "redis");
3334        assert_eq!(
3335            config.jobs.redis.url.as_deref(),
3336            Some("redis://localhost:6379/5")
3337        );
3338        assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
3339        assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
3340    }
3341
3342    #[test]
3343    fn channels_defaults_to_in_process_backend() {
3344        let config = AutumnConfig::default();
3345
3346        assert_eq!(config.channels.backend, ChannelBackend::InProcess);
3347        assert_eq!(config.channels.capacity, 32);
3348        assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
3349        assert!(config.channels.redis.url.is_none());
3350    }
3351
3352    #[test]
3353    fn channels_env_overrides_fields() {
3354        let env = MockEnv::new()
3355            .with("AUTUMN_CHANNELS__BACKEND", "redis")
3356            .with("AUTUMN_CHANNELS__CAPACITY", "128")
3357            .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
3358            .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
3359        let mut config = AutumnConfig::default();
3360
3361        config.apply_env_overrides_with_env(&env);
3362
3363        assert_eq!(config.channels.backend, ChannelBackend::Redis);
3364        assert_eq!(config.channels.capacity, 128);
3365        assert_eq!(
3366            config.channels.redis.url.as_deref(),
3367            Some("redis://channels:6379/4")
3368        );
3369        assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
3370    }
3371
3372    #[test]
3373    fn channels_toml_deserializes_redis_backend() {
3374        let config: AutumnConfig = toml::from_str(
3375            r#"
3376            [channels]
3377            backend = "redis"
3378            capacity = 64
3379
3380            [channels.redis]
3381            url = "redis://localhost:6379/5"
3382            key_prefix = "demo:channels"
3383            "#,
3384        )
3385        .unwrap();
3386
3387        assert_eq!(config.channels.backend, ChannelBackend::Redis);
3388        assert_eq!(config.channels.capacity, 64);
3389        assert_eq!(
3390            config.channels.redis.url.as_deref(),
3391            Some("redis://localhost:6379/5")
3392        );
3393        assert_eq!(config.channels.redis.key_prefix, "demo:channels");
3394    }
3395
3396    #[test]
3397    fn env_override_invalid_jobs_numeric_values_ignored() {
3398        let env = MockEnv::new()
3399            .with("AUTUMN_JOBS__WORKERS", "many")
3400            .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
3401            .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
3402        let mut config = AutumnConfig::default();
3403        config.apply_env_overrides_with_env(&env);
3404
3405        assert_eq!(config.jobs.workers, 1);
3406        assert_eq!(config.jobs.max_attempts, 5);
3407        assert_eq!(config.jobs.initial_backoff_ms, 250);
3408    }
3409
3410    // ── Server env override tests ────────────────────────────────
3411
3412    #[test]
3413    fn env_override_server_port() {
3414        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
3415        let mut config = AutumnConfig::default();
3416        config.apply_env_overrides_with_env(&env);
3417        assert_eq!(config.server.port, 8080);
3418    }
3419
3420    #[test]
3421    fn parse_env_works() {
3422        let env = MockEnv::new().with("SOME_NUM", "123");
3423        let mut target: u32 = 0;
3424        parse_env(&env, "SOME_NUM", &mut target);
3425        assert_eq!(target, 123);
3426
3427        let env_err = MockEnv::new().with("SOME_NUM", "abc");
3428        let mut target_err: u32 = 0;
3429        parse_env(&env_err, "SOME_NUM", &mut target_err);
3430        assert_eq!(target_err, 0); // Unchanged
3431    }
3432
3433    #[test]
3434    fn parse_env_option_string_works() {
3435        let env = MockEnv::new().with("SOME_OPT", "val");
3436        let mut target = None;
3437        parse_env_option_string(&env, "SOME_OPT", &mut target);
3438        assert_eq!(target, Some("val".to_string()));
3439
3440        let env_empty = MockEnv::new().with("SOME_OPT", "");
3441        let mut target_empty = Some("old".to_string());
3442        parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
3443        assert_eq!(target_empty, None);
3444    }
3445
3446    #[test]
3447    fn parse_env_string_works() {
3448        let env = MockEnv::new().with("SOME_STR", "val");
3449        let mut target = "old".to_string();
3450        parse_env_string(&env, "SOME_STR", &mut target);
3451        assert_eq!(target, "val");
3452    }
3453
3454    #[test]
3455    fn parse_env_bool_works() {
3456        let env = MockEnv::new().with("SOME_BOOL", "true");
3457        let mut target = false;
3458        parse_env_bool(&env, "SOME_BOOL", &mut target);
3459        assert!(target);
3460
3461        let env2 = MockEnv::new().with("SOME_BOOL", "1");
3462        let mut target2 = false;
3463        parse_env_bool(&env2, "SOME_BOOL", &mut target2);
3464        assert!(target2);
3465
3466        let env3 = MockEnv::new().with("SOME_BOOL", "0");
3467        let mut target3 = true;
3468        parse_env_bool(&env3, "SOME_BOOL", &mut target3);
3469        assert!(!target3);
3470
3471        let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
3472        let mut target_err = true;
3473        parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
3474        assert!(target_err); // Unchanged
3475    }
3476
3477    #[test]
3478    fn parse_env_csv_works() {
3479        let env = MockEnv::new().with("SOME_CSV", "a, b,c");
3480        let mut target = vec![];
3481        parse_env_csv(&env, "SOME_CSV", &mut target);
3482        assert_eq!(target, vec!["a", "b", "c"]);
3483    }
3484
3485    #[test]
3486    fn env_override_rate_limit_trusted_proxies() {
3487        let env = MockEnv::new().with(
3488            "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
3489            "10.0.0.10, 203.0.113.0/24",
3490        );
3491        let mut config = AutumnConfig::default();
3492        config.apply_env_overrides_with_env(&env);
3493        assert_eq!(
3494            config.security.rate_limit.trusted_proxies,
3495            vec!["10.0.0.10", "203.0.113.0/24"]
3496        );
3497    }
3498
3499    #[test]
3500    fn env_override_server_host() {
3501        let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
3502        let mut config = AutumnConfig::default();
3503        config.apply_env_overrides_with_env(&env);
3504        assert_eq!(config.server.host, "0.0.0.0");
3505    }
3506
3507    #[test]
3508    fn env_override_server_shutdown_timeout() {
3509        let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
3510        let mut config = AutumnConfig::default();
3511        config.apply_env_overrides_with_env(&env);
3512        assert_eq!(config.server.shutdown_timeout_secs, 60);
3513    }
3514
3515    #[test]
3516    fn env_override_invalid_server_port_ignored() {
3517        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
3518        let mut config = AutumnConfig::default();
3519        config.apply_env_overrides_with_env(&env);
3520        assert_eq!(config.server.port, 3000);
3521    }
3522
3523    #[test]
3524    fn env_override_invalid_shutdown_timeout_ignored() {
3525        let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
3526        let mut config = AutumnConfig::default();
3527        config.apply_env_overrides_with_env(&env);
3528        assert_eq!(config.server.shutdown_timeout_secs, 30);
3529    }
3530
3531    // ── Log env override tests ───────────────────────────────────
3532
3533    #[test]
3534    fn env_override_log_level() {
3535        let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
3536        let mut config = AutumnConfig::default();
3537        config.apply_env_overrides_with_env(&env);
3538        assert_eq!(config.log.level, "debug");
3539    }
3540
3541    #[test]
3542    fn env_override_log_format_json() {
3543        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
3544        let mut config = AutumnConfig::default();
3545        config.apply_env_overrides_with_env(&env);
3546        assert_eq!(config.log.format, LogFormat::Json);
3547    }
3548
3549    #[test]
3550    fn env_override_log_format_pretty() {
3551        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
3552        let mut config = AutumnConfig::default();
3553        config.apply_env_overrides_with_env(&env);
3554        assert_eq!(config.log.format, LogFormat::Pretty);
3555    }
3556
3557    #[test]
3558    fn env_override_invalid_log_format_ignored() {
3559        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
3560        let mut config = AutumnConfig::default();
3561        config.apply_env_overrides_with_env(&env);
3562        assert_eq!(config.log.format, LogFormat::Auto);
3563    }
3564
3565    // ── Health env override tests ────────────────────────────────
3566
3567    #[test]
3568    fn env_override_telemetry_fields() {
3569        let env = MockEnv::new()
3570            .with("AUTUMN_TELEMETRY__ENABLED", "true")
3571            .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
3572            .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
3573            .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
3574            .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
3575            .with(
3576                "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
3577                "http://otel-collector:4317",
3578            )
3579            .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
3580            .with("AUTUMN_TELEMETRY__STRICT", "true");
3581        let mut config = AutumnConfig::default();
3582        config.apply_env_overrides_with_env(&env);
3583        assert!(config.telemetry.enabled);
3584        assert_eq!(config.telemetry.service_name, "orders-api");
3585        assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
3586        assert_eq!(config.telemetry.service_version, "1.2.3");
3587        assert_eq!(config.telemetry.environment, "production");
3588        assert_eq!(
3589            config.telemetry.otlp_endpoint.as_deref(),
3590            Some("http://otel-collector:4317")
3591        );
3592        assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
3593        assert!(config.telemetry.strict);
3594    }
3595
3596    #[test]
3597    fn env_override_invalid_telemetry_protocol_ignored() {
3598        let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
3599        let mut config = AutumnConfig::default();
3600        config.apply_env_overrides_with_env(&env);
3601        assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
3602    }
3603
3604    #[test]
3605    fn env_override_health_path() {
3606        let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
3607        let mut config = AutumnConfig::default();
3608        config.apply_env_overrides_with_env(&env);
3609        assert_eq!(config.health.path, "/healthz");
3610    }
3611
3612    #[test]
3613    fn env_override_probe_paths() {
3614        let env = MockEnv::new()
3615            .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
3616            .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
3617            .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
3618        let mut config = AutumnConfig::default();
3619        config.apply_env_overrides_with_env(&env);
3620        assert_eq!(config.health.live_path, "/livez");
3621        assert_eq!(config.health.ready_path, "/readyz");
3622        assert_eq!(config.health.startup_path, "/startupz");
3623    }
3624
3625    // ── Precedence test ──────────────────────────────────────────
3626
3627    #[test]
3628    fn env_overrides_toml_values() {
3629        let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
3630        let dir = tempfile::tempdir().unwrap();
3631        let path = dir.path().join("autumn.toml");
3632        std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
3633        let mut config = AutumnConfig::load_from(&path).unwrap();
3634        config.apply_env_overrides_with_env(&env);
3635        assert_eq!(config.server.port, 9999); // env wins
3636    }
3637
3638    // ── Validation tests ─────────────────────────────────────────
3639
3640    #[test]
3641    fn validate_rejects_invalid_url_scheme() {
3642        let config = DatabaseConfig {
3643            url: Some("mysql://localhost/test".to_owned()),
3644            ..Default::default()
3645        };
3646        let result = config.validate();
3647        assert!(result.is_err());
3648        assert!(
3649            result
3650                .unwrap_err()
3651                .to_string()
3652                .contains("must start with postgres://")
3653        );
3654    }
3655
3656    #[test]
3657    fn validate_accepts_postgres_url() {
3658        let config = DatabaseConfig {
3659            url: Some("postgres://localhost/test".to_owned()),
3660            ..Default::default()
3661        };
3662        assert!(config.validate().is_ok());
3663    }
3664
3665    #[test]
3666    fn validate_accepts_postgresql_url() {
3667        let config = DatabaseConfig {
3668            url: Some("postgresql://localhost/test".to_owned()),
3669            ..Default::default()
3670        };
3671        assert!(config.validate().is_ok());
3672    }
3673
3674    #[test]
3675    fn validate_accepts_no_url() {
3676        let config = DatabaseConfig::default();
3677        assert!(config.validate().is_ok());
3678    }
3679
3680    // ── Profile tests ──────────────────────────────────────────
3681
3682    #[test]
3683    fn resolve_profile_from_autumn_env() {
3684        let env = MockEnv::new().with("AUTUMN_ENV", "prod");
3685        let profile = resolve_profile(&env);
3686        assert_eq!(profile, "prod");
3687    }
3688
3689    #[test]
3690    fn resolve_profile_from_legacy_env() {
3691        let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
3692        let profile = resolve_profile(&env);
3693        assert_eq!(profile, "staging");
3694    }
3695
3696    #[test]
3697    fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
3698        let env = MockEnv::new()
3699            .with("AUTUMN_ENV", "dev")
3700            .with("AUTUMN_PROFILE", "prod");
3701        let profile = resolve_profile(&env);
3702        assert_eq!(profile, "dev");
3703    }
3704
3705    #[test]
3706    fn resolve_profile_normalizes_production_alias() {
3707        let env = MockEnv::new().with("AUTUMN_ENV", "production");
3708        let profile = resolve_profile(&env);
3709        assert_eq!(profile, "prod");
3710    }
3711
3712    #[test]
3713    fn resolve_profile_normalizes_development_alias_with_whitespace() {
3714        let env = MockEnv::new().with("AUTUMN_ENV", "  development  ");
3715        let profile = resolve_profile(&env);
3716        assert_eq!(profile, "dev");
3717    }
3718
3719    #[test]
3720    fn resolve_profile_normalizes_uppercase_dev_and_prod() {
3721        let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
3722        let prod = resolve_profile(&prod_env);
3723        assert_eq!(prod, "prod");
3724
3725        let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
3726        let dev = resolve_profile(&dev_env);
3727        assert_eq!(dev, "dev");
3728    }
3729
3730    #[test]
3731    fn resolve_profile_preserves_case_for_custom_profiles() {
3732        let env = MockEnv::new().with("AUTUMN_ENV", "QA");
3733        let profile = resolve_profile(&env);
3734        assert_eq!(profile, "QA");
3735    }
3736
3737    #[test]
3738    fn resolve_profile_auto_detect_debug() {
3739        let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
3740        let profile = resolve_profile(&env);
3741        assert_eq!(profile, "dev");
3742    }
3743
3744    #[test]
3745    fn resolve_profile_auto_detect_release() {
3746        let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
3747        let profile = resolve_profile(&env);
3748        assert_eq!(profile, "prod");
3749    }
3750
3751    #[test]
3752    fn resolve_profile_defaults_to_dev_when_no_signal_present() {
3753        let env = MockEnv::new();
3754        let profile = resolve_profile(&env);
3755        assert_eq!(profile, "dev");
3756    }
3757
3758    #[test]
3759    fn dev_profile_smart_defaults() {
3760        let defaults = profile_defaults_as_toml("dev");
3761        let toml_str = toml::to_string(&defaults).unwrap();
3762        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3763
3764        assert_eq!(config.log.level, "debug");
3765        assert_eq!(config.log.format, LogFormat::Pretty);
3766        assert_eq!(config.server.host, "127.0.0.1");
3767        assert_eq!(config.server.shutdown_timeout_secs, 1);
3768        assert_eq!(config.telemetry.environment, "development");
3769        assert!(config.health.detailed);
3770        assert_eq!(config.cors.allowed_origins, vec!["*"]);
3771    }
3772
3773    #[test]
3774    fn prod_profile_smart_defaults() {
3775        let defaults = profile_defaults_as_toml("prod");
3776        let toml_str = toml::to_string(&defaults).unwrap();
3777        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3778
3779        assert_eq!(config.log.level, "info");
3780        assert_eq!(config.log.format, LogFormat::Json);
3781        assert_eq!(config.server.host, "0.0.0.0");
3782        assert_eq!(config.server.shutdown_timeout_secs, 30);
3783        assert_eq!(config.telemetry.environment, "production");
3784        assert!(!config.health.detailed);
3785        // AC: HSTS auto-enabled in the production profile.
3786        assert!(
3787            config.security.headers.strict_transport_security,
3788            "prod profile must auto-enable Strict-Transport-Security"
3789        );
3790        // Defaults should still be secure-by-default in prod.
3791        assert_eq!(config.security.headers.x_frame_options, "DENY");
3792        assert!(config.security.headers.x_content_type_options);
3793        assert!(!config.security.headers.content_security_policy.is_empty());
3794    }
3795
3796    #[test]
3797    fn dev_profile_does_not_auto_enable_hsts() {
3798        let defaults = profile_defaults_as_toml("dev");
3799        let toml_str = toml::to_string(&defaults).unwrap();
3800        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3801
3802        assert!(
3803            !config.security.headers.strict_transport_security,
3804            "dev profile must not force HSTS on (local http development)"
3805        );
3806    }
3807
3808    #[test]
3809    fn custom_profile_no_smart_defaults() {
3810        let defaults = profile_defaults_as_toml("staging");
3811        assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
3812    }
3813
3814    #[test]
3815    fn deep_merge_tables() {
3816        let mut base: toml::Value = toml::from_str(
3817            r#"
3818            [server]
3819            port = 3000
3820            host = "127.0.0.1"
3821            [database]
3822            pool_size = 10
3823            "#,
3824        )
3825        .unwrap();
3826
3827        let overlay: toml::Value = toml::from_str(
3828            r#"
3829            [server]
3830            port = 8080
3831            [database]
3832            url = "postgres://localhost/test"
3833            "#,
3834        )
3835        .unwrap();
3836
3837        deep_merge(&mut base, overlay);
3838
3839        // Overlay value wins
3840        assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
3841        // Base value preserved when not in overlay
3842        assert_eq!(
3843            base["server"]["host"],
3844            toml::Value::String("127.0.0.1".into())
3845        );
3846        // New key from overlay added
3847        assert_eq!(
3848            base["database"]["url"],
3849            toml::Value::String("postgres://localhost/test".into())
3850        );
3851        // Base key preserved
3852        assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
3853    }
3854
3855    #[test]
3856    fn profile_toml_overrides_base_toml() {
3857        let dir = tempfile::tempdir().unwrap();
3858        let base_path = dir.path().join("autumn.toml");
3859        let dev_path = dir.path().join("autumn-dev.toml");
3860
3861        std::fs::write(
3862            &base_path,
3863            r"
3864            [server]
3865            port = 3000
3866            [database]
3867            pool_size = 10
3868            ",
3869        )
3870        .unwrap();
3871
3872        std::fs::write(
3873            &dev_path,
3874            r#"
3875            [database]
3876            url = "postgres://localhost/myapp_dev"
3877            "#,
3878        )
3879        .unwrap();
3880
3881        // Load base
3882        let mut merged = toml::Value::Table(toml::map::Map::new());
3883        let base = load_raw_toml(&base_path).unwrap().unwrap();
3884        deep_merge(&mut merged, base);
3885        let profile = load_raw_toml(&dev_path).unwrap().unwrap();
3886        deep_merge(&mut merged, profile);
3887
3888        let toml_str = toml::to_string(&merged).unwrap();
3889        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3890
3891        assert_eq!(config.server.port, 3000); // from base
3892        assert_eq!(config.database.pool_size, 10); // from base, preserved
3893        assert_eq!(
3894            config.database.url.as_deref(),
3895            Some("postgres://localhost/myapp_dev")
3896        ); // from profile
3897    }
3898
3899    #[test]
3900    fn inline_profile_section_overrides_base_toml() {
3901        let mut merged = toml::Value::Table(toml::map::Map::new());
3902        let base: toml::Value = toml::from_str(
3903            r#"
3904            [server]
3905            port = 3000
3906
3907            [log]
3908            level = "info"
3909
3910            [profile.dev.log]
3911            level = "debug"
3912            "#,
3913        )
3914        .unwrap();
3915
3916        deep_merge(&mut merged, base.clone());
3917        let inline = profile_section_from_base_toml(&base, "dev").unwrap();
3918        deep_merge(&mut merged, inline);
3919
3920        let toml_str = toml::to_string(&merged).unwrap();
3921        let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
3922        assert_eq!(config.server.port, 3000);
3923        assert_eq!(config.log.level, "debug");
3924    }
3925
3926    #[test]
3927    fn levenshtein_basic() {
3928        assert_eq!(levenshtein("dev", "dev"), 0);
3929        assert_eq!(levenshtein("dev", "dve"), 2); // swap = 2 edits (del + ins)
3930        assert_eq!(levenshtein("prod", "prodd"), 1);
3931        assert_eq!(levenshtein("prod", "prd"), 1);
3932        assert_eq!(levenshtein("staging", "dev"), 7);
3933    }
3934
3935    #[test]
3936    fn env_override_health_detailed() {
3937        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
3938        let mut config = AutumnConfig::default();
3939        config.apply_env_overrides_with_env(&env);
3940        assert!(config.health.detailed);
3941    }
3942
3943    #[test]
3944    fn profile_name_accessor() {
3945        let mut config = AutumnConfig::default();
3946        assert!(config.profile_name().is_none());
3947
3948        config.profile = Some("dev".to_owned());
3949        assert_eq!(config.profile_name(), Some("dev"));
3950    }
3951
3952    // ── Mutant-hunting tests ────────────────────────────────────
3953
3954    #[test]
3955    fn find_config_file_falls_back_to_cwd() {
3956        // Without AUTUMN_MANIFEST_DIR, should return just the filename
3957        let env = MockEnv::new();
3958        let path = find_config_file_named("autumn.toml", &env);
3959        assert_eq!(path, PathBuf::from("autumn.toml"));
3960    }
3961
3962    #[test]
3963    fn find_config_file_uses_manifest_dir_when_file_exists() {
3964        let dir = tempfile::tempdir().unwrap();
3965        let config_path = dir.path().join("autumn.toml");
3966        std::fs::write(&config_path, "").unwrap();
3967
3968        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
3969        let path = find_config_file_named("autumn.toml", &env);
3970        assert_eq!(path, config_path);
3971    }
3972
3973    #[test]
3974    fn find_config_file_falls_back_when_manifest_dir_missing_file() {
3975        let dir = tempfile::tempdir().unwrap();
3976        // dir exists but the file doesn't
3977        let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
3978        let path = find_config_file_named("nonexistent.toml", &env);
3979        assert_eq!(path, PathBuf::from("nonexistent.toml"));
3980    }
3981
3982    #[test]
3983    fn resolve_profile_cli_flag_exact_match() {
3984        // resolve_profile checks `--profile` in CLI args. We can't easily
3985        // inject args, but we can verify the env path doesn't match other args.
3986        // The `== "--profile"` guard is the key: if it were `!=`, every arg
3987        // would trigger the branch.
3988        let env = MockEnv::new();
3989        // With no env vars and no matching CLI args, should be None
3990        let profile = resolve_profile(&env);
3991        // This may or may not be None depending on test harness args,
3992        // but the important thing is it doesn't crash or return garbage.
3993        // The env-based tests above cover the positive cases.
3994        drop(profile);
3995    }
3996
3997    #[test]
3998    fn deep_merge_non_table_overlay_replaces_base() {
3999        // When overlay is not a table, it should replace (not merge into) base.
4000        // This kills the `&& → ||` mutant on line 162.
4001        let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
4002        let overlay = toml::Value::String("not_a_table".into());
4003
4004        // When base is table and overlay is NOT table, base should be unchanged
4005        // (the function only merges when BOTH are tables).
4006        deep_merge(&mut base, overlay);
4007        // base should still be the original table (overlay was ignored)
4008        assert!(base.is_table());
4009        assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
4010    }
4011
4012    #[test]
4013    fn deep_merge_when_base_not_table() {
4014        // When base is not a table, overlay should not merge
4015        let mut base = toml::Value::String("original".into());
4016        let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
4017
4018        deep_merge(&mut base, overlay);
4019        // base should be unchanged
4020        assert_eq!(base, toml::Value::String("original".into()));
4021    }
4022
4023    #[test]
4024    fn suggest_profile_close_match() {
4025        // "dve" is edit-distance 2 from "dev" → should suggest "dev"
4026        assert_eq!(suggest_profile("dve"), Some("dev"));
4027    }
4028
4029    #[test]
4030    fn suggest_profile_no_match_when_distant() {
4031        // "xyz" is far from both "dev" and "prod" → no suggestion
4032        assert_eq!(suggest_profile("xyz"), None);
4033    }
4034
4035    #[test]
4036    fn suggest_profile_exact_known_profile() {
4037        // Exact match has distance 0 → suggests itself
4038        assert_eq!(suggest_profile("dev"), Some("dev"));
4039        assert_eq!(suggest_profile("prod"), Some("prod"));
4040    }
4041
4042    #[test]
4043    fn suggest_profile_prd() {
4044        // "prd" is distance 1 from "prod"
4045        assert_eq!(suggest_profile("prd"), Some("prod"));
4046    }
4047
4048    #[test]
4049    fn warn_profile_typo_runs_without_panic() {
4050        warn_profile_typo("dve");
4051        warn_profile_typo("xyz");
4052    }
4053
4054    #[test]
4055    fn should_warn_missing_profile_file_custom_without_inline() {
4056        assert!(should_warn_missing_profile_file("staging", false));
4057    }
4058
4059    #[test]
4060    fn should_not_warn_missing_profile_file_custom_with_inline() {
4061        assert!(!should_warn_missing_profile_file("staging", true));
4062    }
4063
4064    #[test]
4065    fn should_not_warn_missing_profile_file_dev_or_prod() {
4066        assert!(!should_warn_missing_profile_file("dev", false));
4067        assert!(!should_warn_missing_profile_file("prod", false));
4068    }
4069
4070    #[test]
4071    fn levenshtein_threshold_in_warn_profile_typo() {
4072        assert!(levenshtein("dve", "dev") <= 2);
4073        assert!(levenshtein("xyz", "dev") > 2);
4074        assert!(levenshtein("xyz", "prod") > 2);
4075    }
4076
4077    #[test]
4078    fn env_override_cors_allowed_origins() {
4079        let env = MockEnv::new().with(
4080            "AUTUMN_CORS__ALLOWED_ORIGINS",
4081            "https://a.com, https://b.com",
4082        );
4083        let mut config = AutumnConfig::default();
4084        config.apply_env_overrides_with_env(&env);
4085        assert_eq!(
4086            config.cors.allowed_origins,
4087            vec!["https://a.com", "https://b.com"]
4088        );
4089    }
4090
4091    #[test]
4092    fn env_override_cors_allow_credentials() {
4093        let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
4094        let mut config = AutumnConfig::default();
4095        config.apply_env_overrides_with_env(&env);
4096        assert!(config.cors.allow_credentials);
4097    }
4098
4099    #[test]
4100    fn env_override_cors_max_age() {
4101        let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
4102        let mut config = AutumnConfig::default();
4103        config.apply_env_overrides_with_env(&env);
4104        assert_eq!(config.cors.max_age_secs, 3600);
4105    }
4106
4107    #[test]
4108    fn cors_validate_rejects_wildcard_with_credentials() {
4109        let mut config = AutumnConfig::default();
4110        config.cors.allowed_origins = vec!["*".to_owned()];
4111        config.cors.allow_credentials = true;
4112
4113        let result = config.validate();
4114        match result {
4115            Err(ConfigError::Validation(msg)) => {
4116                assert!(
4117                    msg.contains("allow_credentials") && msg.contains('*'),
4118                    "message should mention credentials and wildcard, got: {msg}"
4119                );
4120            }
4121            other => panic!("expected ConfigError::Validation, got {other:?}"),
4122        }
4123    }
4124
4125    #[test]
4126    fn cors_validate_accepts_wildcard_without_credentials() {
4127        let mut config = AutumnConfig::default();
4128        config.cors.allowed_origins = vec!["*".to_owned()];
4129        config.cors.allow_credentials = false;
4130        assert!(config.validate().is_ok());
4131    }
4132
4133    #[test]
4134    fn cors_validate_accepts_explicit_origins_with_credentials() {
4135        let mut config = AutumnConfig::default();
4136        config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
4137        config.cors.allow_credentials = true;
4138        assert!(config.validate().is_ok());
4139    }
4140
4141    #[test]
4142    fn load_uses_profile_layering() {
4143        // Test AutumnConfig::load_with_env() with a dev profile via env var.
4144        // This kills the "replace load → Ok(Default::default())" mutant.
4145        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
4146
4147        let config = AutumnConfig::load_with_env(&env).unwrap();
4148        // With dev profile, smart defaults should apply
4149        assert_eq!(config.profile.as_deref(), Some("dev"));
4150        assert_eq!(config.log.level, "debug"); // dev default
4151        assert_eq!(config.log.format, LogFormat::Pretty); // dev default
4152        assert!(config.health.detailed); // dev default
4153    }
4154
4155    #[test]
4156    fn load_custom_profile_without_toml_warns() {
4157        // Test the typo warning branch: profile != "dev" && profile != "prod"
4158        // without a corresponding autumn-{profile}.toml triggers warn_profile_typo.
4159        // This kills the match guard mutants on line 341.
4160        let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
4161
4162        let config = AutumnConfig::load_with_env(&env).unwrap();
4163        assert_eq!(config.profile.as_deref(), Some("staging"));
4164        // staging has no smart defaults, so values should be framework defaults
4165        assert_eq!(config.server.port, 3000);
4166        assert_eq!(config.log.level, "info");
4167    }
4168
4169    #[test]
4170    fn load_dev_profile_no_profile_toml_no_warn() {
4171        // dev/prod without their profile TOML should NOT trigger warn_profile_typo.
4172        // This tests the `None => {}` branch (line 342).
4173        let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
4174
4175        let config = AutumnConfig::load_with_env(&env).unwrap();
4176        assert_eq!(config.profile.as_deref(), Some("dev"));
4177    }
4178
4179    #[test]
4180    fn load_custom_profile_uses_inline_profile_without_legacy_file() {
4181        let dir = tempfile::tempdir().unwrap();
4182        let base_path = dir.path().join("autumn.toml");
4183        std::fs::write(
4184            &base_path,
4185            r"
4186            [server]
4187            port = 3000
4188
4189            [profile.staging.server]
4190            port = 4100
4191            ",
4192        )
4193        .unwrap();
4194
4195        let env = MockEnv::new()
4196            .with("AUTUMN_ENV", "staging")
4197            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4198
4199        let config = AutumnConfig::load_with_env(&env).unwrap();
4200        assert_eq!(config.profile.as_deref(), Some("staging"));
4201        assert_eq!(config.server.port, 4100);
4202    }
4203
4204    #[test]
4205    fn load_production_profile_reads_inline_profile_production_section() {
4206        let dir = tempfile::tempdir().unwrap();
4207        let base_path = dir.path().join("autumn.toml");
4208        std::fs::write(
4209            &base_path,
4210            r"
4211            [profile.production.server]
4212            port = 4200
4213            ",
4214        )
4215        .unwrap();
4216
4217        let env = MockEnv::new()
4218            .with("AUTUMN_ENV", "production")
4219            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4220
4221        let config = AutumnConfig::load_with_env(&env).unwrap();
4222        assert_eq!(config.profile.as_deref(), Some("prod"));
4223        assert_eq!(config.server.port, 4200);
4224    }
4225
4226    #[test]
4227    fn load_production_profile_reads_legacy_autumn_production_toml() {
4228        let dir = tempfile::tempdir().unwrap();
4229        let production_path = dir.path().join("autumn-production.toml");
4230        std::fs::write(
4231            &production_path,
4232            r"
4233            [server]
4234            port = 4300
4235            ",
4236        )
4237        .unwrap();
4238
4239        let env = MockEnv::new()
4240            .with("AUTUMN_ENV", "production")
4241            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4242
4243        let config = AutumnConfig::load_with_env(&env).unwrap();
4244        assert_eq!(config.profile.as_deref(), Some("prod"));
4245        assert_eq!(config.server.port, 4300);
4246    }
4247
4248    #[test]
4249    fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
4250        let dir = tempfile::tempdir().unwrap();
4251        let prod_path = dir.path().join("autumn-prod.toml");
4252        let production_path = dir.path().join("autumn-production.toml");
4253
4254        std::fs::write(
4255            &prod_path,
4256            r"
4257            [server]
4258            port = 4400
4259            ",
4260        )
4261        .unwrap();
4262        // Malformed TOML should be ignored because `autumn-prod.toml` is chosen first.
4263        std::fs::write(&production_path, "[server\nport = 4500").unwrap();
4264
4265        let env = MockEnv::new()
4266            .with("AUTUMN_ENV", "prod")
4267            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4268
4269        let config = AutumnConfig::load_with_env(&env).unwrap();
4270        assert_eq!(config.profile.as_deref(), Some("prod"));
4271        assert_eq!(config.server.port, 4400);
4272    }
4273
4274    #[test]
4275    fn load_production_prefers_autumn_production_toml_before_prod_alias() {
4276        let dir = tempfile::tempdir().unwrap();
4277        let prod_path = dir.path().join("autumn-prod.toml");
4278        let production_path = dir.path().join("autumn-production.toml");
4279
4280        std::fs::write(
4281            &production_path,
4282            r"
4283            [server]
4284            port = 4500
4285            ",
4286        )
4287        .unwrap();
4288        // Malformed TOML should be ignored because `autumn-production.toml` is chosen first.
4289        std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
4290
4291        let env = MockEnv::new()
4292            .with("AUTUMN_ENV", "production")
4293            .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
4294
4295        let config = AutumnConfig::load_with_env(&env).unwrap();
4296        assert_eq!(config.profile.as_deref(), Some("prod"));
4297        assert_eq!(config.server.port, 4500);
4298    }
4299
4300    #[test]
4301    fn load_from_io_error_is_not_swallowed() {
4302        // load_from should return Err on non-NotFound IO errors.
4303        // On all platforms, trying to read a directory as a file triggers an error.
4304        let dir = tempfile::tempdir().unwrap();
4305        let result = AutumnConfig::load_from(dir.path());
4306        assert!(result.is_err());
4307    }
4308
4309    #[test]
4310    fn load_raw_toml_missing_file_returns_none() {
4311        let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
4312        assert!(result.is_none());
4313    }
4314
4315    #[test]
4316    fn load_raw_toml_directory_returns_io_error() {
4317        // Reading a directory is an IO error, NOT NotFound.
4318        // This kills the "replace match guard NotFound with true" mutant:
4319        // if the guard were always true, this would return Ok(None) instead of Err.
4320        let dir = tempfile::tempdir().unwrap();
4321        let result = load_raw_toml(dir.path());
4322        assert!(result.is_err());
4323    }
4324
4325    #[test]
4326    fn load_raw_toml_valid_file_returns_some() {
4327        let dir = tempfile::tempdir().unwrap();
4328        let path = dir.path().join("test.toml");
4329        std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
4330        let result = load_raw_toml(&path).unwrap();
4331        assert!(result.is_some());
4332        assert_eq!(
4333            result.unwrap()["server"]["port"],
4334            toml::Value::Integer(3000)
4335        );
4336    }
4337
4338    #[test]
4339    fn env_override_log_format_auto() {
4340        // Kills the "delete match arm Auto" mutant
4341        let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
4342        let mut config = AutumnConfig::default();
4343        // Start with non-Auto to prove the override works
4344        config.log.format = LogFormat::Json;
4345        config.apply_env_overrides_with_env(&env);
4346        assert_eq!(config.log.format, LogFormat::Auto);
4347    }
4348
4349    #[test]
4350    fn env_override_health_detailed_false() {
4351        // Kills the 'delete match arm "false" | "0"' mutant
4352        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
4353        let mut config = AutumnConfig::default();
4354        config.health.detailed = true; // start true, override to false
4355        config.apply_env_overrides_with_env(&env);
4356        assert!(!config.health.detailed);
4357    }
4358
4359    #[test]
4360    fn env_override_health_detailed_zero() {
4361        let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
4362        let mut config = AutumnConfig::default();
4363        config.health.detailed = true;
4364        config.apply_env_overrides_with_env(&env);
4365        assert!(!config.health.detailed);
4366    }
4367
4368    #[test]
4369    fn cors_defaults() {
4370        let cors = CorsConfig::default();
4371        assert!(cors.allowed_origins.is_empty());
4372        assert_eq!(cors.allowed_methods.len(), 6);
4373        assert!(cors.allowed_methods.contains(&"GET".to_owned()));
4374        assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
4375        assert!(!cors.allow_credentials);
4376        assert_eq!(cors.max_age_secs, 86400);
4377    }
4378
4379    #[test]
4380    fn cors_in_full_config_defaults() {
4381        let config = AutumnConfig::default();
4382        assert!(config.cors.allowed_origins.is_empty());
4383    }
4384
4385    #[test]
4386    fn actuator_defaults() {
4387        let config = ActuatorConfig::default();
4388        assert_eq!(config.prefix, "/actuator");
4389        assert!(!config.sensitive);
4390    }
4391
4392    #[test]
4393    fn actuator_prefix_in_full_config() {
4394        let config = AutumnConfig::default();
4395        assert_eq!(config.actuator.prefix, "/actuator");
4396    }
4397
4398    #[test]
4399    fn deep_merge_handles_deep_nesting() {
4400        let mut base = toml::Value::Table(toml::map::Map::new());
4401        let mut overlay = toml::Value::Table(toml::map::Map::new());
4402
4403        // Create a 10,000 deep nested table
4404        let mut current_base = &mut base;
4405        let mut current_overlay = &mut overlay;
4406
4407        for _ in 0..10_000 {
4408            if let toml::Value::Table(t) = current_base {
4409                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4410                current_base = t.get_mut("x").unwrap();
4411            }
4412            if let toml::Value::Table(t) = current_overlay {
4413                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4414                current_overlay = t.get_mut("x").unwrap();
4415            }
4416        }
4417
4418        // Add a leaf value to test actual merging
4419        if let toml::Value::Table(t) = current_overlay {
4420            t.insert("y".to_owned(), toml::Value::Integer(42));
4421        }
4422
4423        // Trigger merge, expecting no panic/stack overflow
4424        // 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).
4425        std::thread::Builder::new()
4426            .stack_size(32 * 1024 * 1024)
4427            .spawn(move || {
4428                deep_merge(&mut base, overlay);
4429                // Let the OS clean up the memory instead of dropping deeply nested structure
4430                std::mem::forget(base);
4431            })
4432            .unwrap()
4433            .join()
4434            .unwrap();
4435    }
4436
4437    #[test]
4438    fn deep_merge_stops_at_max_depth() {
4439        let mut base = toml::Value::Table(toml::map::Map::new());
4440        let mut overlay = toml::Value::Table(toml::map::Map::new());
4441
4442        // Create structures nested exactly to MAX_MERGE_DEPTH + 1
4443        let mut current_base = &mut base;
4444        let mut current_overlay = &mut overlay;
4445
4446        for _ in 0..=MAX_MERGE_DEPTH {
4447            if let toml::Value::Table(t) = current_base {
4448                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4449                current_base = t.get_mut("x").unwrap();
4450            }
4451            if let toml::Value::Table(t) = current_overlay {
4452                t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
4453                current_overlay = t.get_mut("x").unwrap();
4454            }
4455        }
4456
4457        // Add a value deep in the overlay
4458        if let toml::Value::Table(t) = current_overlay {
4459            t.insert("deep_value".to_owned(), toml::Value::Integer(123));
4460        }
4461
4462        deep_merge(&mut base, overlay);
4463
4464        // Verify the value was NOT merged due to max depth limit
4465        let mut current_base_check = &base;
4466        for _ in 0..=MAX_MERGE_DEPTH {
4467            if let toml::Value::Table(t) = current_base_check {
4468                current_base_check = t.get("x").unwrap();
4469            }
4470        }
4471
4472        if let toml::Value::Table(t) = current_base_check {
4473            assert!(
4474                !t.contains_key("deep_value"),
4475                "Value beyond MAX_MERGE_DEPTH should not be merged"
4476            );
4477        } else {
4478            panic!("Expected a table");
4479        }
4480    }
4481
4482    // ── AUTUMN_SECURITY__FORBIDDEN_RESPONSE / __ALLOW_UNAUTHORIZED_REPOSITORY_API ──
4483
4484    #[test]
4485    fn env_override_forbidden_response_403() {
4486        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
4487        let mut config = AutumnConfig::default();
4488        config.apply_env_overrides_with_env(&env);
4489        assert_eq!(
4490            config.security.forbidden_response,
4491            crate::authorization::ForbiddenResponse::Forbidden403
4492        );
4493    }
4494
4495    #[test]
4496    fn env_override_forbidden_response_404() {
4497        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
4498        let mut config = AutumnConfig::default();
4499        // Pre-set to 403 to confirm env actually flips it back to 404.
4500        config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
4501        config.apply_env_overrides_with_env(&env);
4502        assert_eq!(
4503            config.security.forbidden_response,
4504            crate::authorization::ForbiddenResponse::NotFound404
4505        );
4506    }
4507
4508    #[test]
4509    fn env_override_forbidden_response_invalid_keeps_existing() {
4510        let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
4511        let mut config = AutumnConfig::default();
4512        config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
4513        config.apply_env_overrides_with_env(&env);
4514        // Invalid value warns and leaves the existing setting alone.
4515        assert_eq!(
4516            config.security.forbidden_response,
4517            crate::authorization::ForbiddenResponse::Forbidden403
4518        );
4519    }
4520
4521    #[test]
4522    fn env_override_allow_unauthorized_repository_api() {
4523        let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
4524        let mut config = AutumnConfig::default();
4525        assert!(!config.security.allow_unauthorized_repository_api);
4526        config.apply_env_overrides_with_env(&env);
4527        assert!(config.security.allow_unauthorized_repository_api);
4528    }
4529
4530    #[test]
4531    fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
4532        let env = MockEnv::new().with(
4533            "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
4534            "false",
4535        );
4536        let mut config = AutumnConfig::default();
4537        config.security.allow_unauthorized_repository_api = true;
4538        config.apply_env_overrides_with_env(&env);
4539        assert!(!config.security.allow_unauthorized_repository_api);
4540    }
4541
4542    // ── [openapi] config section tests (RED phase) ─────────────────────────
4543
4544    #[test]
4545    fn openapi_runtime_config_defaults_enabled() {
4546        // The [openapi] section must default to enabled=true and path="/openapi.json".
4547        let config = AutumnConfig::default();
4548        assert!(
4549            config.openapi_runtime.enabled,
4550            "[openapi] must default to enabled = true"
4551        );
4552        assert_eq!(
4553            config.openapi_runtime.path, "/openapi.json",
4554            "[openapi] must default to path = \"/openapi.json\""
4555        );
4556    }
4557
4558    #[test]
4559    fn openapi_runtime_config_can_be_disabled_via_toml() {
4560        let toml_str = "
4561[openapi]
4562enabled = false
4563";
4564        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
4565        assert!(
4566            !config.openapi_runtime.enabled,
4567            "[openapi] enabled = false must deserialize correctly"
4568        );
4569    }
4570
4571    #[test]
4572    fn openapi_runtime_config_path_can_be_customized() {
4573        let toml_str = r#"
4574[openapi]
4575path = "/api-spec.json"
4576"#;
4577        let config: AutumnConfig = toml::from_str(toml_str).unwrap();
4578        assert_eq!(
4579            config.openapi_runtime.path, "/api-spec.json",
4580            "[openapi] path must deserialize correctly"
4581        );
4582    }
4583
4584    #[test]
4585    fn cache_env_overrides_fields() {
4586        let env = MockEnv::new()
4587            .with("AUTUMN_CACHE__BACKEND", "redis")
4588            .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
4589            .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
4590        let mut config = AutumnConfig::default();
4591
4592        config.apply_env_overrides_with_env(&env);
4593
4594        assert!(config.cache.is_redis(), "backend should be redis");
4595        assert_eq!(
4596            config.cache.redis.url.as_deref(),
4597            Some("redis://cache:6379/1")
4598        );
4599        assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
4600    }
4601
4602    #[test]
4603    fn cache_backend_from_env_value_invalid_is_none() {
4604        assert!(CacheBackend::from_env_value("postgres").is_none());
4605        assert!(CacheBackend::from_env_value("").is_none());
4606    }
4607
4608    #[test]
4609    fn scheduler_validate_rejects_zero_lease_ttl() {
4610        let cfg = SchedulerConfig {
4611            lease_ttl_secs: 0,
4612            ..SchedulerConfig::default()
4613        };
4614        assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
4615    }
4616
4617    #[test]
4618    fn scheduler_validate_rejects_empty_key_prefix() {
4619        let cfg = SchedulerConfig {
4620            key_prefix: "   ".to_owned(),
4621            ..SchedulerConfig::default()
4622        };
4623        assert!(cfg.validate().is_err(), "blank key_prefix must fail");
4624    }
4625
4626    #[test]
4627    fn scheduler_validate_ok_with_defaults() {
4628        assert!(SchedulerConfig::default().validate().is_ok());
4629    }
4630
4631    #[test]
4632    fn scheduler_resolved_replica_id_uses_explicit_value() {
4633        let cfg = SchedulerConfig {
4634            replica_id: Some("my-pod".to_owned()),
4635            ..SchedulerConfig::default()
4636        };
4637        assert_eq!(cfg.resolved_replica_id(), "my-pod");
4638    }
4639
4640    #[test]
4641    fn scheduler_resolved_replica_id_falls_back_to_pid() {
4642        let cfg = SchedulerConfig {
4643            replica_id: None,
4644            ..SchedulerConfig::default()
4645        };
4646        // In CI, FLY_MACHINE_ID and HOSTNAME may or may not be set,
4647        // so just verify we get a non-empty string back.
4648        assert!(!cfg.resolved_replica_id().is_empty());
4649    }
4650
4651    #[cfg(feature = "mail")]
4652    #[test]
4653    fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
4654        let env = MockEnv::new()
4655            .with(
4656                "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
4657                "true",
4658            )
4659            .with("AUTUMN_MAIL__TRANSPORT", "smtp")
4660            .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
4661
4662        let mut config = AutumnConfig::default();
4663        config.apply_mail_env_overrides_with_env(&env);
4664
4665        assert!(
4666            config.mail.allow_in_process_deliver_later_in_production,
4667            "env var should set allow_in_process_deliver_later_in_production"
4668        );
4669    }
4670
4671    #[cfg(feature = "mail")]
4672    #[test]
4673    fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
4674        let env = MockEnv::new();
4675        let mut config = AutumnConfig::default();
4676        config.apply_mail_env_overrides_with_env(&env);
4677
4678        assert!(
4679            !config.mail.allow_in_process_deliver_later_in_production,
4680            "flag should default to false when env var is not set"
4681        );
4682    }
4683}