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}
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
69pub fn set_debug(on: bool) {
73 let _ = DEBUG.set(on);
74}
75
76pub fn debug() -> bool {
78 *DEBUG.get().unwrap_or(&false)
79}
80
81static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
82
83pub 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
95fn 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#[derive(Debug, Clone, Deserialize)]
113#[serde(default)]
114#[non_exhaustive]
115pub struct BackendConfig {
116 pub secure_cookie: bool,
119 pub timezone: String,
123 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
152pub 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 assert_eq!(
229 base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
230 "https://acme.example"
231 );
232 assert_eq!(
234 base_url(Some(" "), "127.0.0.1:9000"),
235 "http://127.0.0.1:9000"
236 );
237 assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
239 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}