1use 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
35pub const DEFAULT_PORT: u16 = 50051;
37
38pub const DEFAULT_SECTION: &str = "grpc";
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
43pub enum Toggle {
44 #[default]
46 Auto,
47 On,
49 Off,
51}
52
53impl Toggle {
54 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
115#[serde(rename_all = "lowercase")]
116#[non_exhaustive]
117pub enum Listener {
118 #[default]
120 Dedicated,
121 Shared,
124}
125
126impl Listener {
127 #[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(default, deny_unknown_fields)]
149#[non_exhaustive]
150#[allow(clippy::struct_excessive_bools)] pub struct GrpcConfig {
152 pub enabled: bool,
154 pub listener: Listener,
157 pub bind: String,
161 pub health: bool,
163 pub reflection: Toggle,
165 pub metrics: bool,
167 pub max_metric_series: usize,
170 pub shutdown_grace_ms: u64,
173 pub timeout_ms: u64,
175 pub max_connections: usize,
178 pub concurrency_limit_per_connection: usize,
181 pub max_concurrent_streams: u32,
184 pub http2_max_local_error_reset_streams: usize,
188 pub tcp_nodelay: bool,
190 pub tcp_keepalive_ms: u64,
192 pub http2_keepalive_interval_ms: u64,
194 pub http2_keepalive_timeout_ms: u64,
197 pub max_connection_age_ms: u64,
199 pub tls: TlsConfig,
201 pub clients: BTreeMap<String, ClientConfig>,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210#[non_exhaustive]
211pub struct ClientConfig {
212 pub endpoint: String,
215 pub timeout_ms: u64,
219 pub connect_timeout_ms: u64,
222 pub tls: ClientTls,
224}
225
226#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
228#[serde(default, deny_unknown_fields)]
229#[non_exhaustive]
230pub struct ClientTls {
231 pub ca_path: String,
235 pub cert_path: String,
237 pub key_path: String,
239 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 #[must_use]
257 pub const fn timeout(&self) -> Option<Duration> {
258 millis(self.timeout_ms)
259 }
260
261 #[must_use]
263 pub const fn connect_timeout(&self) -> Duration {
264 Duration::from_millis(self.connect_timeout_ms)
265 }
266
267 #[must_use]
270 pub fn is_https(&self) -> bool {
271 self.endpoint.trim().starts_with("https://")
272 }
273
274 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
330pub 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
347fn 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
381#[serde(default, deny_unknown_fields)]
382#[non_exhaustive]
383pub struct TlsConfig {
384 pub cert_path: String,
386 pub key_path: String,
388 pub client_ca_path: String,
390 pub client_auth_optional: bool,
393 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 #[must_use]
413 pub fn is_enabled(&self) -> bool {
414 !self.cert_path.trim().is_empty()
415 }
416
417 #[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
454#[error("invalid gRPC configuration: {0}")]
455pub struct ConfigError(String);
456
457impl ConfigError {
458 #[must_use]
460 pub fn message(&self) -> &str {
461 &self.0
462 }
463}
464
465#[derive(Debug, Clone)]
467pub struct Resolved {
468 pub(crate) config: GrpcConfig,
469 pub(crate) profile: String,
470}
471
472impl Resolved {
473 #[must_use]
475 pub const fn config(&self) -> &GrpcConfig {
476 &self.config
477 }
478
479 #[must_use]
481 pub fn profile(&self) -> &str {
482 &self.profile
483 }
484
485 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 #[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 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 #[must_use]
537 pub const fn timeout(&self) -> Option<Duration> {
538 millis(self.timeout_ms)
539 }
540
541 #[must_use]
543 pub const fn shutdown_grace(&self) -> Duration {
544 Duration::from_millis(self.shutdown_grace_ms)
545 }
546
547 #[must_use]
549 pub const fn tcp_keepalive(&self) -> Option<Duration> {
550 millis(self.tcp_keepalive_ms)
551 }
552
553 #[must_use]
555 pub const fn http2_keepalive_interval(&self) -> Option<Duration> {
556 millis(self.http2_keepalive_interval_ms)
557 }
558
559 #[must_use]
561 pub const fn http2_keepalive_timeout(&self) -> Option<Duration> {
562 millis(self.http2_keepalive_timeout_ms)
563 }
564
565 #[must_use]
567 pub const fn max_connection_age(&self) -> Option<Duration> {
568 millis(self.max_connection_age_ms)
569 }
570
571 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 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 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 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 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(§ion_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 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 #[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
803fn env_prefix(section: &str) -> String {
805 format!("AUTUMN_{}__", section.to_ascii_uppercase())
806}
807
808fn 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
899fn 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
907fn 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
945fn 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
988fn 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; assert!(config.dedicated_only_settings().is_empty(), "blank bind");
1061 }
1062}