use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("Missing required configuration: {0}")]
MissingField(String),
#[error("Missing required configuration: dbnexus.url")]
MissingUrl,
#[error("Invalid cache capacity: {0}")]
InvalidCacheCapacity(String),
#[error("Invalid configuration value for '{key}': {message}")]
InvalidValue {
key: String,
message: String,
},
#[error("Invalid configuration format: {0}")]
InvalidFormat(String),
#[error("Configuration file not found: {0}")]
FileNotFound(String),
#[error("IO error: {0}")]
IoError(String),
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Unsupported database protocol: {0}")]
UnsupportedProtocol(String),
#[error("Parse error: {0}")]
ParseError(String),
#[error("Validation error: {0}")]
ValidationError(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
#[serde(default = "default_policy_cache_capacity")]
pub policy_cache_capacity: u64,
#[serde(default = "default_sql_parse_cache_capacity")]
pub sql_parse_cache_capacity: u64,
#[serde(default = "default_query_cache_capacity")]
pub query_cache_capacity: u64,
#[serde(default = "default_cache_ttl")]
pub default_ttl: u64,
}
fn default_policy_cache_capacity() -> u64 {
4096
}
fn default_sql_parse_cache_capacity() -> u64 {
1000
}
fn default_query_cache_capacity() -> u64 {
10000
}
fn default_cache_ttl() -> u64 {
300
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
policy_cache_capacity: default_policy_cache_capacity(),
sql_parse_cache_capacity: default_sql_parse_cache_capacity(),
query_cache_capacity: default_query_cache_capacity(),
default_ttl: default_cache_ttl(),
}
}
}
impl CacheConfig {
pub fn default_ttl_duration(&self) -> Duration {
Duration::from_secs(self.default_ttl)
}
#[cfg(feature = "yaml")]
pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
serde_yaml_ng::from_str(yaml)
}
pub fn from_json_value(v: serde_json::Value) -> Result<Self, serde_json::Error> {
serde_json::from_value(v)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_idle_timeout")]
pub idle_timeout: u64,
#[serde(default = "default_acquire_timeout")]
pub acquire_timeout: u64,
}
fn default_max_connections() -> u32 {
20
}
fn default_min_connections() -> u32 {
5
}
fn default_idle_timeout() -> u64 {
300
}
fn default_acquire_timeout() -> u64 {
5000
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
max_connections: default_max_connections(),
min_connections: default_min_connections(),
idle_timeout: default_idle_timeout(),
acquire_timeout: default_acquire_timeout(),
}
}
}
impl PoolConfig {
pub fn idle_timeout_duration(&self) -> Duration {
Duration::from_secs(self.idle_timeout)
}
pub fn acquire_timeout_duration(&self) -> Duration {
Duration::from_millis(self.acquire_timeout)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum DatabaseType {
Postgres,
MySql,
#[default]
Sqlite,
DuckDb,
}
impl DatabaseType {
pub fn from_url(url: &str) -> Result<Self, crate::common::error::DbNexusError> {
let lower = url.to_lowercase();
if lower == "sqlite::memory:" || lower.starts_with("sqlite://") || lower.starts_with("sqlite3://") {
return Ok(DatabaseType::Sqlite);
}
if lower.starts_with("duckdb:") {
return Ok(DatabaseType::DuckDb);
}
let parsed = url::Url::parse(url).map_err(|_| {
crate::common::error::DbNexusError::UnsupportedDatabaseScheme(format!("failed to parse URL: {url}"))
})?;
match parsed.scheme() {
"sqlite" | "sqlite3" => Ok(DatabaseType::Sqlite),
"postgres" | "postgresql" => Ok(DatabaseType::Postgres),
"mysql" => Ok(DatabaseType::MySql),
"duckdb" => Ok(DatabaseType::DuckDb),
other => Err(crate::common::error::DbNexusError::UnsupportedDatabaseScheme(format!(
"'{other}' is not a supported database scheme"
))),
}
}
pub fn parse_database_type(url: &str) -> Result<Self, crate::common::error::DbNexusError> {
Self::from_url(url)
}
pub fn as_str(&self) -> &'static str {
match self {
DatabaseType::Postgres => "postgres",
DatabaseType::MySql => "mysql",
DatabaseType::Sqlite => "sqlite",
DatabaseType::DuckDb => "duckdb",
}
}
pub fn is_embedded(&self) -> bool {
matches!(self, DatabaseType::Sqlite | DatabaseType::DuckDb)
}
pub fn is_server_side(&self) -> bool {
matches!(self, DatabaseType::Postgres | DatabaseType::MySql)
}
pub fn is_real_database(&self) -> bool {
matches!(self, DatabaseType::Postgres | DatabaseType::MySql)
}
}
impl std::fmt::Display for DatabaseType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbConfig {
pub url: String,
#[serde(default = "default_max_connections")]
pub max_connections: u32,
#[serde(default = "default_min_connections")]
pub min_connections: u32,
#[serde(default = "default_idle_timeout")]
pub idle_timeout: u64,
#[serde(default = "default_acquire_timeout")]
pub acquire_timeout: u64,
#[serde(default)]
pub permissions_path: Option<String>,
#[serde(default)]
pub migrations_dir: Option<PathBuf>,
#[serde(default)]
pub auto_migrate: bool,
#[serde(default = "default_migration_timeout")]
pub migration_timeout: u64,
#[serde(default = "default_admin_role")]
pub admin_role: String,
#[serde(default = "default_warmup_timeout")]
pub warmup_timeout: u64,
#[serde(default = "default_warmup_retries")]
pub warmup_retries: u32,
#[serde(default)]
pub cache_config: CacheConfig,
}
impl Default for DbConfig {
fn default() -> Self {
Self {
url: String::new(),
max_connections: default_max_connections(),
min_connections: default_min_connections(),
idle_timeout: default_idle_timeout(),
acquire_timeout: default_acquire_timeout(),
permissions_path: None,
migrations_dir: None,
auto_migrate: false,
migration_timeout: default_migration_timeout(),
admin_role: default_admin_role(),
warmup_timeout: default_warmup_timeout(),
warmup_retries: default_warmup_retries(),
cache_config: CacheConfig::default(),
}
}
}
fn default_admin_role() -> String {
"admin".to_string()
}
fn default_migration_timeout() -> u64 {
60
}
fn default_warmup_timeout() -> u64 {
30
}
fn default_warmup_retries() -> u32 {
3
}
impl DbConfig {
#[cfg(feature = "config-env")]
pub fn from_env() -> Result<Self, ConfigError> {
let url = std::env::var("DATABASE_URL").map_err(|_| ConfigError::MissingUrl)?;
Ok(Self {
url,
max_connections: std::env::var("DB_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(20),
min_connections: std::env::var("DB_MIN_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5),
idle_timeout: std::env::var("DB_IDLE_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(300),
acquire_timeout: std::env::var("DB_ACQUIRE_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(5000),
admin_role: std::env::var("DB_ADMIN_ROLE").unwrap_or_else(|_| "admin".to_string()),
permissions_path: std::env::var("DB_PERMISSIONS_PATH").ok(),
migrations_dir: std::env::var("DB_MIGRATIONS_DIR").ok().map(PathBuf::from),
auto_migrate: std::env::var("DB_AUTO_MIGRATE")
.ok()
.map(|s| s.to_lowercase() == "true")
.unwrap_or(false),
migration_timeout: std::env::var("DB_MIGRATION_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(60),
warmup_timeout: std::env::var("DB_WARMUP_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30),
warmup_retries: std::env::var("DB_WARMUP_RETRIES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(3),
cache_config: CacheConfig::default(),
})
}
#[cfg(feature = "yaml")]
pub fn from_yaml_str(yaml: &str) -> Result<Self, serde_yaml_ng::Error> {
serde_yaml_ng::from_str(yaml)
}
pub fn from_json_str(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
pub fn database_type(&self) -> Result<DatabaseType, crate::common::error::DbNexusError> {
DatabaseType::from_url(&self.url)
}
pub fn idle_timeout_duration(&self) -> Duration {
Duration::from_secs(self.idle_timeout)
}
pub fn acquire_timeout_duration(&self) -> Duration {
Duration::from_millis(self.acquire_timeout)
}
pub fn migration_timeout_duration(&self) -> Duration {
Duration::from_secs(self.migration_timeout)
}
pub fn cache_config(&self) -> &CacheConfig {
&self.cache_config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_error_display_variants() {
assert_eq!(
ConfigError::MissingField("url".into()).to_string(),
"Missing required configuration: url"
);
assert_eq!(
ConfigError::MissingUrl.to_string(),
"Missing required configuration: dbnexus.url"
);
assert_eq!(
ConfigError::InvalidCacheCapacity("negative".into()).to_string(),
"Invalid cache capacity: negative"
);
assert_eq!(
ConfigError::InvalidValue {
key: "max".into(),
message: "too large".into(),
}
.to_string(),
"Invalid configuration value for 'max': too large"
);
assert_eq!(
ConfigError::InvalidFormat("yaml".into()).to_string(),
"Invalid configuration format: yaml"
);
assert_eq!(
ConfigError::FileNotFound("/tmp/cfg".into()).to_string(),
"Configuration file not found: /tmp/cfg"
);
assert_eq!(
ConfigError::IoError("read fail".into()).to_string(),
"IO error: read fail"
);
assert_eq!(ConfigError::InvalidUrl("bad".into()).to_string(), "Invalid URL: bad");
assert_eq!(
ConfigError::UnsupportedProtocol("ftp".into()).to_string(),
"Unsupported database protocol: ftp"
);
assert_eq!(
ConfigError::ParseError("syntax".into()).to_string(),
"Parse error: syntax"
);
assert_eq!(
ConfigError::ValidationError("bad value".into()).to_string(),
"Validation error: bad value"
);
}
#[test]
fn test_cache_config_default() {
let cfg = CacheConfig::default();
assert_eq!(cfg.policy_cache_capacity, 4096);
assert_eq!(cfg.sql_parse_cache_capacity, 1000);
assert_eq!(cfg.query_cache_capacity, 10000);
assert_eq!(cfg.default_ttl, 300);
}
#[test]
fn test_cache_config_default_ttl_duration() {
let cfg = CacheConfig::default();
assert_eq!(cfg.default_ttl_duration(), Duration::from_secs(300));
}
#[test]
fn test_cache_config_serde_roundtrip() {
let cfg = CacheConfig {
policy_cache_capacity: 100,
sql_parse_cache_capacity: 200,
query_cache_capacity: 300,
default_ttl: 60,
};
let json = serde_json::to_string(&cfg).unwrap();
let deserialized: CacheConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.policy_cache_capacity, 100);
assert_eq!(deserialized.sql_parse_cache_capacity, 200);
assert_eq!(deserialized.query_cache_capacity, 300);
assert_eq!(deserialized.default_ttl, 60);
}
#[test]
fn test_cache_config_serde_defaults_applied() {
let json = r#"{}"#;
let cfg: CacheConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.policy_cache_capacity, 4096);
assert_eq!(cfg.sql_parse_cache_capacity, 1000);
assert_eq!(cfg.query_cache_capacity, 10000);
assert_eq!(cfg.default_ttl, 300);
}
#[test]
fn test_pool_config_default() {
let cfg = PoolConfig::default();
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.min_connections, 5);
assert_eq!(cfg.idle_timeout, 300);
assert_eq!(cfg.acquire_timeout, 5000);
}
#[test]
fn test_pool_config_duration_methods() {
let cfg = PoolConfig::default();
assert_eq!(cfg.idle_timeout_duration(), Duration::from_secs(300));
assert_eq!(cfg.acquire_timeout_duration(), Duration::from_millis(5000));
}
#[test]
fn test_pool_config_serde_defaults_applied() {
let json = r#"{}"#;
let cfg: PoolConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.min_connections, 5);
assert_eq!(cfg.idle_timeout, 300);
assert_eq!(cfg.acquire_timeout, 5000);
}
#[test]
fn test_database_type_from_url_postgres() {
assert_eq!(
DatabaseType::from_url("postgres://localhost/db").unwrap(),
DatabaseType::Postgres
);
assert_eq!(
DatabaseType::from_url("postgresql://localhost/db").unwrap(),
DatabaseType::Postgres
);
assert_eq!(
DatabaseType::from_url("POSTGRES://localhost/db").unwrap(),
DatabaseType::Postgres
);
}
#[test]
fn test_database_type_from_url_mysql() {
assert_eq!(
DatabaseType::from_url("mysql://localhost/db").unwrap(),
DatabaseType::MySql
);
assert_eq!(
DatabaseType::from_url("MYSQL://localhost/db").unwrap(),
DatabaseType::MySql
);
}
#[test]
fn test_database_type_from_url_sqlite() {
assert_eq!(DatabaseType::from_url("sqlite::memory:").unwrap(), DatabaseType::Sqlite);
assert_eq!(
DatabaseType::from_url("sqlite://test.db").unwrap(),
DatabaseType::Sqlite
);
}
#[test]
fn test_database_type_from_url_duckdb() {
assert_eq!(DatabaseType::from_url("duckdb::memory:").unwrap(), DatabaseType::DuckDb);
assert_eq!(
DatabaseType::from_url("duckdb://test.db").unwrap(),
DatabaseType::DuckDb
);
assert_eq!(DatabaseType::from_url("duckdb:test.ddb").unwrap(), DatabaseType::DuckDb);
}
#[test]
fn test_database_type_from_url_unknown_scheme_returns_error() {
assert!(DatabaseType::from_url("unknown://foo").is_err());
assert!(DatabaseType::from_url("").is_err());
assert!(DatabaseType::from_url("/path/to/db.db").is_err());
}
#[test]
fn test_database_type_parse_database_type_alias() {
assert_eq!(
DatabaseType::parse_database_type("mysql://x").unwrap(),
DatabaseType::MySql
);
}
#[test]
fn test_database_type_as_str() {
assert_eq!(DatabaseType::Postgres.as_str(), "postgres");
assert_eq!(DatabaseType::MySql.as_str(), "mysql");
assert_eq!(DatabaseType::Sqlite.as_str(), "sqlite");
assert_eq!(DatabaseType::DuckDb.as_str(), "duckdb");
}
#[test]
fn test_database_type_is_embedded() {
assert!(DatabaseType::Sqlite.is_embedded());
assert!(DatabaseType::DuckDb.is_embedded());
assert!(!DatabaseType::Postgres.is_embedded());
assert!(!DatabaseType::MySql.is_embedded());
}
#[test]
fn test_database_type_is_server_side() {
assert!(DatabaseType::Postgres.is_server_side());
assert!(DatabaseType::MySql.is_server_side());
assert!(!DatabaseType::Sqlite.is_server_side());
assert!(!DatabaseType::DuckDb.is_server_side());
}
#[test]
fn test_database_type_is_real_database() {
assert!(DatabaseType::Postgres.is_real_database());
assert!(DatabaseType::MySql.is_real_database());
assert!(!DatabaseType::Sqlite.is_real_database());
assert!(!DatabaseType::DuckDb.is_real_database());
}
#[test]
fn test_database_type_display() {
assert_eq!(DatabaseType::Postgres.to_string(), "postgres");
assert_eq!(DatabaseType::MySql.to_string(), "mysql");
assert_eq!(DatabaseType::Sqlite.to_string(), "sqlite");
assert_eq!(DatabaseType::DuckDb.to_string(), "duckdb");
}
#[test]
fn test_database_type_default_is_sqlite() {
let db_type = DatabaseType::default();
assert!(matches!(db_type, DatabaseType::Sqlite));
}
#[test]
fn test_database_type_serde_round_trip() {
let cases = [
DatabaseType::Sqlite,
DatabaseType::Postgres,
DatabaseType::MySql,
DatabaseType::DuckDb,
];
for original in cases {
let json = serde_json::to_string(&original).expect("serialize should succeed");
let restored: DatabaseType = serde_json::from_str(&json).expect("deserialize should succeed");
assert_eq!(original, restored, "round-trip failed for {:?}", original);
}
}
#[test]
fn test_db_config_default() {
let cfg = DbConfig::default();
assert_eq!(cfg.url, String::new());
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.min_connections, 5);
assert_eq!(cfg.idle_timeout, 300);
assert_eq!(cfg.acquire_timeout, 5000);
assert_eq!(cfg.admin_role, "admin");
assert_eq!(cfg.migration_timeout, 60);
assert_eq!(cfg.warmup_timeout, 30);
assert_eq!(cfg.warmup_retries, 3);
assert!(!cfg.auto_migrate);
assert!(cfg.permissions_path.is_none());
assert!(cfg.migrations_dir.is_none());
assert_eq!(cfg.cache_config.policy_cache_capacity, 4096);
}
#[test]
fn test_db_config_database_type() {
let cfg = DbConfig {
url: "postgres://localhost/db".into(),
..Default::default()
};
assert_eq!(cfg.database_type().unwrap(), DatabaseType::Postgres);
let cfg = DbConfig {
url: "mysql://localhost/db".into(),
..Default::default()
};
assert_eq!(cfg.database_type().unwrap(), DatabaseType::MySql);
let cfg = DbConfig {
url: "sqlite::memory:".into(),
..Default::default()
};
assert_eq!(cfg.database_type().unwrap(), DatabaseType::Sqlite);
}
#[test]
fn test_db_config_duration_methods() {
let cfg = DbConfig::default();
assert_eq!(cfg.idle_timeout_duration(), Duration::from_secs(300));
assert_eq!(cfg.acquire_timeout_duration(), Duration::from_millis(5000));
assert_eq!(cfg.migration_timeout_duration(), Duration::from_secs(60));
}
#[test]
fn test_db_config_cache_config_ref() {
let cfg = DbConfig::default();
let cache = cfg.cache_config();
assert_eq!(cache.default_ttl, 300);
}
#[test]
fn test_db_config_serde_roundtrip() {
let cfg = DbConfig {
url: "sqlite::memory:".to_string(),
max_connections: 10,
min_connections: 2,
idle_timeout: 100,
acquire_timeout: 3000,
permissions_path: Some("/tmp/perms.yaml".into()),
migrations_dir: Some(PathBuf::from("/tmp/migrations")),
auto_migrate: true,
migration_timeout: 120,
admin_role: "root".to_string(),
warmup_timeout: 15,
warmup_retries: 5,
cache_config: CacheConfig {
policy_cache_capacity: 512,
sql_parse_cache_capacity: 256,
query_cache_capacity: 1024,
default_ttl: 60,
},
};
let json = serde_json::to_string(&cfg).unwrap();
let deserialized: DbConfig = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.url, "sqlite::memory:");
assert_eq!(deserialized.max_connections, 10);
assert_eq!(deserialized.min_connections, 2);
assert_eq!(deserialized.idle_timeout, 100);
assert_eq!(deserialized.acquire_timeout, 3000);
assert_eq!(deserialized.permissions_path, Some("/tmp/perms.yaml".to_string()));
assert_eq!(deserialized.migrations_dir, Some(PathBuf::from("/tmp/migrations")));
assert!(deserialized.auto_migrate);
assert_eq!(deserialized.migration_timeout, 120);
assert_eq!(deserialized.admin_role, "root");
assert_eq!(deserialized.warmup_timeout, 15);
assert_eq!(deserialized.warmup_retries, 5);
assert_eq!(deserialized.cache_config.policy_cache_capacity, 512);
}
#[test]
fn test_db_config_serde_partial_uses_defaults() {
let json = r#"{"url":"sqlite::memory:"}"#;
let cfg: DbConfig = serde_json::from_str(json).unwrap();
assert_eq!(cfg.url, "sqlite::memory:");
assert_eq!(cfg.max_connections, 20);
assert_eq!(cfg.min_connections, 5);
assert_eq!(cfg.admin_role, "admin");
assert!(!cfg.auto_migrate);
}
}