Skip to main content

laterite_core/
config.rs

1//! Layered configuration loading.
2
3use std::path::Path;
4
5use config::{Config, Environment, File};
6use serde::de::DeserializeOwned;
7use serde::Deserialize;
8
9use crate::error::{CoreError, CoreResult};
10
11/// Database connection settings.
12#[derive(Debug, Clone, Deserialize)]
13pub struct DatabaseConfig {
14    pub url: String,
15    #[serde(default = "default_max_connections")]
16    pub max_connections: u32,
17    #[serde(default = "default_acquire_timeout_secs")]
18    pub acquire_timeout_secs: u64,
19}
20
21/// HTTP listener settings.
22#[derive(Debug, Clone, Deserialize)]
23pub struct ServerConfig {
24    #[serde(default = "default_listen")]
25    pub listen: String,
26}
27
28/// Application-level metadata. The `name` is the human-readable application
29/// name, the baseline for the admin brand: a `BrandSetting` in the admin can
30/// override it, but this is the default when none is set.
31///
32/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
33/// in a later release is additive.
34#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36#[non_exhaustive]
37pub struct AppMeta {
38    /// The display name of the application (e.g. `"Acme Blog"`). Shown as the
39    /// admin brand unless overridden by a brand setting.
40    pub name: String,
41    /// The application's public base URL (scheme and host, with any port), such
42    /// as `https://acme.example`. It is the canonical origin absolute links build
43    /// on, distinct from the server bind address (they differ behind a proxy or a
44    /// local `.test` domain). When unset, [`base_url`] derives it from the bind
45    /// address.
46    pub url: Option<String>,
47    /// The application's default locale (BCP 47, e.g. `"en"`): the deployment
48    /// default for UI-string translation, the last stop in a request's locale
49    /// chain before the English source when neither the operator's preference nor
50    /// the request's `Accept-Language` resolves.
51    pub locale: String,
52    /// Debug mode: error pages include the internal cause. Off by default;
53    /// enable only in development. Read via [`debug`] where request context is
54    /// not available (error rendering).
55    pub debug: bool,
56}
57
58impl Default for AppMeta {
59    fn default() -> Self {
60        Self {
61            name: "Laterite".to_string(),
62            url: None,
63            locale: "en".to_string(),
64            debug: false,
65        }
66    }
67}
68
69/// Records whether the deployment is in debug mode, once, at boot from the loaded
70/// config. Later calls are ignored (the first wins), so error rendering has a
71/// stable answer without threading config through every handler.
72pub fn set_debug(on: bool) {
73    let _ = DEBUG.set(on);
74}
75
76/// Whether the deployment is in debug mode (default `false` until [`set_debug`]).
77pub fn debug() -> bool {
78    *DEBUG.get().unwrap_or(&false)
79}
80
81static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
82
83/// The application's public base URL, without a trailing slash: the configured
84/// `url` when set and non-empty, otherwise derived from the server bind address
85/// as `http://<listen>`. A wildcard bind host (`0.0.0.0` or `[::]`) is shown via
86/// loopback so the result is browsable. Absolute links (the admin URL in the
87/// startup banner, and later notifications and share cards) build on this.
88pub fn base_url(url: Option<&str>, listen: &str) -> String {
89    match url.map(str::trim).filter(|u| !u.is_empty()) {
90        Some(url) => url.trim_end_matches('/').to_string(),
91        None => format!("http://{}", browsable_host(listen)),
92    }
93}
94
95/// Rewrites a wildcard bind host to a loopback address so a bind string like
96/// `0.0.0.0:8080` yields a URL a browser can actually open.
97fn browsable_host(listen: &str) -> String {
98    if let Some(port) = listen.strip_prefix("0.0.0.0") {
99        format!("127.0.0.1{port}")
100    } else if let Some(port) = listen.strip_prefix("[::]") {
101        format!("[::1]{port}")
102    } else {
103        listen.to_string()
104    }
105}
106
107/// Deployment-level backend settings. Per-install brand and per-operator preferences
108/// live in the settings and preferences stores, not here.
109///
110/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
111/// in a later release is additive.
112#[derive(Debug, Clone, Deserialize)]
113#[serde(default)]
114#[non_exhaustive]
115pub struct BackendConfig {
116    /// Set the `Secure` attribute on the admin session cookie. Enable behind HTTPS
117    /// in production; leave off for plain-HTTP local development.
118    pub secure_cookie: bool,
119    /// The default display timezone for the admin (an IANA name like
120    /// `Asia/Kolkata`). Storage is always UTC; this only affects how dates render.
121    /// An operator's own preference overrides it (later); it falls back to UTC.
122    pub timezone: String,
123    /// The URL path the admin panel is mounted under, without a trailing slash
124    /// (default `/admin`). Change it to move or obscure the panel (`/manage`,
125    /// `/backend`). A leading slash is added if missing and a trailing slash is
126    /// stripped; an empty value falls back to `/admin`.
127    pub path: String,
128}
129
130impl Default for BackendConfig {
131    fn default() -> Self {
132        Self {
133            secure_cookie: false,
134            timezone: "UTC".to_string(),
135            path: "/admin".to_string(),
136        }
137    }
138}
139
140fn default_max_connections() -> u32 {
141    10
142}
143
144fn default_acquire_timeout_secs() -> u64 {
145    5
146}
147
148fn default_listen() -> String {
149    "127.0.0.1:8080".to_string()
150}
151
152/// Loads a layered configuration into any deserializable type.
153///
154/// Layers, later overriding earlier:
155/// 1. `<dir>/default.toml` (required)
156/// 2. `<dir>/<APP_ENV>.toml` (optional; `APP_ENV` defaults to `development`)
157/// 3. `<dir>/local.toml` (optional, git-ignored developer overrides)
158/// 4. Environment variables `<PREFIX>__SECTION__KEY` (e.g. `APP__DATABASE__URL`)
159pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
160    let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
161    Config::builder()
162        .add_source(File::from(dir.join("default.toml")).required(true))
163        .add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
164        .add_source(File::from(dir.join("local.toml")).required(false))
165        .add_source(
166            Environment::with_prefix(env_prefix)
167                .prefix_separator("__")
168                .separator("__"),
169        )
170        .build()
171        .and_then(Config::try_deserialize)
172        .map_err(|e| CoreError::Config(e.to_string()))
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[derive(Deserialize)]
180    struct TestConfig {
181        server: ServerConfig,
182        database: DatabaseConfig,
183    }
184
185    #[test]
186    fn loads_defaults_env_overrides_and_serde_defaults() {
187        let dir = tempfile::tempdir().unwrap();
188        std::fs::write(
189            dir.path().join("default.toml"),
190            "[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
191        )
192        .unwrap();
193        std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
194        let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
195        std::env::remove_var("LATERITE_TEST__DATABASE__URL");
196        assert_eq!(cfg.server.listen, "0.0.0.0:9999");
197        assert_eq!(cfg.database.url, "postgres://from-env");
198        assert_eq!(cfg.database.max_connections, 10);
199        assert_eq!(cfg.database.acquire_timeout_secs, 5);
200    }
201
202    #[test]
203    fn backend_config_defaults_and_loads() {
204        #[derive(Deserialize)]
205        struct C {
206            #[serde(default)]
207            backend: BackendConfig,
208        }
209        let dir = tempfile::tempdir().unwrap();
210        std::fs::write(dir.path().join("default.toml"), "").unwrap();
211        let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
212        assert!(!c.backend.secure_cookie);
213        assert_eq!(c.backend.timezone, "UTC");
214
215        std::fs::write(
216            dir.path().join("default.toml"),
217            "[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
218        )
219        .unwrap();
220        let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
221        assert!(c.backend.secure_cookie);
222        assert_eq!(c.backend.timezone, "Asia/Kolkata");
223    }
224
225    #[test]
226    fn base_url_prefers_config_then_derives_from_listen() {
227        // Configured URL wins and loses any trailing slash.
228        assert_eq!(
229            base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
230            "https://acme.example"
231        );
232        // Blank configured URL falls through to the derived form.
233        assert_eq!(
234            base_url(Some("  "), "127.0.0.1:9000"),
235            "http://127.0.0.1:9000"
236        );
237        // No URL: derive from the bind address.
238        assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
239        // A wildcard bind host is shown via loopback so the URL is browsable.
240        assert_eq!(base_url(None, "0.0.0.0:8080"), "http://127.0.0.1:8080");
241        assert_eq!(base_url(None, "[::]:8080"), "http://[::1]:8080");
242    }
243
244    #[test]
245    fn missing_default_file_is_a_config_error() {
246        let dir = tempfile::tempdir().unwrap();
247        let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
248        assert!(matches!(result, Err(CoreError::Config(_))));
249    }
250}