use serde::Deserialize;
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct Settings {
pub database: DatabaseSettings,
pub secret_key: Option<String>,
pub admin: AdminSettings,
pub tenancy: TenancySettings,
pub cache: CacheSettings,
pub jobs: JobsSettings,
pub mail: MailSettings,
pub server: ServerSettings,
pub auth: AuthSettings,
pub brand: BrandSettings,
pub security: SecuritySettings,
pub routes: RoutesSettings,
pub audit: AuditSettings,
pub logging: LoggingSettings,
}
impl Settings {
#[must_use]
pub fn detected_features() -> Vec<&'static str> {
let mut out: Vec<&'static str> = Vec::new();
macro_rules! feat {
($name:literal) => {
#[cfg(feature = $name)]
out.push($name);
};
}
feat!("postgres");
feat!("mysql");
feat!("sqlite");
feat!("tenancy");
feat!("admin");
feat!("manage");
feat!("config");
feat!("forms");
feat!("serializer");
feat!("cache");
feat!("signals");
feat!("email");
feat!("storage");
feat!("storage-s3");
feat!("scheduler");
feat!("secrets");
feat!("totp");
feat!("webhook");
feat!("webhook-delivery");
feat!("api_keys");
feat!("passwords");
feat!("signed_url");
feat!("notifications");
feat!("jobs");
feat!("jobs-postgres");
feat!("auth_flows");
feat!("sse");
feat!("websocket");
feat!("oauth2");
feat!("http-client");
feat!("compression");
feat!("openapi");
feat!("csp-nonce");
feat!("sessions");
feat!("hmac-auth");
feat!("jwt");
feat!("uploads");
feat!("media");
feat!("runserver");
out
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct DatabaseSettings {
pub url: Option<String>,
pub backend: Option<String>,
pub pool_max_size: Option<u32>,
pub pool_min_size: Option<u32>,
}
impl DatabaseSettings {
#[must_use]
pub fn resolved_backend(&self) -> Option<&'static str> {
if let Some(b) = self.backend.as_deref() {
return Some(canonicalize_backend(b));
}
let url = self.url.as_deref()?;
let scheme = url.split(':').next().unwrap_or("").to_ascii_lowercase();
match scheme.as_str() {
"postgres" | "postgresql" => Some("postgres"),
"mysql" | "mariadb" => Some("mysql"),
"sqlite" => Some("sqlite"),
_ => None,
}
}
}
fn canonicalize_backend(raw: &str) -> &'static str {
match raw.to_ascii_lowercase().as_str() {
"postgres" | "postgresql" | "pg" => "postgres",
"mysql" | "mariadb" => "mysql",
"sqlite" | "sqlite3" => "sqlite",
_ => "postgres", }
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct AdminSettings {
pub allowed_tables: Vec<String>,
pub read_only_tables: Vec<String>,
pub title: Option<String>,
pub subtitle: Option<String>,
pub logo_url: Option<String>,
pub primary_color: Option<String>,
pub theme_mode: Option<String>,
pub url_prefix: Option<String>,
pub csrf_cookie_secure: Option<bool>,
pub session_timeout_minutes: Option<u32>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct TenancySettings {
pub apex_domain: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct CacheSettings {
pub backend: Option<String>,
pub redis_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct JobsSettings {
pub backend: Option<String>,
pub concurrency: Option<u32>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct MailSettings {
pub backend: Option<String>,
pub smtp_host: Option<String>,
pub from_address: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct ServerSettings {
pub bind: Option<String>,
pub request_timeout_secs: Option<u64>,
pub max_body_bytes: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuthSettings {
pub jwt: JwtSettings,
pub argon2_memory_kib: Option<u32>,
pub argon2_iterations: Option<u32>,
pub argon2_parallelism: Option<u32>,
pub lockout_threshold: Option<u32>,
pub lockout_duration_secs: Option<u64>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct JwtSettings {
pub access_ttl_secs: Option<u64>,
pub refresh_ttl_secs: Option<u64>,
pub issuer: Option<String>,
pub audience: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct BrandSettings {
pub name: Option<String>,
pub tagline: Option<String>,
pub logo_url: Option<String>,
pub primary_color: Option<String>,
pub theme_mode: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct SecuritySettings {
pub headers_preset: Option<String>,
pub csp: Option<String>,
pub hsts_max_age_secs: Option<u64>,
pub cors_allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct RoutesSettings {
pub legacy_preset: Option<bool>,
pub login_url: Option<String>,
pub logout_url: Option<String>,
pub admin_url: Option<String>,
pub audit_url: Option<String>,
pub static_url: Option<String>,
pub brand_url: Option<String>,
pub change_password_url: Option<String>,
pub impersonation_handoff_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct AuditSettings {
pub retention_days: Option<u32>,
pub redact_query_params: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(default)]
pub struct LoggingSettings {
pub level: Option<String>,
pub format: Option<String>,
pub with_thread_ids: Option<bool>,
pub with_line_numbers: Option<bool>,
pub without_targets: Option<bool>,
pub file_dir: Option<String>,
pub file_prefix: Option<String>,
pub file_rotation: Option<String>,
pub file_only: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolved_backend_uses_explicit_value() {
let mut s = DatabaseSettings::default();
s.backend = Some("mysql".into());
s.url = Some("postgres://x".into()); assert_eq!(s.resolved_backend(), Some("mysql"));
}
#[test]
fn resolved_backend_canonicalizes_aliases() {
let s = DatabaseSettings {
backend: Some("postgresql".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("postgres"));
let s = DatabaseSettings {
backend: Some("mariadb".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("mysql"));
}
#[test]
fn resolved_backend_sniffs_from_url_scheme() {
let s = DatabaseSettings {
url: Some("sqlite::memory:".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("sqlite"));
let s = DatabaseSettings {
url: Some("mysql://root@localhost/x".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("mysql"));
let s = DatabaseSettings {
url: Some("postgresql://x".into()),
..Default::default()
};
assert_eq!(s.resolved_backend(), Some("postgres"));
}
#[test]
fn resolved_backend_none_when_neither_set() {
let s = DatabaseSettings::default();
assert_eq!(s.resolved_backend(), None);
}
#[test]
fn admin_settings_extended_fields_default_to_none() {
let s = AdminSettings::default();
assert!(s.title.is_none());
assert!(s.subtitle.is_none());
assert!(s.logo_url.is_none());
assert!(s.primary_color.is_none());
assert!(s.theme_mode.is_none());
assert!(s.url_prefix.is_none());
assert!(s.csrf_cookie_secure.is_none());
assert!(s.session_timeout_minutes.is_none());
assert!(s.allowed_tables.is_empty());
assert!(s.read_only_tables.is_empty());
}
#[test]
fn admin_settings_parses_full_section() {
let toml = r##"
title = "Acme Admin"
subtitle = "Tenant management"
logo_url = "/assets/acme.png"
primary_color = "#2c6fb0"
theme_mode = "dark"
url_prefix = "/admin"
csrf_cookie_secure = true
session_timeout_minutes = 30
allowed_tables = ["post", "author"]
read_only_tables = ["audit_log"]
"##;
let parsed: AdminSettings = toml::from_str(toml).expect("valid TOML");
assert_eq!(parsed.title.as_deref(), Some("Acme Admin"));
assert_eq!(parsed.subtitle.as_deref(), Some("Tenant management"));
assert_eq!(parsed.logo_url.as_deref(), Some("/assets/acme.png"));
assert_eq!(parsed.primary_color.as_deref(), Some("#2c6fb0"));
assert_eq!(parsed.theme_mode.as_deref(), Some("dark"));
assert_eq!(parsed.url_prefix.as_deref(), Some("/admin"));
assert_eq!(parsed.csrf_cookie_secure, Some(true));
assert_eq!(parsed.session_timeout_minutes, Some(30));
assert_eq!(parsed.allowed_tables, vec!["post", "author"]);
assert_eq!(parsed.read_only_tables, vec!["audit_log"]);
}
}