Skip to main content

churust_core/
config.rs

1//! Layered configuration: defaults < `churust.toml` < env (`CHURUST_*`) < code.
2
3use serde::Deserialize;
4
5/// The fully-resolved application configuration.
6///
7/// Deserializes from a `churust.toml` file (with a `[server]` table and an
8/// optional `[tls]` table) and can be overlaid with `CHURUST_*` environment
9/// variables via [`apply_env`](Config::apply_env). Apply it to a builder with
10/// [`AppBuilder::with_config`](crate::AppBuilder::with_config), or use
11/// [`Churust::from_config`](crate::Churust::from_config) to load and apply the
12/// defaults in one step. Unspecified fields fall back to sane defaults.
13///
14/// ```
15/// use churust_core::Config;
16///
17/// let cfg: Config = toml::from_str(r#"
18///     [server]
19///     host = "0.0.0.0"
20///     port = 9090
21/// "#).unwrap();
22/// assert_eq!(cfg.server.host, "0.0.0.0");
23/// assert_eq!(cfg.server.port, 9090);
24/// // unspecified fields keep their defaults
25/// assert_eq!(cfg.server.max_body_bytes, 1 << 20);
26/// assert!(cfg.tls.is_none());
27/// ```
28#[derive(Debug, Clone, Default, Deserialize)]
29#[serde(default)]
30pub struct Config {
31    /// Server-level settings (`[server]` table).
32    pub server: ServerSection,
33    /// Optional TLS settings (`[tls]` table); `None` serves plaintext HTTP.
34    pub tls: Option<TlsSection>,
35}
36
37/// The `[server]` configuration table.
38///
39/// Every field has a default (see [`ServerSection::default`]), so a partial
40/// `[server]` table is valid.
41#[derive(Debug, Clone, Deserialize)]
42#[serde(default)]
43pub struct ServerSection {
44    /// Bind address host (default `"127.0.0.1"`).
45    pub host: String,
46    /// Bind port (default `8080`).
47    pub port: u16,
48    /// Maximum accepted request body size in bytes (default `1 MiB`). Larger
49    /// bodies are rejected with `413 Payload Too Large`.
50    pub max_body_bytes: usize,
51    /// Per-request timeout in milliseconds (default `30000`). `0` disables the
52    /// timeout.
53    pub request_timeout_ms: u64,
54}
55
56/// The `[tls]` configuration table: paths to a PEM certificate chain and
57/// private key.
58///
59/// Only meaningful when the `tls` feature is enabled; otherwise the paths are
60/// ignored. Set it on a builder with [`AppBuilder::tls`](crate::AppBuilder::tls).
61#[derive(Debug, Clone, Deserialize)]
62pub struct TlsSection {
63    /// Path to the PEM-encoded certificate chain file.
64    pub cert: String,
65    /// Path to the PEM-encoded private key file.
66    pub key: String,
67}
68
69impl Default for ServerSection {
70    fn default() -> Self {
71        Self {
72            host: "127.0.0.1".into(),
73            port: 8080,
74            max_body_bytes: 1 << 20,
75            request_timeout_ms: 30_000,
76        }
77    }
78}
79
80impl Config {
81    /// Load configuration by layering, in increasing precedence: built-in
82    /// defaults, then the TOML file at `path` (if it exists and parses), then
83    /// `CHURUST_*` environment variables.
84    ///
85    /// A missing or unparseable file is treated as "use defaults" rather than an
86    /// error, so calling this without a config file present is safe.
87    ///
88    /// ```no_run
89    /// use churust_core::Config;
90    /// // Reads ./my-app.toml if present, then applies CHURUST_* env overrides.
91    /// let cfg = Config::load("my-app.toml");
92    /// let _ = cfg.server.port;
93    /// ```
94    pub fn load(path: &str) -> Self {
95        let mut cfg = match std::fs::read_to_string(path) {
96            Ok(text) => toml::from_str::<Config>(&text).unwrap_or_default(),
97            Err(_) => Config::default(),
98        };
99        cfg.apply_env(|k| std::env::var(k).ok());
100        cfg
101    }
102
103    /// Load configuration from the conventional `churust.toml` path (plus
104    /// `CHURUST_*` env overrides). A shorthand for `Config::load("churust.toml")`;
105    /// this is what [`Churust::from_config`](crate::Churust::from_config) uses.
106    ///
107    /// ```no_run
108    /// use churust_core::Config;
109    /// let cfg = Config::load_default();
110    /// let _ = cfg.server.host;
111    /// ```
112    pub fn load_default() -> Self {
113        Self::load("churust.toml")
114    }
115
116    /// Overlay `CHURUST_*` environment variables onto this config, using the
117    /// provided `get` lookup so the source can be injected in tests.
118    ///
119    /// Recognized keys: `CHURUST_SERVER_HOST`, `CHURUST_SERVER_PORT`,
120    /// `CHURUST_SERVER_MAX_BODY_BYTES`, `CHURUST_SERVER_REQUEST_TIMEOUT_MS`.
121    /// Values that fail to parse (e.g. a non-numeric port) are ignored, leaving
122    /// the existing value in place.
123    ///
124    /// ```
125    /// use churust_core::Config;
126    /// use std::collections::HashMap;
127    ///
128    /// let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
129    /// let mut cfg = Config::default();
130    /// cfg.apply_env(|k| env.get(k).map(|s| s.to_string()));
131    /// assert_eq!(cfg.server.port, 7000);
132    /// ```
133    pub fn apply_env(&mut self, get: impl Fn(&str) -> Option<String>) {
134        if let Some(v) = get("CHURUST_SERVER_HOST") {
135            self.server.host = v;
136        }
137        if let Some(v) = get("CHURUST_SERVER_PORT").and_then(|s| s.parse().ok()) {
138            self.server.port = v;
139        }
140        if let Some(v) = get("CHURUST_SERVER_MAX_BODY_BYTES").and_then(|s| s.parse().ok()) {
141            self.server.max_body_bytes = v;
142        }
143        if let Some(v) = get("CHURUST_SERVER_REQUEST_TIMEOUT_MS").and_then(|s| s.parse().ok()) {
144            self.server.request_timeout_ms = v;
145        }
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::collections::HashMap;
153
154    #[test]
155    fn defaults_are_sane() {
156        let c = Config::default();
157        assert_eq!(c.server.port, 8080);
158        assert_eq!(c.server.max_body_bytes, 1 << 20);
159        assert!(c.tls.is_none());
160    }
161
162    #[test]
163    fn parses_toml() {
164        let text = r#"
165            [server]
166            host = "0.0.0.0"
167            port = 9090
168        "#;
169        let c: Config = toml::from_str(text).unwrap();
170        assert_eq!(c.server.host, "0.0.0.0");
171        assert_eq!(c.server.port, 9090);
172        // unspecified fields fall back to defaults
173        assert_eq!(c.server.max_body_bytes, 1 << 20);
174    }
175
176    #[test]
177    fn env_overrides_file() {
178        let mut c = Config::default();
179        let env: HashMap<&str, &str> = [("CHURUST_SERVER_PORT", "7000")].into_iter().collect();
180        c.apply_env(|k| env.get(k).map(|s| s.to_string()));
181        assert_eq!(c.server.port, 7000);
182    }
183}