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#[derive(Debug, Clone, Deserialize)]
32#[serde(default)]
33pub struct AppMeta {
34    /// The display name of the application (e.g. `"Acme Blog"`). Shown as the
35    /// admin brand unless overridden by a brand setting.
36    pub name: String,
37}
38
39impl Default for AppMeta {
40    fn default() -> Self {
41        Self {
42            name: "Laterite".to_string(),
43        }
44    }
45}
46
47/// Deployment-level backend settings. Per-install brand and per-operator preferences
48/// live in the settings and preferences stores, not here.
49#[derive(Debug, Clone, Deserialize)]
50#[serde(default)]
51pub struct BackendConfig {
52    /// Set the `Secure` attribute on the admin session cookie. Enable behind HTTPS
53    /// in production; leave off for plain-HTTP local development.
54    pub secure_cookie: bool,
55    /// The default display timezone for the admin (an IANA name like
56    /// `Asia/Kolkata`). Storage is always UTC; this only affects how dates render.
57    /// An operator's own preference overrides it (later); it falls back to UTC.
58    pub timezone: String,
59}
60
61impl Default for BackendConfig {
62    fn default() -> Self {
63        Self {
64            secure_cookie: false,
65            timezone: "UTC".to_string(),
66        }
67    }
68}
69
70fn default_max_connections() -> u32 {
71    10
72}
73
74fn default_acquire_timeout_secs() -> u64 {
75    5
76}
77
78fn default_listen() -> String {
79    "127.0.0.1:8080".to_string()
80}
81
82/// Loads a layered configuration into any deserializable type.
83///
84/// Layers, later overriding earlier:
85/// 1. `<dir>/default.toml` (required)
86/// 2. `<dir>/<APP_ENV>.toml` (optional; `APP_ENV` defaults to `development`)
87/// 3. `<dir>/local.toml` (optional, git-ignored developer overrides)
88/// 4. Environment variables `<PREFIX>__SECTION__KEY` (e.g. `APP__DATABASE__URL`)
89pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
90    let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
91    Config::builder()
92        .add_source(File::from(dir.join("default.toml")).required(true))
93        .add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
94        .add_source(File::from(dir.join("local.toml")).required(false))
95        .add_source(
96            Environment::with_prefix(env_prefix)
97                .prefix_separator("__")
98                .separator("__"),
99        )
100        .build()
101        .and_then(Config::try_deserialize)
102        .map_err(|e| CoreError::Config(e.to_string()))
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[derive(Deserialize)]
110    struct TestConfig {
111        server: ServerConfig,
112        database: DatabaseConfig,
113    }
114
115    #[test]
116    fn loads_defaults_env_overrides_and_serde_defaults() {
117        let dir = tempfile::tempdir().unwrap();
118        std::fs::write(
119            dir.path().join("default.toml"),
120            "[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
121        )
122        .unwrap();
123        std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
124        let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
125        std::env::remove_var("LATERITE_TEST__DATABASE__URL");
126        assert_eq!(cfg.server.listen, "0.0.0.0:9999");
127        assert_eq!(cfg.database.url, "postgres://from-env");
128        assert_eq!(cfg.database.max_connections, 10);
129        assert_eq!(cfg.database.acquire_timeout_secs, 5);
130    }
131
132    #[test]
133    fn backend_config_defaults_and_loads() {
134        #[derive(Deserialize)]
135        struct C {
136            #[serde(default)]
137            backend: BackendConfig,
138        }
139        let dir = tempfile::tempdir().unwrap();
140        std::fs::write(dir.path().join("default.toml"), "").unwrap();
141        let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
142        assert!(!c.backend.secure_cookie);
143        assert_eq!(c.backend.timezone, "UTC");
144
145        std::fs::write(
146            dir.path().join("default.toml"),
147            "[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
148        )
149        .unwrap();
150        let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
151        assert!(c.backend.secure_cookie);
152        assert_eq!(c.backend.timezone, "Asia/Kolkata");
153    }
154
155    #[test]
156    fn missing_default_file_is_a_config_error() {
157        let dir = tempfile::tempdir().unwrap();
158        let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
159        assert!(matches!(result, Err(CoreError::Config(_))));
160    }
161}