use std::path::Path;
use config::{Config, Environment, File};
use serde::de::DeserializeOwned;
use serde::Deserialize;
use crate::error::{CoreError, CoreResult};
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_acquire_timeout_secs")]
pub acquire_timeout_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_listen")]
pub listen: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct AppMeta {
pub name: String,
pub url: Option<String>,
pub locale: String,
pub debug: bool,
pub env_prefix: Option<String>,
}
impl Default for AppMeta {
fn default() -> Self {
Self {
name: "Laterite".to_string(),
url: None,
locale: "en".to_string(),
debug: false,
env_prefix: None,
}
}
}
pub fn set_debug(on: bool) {
let _ = DEBUG.set(on);
}
pub fn debug() -> bool {
*DEBUG.get().unwrap_or(&false)
}
static DEBUG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
pub fn base_url(url: Option<&str>, listen: &str) -> String {
match url.map(str::trim).filter(|u| !u.is_empty()) {
Some(url) => url.trim_end_matches('/').to_string(),
None => format!("http://{}", browsable_host(listen)),
}
}
fn browsable_host(listen: &str) -> String {
if let Some(port) = listen.strip_prefix("0.0.0.0") {
format!("127.0.0.1{port}")
} else if let Some(port) = listen.strip_prefix("[::]") {
format!("[::1]{port}")
} else {
listen.to_string()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
#[non_exhaustive]
pub struct BackendConfig {
pub secure_cookie: bool,
pub secret: Option<String>,
pub timezone: String,
pub paths: std::collections::BTreeMap<String, String>,
pub path: String,
pub trusted_proxies: Vec<String>,
pub storage: String,
}
impl Default for BackendConfig {
fn default() -> Self {
Self {
secure_cookie: false,
secret: None,
timezone: "UTC".to_string(),
paths: std::collections::BTreeMap::new(),
path: "/admin".to_string(),
trusted_proxies: Vec::new(),
storage: "storage".to_string(),
}
}
}
fn default_max_connections() -> u32 {
10
}
fn default_acquire_timeout_secs() -> u64 {
5
}
fn default_listen() -> String {
"127.0.0.1:8080".to_string()
}
pub fn load<T: DeserializeOwned>(dir: &Path, env_prefix: &str) -> CoreResult<T> {
file_layers(dir)
.add_source(
Environment::with_prefix(env_prefix)
.prefix_separator("__")
.separator("__"),
)
.build()
.and_then(Config::try_deserialize)
.map_err(|e| CoreError::Config(e.to_string()))
}
pub fn declared_env_prefix(dir: &Path) -> CoreResult<Option<String>> {
#[derive(Deserialize)]
struct Declared {
#[serde(default)]
app: DeclaredApp,
}
#[derive(Deserialize, Default)]
struct DeclaredApp {
env_prefix: Option<String>,
}
let declared: Declared = file_layers(dir)
.build()
.and_then(Config::try_deserialize)
.map_err(|e| CoreError::Config(e.to_string()))?;
Ok(declared
.app
.env_prefix
.map(|prefix| prefix.trim().to_string())
.filter(|prefix| !prefix.is_empty()))
}
fn file_layers(dir: &Path) -> config::ConfigBuilder<config::builder::DefaultState> {
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".into());
Config::builder()
.add_source(File::from(dir.join("default.toml")).required(true))
.add_source(File::from(dir.join(format!("{app_env}.toml"))).required(false))
.add_source(File::from(dir.join("local.toml")).required(false))
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Deserialize)]
struct TestConfig {
server: ServerConfig,
database: DatabaseConfig,
}
#[test]
fn loads_defaults_env_overrides_and_serde_defaults() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("default.toml"),
"[server]\nlisten = \"0.0.0.0:9999\"\n\n[database]\nurl = \"postgres://from-file\"\n",
)
.unwrap();
std::env::set_var("LATERITE_TEST__DATABASE__URL", "postgres://from-env");
let cfg: TestConfig = load(dir.path(), "LATERITE_TEST").unwrap();
std::env::remove_var("LATERITE_TEST__DATABASE__URL");
assert_eq!(cfg.server.listen, "0.0.0.0:9999");
assert_eq!(cfg.database.url, "postgres://from-env");
assert_eq!(cfg.database.max_connections, 10);
assert_eq!(cfg.database.acquire_timeout_secs, 5);
}
#[test]
fn backend_config_defaults_and_loads() {
#[derive(Deserialize)]
struct C {
#[serde(default)]
backend: BackendConfig,
}
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("default.toml"), "").unwrap();
let c: C = load(dir.path(), "LATERITE_BE_NONE").unwrap();
assert!(!c.backend.secure_cookie);
assert_eq!(c.backend.timezone, "UTC");
std::fs::write(
dir.path().join("default.toml"),
"[backend]\nsecure_cookie = true\ntimezone = \"Asia/Kolkata\"\n",
)
.unwrap();
let c: C = load(dir.path(), "LATERITE_BE_SET").unwrap();
assert!(c.backend.secure_cookie);
assert_eq!(c.backend.timezone, "Asia/Kolkata");
}
#[test]
fn declared_env_prefix_comes_from_the_files_alone() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("default.toml"), "[app]\nname = \"Acme\"\n").unwrap();
assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
std::fs::write(
dir.path().join("default.toml"),
"[app]\nenv_prefix = \"ACME\"\n",
)
.unwrap();
assert_eq!(
declared_env_prefix(dir.path()).unwrap().as_deref(),
Some("ACME")
);
std::fs::write(
dir.path().join("local.toml"),
"[app]\nenv_prefix = \" \"\n",
)
.unwrap();
assert_eq!(declared_env_prefix(dir.path()).unwrap(), None);
}
#[test]
fn base_url_prefers_config_then_derives_from_listen() {
assert_eq!(
base_url(Some("https://acme.example/"), "127.0.0.1:8080"),
"https://acme.example"
);
assert_eq!(
base_url(Some(" "), "127.0.0.1:9000"),
"http://127.0.0.1:9000"
);
assert_eq!(base_url(None, "127.0.0.1:8080"), "http://127.0.0.1:8080");
assert_eq!(base_url(None, "0.0.0.0:8080"), "http://127.0.0.1:8080");
assert_eq!(base_url(None, "[::]:8080"), "http://[::1]:8080");
}
#[test]
fn missing_default_file_is_a_config_error() {
let dir = tempfile::tempdir().unwrap();
let result: CoreResult<TestConfig> = load(dir.path(), "LATERITE_TEST_MISSING");
assert!(matches!(result, Err(CoreError::Config(_))));
}
}