#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("DB_NAME (file path) is required for SQLite")]
MissingSqliteDbName,
#[error("{0} file not found: {1}")]
SslCertNotFound(String, String),
#[error("HTTP_HOST must not be empty")]
EmptyHttpHost,
#[error("DB_PAGE_SIZE must be between 1 and {max}, got {value}")]
PageSizeOutOfRange {
value: u16,
max: u16,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum DatabaseBackend {
Mysql,
Mariadb,
Postgres,
Sqlite,
}
impl std::fmt::Display for DatabaseBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Mysql => write!(f, "mysql"),
Self::Mariadb => write!(f, "mariadb"),
Self::Postgres => write!(f, "postgres"),
Self::Sqlite => write!(f, "sqlite"),
}
}
}
impl DatabaseBackend {
#[must_use]
pub fn default_port(self) -> u16 {
match self {
Self::Postgres => 5432,
Self::Mysql | Self::Mariadb => 3306,
Self::Sqlite => 0,
}
}
#[must_use]
pub fn default_user(self) -> &'static str {
match self {
Self::Mysql | Self::Mariadb => "root",
Self::Postgres => "postgres",
Self::Sqlite => "",
}
}
}
#[derive(Clone)]
pub struct DatabaseConfig {
pub backend: DatabaseBackend,
pub host: String,
pub port: u16,
pub user: String,
pub password: Option<String>,
pub name: Option<String>,
pub charset: Option<String>,
pub ssl: bool,
pub ssl_ca: Option<String>,
pub ssl_cert: Option<String>,
pub ssl_key: Option<String>,
pub ssl_verify_cert: bool,
pub read_only: bool,
pub max_pool_size: u32,
pub connection_timeout: Option<u64>,
pub query_timeout: Option<u64>,
pub page_size: u16,
}
impl std::fmt::Debug for DatabaseConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DatabaseConfig")
.field("backend", &self.backend)
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field("password", &"[REDACTED]")
.field("name", &self.name)
.field("charset", &self.charset)
.field("ssl", &self.ssl)
.field("ssl_ca", &self.ssl_ca)
.field("ssl_cert", &self.ssl_cert)
.field("ssl_key", &self.ssl_key)
.field("ssl_verify_cert", &self.ssl_verify_cert)
.field("read_only", &self.read_only)
.field("max_pool_size", &self.max_pool_size)
.field("connection_timeout", &self.connection_timeout)
.field("query_timeout", &self.query_timeout)
.field("page_size", &self.page_size)
.finish()
}
}
impl DatabaseConfig {
pub const DEFAULT_BACKEND: DatabaseBackend = DatabaseBackend::Mysql;
pub const DEFAULT_HOST: &'static str = "localhost";
pub const DEFAULT_SSL: bool = false;
pub const DEFAULT_SSL_VERIFY_CERT: bool = true;
pub const DEFAULT_READ_ONLY: bool = true;
pub const DEFAULT_MAX_POOL_SIZE: u32 = 5;
pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 600;
pub const DEFAULT_MAX_LIFETIME_SECS: u64 = 1800;
pub const DEFAULT_MIN_CONNECTIONS: u32 = 1;
pub const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_PAGE_SIZE: u16 = 100;
pub const MAX_PAGE_SIZE: u16 = 500;
pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
let mut errors = Vec::new();
if self.backend == DatabaseBackend::Sqlite && self.name.as_deref().unwrap_or_default().is_empty() {
errors.push(ConfigError::MissingSqliteDbName);
}
if self.ssl {
for (name, path) in [
("DB_SSL_CA", &self.ssl_ca),
("DB_SSL_CERT", &self.ssl_cert),
("DB_SSL_KEY", &self.ssl_key),
] {
if let Some(path) = path
&& !std::path::Path::new(path).exists()
{
errors.push(ConfigError::SslCertNotFound(name.into(), path.clone()));
}
}
}
if !(1..=Self::MAX_PAGE_SIZE).contains(&self.page_size) {
errors.push(ConfigError::PageSizeOutOfRange {
value: self.page_size,
max: Self::MAX_PAGE_SIZE,
});
}
errors.is_empty().then_some(()).ok_or(errors)
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
backend: Self::DEFAULT_BACKEND,
host: Self::DEFAULT_HOST.into(),
port: Self::DEFAULT_BACKEND.default_port(),
user: Self::DEFAULT_BACKEND.default_user().into(),
password: None,
name: None,
charset: None,
ssl: Self::DEFAULT_SSL,
ssl_ca: None,
ssl_cert: None,
ssl_key: None,
ssl_verify_cert: Self::DEFAULT_SSL_VERIFY_CERT,
read_only: Self::DEFAULT_READ_ONLY,
max_pool_size: Self::DEFAULT_MAX_POOL_SIZE,
connection_timeout: None,
query_timeout: None,
page_size: Self::DEFAULT_PAGE_SIZE,
}
}
}
#[derive(Clone, Debug)]
pub struct HttpConfig {
pub host: String,
pub port: u16,
pub allowed_origins: Vec<String>,
pub allowed_hosts: Vec<String>,
}
impl HttpConfig {
pub const DEFAULT_HOST: &'static str = "127.0.0.1";
pub const DEFAULT_PORT: u16 = 9001;
#[must_use]
pub fn default_allowed_origins() -> Vec<String> {
vec![
"http://localhost".into(),
"http://127.0.0.1".into(),
"https://localhost".into(),
"https://127.0.0.1".into(),
]
}
#[must_use]
pub fn default_allowed_hosts() -> Vec<String> {
vec!["localhost".into(), "127.0.0.1".into()]
}
pub fn validate(&self) -> Result<(), Vec<ConfigError>> {
let mut errors = Vec::new();
if self.host.trim().is_empty() {
errors.push(ConfigError::EmptyHttpHost);
}
errors.is_empty().then_some(()).ok_or(errors)
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub database: DatabaseConfig,
pub http: Option<HttpConfig>,
}
#[cfg(test)]
mod tests {
use super::*;
fn db_config(backend: DatabaseBackend) -> DatabaseConfig {
DatabaseConfig {
backend,
port: backend.default_port(),
user: backend.default_user().into(),
..DatabaseConfig::default()
}
}
fn base_config(backend: DatabaseBackend) -> Config {
Config {
database: db_config(backend),
http: None,
}
}
fn mysql_config() -> Config {
Config {
database: DatabaseConfig {
port: 3306,
user: "root".into(),
password: Some("secret".into()),
..db_config(DatabaseBackend::Mysql)
},
..base_config(DatabaseBackend::Mysql)
}
}
#[test]
fn debug_redacts_password() {
let config = Config {
database: DatabaseConfig {
password: Some("super_secret_password".into()),
..mysql_config().database
},
..mysql_config()
};
let debug_output = format!("{config:?}");
assert!(
!debug_output.contains("super_secret_password"),
"password leaked in debug output: {debug_output}"
);
assert!(
debug_output.contains("[REDACTED]"),
"expected [REDACTED] in debug output: {debug_output}"
);
}
#[test]
fn valid_mysql_config_passes() {
assert!(mysql_config().database.validate().is_ok());
}
#[test]
fn valid_postgres_config_passes() {
let config = Config {
database: DatabaseConfig {
user: "pguser".into(),
port: 5432,
..db_config(DatabaseBackend::Postgres)
},
..base_config(DatabaseBackend::Postgres)
};
assert!(config.database.validate().is_ok());
}
#[test]
fn valid_sqlite_config_passes() {
let config = Config {
database: DatabaseConfig {
name: Some("./test.db".into()),
..db_config(DatabaseBackend::Sqlite)
},
..base_config(DatabaseBackend::Sqlite)
};
assert!(config.database.validate().is_ok());
}
#[test]
fn defaults_resolved_at_construction() {
let mysql = base_config(DatabaseBackend::Mysql);
assert_eq!(mysql.database.host, "localhost");
assert_eq!(mysql.database.port, 3306);
assert_eq!(mysql.database.user, "root");
let pg = base_config(DatabaseBackend::Postgres);
assert_eq!(pg.database.port, 5432);
assert_eq!(pg.database.user, "postgres");
let sqlite = base_config(DatabaseBackend::Sqlite);
assert_eq!(sqlite.database.port, 0);
assert_eq!(sqlite.database.user, "");
}
#[test]
fn explicit_values_override_defaults() {
let config = Config {
database: DatabaseConfig {
host: "dbserver.example.com".into(),
port: 13306,
user: "myuser".into(),
..db_config(DatabaseBackend::Mysql)
},
..base_config(DatabaseBackend::Mysql)
};
assert_eq!(config.database.host, "dbserver.example.com");
assert_eq!(config.database.port, 13306);
assert_eq!(config.database.user, "myuser");
}
#[test]
fn mysql_without_user_gets_default() {
let config = base_config(DatabaseBackend::Mysql);
assert_eq!(config.database.user, "root");
assert!(config.database.validate().is_ok());
}
#[test]
fn sqlite_requires_db_name() {
let config = base_config(DatabaseBackend::Sqlite);
let errors = config
.database
.validate()
.expect_err("sqlite without db name must fail");
assert!(errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)));
}
#[test]
fn multiple_errors_accumulated() {
let config = Config {
database: DatabaseConfig {
ssl: true,
ssl_ca: Some("/nonexistent/ca.pem".into()),
ssl_cert: Some("/nonexistent/cert.pem".into()),
ssl_key: Some("/nonexistent/key.pem".into()),
..db_config(DatabaseBackend::Mysql)
},
..base_config(DatabaseBackend::Mysql)
};
let errors = config
.database
.validate()
.expect_err("missing ssl cert files must fail");
assert!(
errors.len() >= 3,
"expected at least 3 errors, got {}: {errors:?}",
errors.len()
);
}
#[test]
fn mariadb_backend_is_valid() {
let config = base_config(DatabaseBackend::Mariadb);
assert!(config.database.validate().is_ok());
}
#[test]
fn query_timeout_default_is_none() {
let config = DatabaseConfig::default();
assert!(config.query_timeout.is_none());
}
#[test]
fn page_size_default_is_100() {
let config = DatabaseConfig::default();
assert_eq!(config.page_size, 100);
}
#[test]
fn page_size_zero_rejected() {
let config = DatabaseConfig {
page_size: 0,
..mysql_config().database
};
let errors = config.validate().expect_err("page_size=0 must be rejected");
assert!(
errors
.iter()
.any(|e| matches!(e, ConfigError::PageSizeOutOfRange { value: 0, max: 500 })),
"expected PageSizeOutOfRange {{ value: 0, max: 500 }}, got {errors:?}"
);
}
#[test]
fn page_size_above_max_rejected() {
let config = DatabaseConfig {
page_size: 501,
..mysql_config().database
};
let errors = config.validate().expect_err("page_size above max must be rejected");
assert!(
errors
.iter()
.any(|e| matches!(e, ConfigError::PageSizeOutOfRange { value: 501, max: 500 })),
"expected PageSizeOutOfRange {{ value: 501, max: 500 }}, got {errors:?}"
);
}
#[test]
fn page_size_at_min_accepted() {
let config = DatabaseConfig {
page_size: 1,
..mysql_config().database
};
assert!(config.validate().is_ok(), "page_size=1 must be accepted");
}
#[test]
fn page_size_at_max_accepted() {
let config = DatabaseConfig {
page_size: DatabaseConfig::MAX_PAGE_SIZE,
..mysql_config().database
};
assert!(config.validate().is_ok(), "page_size=MAX_PAGE_SIZE must be accepted");
}
#[test]
fn page_size_errors_accumulate_with_others() {
let config = Config {
database: DatabaseConfig {
page_size: 0,
..db_config(DatabaseBackend::Sqlite)
},
..base_config(DatabaseBackend::Sqlite)
};
let errors = config
.database
.validate()
.expect_err("multiple errors should be accumulated");
assert!(
errors.iter().any(|e| matches!(e, ConfigError::MissingSqliteDbName)),
"expected MissingSqliteDbName in {errors:?}"
);
assert!(
errors
.iter()
.any(|e| matches!(e, ConfigError::PageSizeOutOfRange { value: 0, .. })),
"expected PageSizeOutOfRange in {errors:?}"
);
}
#[test]
fn debug_includes_page_size() {
let config = DatabaseConfig {
page_size: 250,
..mysql_config().database
};
let debug = format!("{config:?}");
assert!(
debug.contains("page_size: 250"),
"expected page_size in debug output: {debug}"
);
}
fn http_config() -> HttpConfig {
HttpConfig {
host: HttpConfig::DEFAULT_HOST.into(),
port: HttpConfig::DEFAULT_PORT,
allowed_origins: HttpConfig::default_allowed_origins(),
allowed_hosts: HttpConfig::default_allowed_hosts(),
}
}
#[test]
fn valid_http_config_passes() {
assert!(http_config().validate().is_ok());
}
#[test]
fn empty_http_host_rejected() {
let config = HttpConfig {
host: String::new(),
..http_config()
};
let errors = config.validate().expect_err("empty host must fail");
assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
}
#[test]
fn whitespace_http_host_rejected() {
let config = HttpConfig {
host: " ".into(),
..http_config()
};
let errors = config.validate().expect_err("whitespace host must fail");
assert!(errors.iter().any(|e| matches!(e, ConfigError::EmptyHttpHost)));
}
#[test]
fn debug_includes_query_timeout() {
let config = Config {
database: DatabaseConfig {
query_timeout: Some(30),
..db_config(DatabaseBackend::Mysql)
},
..base_config(DatabaseBackend::Mysql)
};
let debug = format!("{config:?}");
assert!(
debug.contains("query_timeout: Some(30)"),
"expected query_timeout in debug output: {debug}"
);
}
}