use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseDriver {
#[serde(rename = "postgres")]
PostgreSQL,
#[serde(rename = "mysql")]
MySQL,
#[serde(rename = "sqlite")]
#[default]
SQLite,
#[serde(rename = "duckdb")]
DuckDB,
}
impl std::str::FromStr for DatabaseDriver {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("postgres") || s.eq_ignore_ascii_case("postgresql") {
Ok(DatabaseDriver::PostgreSQL)
} else if s.eq_ignore_ascii_case("mysql") {
Ok(DatabaseDriver::MySQL)
} else if s.eq_ignore_ascii_case("sqlite") || s.eq_ignore_ascii_case("sqlite3") {
Ok(DatabaseDriver::SQLite)
} else if s.eq_ignore_ascii_case("duckdb") {
Ok(DatabaseDriver::DuckDB)
} else {
let mut args = fluent_bundle::FluentArgs::new();
args.set("driver", s);
args.set("valid", "postgres, mysql, sqlite, duckdb");
Err(crate::i18n::tr_args("config-unknown_db_driver", args))
}
}
}
impl std::fmt::Display for DatabaseDriver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DatabaseDriver::PostgreSQL => write!(f, "postgres"),
DatabaseDriver::MySQL => write!(f, "mysql"),
DatabaseDriver::SQLite => write!(f, "sqlite"),
DatabaseDriver::DuckDB => write!(f, "duckdb"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum PartitionStrategy {
#[serde(rename = "monthly")]
#[default]
Monthly,
#[serde(rename = "yearly")]
Yearly,
}
impl std::str::FromStr for PartitionStrategy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("monthly") || s.eq_ignore_ascii_case("month") {
Ok(PartitionStrategy::Monthly)
} else if s.eq_ignore_ascii_case("yearly") || s.eq_ignore_ascii_case("year") {
Ok(PartitionStrategy::Yearly)
} else {
let mut args = fluent_bundle::FluentArgs::new();
args.set("strategy", s);
Err(crate::i18n::tr_args(
"config-unknown_partition_strategy",
args,
))
}
}
}
impl std::fmt::Display for PartitionStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PartitionStrategy::Monthly => write!(f, "monthly"),
PartitionStrategy::Yearly => write!(f, "yearly"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ParquetConfig {
#[serde(default = "default_parquet_compression_level")]
pub compression_level: i32,
#[serde(default = "default_parquet_encoding")]
pub encoding: String,
#[serde(default = "default_parquet_max_row_group_size")]
pub max_row_group_size: usize,
#[serde(default = "default_parquet_max_page_size")]
pub max_page_size: usize,
#[serde(default)]
pub include_fields: Vec<String>,
}
fn default_parquet_compression_level() -> i32 {
3
}
fn default_parquet_encoding() -> String {
"PLAIN".to_string()
}
fn default_parquet_max_row_group_size() -> usize {
10000
}
fn default_parquet_max_page_size() -> usize {
1048576
}
impl Default for ParquetConfig {
fn default() -> Self {
Self {
compression_level: default_parquet_compression_level(),
encoding: default_parquet_encoding(),
max_row_group_size: default_parquet_max_row_group_size(),
max_page_size: default_parquet_max_page_size(),
include_fields: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ArchiveFormat {
#[default]
Json,
Parquet,
Csv,
}
impl std::str::FromStr for ArchiveFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.eq_ignore_ascii_case("json") {
Ok(ArchiveFormat::Json)
} else if s.eq_ignore_ascii_case("parquet") {
Ok(ArchiveFormat::Parquet)
} else if s.eq_ignore_ascii_case("csv") {
Ok(ArchiveFormat::Csv)
} else {
let mut args = fluent_bundle::FluentArgs::new();
args.set("format", s);
args.set("valid", "json, parquet, csv");
Err(crate::i18n::tr_args("config-unknown_archive_format", args))
}
}
}
impl std::fmt::Display for ArchiveFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ArchiveFormat::Json => write!(f, "json"),
ArchiveFormat::Parquet => write!(f, "parquet"),
ArchiveFormat::Csv => write!(f, "csv"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DatabaseSinkConfig {
#[serde(default = "default_db_sink_name")]
pub name: String,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub driver: DatabaseDriver,
#[serde(default = "default_db_url")]
pub url: String,
#[serde(default = "default_db_pool_size")]
pub pool_size: u32,
#[serde(default = "default_db_batch_size")]
pub batch_size: usize,
#[serde(default = "default_db_flush_interval_ms")]
pub flush_interval_ms: u64,
#[serde(default)]
pub partition: PartitionStrategy,
#[serde(default = "default_db_table_name")]
pub table_name: String,
#[serde(default)]
pub archive_format: ArchiveFormat,
#[serde(default)]
pub parquet_config: ParquetConfig,
#[serde(default)]
pub permissions_path: Option<String>,
#[serde(default = "default_admin_role")]
pub admin_role: String,
}
fn default_db_sink_name() -> String {
"default".to_string()
}
fn default_db_url() -> String {
"sqlite::memory:".to_string()
}
fn default_db_pool_size() -> u32 {
10
}
fn default_db_batch_size() -> usize {
100
}
fn default_db_flush_interval_ms() -> u64 {
500
}
fn default_db_table_name() -> String {
"logs".to_string()
}
fn default_admin_role() -> String {
"admin".to_string()
}
impl Default for DatabaseSinkConfig {
fn default() -> Self {
Self {
name: default_db_sink_name(),
enabled: false,
driver: DatabaseDriver::default(),
url: default_db_url(),
pool_size: default_db_pool_size(),
batch_size: default_db_batch_size(),
flush_interval_ms: default_db_flush_interval_ms(),
partition: PartitionStrategy::default(),
table_name: default_db_table_name(),
archive_format: ArchiveFormat::default(),
parquet_config: ParquetConfig::default(),
permissions_path: None,
admin_role: default_admin_role(),
}
}
}
impl DatabaseSinkConfig {
pub fn validate(&mut self) {
if self.driver == DatabaseDriver::SQLite && self.pool_size != 1 {
self.pool_size = 1;
}
if self.batch_size == 0 {
tracing::warn!("{}", crate::i18n::tr("warn-db_batch_size_zero"));
self.batch_size = 100;
}
if self.flush_interval_ms == 0 {
tracing::warn!("{}", crate::i18n::tr("warn-db_flush_interval_zero"));
self.flush_interval_ms = 500;
}
if !(1..=22).contains(&self.parquet_config.compression_level) {
tracing::warn!("{}", crate::i18n::tr("warn-db_compression_level_clamp"));
self.parquet_config.compression_level = 3;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sqlite_pool_size_defaults_to_one() {
let mut config = DatabaseSinkConfig::default();
config.driver = DatabaseDriver::SQLite;
config.pool_size = 10;
config.validate();
assert_eq!(config.pool_size, 1, "SQLite pool_size must be 1");
}
#[test]
fn test_non_sqlite_pool_size_unchanged() {
let mut config = DatabaseSinkConfig::default();
config.driver = DatabaseDriver::PostgreSQL;
config.pool_size = 10;
config.validate();
assert_eq!(
config.pool_size, 10,
"non-SQLite pool_size should be unchanged"
);
}
#[test]
fn test_database_driver_from_str() {
assert_eq!(
"postgres".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::PostgreSQL
);
assert_eq!(
"PostgreSQL".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::PostgreSQL
);
assert_eq!(
"postgresql".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::PostgreSQL
);
assert_eq!(
"mysql".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::MySQL
);
assert_eq!(
"MYSQL".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::MySQL
);
assert_eq!(
"sqlite".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::SQLite
);
assert_eq!(
"sqlite3".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::SQLite
);
assert_eq!(
"duckdb".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::DuckDB
);
assert_eq!(
"DuckDB".parse::<DatabaseDriver>().unwrap(),
DatabaseDriver::DuckDB
);
assert!("oracle".parse::<DatabaseDriver>().is_err());
}
#[test]
fn test_database_driver_display() {
assert_eq!(DatabaseDriver::PostgreSQL.to_string(), "postgres");
assert_eq!(DatabaseDriver::MySQL.to_string(), "mysql");
assert_eq!(DatabaseDriver::SQLite.to_string(), "sqlite");
assert_eq!(DatabaseDriver::DuckDB.to_string(), "duckdb");
}
#[test]
fn test_partition_strategy_from_str() {
assert_eq!(
"monthly".parse::<PartitionStrategy>().unwrap(),
PartitionStrategy::Monthly
);
assert_eq!(
"month".parse::<PartitionStrategy>().unwrap(),
PartitionStrategy::Monthly
);
assert_eq!(
"yearly".parse::<PartitionStrategy>().unwrap(),
PartitionStrategy::Yearly
);
assert_eq!(
"year".parse::<PartitionStrategy>().unwrap(),
PartitionStrategy::Yearly
);
assert!("weekly".parse::<PartitionStrategy>().is_err());
}
#[test]
fn test_partition_strategy_display() {
assert_eq!(PartitionStrategy::Monthly.to_string(), "monthly");
assert_eq!(PartitionStrategy::Yearly.to_string(), "yearly");
}
#[test]
fn test_archive_format_from_str() {
assert_eq!(
"json".parse::<ArchiveFormat>().unwrap(),
ArchiveFormat::Json
);
assert_eq!(
"JSON".parse::<ArchiveFormat>().unwrap(),
ArchiveFormat::Json
);
assert_eq!(
"parquet".parse::<ArchiveFormat>().unwrap(),
ArchiveFormat::Parquet
);
assert_eq!("csv".parse::<ArchiveFormat>().unwrap(), ArchiveFormat::Csv);
assert!("xml".parse::<ArchiveFormat>().is_err());
}
#[test]
fn test_archive_format_display() {
assert_eq!(ArchiveFormat::Json.to_string(), "json");
assert_eq!(ArchiveFormat::Parquet.to_string(), "parquet");
assert_eq!(ArchiveFormat::Csv.to_string(), "csv");
}
#[test]
fn test_validate_batch_size_zero() {
let mut config = DatabaseSinkConfig::default();
config.batch_size = 0;
config.validate();
assert_eq!(config.batch_size, 100);
}
#[test]
fn test_validate_flush_interval_zero() {
let mut config = DatabaseSinkConfig::default();
config.flush_interval_ms = 0;
config.validate();
assert_eq!(config.flush_interval_ms, 500);
}
#[test]
fn test_validate_compression_level_clamp() {
let mut config = DatabaseSinkConfig::default();
config.parquet_config.compression_level = 99;
config.validate();
assert_eq!(config.parquet_config.compression_level, 3);
}
#[test]
fn test_validate_compression_level_zero() {
let mut config = DatabaseSinkConfig::default();
config.parquet_config.compression_level = 0;
config.validate();
assert_eq!(config.parquet_config.compression_level, 3);
}
#[test]
fn test_config_default_has_new_fields() {
let config = DatabaseSinkConfig::default();
assert_eq!(config.admin_role, "admin");
assert!(config.permissions_path.is_none());
}
#[test]
fn test_config_serialize_with_new_fields() {
let mut config = DatabaseSinkConfig::default();
config.permissions_path = Some("/etc/inklog/perms.yaml".to_string());
config.admin_role = "superadmin".to_string();
let toml_str = toml::to_string(&config).expect("serialize should succeed");
assert!(toml_str.contains("permissions_path"));
assert!(toml_str.contains("superadmin"));
}
#[test]
fn test_config_deserialize_default_values() {
let toml_str = r#"
name = "test"
enabled = true
url = "sqlite::memory:"
"#;
let config: DatabaseSinkConfig =
toml::from_str(toml_str).expect("deserialize should succeed");
assert_eq!(config.admin_role, "admin");
assert!(config.permissions_path.is_none());
}
#[test]
fn test_config_deserialize_custom_values() {
let toml_str = r#"
name = "test"
enabled = true
url = "sqlite::memory:"
permissions_path = "/etc/perms.yaml"
admin_role = "dba"
"#;
let config: DatabaseSinkConfig =
toml::from_str(toml_str).expect("deserialize should succeed");
assert_eq!(config.admin_role, "dba");
assert_eq!(config.permissions_path, Some("/etc/perms.yaml".to_string()));
}
}