Skip to main content

autumn_plugin_grpc/
config.rs

1//! The `[grpc]` 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. `[grpc]` in `autumn.toml`.
7//! 2. `[profile.<name>.grpc]` in `autumn.toml`.
8//! 3. `[grpc]` in `autumn-<profile>.toml`.
9//! 4. `AUTUMN_GRPC__*` environment variables (also from `.env` files).
10//!
11//! ```toml
12//! [grpc]
13//! bind = "0.0.0.0:50051"
14//! reflection = "auto"        # on in dev/test, off in other profiles
15//! shutdown_grace_ms = 10000
16//!
17//! [grpc.tls]                 # needs the `tls` feature
18//! cert_path = "certs/server.pem"
19//! key_path = "certs/server.key"
20//!
21//! [grpc.clients.billing]     # needs the `client` feature
22//! endpoint = "http://billing:50051"
23//! timeout_ms = 10000
24//! ```
25
26use std::collections::BTreeMap;
27use std::fmt;
28use std::net::SocketAddr;
29use std::path::{Path, PathBuf};
30use std::time::Duration;
31
32use autumn_web::config::Env;
33use serde::{Deserialize, Deserializer, Serialize, Serializer};
34
35/// The default gRPC port.
36pub const DEFAULT_PORT: u16 = 50051;
37
38/// The default config section.
39pub const DEFAULT_SECTION: &str = "grpc";
40
41/// A three-state switch. In TOML, write a boolean or `"auto"`.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Toggle {
44    /// The active profile decides. Each field gives the rule.
45    #[default]
46    Auto,
47    /// Always on.
48    On,
49    /// Always off.
50    Off,
51}
52
53impl Toggle {
54    /// The value, with `auto` set to `auto_value`.
55    #[must_use]
56    pub const fn resolve(self, auto_value: bool) -> bool {
57        match self {
58            Self::Auto => auto_value,
59            Self::On => true,
60            Self::Off => false,
61        }
62    }
63}
64
65impl From<bool> for Toggle {
66    fn from(value: bool) -> Self {
67        if value { Self::On } else { Self::Off }
68    }
69}
70
71impl fmt::Display for Toggle {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(match self {
74            Self::Auto => "auto",
75            Self::On => "true",
76            Self::Off => "false",
77        })
78    }
79}
80
81impl Serialize for Toggle {
82    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
83        match self {
84            Self::Auto => serializer.serialize_str("auto"),
85            Self::On => serializer.serialize_bool(true),
86            Self::Off => serializer.serialize_bool(false),
87        }
88    }
89}
90
91impl<'de> Deserialize<'de> for Toggle {
92    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
93        #[derive(Deserialize)]
94        #[serde(untagged)]
95        enum Raw {
96            Bool(bool),
97            Text(String),
98        }
99        match Raw::deserialize(deserializer)? {
100            Raw::Bool(value) => Ok(value.into()),
101            Raw::Text(text) => match text.trim().to_ascii_lowercase().as_str() {
102                "auto" => Ok(Self::Auto),
103                "true" | "on" | "enabled" | "yes" => Ok(Self::On),
104                "false" | "off" | "disabled" | "no" => Ok(Self::Off),
105                other => Err(serde::de::Error::custom(format!(
106                    "expected true, false or \"auto\", found \"{other}\""
107                ))),
108            },
109        }
110    }
111}
112
113/// Where the gRPC server listens.
114#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116#[non_exhaustive]
117pub enum Listener {
118    /// A listener of its own, on `bind` (ADR 0002).
119    #[default]
120    Dedicated,
121    /// Autumn's HTTP port. HTTP/2 requests with a gRPC content type go to
122    /// the gRPC services. Needs the `multiplex` feature (ADR 0008).
123    Shared,
124}
125
126impl Listener {
127    /// The name in TOML.
128    #[must_use]
129    pub const fn as_str(self) -> &'static str {
130        match self {
131            Self::Dedicated => "dedicated",
132            Self::Shared => "shared",
133        }
134    }
135}
136
137impl fmt::Display for Listener {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        f.write_str(self.as_str())
140    }
141}
142
143/// Settings for one gRPC server.
144///
145/// The defaults limit resource use. For a setting that says "`0`: off",
146/// the value `0` removes that limit or feature.
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(default, deny_unknown_fields)]
149#[non_exhaustive]
150#[allow(clippy::struct_excessive_bools)] // independent switches, as in TOML
151pub struct GrpcConfig {
152    /// Start the server. Default: `true`.
153    pub enabled: bool,
154    /// `dedicated`: a listener of its own, on `bind`. `shared`: Autumn's
155    /// HTTP port (needs the `multiplex` feature). Default: `dedicated`.
156    pub listener: Listener,
157    /// Listen address, `IP:port`. Port `0` picks a free port. Empty: the
158    /// profile decides. `dev` and `test` use `127.0.0.1:50051`. Other
159    /// profiles use `0.0.0.0:50051`. Default: empty.
160    pub bind: String,
161    /// Serve `grpc.health.v1.Health`. Default: `true`.
162    pub health: bool,
163    /// Serve server reflection. `auto` is on in `dev` and `test` only.
164    pub reflection: Toggle,
165    /// Record `grpc_server_*` metrics. Default: `true`.
166    pub metrics: bool,
167    /// Maximum label sets per metric. The plugin counts new label sets
168    /// over this limit as `other`. Default: `1000`.
169    pub max_metric_series: usize,
170    /// Time for in-flight calls to finish at shutdown. Must be more than
171    /// `0`. Default: `10000`.
172    pub shutdown_grace_ms: u64,
173    /// Per-call timeout for unary calls. `0`: off. Default: `0`.
174    pub timeout_ms: u64,
175    /// Open connections. The listener waits when it reaches the limit.
176    /// `0`: off. Default: `1000`.
177    pub max_connections: usize,
178    /// Concurrent calls per connection. A streaming call counts until its
179    /// response starts. `0`: off. Default: `0`.
180    pub concurrency_limit_per_connection: usize,
181    /// HTTP/2 `SETTINGS_MAX_CONCURRENT_STREAMS`. Must be more than `0`.
182    /// Default: `200`.
183    pub max_concurrent_streams: u32,
184    /// Server-side stream resets per connection before the server closes
185    /// it (a protection against reset floods). Must be more than `0`.
186    /// Default: `1024`.
187    pub http2_max_local_error_reset_streams: usize,
188    /// Set `TCP_NODELAY`. Default: `true`.
189    pub tcp_nodelay: bool,
190    /// TCP keepalive idle time. `0`: off. Default: `0`.
191    pub tcp_keepalive_ms: u64,
192    /// HTTP/2 PING interval. `0`: off. Default: `60000`.
193    pub http2_keepalive_interval_ms: u64,
194    /// Time to wait for a PING reply. `0`: tonic default (20 s).
195    /// Default: `20000`.
196    pub http2_keepalive_timeout_ms: u64,
197    /// Close connections after this age. `0`: off. Default: `0`.
198    pub max_connection_age_ms: u64,
199    /// TLS settings. Empty paths mean plain text.
200    pub tls: TlsConfig,
201    /// Downstream services to call, by name (`[grpc.clients.<name>]`).
202    /// Needs the `client` feature. The server does not use them.
203    pub clients: BTreeMap<String, ClientConfig>,
204}
205
206/// Settings for one downstream gRPC service (`[grpc.clients.<name>]`).
207/// Needs the `client` feature.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210#[non_exhaustive]
211pub struct ClientConfig {
212    /// `http://host:port` or `https://host:port`, with no path. Empty is
213    /// an error at boot, unless a test double serves the client.
214    pub endpoint: String,
215    /// Longest time for one call. The client sends the smallest of this
216    /// value, the time left on the incoming request and the caller's
217    /// timeout as `grpc-timeout`. `0`: off. Default: `10000`.
218    pub timeout_ms: u64,
219    /// Longest time to open a connection. Must be more than `0`.
220    /// Default: `5000`.
221    pub connect_timeout_ms: u64,
222    /// TLS for an `https` endpoint. Needs the `tls` feature.
223    pub tls: ClientTls,
224}
225
226/// Client TLS settings. Empty values mean "not set".
227#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(default, deny_unknown_fields)]
229#[non_exhaustive]
230pub struct ClientTls {
231    /// PEM CA bundle that signs the server certificate. Necessary for
232    /// `https`. A system bundle (for example
233    /// `/etc/ssl/certs/ca-certificates.crt`) works for public services.
234    pub ca_path: String,
235    /// PEM client certificate chain, for mTLS. Needs `key_path`.
236    pub cert_path: String,
237    /// PEM client private key, for mTLS. Needs `cert_path`.
238    pub key_path: String,
239    /// Server name to verify. Empty: the endpoint host.
240    pub domain_name: String,
241}
242
243impl Default for ClientConfig {
244    fn default() -> Self {
245        Self {
246            endpoint: String::new(),
247            timeout_ms: 10_000,
248            connect_timeout_ms: 5_000,
249            tls: ClientTls::default(),
250        }
251    }
252}
253
254impl ClientConfig {
255    /// Per-call timeout. `None` when `timeout_ms` is `0`.
256    #[must_use]
257    pub const fn timeout(&self) -> Option<Duration> {
258        millis(self.timeout_ms)
259    }
260
261    /// Connect timeout.
262    #[must_use]
263    pub const fn connect_timeout(&self) -> Duration {
264        Duration::from_millis(self.connect_timeout_ms)
265    }
266
267    /// `true` for an `https` endpoint. Validation accepts only the
268    /// lowercase schemes, so this test is safe.
269    #[must_use]
270    pub fn is_https(&self) -> bool {
271        self.endpoint.trim().starts_with("https://")
272    }
273
274    /// Check one client. `name` is the key in `clients`.
275    fn validate(&self, name: &str) -> Result<(), ConfigError> {
276        let key = |field: &str| format!("clients.{name}.{field}");
277        if self.connect_timeout_ms == 0 {
278            return Err(ConfigError(format!(
279                "`{}` must be more than 0",
280                key("connect_timeout_ms")
281            )));
282        }
283        validate_endpoint(&self.endpoint, &key("endpoint"))?;
284        let tls = &self.tls;
285        let has = |value: &str| !value.trim().is_empty();
286        match (has(&tls.cert_path), has(&tls.key_path)) {
287            (true, false) => {
288                return Err(ConfigError(format!(
289                    "`{}` is set, so `{}` is necessary",
290                    key("tls.cert_path"),
291                    key("tls.key_path")
292                )));
293            }
294            (false, true) => {
295                return Err(ConfigError(format!(
296                    "`{}` is set, so `{}` is necessary",
297                    key("tls.key_path"),
298                    key("tls.cert_path")
299                )));
300            }
301            _ => {}
302        }
303        let any_tls =
304            has(&tls.ca_path) || has(&tls.cert_path) || has(&tls.key_path) || has(&tls.domain_name);
305        if !self.is_https() {
306            if any_tls {
307                return Err(ConfigError(format!(
308                    "`{}` needs an https:// endpoint",
309                    key("tls")
310                )));
311            }
312            return Ok(());
313        }
314        if !cfg!(feature = "tls") {
315            return Err(ConfigError(format!(
316                "`{}` uses https, which needs the `tls` feature of autumn-plugin-grpc",
317                key("endpoint")
318            )));
319        }
320        if !has(&tls.ca_path) {
321            return Err(ConfigError(format!(
322                "`{}` is necessary for an https endpoint",
323                key("tls.ca_path")
324            )));
325        }
326        Ok(())
327    }
328}
329
330/// A client name is a TOML key and part of an env variable name.
331pub fn validate_client_name(name: &str) -> Result<(), ConfigError> {
332    let valid = !name.is_empty()
333        && name.len() <= 64
334        && name.starts_with(|c: char| c.is_ascii_lowercase())
335        && name
336            .bytes()
337            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_');
338    if valid {
339        Ok(())
340    } else {
341        Err(ConfigError(format!(
342            "gRPC client name `{name}` must start with a-z and use only a-z, 0-9 and _ (at most 64)"
343        )))
344    }
345}
346
347/// An empty endpoint is "unset" here. The plugin checks it at boot.
348fn validate_endpoint(endpoint: &str, key: &str) -> Result<(), ConfigError> {
349    let endpoint = endpoint.trim();
350    if endpoint.is_empty() {
351        return Ok(());
352    }
353    let uri: http::Uri = endpoint.parse().map_err(|_| {
354        ConfigError(format!(
355            "`{key}` must be a URI like \"http://host:50051\", found \"{endpoint}\""
356        ))
357    })?;
358    // Lowercase only: `is_https` must agree with the URI parser, which
359    // accepts `HTTPS://` too.
360    if !(endpoint.starts_with("http://") || endpoint.starts_with("https://")) {
361        return Err(ConfigError(format!(
362            "`{key}` must start with http:// or https://, found \"{endpoint}\""
363        )));
364    }
365    if uri.host().is_none_or(str::is_empty) {
366        return Err(ConfigError(format!(
367            "`{key}` needs a host, found \"{endpoint}\""
368        )));
369    }
370    let path = uri.path_and_query().map_or("", |p| p.as_str());
371    if !matches!(path, "" | "/") {
372        return Err(ConfigError(format!(
373            "`{key}` must have no path or query, found \"{endpoint}\""
374        )));
375    }
376    Ok(())
377}
378
379/// TLS settings. Needs the `tls` crate feature.
380#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(default, deny_unknown_fields)]
382#[non_exhaustive]
383pub struct TlsConfig {
384    /// PEM certificate chain of the server.
385    pub cert_path: String,
386    /// PEM private key of the server.
387    pub key_path: String,
388    /// PEM CA bundle. When set, clients must show a certificate (mTLS).
389    pub client_ca_path: String,
390    /// With `client_ca_path`, also accept clients without a certificate.
391    /// Your interceptor must then check `request.peer_certs()`.
392    pub client_auth_optional: bool,
393    /// Time for a client to finish the TLS handshake. Must be more than
394    /// `0`. Default: `10000`.
395    pub handshake_timeout_ms: u64,
396}
397
398impl Default for TlsConfig {
399    fn default() -> Self {
400        Self {
401            cert_path: String::new(),
402            key_path: String::new(),
403            client_ca_path: String::new(),
404            client_auth_optional: false,
405            handshake_timeout_ms: 10_000,
406        }
407    }
408}
409
410impl TlsConfig {
411    /// `true` when a certificate is set.
412    #[must_use]
413    pub fn is_enabled(&self) -> bool {
414        !self.cert_path.trim().is_empty()
415    }
416
417    /// TLS handshake timeout.
418    #[must_use]
419    pub const fn handshake_timeout(&self) -> Duration {
420        Duration::from_millis(self.handshake_timeout_ms)
421    }
422}
423
424impl Default for GrpcConfig {
425    fn default() -> Self {
426        Self {
427            enabled: true,
428            listener: Listener::Dedicated,
429            bind: String::new(),
430            health: true,
431            reflection: Toggle::Auto,
432            metrics: true,
433            max_metric_series: 1000,
434            shutdown_grace_ms: 10_000,
435            timeout_ms: 0,
436            max_connections: 1000,
437            concurrency_limit_per_connection: 0,
438            max_concurrent_streams: 200,
439            http2_max_local_error_reset_streams: 1024,
440            tcp_nodelay: true,
441            tcp_keepalive_ms: 0,
442            http2_keepalive_interval_ms: 60_000,
443            http2_keepalive_timeout_ms: 20_000,
444            max_connection_age_ms: 0,
445            tls: TlsConfig::default(),
446            clients: BTreeMap::new(),
447        }
448    }
449}
450
451/// A configuration problem. Boot stops. The plugin does not fall back to
452/// defaults.
453#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
454#[error("invalid gRPC configuration: {0}")]
455pub struct ConfigError(String);
456
457impl ConfigError {
458    /// The problem, without the prefix.
459    #[must_use]
460    pub fn message(&self) -> &str {
461        &self.0
462    }
463}
464
465/// A resolved configuration and the active profile.
466#[derive(Debug, Clone)]
467pub struct Resolved {
468    pub(crate) config: GrpcConfig,
469    pub(crate) profile: String,
470}
471
472impl Resolved {
473    /// The merged, validated configuration.
474    #[must_use]
475    pub const fn config(&self) -> &GrpcConfig {
476        &self.config
477    }
478
479    /// The canonical active profile, for example `dev`, `prod` or `test`.
480    #[must_use]
481    pub fn profile(&self) -> &str {
482        &self.profile
483    }
484
485    /// Configuration from code. The plugin reads no files. The profile
486    /// still comes from the environment.
487    pub(crate) fn explicit(config: GrpcConfig) -> Self {
488        let profile = autumn_web::dotenv::os_env_with_dotenv().map_or_else(
489            |_| resolve_active_profile(&autumn_web::config::OsEnv).1,
490            |env| resolve_active_profile(&env).1,
491        );
492        Self { config, profile }
493    }
494
495    /// `true` for the `dev` and `test` profiles.
496    #[must_use]
497    pub fn is_development(&self) -> bool {
498        matches!(self.profile.as_str(), "dev" | "test")
499    }
500}
501
502const fn millis(ms: u64) -> Option<Duration> {
503    if ms == 0 {
504        None
505    } else {
506        Some(Duration::from_millis(ms))
507    }
508}
509
510impl GrpcConfig {
511    /// The listen address. An empty `bind` gives `127.0.0.1:50051` in
512    /// development and `0.0.0.0:50051` in other profiles.
513    ///
514    /// # Errors
515    ///
516    /// Returns [`ConfigError`] when `bind` is not `IP:port`.
517    pub fn bind_addr(&self, development: bool) -> Result<SocketAddr, ConfigError> {
518        let bind = self.bind.trim();
519        if bind.is_empty() {
520            let host = if development {
521                [127, 0, 0, 1]
522            } else {
523                [0, 0, 0, 0]
524            };
525            return Ok(SocketAddr::from((host, DEFAULT_PORT)));
526        }
527        bind.parse().map_err(|_| {
528            ConfigError(format!(
529                "`bind` must be IP:port (for example \"0.0.0.0:50051\"), found \"{}\"",
530                self.bind
531            ))
532        })
533    }
534
535    /// Per-call timeout.
536    #[must_use]
537    pub const fn timeout(&self) -> Option<Duration> {
538        millis(self.timeout_ms)
539    }
540
541    /// Shutdown grace period.
542    #[must_use]
543    pub const fn shutdown_grace(&self) -> Duration {
544        Duration::from_millis(self.shutdown_grace_ms)
545    }
546
547    /// TCP keepalive idle time.
548    #[must_use]
549    pub const fn tcp_keepalive(&self) -> Option<Duration> {
550        millis(self.tcp_keepalive_ms)
551    }
552
553    /// HTTP/2 PING interval.
554    #[must_use]
555    pub const fn http2_keepalive_interval(&self) -> Option<Duration> {
556        millis(self.http2_keepalive_interval_ms)
557    }
558
559    /// HTTP/2 PING ack timeout.
560    #[must_use]
561    pub const fn http2_keepalive_timeout(&self) -> Option<Duration> {
562        millis(self.http2_keepalive_timeout_ms)
563    }
564
565    /// Maximum connection age.
566    #[must_use]
567    pub const fn max_connection_age(&self) -> Option<Duration> {
568        millis(self.max_connection_age_ms)
569    }
570
571    /// Parse `[section]` from a whole `autumn.toml` text. No profiles and
572    /// no environment.
573    ///
574    /// # Errors
575    ///
576    /// Returns [`ConfigError`] for bad TOML, unknown keys, wrong types or
577    /// values that [`validate`](Self::validate) rejects.
578    pub fn from_toml_str(text: &str, section: &str) -> Result<Self, ConfigError> {
579        let document: toml::Table = toml::from_str(text).map_err(|e| ConfigError(e.to_string()))?;
580        let config = Self::from_section(document.get(section))?;
581        config.validate()?;
582        Ok(config)
583    }
584
585    fn from_section(section: Option<&toml::Value>) -> Result<Self, ConfigError> {
586        section.map_or_else(
587            || Ok(Self::default()),
588            |value| {
589                value
590                    .clone()
591                    .try_into()
592                    .map_err(|e: toml::de::Error| ConfigError(e.to_string()))
593            },
594        )
595    }
596
597    /// Resolve `[section]` from the app's files and the process environment.
598    ///
599    /// # Errors
600    ///
601    /// Returns [`ConfigError`] when a file cannot be read or parsed, or when
602    /// the merged section is not valid.
603    pub fn resolve(section: &str) -> Result<Resolved, ConfigError> {
604        let env = autumn_web::dotenv::os_env_with_dotenv()
605            .map_err(|e| ConfigError(format!("cannot read .env files: {e}")))?;
606        Self::resolve_with_env(section, &env)
607    }
608
609    /// Like [`resolve`](Self::resolve), with env leaves for the client
610    /// names from code.
611    pub(crate) fn resolve_with_clients(
612        section: &str,
613        clients: &[&str],
614    ) -> Result<Resolved, ConfigError> {
615        let env = autumn_web::dotenv::os_env_with_dotenv()
616            .map_err(|e| ConfigError(format!("cannot read .env files: {e}")))?;
617        Self::resolve_with_env_and_clients(section, &env, clients)
618    }
619
620    /// Like [`resolve`](Self::resolve), but reads only `env`.
621    ///
622    /// # Errors
623    ///
624    /// See [`resolve`](Self::resolve).
625    pub fn resolve_with_env(section: &str, env: &dyn Env) -> Result<Resolved, ConfigError> {
626        Self::resolve_with_env_and_clients(section, env, &[])
627    }
628
629    /// Like [`resolve_with_env`](Self::resolve_with_env). Env overrides
630    /// apply to each client in the files and to each name in `clients`
631    /// (the plugin passes the names from [`client`](crate::GrpcPlugin)).
632    ///
633    /// # Errors
634    ///
635    /// See [`resolve`](Self::resolve).
636    pub fn resolve_with_env_and_clients(
637        section: &str,
638        env: &dyn Env,
639        clients: &[&str],
640    ) -> Result<Resolved, ConfigError> {
641        let (selected, canonical) = resolve_active_profile(env);
642        let mut merged = toml::Value::Table(toml::map::Map::new());
643
644        if let Some(base) = read_optional_toml(&find_config_file("autumn.toml", env))? {
645            deep_merge(&mut merged, base.clone());
646            for name in profile_inline_lookup_names(&canonical) {
647                if let Some(profile) = profile_section(&base, name) {
648                    deep_merge(&mut merged, profile);
649                }
650            }
651        }
652        for name in autumn_web::config::profile_override_file_lookup_names(&canonical, &selected) {
653            let path = find_config_file(&format!("autumn-{name}.toml"), env);
654            if let Some(overlay) = read_optional_toml(&path)? {
655                deep_merge(&mut merged, overlay);
656                break;
657            }
658        }
659
660        let mut section_value = merged
661            .get(section)
662            .cloned()
663            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
664        if !section_value.is_table() {
665            return Err(ConfigError(format!("`{section}` must be a table")));
666        }
667        let mut config = Self::from_section(Some(&section_value))?;
668        apply_env_overrides(section, &mut section_value, &mut config, env, clients)?;
669        config.validate()?;
670        Ok(Resolved {
671            config,
672            profile: canonical,
673        })
674    }
675
676    /// Reject values that fail at runtime.
677    ///
678    /// # Errors
679    ///
680    /// Returns [`ConfigError`] that names the bad key.
681    pub fn validate(&self) -> Result<(), ConfigError> {
682        self.bind_addr(true)?;
683        for (key, value) in [
684            ("shutdown_grace_ms", self.shutdown_grace_ms),
685            ("max_metric_series", self.max_metric_series as u64),
686            (
687                "max_concurrent_streams",
688                u64::from(self.max_concurrent_streams),
689            ),
690            (
691                "http2_max_local_error_reset_streams",
692                self.http2_max_local_error_reset_streams as u64,
693            ),
694            ("tls.handshake_timeout_ms", self.tls.handshake_timeout_ms),
695        ] {
696            if value == 0 {
697                return Err(ConfigError(format!("`{key}` must be more than 0")));
698            }
699        }
700        let tls = &self.tls;
701        let has = |value: &str| !value.trim().is_empty();
702        match (has(&tls.cert_path), has(&tls.key_path)) {
703            (true, false) => {
704                return Err(ConfigError(
705                    "`tls.cert_path` is set, so `tls.key_path` is necessary".to_owned(),
706                ));
707            }
708            (false, true) => {
709                return Err(ConfigError(
710                    "`tls.key_path` is set, so `tls.cert_path` is necessary".to_owned(),
711                ));
712            }
713            _ => {}
714        }
715        if has(&tls.client_ca_path) && !has(&tls.cert_path) {
716            return Err(ConfigError(
717                "`tls.client_ca_path` needs `tls.cert_path` and `tls.key_path`".to_owned(),
718            ));
719        }
720        if tls.client_auth_optional && !has(&tls.client_ca_path) {
721            return Err(ConfigError(
722                "`tls.client_auth_optional` needs `tls.client_ca_path`".to_owned(),
723            ));
724        }
725        if !self.clients.is_empty() && !cfg!(feature = "client") {
726            return Err(ConfigError(
727                "`clients` needs the `client` feature of autumn-plugin-grpc".to_owned(),
728            ));
729        }
730        for (name, client) in &self.clients {
731            validate_client_name(name)?;
732            client.validate(name)?;
733        }
734        if self.listener == Listener::Shared {
735            if has(&tls.cert_path) || has(&tls.client_ca_path) {
736                return Err(ConfigError(
737                    "`tls` has no effect with `listener = \"shared\"`; set TLS in `[server.tls]`"
738                        .to_owned(),
739                ));
740            }
741            if !cfg!(feature = "multiplex") {
742                return Err(ConfigError(
743                    "`listener = \"shared\"` needs the `multiplex` feature of autumn-plugin-grpc"
744                        .to_owned(),
745                ));
746            }
747        }
748        Ok(())
749    }
750
751    /// Settings of the dedicated listener that are not at their defaults.
752    /// They have no effect with `listener = "shared"`.
753    #[must_use]
754    pub(crate) fn dedicated_only_settings(&self) -> Vec<&'static str> {
755        let base = Self::default();
756        [
757            ("bind", self.bind.trim() != base.bind),
758            (
759                "max_connections",
760                self.max_connections != base.max_connections,
761            ),
762            (
763                "concurrency_limit_per_connection",
764                self.concurrency_limit_per_connection != base.concurrency_limit_per_connection,
765            ),
766            (
767                "max_concurrent_streams",
768                self.max_concurrent_streams != base.max_concurrent_streams,
769            ),
770            (
771                "http2_max_local_error_reset_streams",
772                self.http2_max_local_error_reset_streams
773                    != base.http2_max_local_error_reset_streams,
774            ),
775            ("tcp_nodelay", self.tcp_nodelay != base.tcp_nodelay),
776            (
777                "tcp_keepalive_ms",
778                self.tcp_keepalive_ms != base.tcp_keepalive_ms,
779            ),
780            (
781                "http2_keepalive_interval_ms",
782                self.http2_keepalive_interval_ms != base.http2_keepalive_interval_ms,
783            ),
784            (
785                "http2_keepalive_timeout_ms",
786                self.http2_keepalive_timeout_ms != base.http2_keepalive_timeout_ms,
787            ),
788            (
789                "max_connection_age_ms",
790                self.max_connection_age_ms != base.max_connection_age_ms,
791            ),
792            (
793                "tls.handshake_timeout_ms",
794                self.tls.handshake_timeout_ms != base.tls.handshake_timeout_ms,
795            ),
796        ]
797        .into_iter()
798        .filter_map(|(key, changed)| changed.then_some(key))
799        .collect()
800    }
801}
802
803/// The env prefix for a section: `grpc` → `AUTUMN_GRPC__`.
804fn env_prefix(section: &str) -> String {
805    format!("AUTUMN_{}__", section.to_ascii_uppercase())
806}
807
808/// Apply `AUTUMN_<SECTION>__<PATH>` overrides for each known leaf key.
809///
810/// Values are TOML literals (`true`, `10`, `"x"`) or bare strings. An
811/// override with the wrong type is an error: a bad value must not leave a
812/// file value (for example `reflection = true`) in place.
813///
814/// A map has no leaves in the defaults. Client leaves come from the names
815/// in the section and in `clients`.
816fn apply_env_overrides(
817    section_name: &str,
818    section: &mut toml::Value,
819    config: &mut GrpcConfig,
820    env: &dyn Env,
821    clients: &[&str],
822) -> Result<(), ConfigError> {
823    let defaults =
824        toml::Value::try_from(GrpcConfig::default()).map_err(|e| ConfigError(e.to_string()))?;
825    let prefix = env_prefix(section_name);
826    let mut leaves = Vec::new();
827    collect_leaves(&defaults, &mut Vec::new(), &mut leaves);
828    let client_defaults =
829        toml::Value::try_from(ClientConfig::default()).map_err(|e| ConfigError(e.to_string()))?;
830    let mut names: Vec<String> = section
831        .get("clients")
832        .and_then(toml::Value::as_table)
833        .map(|table| table.keys().cloned().collect())
834        .unwrap_or_default();
835    names.extend(clients.iter().map(|name| (*name).to_owned()));
836    names.sort();
837    names.dedup();
838    for name in names {
839        let mut prefix = vec!["clients".to_owned(), name];
840        collect_leaves(&client_defaults, &mut prefix, &mut leaves);
841    }
842    for path in leaves {
843        let key = format!(
844            "{prefix}{}",
845            path.iter()
846                .map(|segment| segment.to_ascii_uppercase())
847                .collect::<Vec<_>>()
848                .join("__")
849        );
850        let Some(raw) = env_trimmed(env, &key) else {
851            continue;
852        };
853        let mut candidate = section.clone();
854        set_path(&mut candidate, &path, parse_env_value(&raw));
855        *config = GrpcConfig::from_section(Some(&candidate))
856            .map_err(|error| ConfigError(format!("{key}: {}", error.message())))?;
857        *section = candidate;
858    }
859    Ok(())
860}
861
862fn parse_env_value(raw: &str) -> toml::Value {
863    toml::from_str::<toml::Table>(&format!("v = {raw}"))
864        .ok()
865        .and_then(|mut table| table.remove("v"))
866        .unwrap_or_else(|| toml::Value::String(raw.to_owned()))
867}
868
869fn collect_leaves(value: &toml::Value, prefix: &mut Vec<String>, out: &mut Vec<Vec<String>>) {
870    if let toml::Value::Table(table) = value {
871        for (key, child) in table {
872            prefix.push(key.clone());
873            collect_leaves(child, prefix, out);
874            prefix.pop();
875        }
876    } else {
877        out.push(prefix.clone());
878    }
879}
880
881fn set_path(root: &mut toml::Value, path: &[String], value: toml::Value) {
882    let Some((last, parents)) = path.split_last() else {
883        return;
884    };
885    let mut cursor = root;
886    for segment in parents {
887        let Some(table) = cursor.as_table_mut() else {
888            return;
889        };
890        cursor = table
891            .entry(segment.clone())
892            .or_insert_with(|| toml::Value::Table(toml::map::Map::new()));
893    }
894    if let Some(table) = cursor.as_table_mut() {
895        table.insert(last.clone(), value);
896    }
897}
898
899/// A non-blank env value. A blank value counts as unset, as in core.
900fn env_trimmed(env: &dyn Env, key: &str) -> Option<String> {
901    env.var(key)
902        .ok()
903        .map(|value| value.trim().to_owned())
904        .filter(|value| !value.is_empty())
905}
906
907/// `(selected spelling, canonical name)` of the active profile.
908///
909/// Same order as core: `AUTUMN_ENV`, `AUTUMN_PROFILE`, `--profile`,
910/// `AUTUMN_IS_DEBUG=0` (→ `prod`), then `dev`.
911fn resolve_active_profile(env: &dyn Env) -> (String, String) {
912    let selected = resolve_profile_input(env);
913    let canonical =
914        autumn_web::config::normalize_profile_name(&selected).unwrap_or_else(|| "dev".to_owned());
915    (selected, canonical)
916}
917
918fn resolve_profile_input(env: &dyn Env) -> String {
919    if let Some(value) = env_trimmed(env, "AUTUMN_ENV") {
920        return value;
921    }
922    if let Some(value) = env_trimmed(env, "AUTUMN_PROFILE") {
923        return value;
924    }
925    let args: Vec<String> = std::env::args().collect();
926    for (index, arg) in args.iter().enumerate() {
927        if arg == "--profile"
928            && let Some(profile) = args.get(index.saturating_add(1))
929            && !profile.trim().is_empty()
930        {
931            return profile.trim().to_owned();
932        }
933        if let Some(profile) = arg.strip_prefix("--profile=")
934            && !profile.trim().is_empty()
935        {
936            return profile.trim().to_owned();
937        }
938    }
939    if env_trimmed(env, "AUTUMN_IS_DEBUG").as_deref() == Some("0") {
940        return "prod".to_owned();
941    }
942    "dev".to_owned()
943}
944
945/// Find a config file as core does: `AUTUMN_MANIFEST_DIR` first, then the
946/// working directory.
947fn find_config_file(filename: &str, env: &dyn Env) -> PathBuf {
948    if let Some(dir) = env_trimmed(env, "AUTUMN_MANIFEST_DIR") {
949        let candidate = PathBuf::from(dir).join(filename);
950        if candidate.exists() {
951            return candidate;
952        }
953    }
954    PathBuf::from(filename)
955}
956
957fn read_optional_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
958    match std::fs::read_to_string(path) {
959        Ok(contents) => {
960            let table = toml::from_str::<toml::Table>(&contents)
961                .map_err(|e| ConfigError(format!("cannot parse {}: {e}", path.display())))?;
962            Ok(Some(toml::Value::Table(table)))
963        }
964        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
965        Err(error) => Err(ConfigError(format!(
966            "cannot read {}: {error}",
967            path.display()
968        ))),
969    }
970}
971
972fn profile_inline_lookup_names(canonical: &str) -> Vec<&str> {
973    match canonical {
974        "prod" => vec!["production", "prod"],
975        "dev" => vec!["development", "dev"],
976        other => vec![other],
977    }
978}
979
980fn profile_section(base: &toml::Value, profile: &str) -> Option<toml::Value> {
981    base.get("profile")
982        .and_then(toml::Value::as_table)
983        .and_then(|profiles| profiles.get(profile))
984        .and_then(toml::Value::as_table)
985        .map(|table| toml::Value::Table(table.clone()))
986}
987
988/// Merge `overlay` into `base`. Tables merge; other values replace.
989fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
990    deep_merge_at(base, overlay, 0);
991}
992
993fn deep_merge_at(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
994    const MAX_MERGE_DEPTH: usize = 16;
995    if depth > MAX_MERGE_DEPTH {
996        return;
997    }
998    let toml::Value::Table(overlay_table) = overlay else {
999        return;
1000    };
1001    let Some(base_table) = base.as_table_mut() else {
1002        return;
1003    };
1004    for (key, overlay_value) in overlay_table {
1005        let recurse =
1006            overlay_value.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
1007        if recurse {
1008            if let Some(base_value) = base_table.get_mut(&key) {
1009                deep_merge_at(base_value, overlay_value, depth.saturating_add(1));
1010            }
1011        } else {
1012            base_table.insert(key, overlay_value);
1013        }
1014    }
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use super::*;
1020
1021    type Setter = fn(&mut GrpcConfig);
1022
1023    #[test]
1024    fn dedicated_only_settings_names_each_changed_key() {
1025        assert_eq!(
1026            GrpcConfig::default().dedicated_only_settings(),
1027            Vec::<&str>::new()
1028        );
1029        let setters: [(&str, Setter); 11] = [
1030            ("bind", |c| c.bind = "127.0.0.1:0".to_owned()),
1031            ("max_connections", |c| c.max_connections = 1),
1032            ("concurrency_limit_per_connection", |c| {
1033                c.concurrency_limit_per_connection = 1;
1034            }),
1035            ("max_concurrent_streams", |c| c.max_concurrent_streams = 1),
1036            ("http2_max_local_error_reset_streams", |c| {
1037                c.http2_max_local_error_reset_streams = 1;
1038            }),
1039            ("tcp_nodelay", |c| c.tcp_nodelay = false),
1040            ("tcp_keepalive_ms", |c| c.tcp_keepalive_ms = 1),
1041            ("http2_keepalive_interval_ms", |c| {
1042                c.http2_keepalive_interval_ms = 1;
1043            }),
1044            ("http2_keepalive_timeout_ms", |c| {
1045                c.http2_keepalive_timeout_ms = 1;
1046            }),
1047            ("max_connection_age_ms", |c| c.max_connection_age_ms = 1),
1048            ("tls.handshake_timeout_ms", |c| {
1049                c.tls.handshake_timeout_ms = 1;
1050            }),
1051        ];
1052        for (key, set) in setters {
1053            let mut config = GrpcConfig::default();
1054            set(&mut config);
1055            assert_eq!(config.dedicated_only_settings(), [key]);
1056        }
1057        let mut config = GrpcConfig::default();
1058        " ".clone_into(&mut config.bind);
1059        config.timeout_ms = 5; // applies in both modes
1060        assert!(config.dedicated_only_settings().is_empty(), "blank bind");
1061    }
1062}