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 /// The prefix of the environment variables that override this configuration
57 /// (`<PREFIX>__SECTION__KEY`). Declared here so the application and the `lat`
58 /// command agree on it; unset means the framework default. Read from the files
59 /// alone by [`declared_env_prefix`], since it selects the environment layer.
60 pub env_prefix: Option<String>,
61}
62
63impl Default for AppMeta {
64 fn default() -> Self {
65 Self {
66 name: "Laterite".to_string(),
67 url: None,
68 locale: "en".to_string(),
69 debug: false,
70 env_prefix: None,
71 }
72 }
73}
74
75/// Records whether the deployment is in debug mode, once, at boot from the loaded
76/// config. Later calls are ignored (the first wins), so error rendering has a
77/// stable answer without threading config through every handler.
78pub fn set_debug(on: bool) {
79 let _ = DEBUG.set(on);
80}
81
82/// Whether the deployment is in debug mode (default `false` until [`set_debug`]).
83pub fn debug() -> bool {
84 *DEBUG.get().unwrap_or(&false)
85}
86
87static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
88
89/// The application's public base URL, without a trailing slash: the configured
90/// `url` when set and non-empty, otherwise derived from the server bind address
91/// as `http://<listen>`. A wildcard bind host (`0.0.0.0` or `[::]`) is shown via
92/// loopback so the result is browsable. Absolute links (the admin URL in the
93/// startup banner, and later notifications and share cards) build on this.
94pub fn base_url(url: Option<&str>, listen: &str) -> String {
95 match url.map(str::trim).filter(|u| !u.is_empty()) {
96 Some(url) => url.trim_end_matches('/').to_string(),
97 None => format!("http://{}", browsable_host(listen)),
98 }
99}
100
101/// Rewrites a wildcard bind host to a loopback address so a bind string like
102/// `0.0.0.0:8080` yields a URL a browser can actually open.
103fn browsable_host(listen: &str) -> String {
104 if let Some(port) = listen.strip_prefix("0.0.0.0") {
105 format!("127.0.0.1{port}")
106 } else if let Some(port) = listen.strip_prefix("[::]") {
107 format!("[::1]{port}")
108 } else {
109 listen.to_string()
110 }
111}
112
113/// Deployment-level backend settings. Per-install brand and per-operator preferences
114/// live in the settings and preferences stores, not here.
115///
116/// Non-exhaustive: build one with [`Default`] and set fields, so a field added
117/// in a later release is additive.
118#[derive(Debug, Clone, Deserialize)]
119#[serde(default)]
120#[non_exhaustive]
121pub struct BackendConfig {
122 /// Set the `Secure` attribute on the admin session cookie. Enable behind HTTPS
123 /// in production; leave off for plain-HTTP local development.
124 pub secure_cookie: bool,
125 /// The key the admin signs its tokens with (a private file's URL). Unset,
126 /// a key is generated at the first boot and kept in the settings table, so
127 /// set it when several instances share a database behind one hostname.
128 pub secret: Option<String>,
129 /// The default display timezone for the admin (an IANA name like
130 /// `Asia/Kolkata`). Storage is always UTC; this only affects how dates render.
131 /// An operator's own preference overrides it (later); it falls back to UTC.
132 pub timezone: String,
133 /// Admin path overrides, keyed by module id (`"rainmill.location"`) or by a
134 /// module id and one contribution's declared path
135 /// (`"rainmill.location/nodes"`), the more specific winning. Values are
136 /// absolute under the admin mount, so they survive a relocated panel. Lets a
137 /// deployment move a plugin's screens without forking it.
138 pub paths: std::collections::BTreeMap<String, String>,
139 /// Arrangement overrides, keyed by hierarchy (`"backend.nav"`,
140 /// `"backend.settings"`, a plugin's own): the keys in the wanted order.
141 /// Keys left out keep their arrangement after the listed ones; a fixed
142 /// hierarchy ignores its entry.
143 pub order: std::collections::BTreeMap<String, Vec<String>>,
144 /// Entries moved from the home their author gave them, keyed by the
145 /// entry (`"backend.access_log"`, `"resource:/orders"`); see
146 /// [`crate::arrange::Move`].
147 pub menu: std::collections::BTreeMap<String, crate::arrange::Move>,
148 /// The URL path the admin panel is mounted under, without a trailing slash
149 /// (default `/admin`). Change it to move or obscure the panel (`/manage`,
150 /// `/backend`). A leading slash is added if missing and a trailing slash is
151 /// stripped; an empty value falls back to `/admin`.
152 pub path: String,
153 /// Networks whose `X-Forwarded-For` header may be believed, as CIDR ranges
154 /// (`"10.0.0.0/8"`). The header is caller-controlled, so it is read only when
155 /// the connecting peer falls inside one of these; the client is then the
156 /// rightmost address in the chain that is not itself a trusted proxy.
157 ///
158 /// Empty (the default) trusts nothing and records the peer address, which is
159 /// right for a directly bound server. Set it to the load balancer's network
160 /// when there is one: leaving it empty behind a proxy records the proxy on
161 /// every row, and filling it in for a network that is not a proxy lets anyone
162 /// on it forge the address in the audit log.
163 pub trusted_proxies: Vec<String>,
164 /// The directory files the admin keeps are stored under (default
165 /// `storage`), relative to the working directory unless absolute. Uploaded
166 /// import files go to `private` beneath it.
167 pub storage: String,
168}
169
170impl Default for BackendConfig {
171 fn default() -> Self {
172 Self {
173 secure_cookie: false,
174 secret: None,
175 timezone: "UTC".to_string(),
176 paths: std::collections::BTreeMap::new(),
177 order: std::collections::BTreeMap::new(),
178 menu: std::collections::BTreeMap::new(),
179 path: "/admin".to_string(),
180 trusted_proxies: Vec::new(),
181 storage: "storage".to_string(),
182 }
183 }
184}
185
186fn default_max_connections() -> u32 {
187 10
188}
189
190fn default_acquire_timeout_secs() -> u64 {
191 5
192}
193
194fn default_listen() -> String {
195 "127.0.0.1:8080".to_string()
196}
197
198/// Loads a layered configuration into any deserializable type.
199///
200/// Layers, later overriding earlier:
201/// 1. `<dir>/default.toml` (required)
202/// 2. `<dir>/<APP_ENV>.toml` (optional; `APP_ENV` defaults to `development`)
203/// 3. `<dir>/local.toml` (optional, git-ignored developer overrides)
204/// 4. Environment variables `<PREFIX>__SECTION__KEY` (e.g. `LAT__DATABASE__URL`);
205/// the prefix an application declares as `app.env_prefix` is read with
206/// [`declared_env_prefix`] and passed here.
207pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
208 file_layers(dir)
209 .add_source(
210 Environment::with_prefix(env_prefix)
211 .prefix_separator("__")
212 .separator("__"),
213 )
214 .build()
215 .and_then(Config::try_deserialize)
216 .map_err(|e| CoreError::Config(e.to_string()))
217}
218
219/// The environment-variable prefix a configuration declares as `app.env_prefix`,
220/// read from the files alone: it selects the environment layer, so it cannot come
221/// from there. `None` when undeclared or blank, meaning the framework default.
222pub fn declared_env_prefix(dir: &Path) -> CoreResult<Option<String>> {
223 #[derive(Deserialize)]
224 struct Declared {
225 #[serde(default)]
226 app: DeclaredApp,
227 }
228 #[derive(Deserialize, Default)]
229 struct DeclaredApp {
230 env_prefix: Option<String>,
231 }
232 let declared: Declared = file_layers(dir)
233 .build()
234 .and_then(Config::try_deserialize)
235 .map_err(|e| CoreError::Config(e.to_string()))?;
236 Ok(declared
237 .app
238 .env_prefix
239 .map(|prefix| prefix.trim().to_string())
240 .filter(|prefix| !prefix.is_empty()))
241}
242
243/// The file layers of a config directory, in override order: `default.toml`
244/// (required), `<APP_ENV>.toml`, then `local.toml`.
245fn file_layers(dir: &Path) -> config::ConfigBuilder<config::builder::DefaultState> {
246 let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
247 Config::builder()
248 .add_source(File::from(dir.join("default.toml")).required(true))
249 .add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
250 .add_source(File::from(dir.join("local.toml")).required(false))
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[derive(Deserialize)]
258 struct TestConfig {
259 server: ServerConfig,
260 database: DatabaseConfig,
261 }
262
263 #[test]
264 fn loads_defaults_env_overrides_and_serde_defaults() {
265 let dir = tempfile::tempdir().unwrap();
266 std::fs::write(
267 dir.path().join("default.toml"),
268 "[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
269 )
270 .unwrap();
271 std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
272 let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
273 std::env::remove_var("LATERITE_TEST__DATABASE__URL");
274 assert_eq!(cfg.server.listen, "0.0.0.0:9999");
275 assert_eq!(cfg.database.url, "postgres://from-env");
276 assert_eq!(cfg.database.max_connections, 10);
277 assert_eq!(cfg.database.acquire_timeout_secs, 5);
278 }
279
280 #[test]
281 fn backend_config_defaults_and_loads() {
282 #[derive(Deserialize)]
283 struct C {
284 #[serde(default)]
285 backend: BackendConfig,
286 }
287 let dir = tempfile::tempdir().unwrap();
288 std::fs::write(dir.path().join("default.toml"), "").unwrap();
289 let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
290 assert!(!c.backend.secure_cookie);
291 assert_eq!(c.backend.timezone, "UTC");
292
293 std::fs::write(
294 dir.path().join("default.toml"),
295 "[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
296 )
297 .unwrap();
298 let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
299 assert!(c.backend.secure_cookie);
300 assert_eq!(c.backend.timezone, "Asia/Kolkata");
301 }
302
303 #[test]
304 fn declared_env_prefix_comes_from_the_files_alone() {
305 let dir = tempfile::tempdir().unwrap();
306 std::fs::write(dir.path().join("default.toml"), "[app]\nname = \"Acme\"\n").unwrap();
307 assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
308 std::fs::write(
309 dir.path().join("default.toml"),
310 "[app]\nenv_prefix = \"ACME\"\n",
311 )
312 .unwrap();
313 assert_eq!(
314 declared_env_prefix(dir.path()).unwrap().as_deref(),
315 Some("ACME")
316 );
317 // local.toml overrides it, and a blank value counts as undeclared.
318 std::fs::write(
319 dir.path().join("local.toml"),
320 "[app]\nenv_prefix = \" \"\n",
321 )
322 .unwrap();
323 assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
324 }
325
326 #[test]
327 fn base_url_prefers_config_then_derives_from_listen() {
328 // Configured URL wins and loses any trailing slash.
329 assert_eq!(
330 base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
331 "https://acme.example"
332 );
333 // Blank configured URL falls through to the derived form.
334 assert_eq!(
335 base_url(Some(" "), "127.0.0.1:9000"),
336 "http://127.0.0.1:9000"
337 );
338 // No URL: derive from the bind address.
339 assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
340 // A wildcard bind host is shown via loopback so the URL is browsable.
341 assert_eq!(base_url(None, "0.0.0.0:8080"), "http://127.0.0.1:8080");
342 assert_eq!(base_url(None, "[::]:8080"), "http://[::1]:8080");
343 }
344
345 #[test]
346 fn missing_default_file_is_a_config_error() {
347 let dir = tempfile::tempdir().unwrap();
348 let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
349 assert!(matches!(result, Err(CoreError::Config(_))));
350 }
351}