Skip to main content

autumn_plugin_pingora/
config.rs

1//! The `[pingora]` section of `autumn.toml`.
2//!
3//! Each key has a safe default. The plugin merges the section in the same
4//! order as Autumn core:
5//!
6//! 1. `[pingora]` in `autumn.toml`.
7//! 2. `[profile.<name>.pingora]` in `autumn.toml`.
8//! 3. `[pingora]` in `autumn-<profile>.toml`.
9//! 4. `AUTUMN_PINGORA__*` environment variables (also from `.env` files).
10//!
11//! ```toml
12//! [pingora]
13//! bind = "0.0.0.0:8080"
14//! fallback = "app"           # unmatched requests go to the Autumn app
15//!
16//! [[pingora.routes]]
17//! name = "billing"
18//! path_prefix = "/billing"
19//! upstreams = ["10.0.0.7:8080", "10.0.0.8:8080"]
20//! strip_prefix = true
21//! ```
22
23use std::collections::HashSet;
24use std::fmt;
25use std::net::SocketAddr;
26use std::path::{Path, PathBuf};
27use std::time::Duration;
28
29use autumn_web::config::Env;
30use serde::{Deserialize, Serialize};
31
32/// The default proxy port.
33pub const DEFAULT_PORT: u16 = 8080;
34
35/// The default config section.
36pub const DEFAULT_SECTION: &str = "pingora";
37
38/// Where unmatched requests go.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41#[non_exhaustive]
42pub enum Fallback {
43    /// To the Autumn app (ADR 0002).
44    #[default]
45    App,
46    /// Respond 404. In TOML: `"none"`.
47    #[serde(rename = "none")]
48    NotFound,
49}
50
51impl Fallback {
52    /// The name in TOML.
53    #[must_use]
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            Self::App => "app",
57            Self::NotFound => "none",
58        }
59    }
60}
61
62impl fmt::Display for Fallback {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(self.as_str())
65    }
66}
67
68/// How a route picks an upstream.
69#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
70#[serde(rename_all = "snake_case")]
71#[non_exhaustive]
72pub enum Selection {
73    /// Each upstream in turn.
74    #[default]
75    RoundRobin,
76    /// Consistent hash of the client IP.
77    Consistent,
78}
79
80impl fmt::Display for Selection {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        f.write_str(match self {
83            Self::RoundRobin => "round_robin",
84            Self::Consistent => "consistent",
85        })
86    }
87}
88
89/// One route: a match rule and an upstream pool.
90#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(default, deny_unknown_fields)]
92#[non_exhaustive]
93pub struct Route {
94    /// Unique name, used in metrics and logs. Letters, digits, `_`, `-`
95    /// and `.` only. `fallback` and `unmatched` are reserved.
96    pub name: String,
97    /// Host to match: `api.example.com` or `*.example.com`. Empty: any
98    /// host. The match ignores case and the port.
99    pub host: String,
100    /// Path prefix to match, on a segment boundary: `/api` matches `/api`
101    /// and `/api/x`, not `/apix`. Default: `/`.
102    pub path_prefix: String,
103    /// Upstream addresses, `host:port`. Hostnames resolve at boot.
104    pub upstreams: Vec<String>,
105    /// Remove `path_prefix` before the request goes upstream.
106    /// Default: `false`.
107    pub strip_prefix: bool,
108    /// `Host` header for the upstream. Empty: keep the client `Host`.
109    pub upstream_host: String,
110    /// How to pick an upstream. Default: `round_robin`.
111    pub selection: Selection,
112}
113
114impl Default for Route {
115    fn default() -> Self {
116        Self {
117            name: String::new(),
118            host: String::new(),
119            path_prefix: "/".to_owned(),
120            upstreams: Vec::new(),
121            strip_prefix: false,
122            upstream_host: String::new(),
123            selection: Selection::RoundRobin,
124        }
125    }
126}
127
128/// Settings for the proxy.
129///
130/// For a setting that says "`0`: off", the value `0` removes that limit.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(default, deny_unknown_fields)]
133#[non_exhaustive]
134pub struct PingoraConfig {
135    /// Start the proxy. Default: `true`.
136    pub enabled: bool,
137    /// Listen address, `IP:port`. Port `0` picks a free port. Empty: the
138    /// profile decides. `dev` and `test` use `127.0.0.1:8080`. Other
139    /// profiles use `0.0.0.0:8080`. Default: empty.
140    pub bind: String,
141    /// Where unmatched requests go: `app` (the Autumn app) or `none`
142    /// (404). Default: `app`.
143    pub fallback: Fallback,
144    /// Peers (IPs or CIDR ranges) whose `X-Forwarded-*` and other identity
145    /// headers the proxy keeps. For other peers, the proxy replaces them.
146    /// Put only your own load balancers here. Default: empty.
147    pub trusted_proxies: Vec<String>,
148    /// Upstream connect timeout. Must be more than `0`. Default: `5000`.
149    pub connect_timeout_ms: u64,
150    /// Upstream read timeout. `0`: off. Default: `60000`.
151    pub read_timeout_ms: u64,
152    /// Upstream write timeout. `0`: off. Default: `60000`.
153    pub write_timeout_ms: u64,
154    /// Connect retries on another upstream. Pingora also retries an
155    /// idempotent request once or twice when a pooled connection is stale.
156    /// Default: `1`.
157    pub max_retries: usize,
158    /// Upstream TCP health check interval. `0`: off. Default: `5000`.
159    pub health_check_interval_ms: u64,
160    /// Health check connect timeout. Must be more than `0`.
161    /// Default: `1000`.
162    pub health_check_timeout_ms: u64,
163    /// Largest request body. Bigger requests get 413. `0`: off.
164    /// Default: `10485760` (10 MiB).
165    pub max_request_body_bytes: u64,
166    /// Maximum open client connections. At the limit, the proxy stops
167    /// accepting until a connection closes. `0`: no limit. At most
168    /// `1000000`. Default: `10000`.
169    pub max_connections: usize,
170    /// Maximum open connections from one client IP. The proxy closes
171    /// connections over the limit at once. Keep `0` behind a load balancer,
172    /// because all connections then have its IP. `0`: no limit.
173    /// Default: `0`.
174    pub max_connections_per_ip: usize,
175    /// Time for open requests to finish at shutdown. Must be more than
176    /// `0`. The plugin caps it to fit `server.shutdown_timeout_secs`.
177    /// Default: `10000`.
178    pub shutdown_grace_ms: u64,
179    /// Record `pingora_proxy_*` metrics. Default: `true`.
180    pub metrics: bool,
181    /// The routes. Env: `AUTUMN_PINGORA__ROUTES` replaces all routes with
182    /// a TOML array.
183    pub routes: Vec<Route>,
184}
185
186impl Default for PingoraConfig {
187    fn default() -> Self {
188        Self {
189            enabled: true,
190            bind: String::new(),
191            fallback: Fallback::App,
192            trusted_proxies: Vec::new(),
193            connect_timeout_ms: 5_000,
194            read_timeout_ms: 60_000,
195            write_timeout_ms: 60_000,
196            max_retries: 1,
197            health_check_interval_ms: 5_000,
198            health_check_timeout_ms: 1_000,
199            max_request_body_bytes: 10 * 1024 * 1024,
200            max_connections: 10_000,
201            max_connections_per_ip: 0,
202            shutdown_grace_ms: 10_000,
203            metrics: true,
204            routes: Vec::new(),
205        }
206    }
207}
208
209/// A configuration problem. Boot stops.
210#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
211#[error("invalid pingora configuration: {0}")]
212pub struct ConfigError(String);
213
214impl ConfigError {
215    /// The problem, without the prefix.
216    #[must_use]
217    pub fn message(&self) -> &str {
218        &self.0
219    }
220}
221
222/// A resolved configuration and the active profile.
223#[derive(Debug, Clone)]
224pub struct Resolved {
225    pub(crate) config: PingoraConfig,
226    pub(crate) profile: String,
227}
228
229impl Resolved {
230    /// The merged, validated configuration.
231    #[must_use]
232    pub const fn config(&self) -> &PingoraConfig {
233        &self.config
234    }
235
236    /// The canonical active profile.
237    #[must_use]
238    pub fn profile(&self) -> &str {
239        &self.profile
240    }
241
242    /// `true` for the `dev` and `test` profiles.
243    #[must_use]
244    pub fn is_development(&self) -> bool {
245        matches!(self.profile.as_str(), "dev" | "test")
246    }
247
248    /// Configuration from code. The plugin reads no files. The profile
249    /// still comes from the environment.
250    pub(crate) fn explicit(config: PingoraConfig) -> Self {
251        let profile = autumn_web::dotenv::os_env_with_dotenv().map_or_else(
252            |_| resolve_active_profile(&autumn_web::config::OsEnv).1,
253            |env| resolve_active_profile(&env).1,
254        );
255        Self { config, profile }
256    }
257}
258
259const fn millis(ms: u64) -> Option<Duration> {
260    if ms == 0 {
261        None
262    } else {
263        Some(Duration::from_millis(ms))
264    }
265}
266
267/// Largest `max_connections`.
268pub const MAX_CONNECTIONS_LIMIT: usize = 1_000_000;
269
270/// Names that metrics use for requests with no route.
271pub const RESERVED_NAMES: [&str; 2] = ["fallback", "unmatched"];
272
273impl PingoraConfig {
274    /// The listen address. An empty `bind` gives `127.0.0.1:8080` in
275    /// development and `0.0.0.0:8080` in other profiles.
276    ///
277    /// # Errors
278    ///
279    /// Returns [`ConfigError`] when `bind` is not `IP:port`.
280    pub fn bind_addr(&self, development: bool) -> Result<SocketAddr, ConfigError> {
281        let bind = self.bind.trim();
282        if bind.is_empty() {
283            let host = if development {
284                [127, 0, 0, 1]
285            } else {
286                [0, 0, 0, 0]
287            };
288            return Ok(SocketAddr::from((host, DEFAULT_PORT)));
289        }
290        bind.parse().map_err(|_| {
291            ConfigError(format!(
292                "`bind` must be IP:port (for example \"0.0.0.0:8080\"), found \"{}\"",
293                self.bind
294            ))
295        })
296    }
297
298    /// Upstream connect timeout.
299    #[must_use]
300    pub const fn connect_timeout(&self) -> Duration {
301        Duration::from_millis(self.connect_timeout_ms)
302    }
303
304    /// Upstream read timeout.
305    #[must_use]
306    pub const fn read_timeout(&self) -> Option<Duration> {
307        millis(self.read_timeout_ms)
308    }
309
310    /// Upstream write timeout.
311    #[must_use]
312    pub const fn write_timeout(&self) -> Option<Duration> {
313        millis(self.write_timeout_ms)
314    }
315
316    /// Health check interval.
317    #[must_use]
318    pub const fn health_check_interval(&self) -> Option<Duration> {
319        millis(self.health_check_interval_ms)
320    }
321
322    /// Health check connect timeout.
323    #[must_use]
324    pub const fn health_check_timeout(&self) -> Duration {
325        Duration::from_millis(self.health_check_timeout_ms)
326    }
327
328    /// Shutdown grace period.
329    #[must_use]
330    pub const fn shutdown_grace(&self) -> Duration {
331        Duration::from_millis(self.shutdown_grace_ms)
332    }
333
334    /// Parse `[section]` from a whole `autumn.toml` text. No profiles and
335    /// no environment.
336    ///
337    /// # Errors
338    ///
339    /// Returns [`ConfigError`] for bad TOML, unknown keys, wrong types or
340    /// values that [`validate`](Self::validate) rejects.
341    pub fn from_toml_str(text: &str, section: &str) -> Result<Self, ConfigError> {
342        let document: toml::Table = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
343        let config = Self::from_section(document.get(section))?;
344        config.validate()?;
345        Ok(config)
346    }
347
348    fn from_section(section: Option<&toml::Value>) -> Result<Self, ConfigError> {
349        section.map_or_else(
350            || Ok(Self::default()),
351            |value| {
352                value
353                    .clone()
354                    .try_into()
355                    .map_err(|e: toml::de::Error| ConfigError(e.to_string()))
356            },
357        )
358    }
359
360    /// Resolve `[section]` from the app's files and the process environment.
361    ///
362    /// # Errors
363    ///
364    /// Returns [`ConfigError`] when a file cannot be read or parsed, or when
365    /// the merged section is not valid.
366    pub fn resolve(section: &str) -> Result<Resolved, ConfigError> {
367        let env = autumn_web::dotenv::os_env_with_dotenv()
368            .map_err(|e| ConfigError(format!("cannot read .env files: {e}")))?;
369        Self::resolve_with_env(section, &env)
370    }
371
372    /// Like [`resolve`](Self::resolve), but reads only `env`.
373    ///
374    /// # Errors
375    ///
376    /// See [`resolve`](Self::resolve).
377    pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Resolved, ConfigError> {
378        let (selected, canonical) = resolve_active_profile(env);
379        let mut merged = toml::Value::Table(toml::map::Map::new());
380
381        if let Some(base) = read_optional_toml(&find_config_file("autumn.toml", env))? {
382            deep_merge(&mut merged, base.clone());
383            for name in profile_inline_lookup_names(&canonical) {
384                if let Some(profile) = profile_section(&base, name) {
385                    deep_merge(&mut merged, profile);
386                }
387            }
388        }
389        for name in autumn_web::config::profile_override_file_lookup_names(&canonical, &selected) {
390            let path = find_config_file(&format!("autumn-{name}.toml"), env);
391            if let Some(overlay) = read_optional_toml(&path)? {
392                deep_merge(&mut merged, overlay);
393                break;
394            }
395        }
396
397        let mut section_value = merged
398            .get(section)
399            .cloned()
400            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
401        if !section_value.is_table() {
402            return Err(ConfigError(format!("`{section}` must be a table")));
403        }
404        let mut config = Self::from_section(Some(&section_value))?;
405        apply_env_overrides(section, &mut section_value, &mut config, env)?;
406        config.validate()?;
407        Ok(Resolved {
408            config,
409            profile: canonical,
410        })
411    }
412
413    /// Reject values that fail at runtime.
414    ///
415    /// # Errors
416    ///
417    /// Returns [`ConfigError`] that names the bad key.
418    pub fn validate(&self) -> Result<(), ConfigError> {
419        self.bind_addr(true)?;
420        for (key, value) in [
421            ("shutdown_grace_ms", self.shutdown_grace_ms),
422            ("connect_timeout_ms", self.connect_timeout_ms),
423            ("health_check_timeout_ms", self.health_check_timeout_ms),
424        ] {
425            if value == 0 {
426                return Err(ConfigError(format!("`{key}` must be more than 0")));
427            }
428        }
429        for range in &self.trusted_proxies {
430            if crate::forwarded::IpRange::parse(range).is_none() {
431                return Err(ConfigError(format!(
432                    "`trusted_proxies` entry \"{range}\" must be an IP or a CIDR range"
433                )));
434            }
435        }
436        if self.max_connections > MAX_CONNECTIONS_LIMIT {
437            return Err(ConfigError(format!(
438                "`max_connections` must be at most {MAX_CONNECTIONS_LIMIT}"
439            )));
440        }
441        let mut names = HashSet::new();
442        let mut rules = HashSet::new();
443        for route in &self.routes {
444            route.validate()?;
445            if !names.insert(route.name.as_str()) {
446                return Err(ConfigError(format!(
447                    "two routes have the name `{}`",
448                    route.name
449                )));
450            }
451            let rule = (
452                route.host.to_ascii_lowercase(),
453                normalize_prefix(&route.path_prefix),
454            );
455            if !rules.insert(rule) {
456                return Err(ConfigError(format!(
457                    "route `{}` has the same host and path_prefix as an earlier route",
458                    route.name
459                )));
460            }
461        }
462        Ok(())
463    }
464}
465
466/// A prefix without a trailing `/`. The root prefix is empty.
467pub fn normalize_prefix(prefix: &str) -> String {
468    let trimmed = prefix.trim();
469    let trimmed = if trimmed.is_empty() { "/" } else { trimmed };
470    trimmed.trim_end_matches('/').to_owned()
471}
472
473impl Route {
474    /// A route with `name`, the root prefix and no upstreams.
475    #[must_use]
476    pub fn new(name: impl Into<String>) -> Self {
477        Self {
478            name: name.into(),
479            ..Self::default()
480        }
481    }
482
483    /// Match this host: `api.example.com` or `*.example.com`.
484    #[must_use]
485    pub fn host(mut self, host: impl Into<String>) -> Self {
486        self.host = host.into();
487        self
488    }
489
490    /// Match this path prefix.
491    #[must_use]
492    pub fn path_prefix(mut self, prefix: impl Into<String>) -> Self {
493        self.path_prefix = prefix.into();
494        self
495    }
496
497    /// Add an upstream, `host:port`.
498    #[must_use]
499    pub fn upstream(mut self, upstream: impl Into<String>) -> Self {
500        self.upstreams.push(upstream.into());
501        self
502    }
503
504    /// Add upstreams, `host:port`.
505    #[must_use]
506    pub fn upstreams<I, S>(mut self, upstreams: I) -> Self
507    where
508        I: IntoIterator<Item = S>,
509        S: Into<String>,
510    {
511        self.upstreams.extend(upstreams.into_iter().map(Into::into));
512        self
513    }
514
515    /// Remove the prefix before the request goes upstream.
516    #[must_use]
517    pub const fn strip_prefix(mut self, strip: bool) -> Self {
518        self.strip_prefix = strip;
519        self
520    }
521
522    /// Send this `Host` header upstream.
523    #[must_use]
524    pub fn upstream_host(mut self, host: impl Into<String>) -> Self {
525        self.upstream_host = host.into();
526        self
527    }
528
529    /// Pick upstreams with `selection`.
530    #[must_use]
531    pub const fn selection(mut self, selection: Selection) -> Self {
532        self.selection = selection;
533        self
534    }
535
536    /// Reject a route that cannot work.
537    ///
538    /// # Errors
539    ///
540    /// Returns [`ConfigError`] that names the route and the key.
541    pub fn validate(&self) -> Result<(), ConfigError> {
542        let fail = |message: String| Err(ConfigError(format!("route `{}`: {message}", self.name)));
543        let name_ok = !self.name.is_empty()
544            && self
545                .name
546                .chars()
547                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'));
548        if !name_ok {
549            return fail(
550                "`name` must be letters, digits, `_`, `-` or `.`, and not empty".to_owned(),
551            );
552        }
553        if RESERVED_NAMES.contains(&self.name.as_str()) {
554            return fail(format!("the name `{}` is reserved", self.name));
555        }
556        if !valid_host_pattern(&self.host) {
557            return fail(format!(
558                "`host` must be a host name or `*.domain`, without a port, found \"{}\"",
559                self.host
560            ));
561        }
562        let prefix = self.path_prefix.trim();
563        if !prefix.starts_with('/')
564            || prefix.contains(['?', '#'])
565            || prefix.chars().any(|c| c.is_whitespace() || c.is_control())
566            || crate::router::unsafe_path(prefix)
567        {
568            return fail(format!(
569                "`path_prefix` must start with `/` and have no query, fragment or dot segment, found \"{}\"",
570                self.path_prefix
571            ));
572        }
573        if self.upstreams.is_empty() {
574            return fail("`upstreams` must have at least one address".to_owned());
575        }
576        let mut seen = HashSet::new();
577        for upstream in &self.upstreams {
578            if !valid_upstream(upstream) {
579                return fail(format!(
580                    "upstream \"{upstream}\" must be host:port (for example \"10.0.0.7:8080\")"
581                ));
582            }
583            if !seen.insert(upstream.to_ascii_lowercase()) {
584                return fail(format!("upstream \"{upstream}\" is in the list two times"));
585            }
586        }
587        if http::HeaderValue::from_str(&self.upstream_host).is_err()
588            || self.upstream_host.chars().any(char::is_whitespace)
589        {
590            return fail("`upstream_host` must be a valid Host header value".to_owned());
591        }
592        Ok(())
593    }
594}
595
596/// `host:port`, where host is a name, an IPv4 address or `[IPv6]`.
597fn valid_upstream(upstream: &str) -> bool {
598    if upstream.parse::<SocketAddr>().is_ok() {
599        return true;
600    }
601    let Some((host, port)) = upstream.rsplit_once(':') else {
602        return false;
603    };
604    port.parse::<u16>().is_ok_and(|p| p > 0) && valid_host_name(host)
605}
606
607/// Letters, digits, `-` and `.`, in labels that are not empty.
608fn valid_host_name(host: &str) -> bool {
609    !host.is_empty()
610        && host.len() <= 253
611        && host.split('.').all(|label| {
612            !label.is_empty()
613                && label.len() <= 63
614                && label.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
615        })
616}
617
618/// Empty, a host name, `[IPv6]`, or `*.` and a host name.
619fn valid_host_pattern(pattern: &str) -> bool {
620    if pattern.is_empty() {
621        return true;
622    }
623    if let Some(inner) = pattern.strip_prefix('[').and_then(|p| p.strip_suffix(']')) {
624        return inner.parse::<std::net::Ipv6Addr>().is_ok();
625    }
626    let name = pattern.strip_prefix("*.").unwrap_or(pattern);
627    valid_host_name(name)
628}
629
630/// The env prefix for a section: `pingora` → `AUTUMN_PINGORA__`.
631fn env_prefix(section: &str) -> String {
632    format!("AUTUMN_{}__", section.to_ascii_uppercase())
633}
634
635/// Apply `AUTUMN_<SECTION>__<KEY>` overrides for each known key.
636///
637/// Each value is a TOML literal (`true`, `10`, `"x"`, `[..]`) or a bare
638/// string. A wrong type causes an error. The file value does not stay.
639/// The caller validates the result once, after all overrides.
640fn apply_env_overrides(
641    section_name: &str,
642    section: &mut toml::Value,
643    config: &mut PingoraConfig,
644    env: &dyn Env,
645) -> Result<(), ConfigError> {
646    let defaults =
647        toml::Value::try_from(PingoraConfig::default()).map_err(|e| ConfigError(e.to_string()))?;
648    let prefix = env_prefix(section_name);
649    let Some(keys) = defaults.as_table() else {
650        return Ok(());
651    };
652    for name in keys.keys() {
653        let key = format!("{prefix}{}", name.to_ascii_uppercase());
654        let Some(raw) = env_trimmed(env, &key) else {
655            continue;
656        };
657        let mut value = parse_env_value(&raw);
658        // Enum names are lower case in TOML. Accept `App` or `NONE` too.
659        if name == "fallback"
660            && let toml::Value::String(text) = &value
661        {
662            value = toml::Value::String(text.to_ascii_lowercase());
663        }
664        let mut candidate = section.clone();
665        if let Some(table) = candidate.as_table_mut() {
666            table.insert(name.clone(), value);
667        }
668        *config = PingoraConfig::from_section(Some(&candidate))
669            .map_err(|error| ConfigError(format!("{key}: {}", error.message())))?;
670        *section = candidate;
671    }
672    Ok(())
673}
674
675fn parse_env_value(raw: &str) -> toml::Value {
676    toml::from_str::<toml::Table>(&format!("v = {raw}"))
677        .ok()
678        .and_then(|mut table| table.remove("v"))
679        .unwrap_or_else(|| toml::Value::String(raw.to_owned()))
680}
681
682/// A non-blank env value. A blank value counts as unset, as in core.
683fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
684    env.var(key)
685        .ok()
686        .map(|value| value.trim().to_owned())
687        .filter(|value| !value.is_empty())
688}
689
690/// `(selected spelling, canonical name)` of the active profile.
691///
692/// Same order as core: `AUTUMN_ENV`, `AUTUMN_PROFILE`, `--profile`,
693/// `AUTUMN_IS_DEBUG=0` (→ `prod`), then `dev`.
694fn resolve_active_profile(env: &dyn Env) -> (String, String) {
695    let selected = resolve_profile_input(env);
696    let canonical =
697        autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
698    (selected, canonical)
699}
700
701fn resolve_profile_input(env: &dyn Env) -> String {
702    if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
703        return value;
704    }
705    if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
706        return value;
707    }
708    let args: Vec<String> = std::env::args().collect();
709    for (index, arg) in args.iter().enumerate() {
710        if arg == "--profile"
711            && let Some(profile) = args.get(index.saturating_add(1))
712            && !profile.trim().is_empty()
713        {
714            return profile.trim().to_owned();
715        }
716        if let Some(profile) = arg.strip_prefix("--profile=")
717            && !profile.trim().is_empty()
718        {
719            return profile.trim().to_owned();
720        }
721    }
722    if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
723        return "prod".to_owned();
724    }
725    "dev".to_owned()
726}
727
728/// Find a config file as core does: `AUTUMN_MANIFEST_DIR` first, then the
729/// working directory.
730fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
731    if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
732        let candidate = PathBuf::from(dir).join(filename);
733        if candidate.exists() {
734            return candidate;
735        }
736    }
737    PathBuf::from(filename)
738}
739
740fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
741    match std::fs::read_to_string(path) {
742        Ok(contents) => {
743            let table = toml::from_str::<toml::Table>(&contents)
744                .map_err(|e| ConfigError(format!("cannot parse {}: {e}", path.display())))?;
745            Ok(Some(toml::Value::Table(table)))
746        }
747        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
748        Err(error) => Err(ConfigError(format!(
749            "cannot read {}: {error}",
750            path.display()
751        ))),
752    }
753}
754
755fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
756    match canonical {
757        "prod" => vec!["production", "prod"],
758        "dev" => vec!["development", "dev"],
759        other => vec![other],
760    }
761}
762
763fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
764    base.get("profile")
765        .and_then(toml::Value::as_table)
766        .and_then(|profiles| profiles.get(profile))
767        .and_then(toml::Value::as_table)
768        .map(|table| toml::Value::Table(table.clone()))
769}
770
771/// Merge `overlay` into `base`. Tables merge; other values replace.
772fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
773    deep_merge_at(base, overlay, 0);
774}
775
776fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
777    const MAX_MERGE_DEPTH: usize = 16;
778    if depth > MAX_MERGE_DEPTH {
779        return;
780    }
781    let toml::Value::Table(overlay_table) = overlay else {
782        return;
783    };
784    let Some(base_table) = base.as_table_mut() else {
785        return;
786    };
787    for (key, overlay_value) in overlay_table {
788        let recurse =
789            overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
790        if recurse {
791            if let Some(base_value) = base_table.get_mut(&key) {
792                deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
793            }
794        } else {
795            base_table.insert(key, overlay_value);
796        }
797    }
798}