1use std::path::Path;
4
5use config::{Config, Environment, File};
6use serde::de::DeserializeOwned;
7use serde::Deserialize;
8
9use crate::error::{CoreError, CoreResult};
10
11#[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#[derive(Debug, Clone, Deserialize)]
23pub struct ServerConfig {
24 #[serde(default = "default_listen")]
25 pub listen: String,
26}
27
28#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36#[non_exhaustive]
37pub struct AppMeta {
38 pub name: String,
41 pub url: Option<String>,
47 pub locale: String,
52 pub debug: bool,
56 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
75pub fn set_debug(on: bool) {
79 let _ = DEBUG.set(on);
80}
81
82pub fn debug() -> bool {
84 *DEBUG.get().unwrap_or(&false)
85}
86
87static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
88
89pub 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
101fn 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#[derive(Debug, Clone, Deserialize)]
119#[serde(default)]
120#[non_exhaustive]
121pub struct BackendConfig {
122 pub secure_cookie: bool,
125 pub timezone: String,
129 pub paths: std::collections::BTreeMap<String, String>,
135 pub path: String,
140}
141
142impl Default for BackendConfig {
143 fn default() -> Self {
144 Self {
145 secure_cookie: false,
146 timezone: "UTC".to_string(),
147 paths: std::collections::BTreeMap::new(),
148 path: "/admin".to_string(),
149 }
150 }
151}
152
153fn default_max_connections() -> u32 {
154 10
155}
156
157fn default_acquire_timeout_secs() -> u64 {
158 5
159}
160
161fn default_listen() -> String {
162 "127.0.0.1:8080".to_string()
163}
164
165pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
175 file_layers(dir)
176 .add_source(
177 Environment::with_prefix(env_prefix)
178 .prefix_separator("__")
179 .separator("__"),
180 )
181 .build()
182 .and_then(Config::try_deserialize)
183 .map_err(|e| CoreError::Config(e.to_string()))
184}
185
186pub fn declared_env_prefix(dir: &Path) -> CoreResult<Option<String>> {
190 #[derive(Deserialize)]
191 struct Declared {
192 #[serde(default)]
193 app: DeclaredApp,
194 }
195 #[derive(Deserialize, Default)]
196 struct DeclaredApp {
197 env_prefix: Option<String>,
198 }
199 let declared: Declared = file_layers(dir)
200 .build()
201 .and_then(Config::try_deserialize)
202 .map_err(|e| CoreError::Config(e.to_string()))?;
203 Ok(declared
204 .app
205 .env_prefix
206 .map(|prefix| prefix.trim().to_string())
207 .filter(|prefix| !prefix.is_empty()))
208}
209
210fn file_layers(dir: &Path) -> config::ConfigBuilder<config::builder::DefaultState> {
213 let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
214 Config::builder()
215 .add_source(File::from(dir.join("default.toml")).required(true))
216 .add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
217 .add_source(File::from(dir.join("local.toml")).required(false))
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[derive(Deserialize)]
225 struct TestConfig {
226 server: ServerConfig,
227 database: DatabaseConfig,
228 }
229
230 #[test]
231 fn loads_defaults_env_overrides_and_serde_defaults() {
232 let dir = tempfile::tempdir().unwrap();
233 std::fs::write(
234 dir.path().join("default.toml"),
235 "[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
236 )
237 .unwrap();
238 std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
239 let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
240 std::env::remove_var("LATERITE_TEST__DATABASE__URL");
241 assert_eq!(cfg.server.listen, "0.0.0.0:9999");
242 assert_eq!(cfg.database.url, "postgres://from-env");
243 assert_eq!(cfg.database.max_connections, 10);
244 assert_eq!(cfg.database.acquire_timeout_secs, 5);
245 }
246
247 #[test]
248 fn backend_config_defaults_and_loads() {
249 #[derive(Deserialize)]
250 struct C {
251 #[serde(default)]
252 backend: BackendConfig,
253 }
254 let dir = tempfile::tempdir().unwrap();
255 std::fs::write(dir.path().join("default.toml"), "").unwrap();
256 let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
257 assert!(!c.backend.secure_cookie);
258 assert_eq!(c.backend.timezone, "UTC");
259
260 std::fs::write(
261 dir.path().join("default.toml"),
262 "[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
263 )
264 .unwrap();
265 let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
266 assert!(c.backend.secure_cookie);
267 assert_eq!(c.backend.timezone, "Asia/Kolkata");
268 }
269
270 #[test]
271 fn declared_env_prefix_comes_from_the_files_alone() {
272 let dir = tempfile::tempdir().unwrap();
273 std::fs::write(dir.path().join("default.toml"), "[app]\nname = \"Acme\"\n").unwrap();
274 assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
275 std::fs::write(
276 dir.path().join("default.toml"),
277 "[app]\nenv_prefix = \"ACME\"\n",
278 )
279 .unwrap();
280 assert_eq!(
281 declared_env_prefix(dir.path()).unwrap().as_deref(),
282 Some("ACME")
283 );
284 std::fs::write(
286 dir.path().join("local.toml"),
287 "[app]\nenv_prefix = \" \"\n",
288 )
289 .unwrap();
290 assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
291 }
292
293 #[test]
294 fn base_url_prefers_config_then_derives_from_listen() {
295 assert_eq!(
297 base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
298 "https://acme.example"
299 );
300 assert_eq!(
302 base_url(Some(" "), "127.0.0.1:9000"),
303 "http://127.0.0.1:9000"
304 );
305 assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
307 assert_eq!(base_url(None, "0.0.0.0:8080"), "http://127.0.0.1:8080");
309 assert_eq!(base_url(None, "[::]:8080"), "http://[::1]:8080");
310 }
311
312 #[test]
313 fn missing_default_file_is_a_config_error() {
314 let dir = tempfile::tempdir().unwrap();
315 let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
316 assert!(matches!(result, Err(CoreError::Config(_))));
317 }
318}