use crate::InklogError;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InklogConfig {
#[serde(default)]
pub global: GlobalConfig,
#[serde(default = "default_console_sink")]
pub console_sink: Option<ConsoleSinkConfig>,
#[serde(default)]
pub file_sink: Option<FileSinkConfig>,
#[serde(default)]
pub database_sink: Option<DatabaseSinkConfig>,
#[serde(default)]
pub performance: PerformanceConfig,
#[serde(default)]
pub http_server: Option<HttpServerConfig>,
}
fn default_console_sink() -> Option<ConsoleSinkConfig> {
Some(ConsoleSinkConfig::default())
}
impl Default for InklogConfig {
fn default() -> Self {
Self {
global: GlobalConfig::default(),
console_sink: default_console_sink(),
file_sink: None,
database_sink: None,
performance: PerformanceConfig::default(),
http_server: None,
}
}
}
impl InklogConfig {
pub fn load_sync() -> Result<Self, InklogError> {
Self::from_search_paths()
.map_err(|e| InklogError::ConfigError(format!("Failed to load config: {}", e)))
}
pub fn load_with_env_overrides() -> Result<Self, InklogError> {
let mut config = Self::load_sync()?;
Self::apply_env_overrides(&mut config);
Ok(config)
}
fn apply_env_overrides(config: &mut Self) {
if let Ok(val) = std::env::var("INKLOG_GLOBAL_LEVEL") {
config.global.level = val;
}
if let Ok(val) = std::env::var("INKLOG_GLOBAL_FORMAT") {
config.global.format = val;
}
if let Ok(val) = std::env::var("INKLOG_GLOBAL_MASKING_ENABLED") {
config.global.masking_enabled = val.parse().unwrap_or(config.global.masking_enabled);
}
if let Ok(val) = std::env::var("INKLOG_GLOBAL_AUTO_FALLBACK") {
config.global.auto_fallback = val.parse().unwrap_or(config.global.auto_fallback);
}
if let Ok(val) = std::env::var("INKLOG_FILE_SINK_ENABLED") {
if val.parse::<bool>().unwrap_or(false) {
let file_config = config.file_sink.get_or_insert_with(Default::default);
file_config.enabled = true;
}
}
if let Ok(val) = std::env::var("INKLOG_FILE_SINK_PATH") {
let file_config = config.file_sink.get_or_insert_with(Default::default);
file_config.path = std::path::PathBuf::from(val);
}
if let Ok(val) = std::env::var("INKLOG_FILE_SINK_MAX_SIZE") {
let file_config = config.file_sink.get_or_insert_with(Default::default);
file_config.max_size = val;
}
if let Ok(val) = std::env::var("INKLOG_HTTP_SERVER_ENABLED") {
if val.parse::<bool>().unwrap_or(false) {
let http_config = config.http_server.get_or_insert_with(Default::default);
http_config.enabled = true;
}
}
if let Ok(val) = std::env::var("INKLOG_HTTP_SERVER_HOST") {
let http_config = config.http_server.get_or_insert_with(Default::default);
http_config.host = val;
}
if let Ok(val) = std::env::var("INKLOG_HTTP_SERVER_PORT") {
let http_config = config.http_server.get_or_insert_with(Default::default);
http_config.port = val.parse().unwrap_or(http_config.port);
}
if let Ok(val) = std::env::var("INKLOG_HTTP_SERVER_METRICS_PATH") {
let http_config = config.http_server.get_or_insert_with(Default::default);
http_config.metrics_path = val;
}
if let Ok(val) = std::env::var("INKLOG_HTTP_SERVER_HEALTH_PATH") {
let http_config = config.http_server.get_or_insert_with(Default::default);
http_config.health_path = val;
}
if let Ok(val) = std::env::var("INKLOG_HTTP_SERVER_ERROR_MODE") {
let http_config = config.http_server.get_or_insert_with(Default::default);
http_config.error_mode = match val.to_lowercase().as_str() {
"strict" => crate::config::HttpErrorMode::Strict,
"warn" => crate::config::HttpErrorMode::Warn,
_ => http_config.error_mode.clone(),
};
}
if let Ok(val) = std::env::var("INKLOG_PERFORMANCE_WORKER_THREADS") {
config.performance.worker_threads =
val.parse().unwrap_or(config.performance.worker_threads);
}
if let Ok(val) = std::env::var("INKLOG_PERFORMANCE_CHANNEL_CAPACITY") {
config.performance.channel_capacity =
val.parse().unwrap_or(config.performance.channel_capacity);
}
}
pub fn from_search_paths() -> Result<Self, InklogError> {
let search_paths = vec![
std::env::var("INKLOG_CONFIG_PATH").ok(),
Some("inklog_config.toml".to_string()),
dirs::config_dir().map(|p| {
p.join("inklog")
.join("config.toml")
.to_string_lossy()
.to_string()
}),
Some("/etc/inklog/config.toml".to_string()),
];
for path_opt in search_paths.into_iter().flatten() {
if std::path::Path::new(&path_opt).exists() {
let content = std::fs::read_to_string(&path_opt).map_err(|e| {
InklogError::ConfigError(format!(
"Failed to read config file '{}': {}",
path_opt, e
))
})?;
let config: Self = toml::from_str(&content).map_err(|e| {
InklogError::ConfigError(format!(
"Failed to parse config file '{}': {}",
path_opt, e
))
})?;
return Ok(config);
}
}
Ok(Self::default())
}
pub fn sinks_enabled(&self) -> Vec<&'static str> {
let mut sinks = Vec::new();
if self.console_sink.as_ref().is_some_and(|c| c.enabled) {
sinks.push("console");
}
if self.file_sink.as_ref().is_some_and(|c| c.enabled) {
sinks.push("file");
}
if self.database_sink.as_ref().is_some_and(|c| c.enabled) {
sinks.push("database");
}
sinks
}
pub fn validate(&self) -> Result<(), InklogError> {
if self.performance.channel_capacity == 0 {
return Err(InklogError::ConfigError(
"channel_capacity cannot be 0".to_string(),
));
}
if self.performance.worker_threads == 0 {
return Err(InklogError::ConfigError(
"worker_threads cannot be 0".to_string(),
));
}
Ok(())
}
}
impl std::str::FromStr for InklogConfig {
type Err = toml::de::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
toml::from_str(s)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct GlobalConfig {
#[serde(default = "default_global_level")]
pub level: String,
#[serde(default = "default_global_format")]
pub format: String,
#[serde(default = "default_true")]
pub masking_enabled: bool,
#[serde(default = "default_true")]
pub auto_fallback: bool,
#[serde(default = "default_fallback_initial_delay")]
pub fallback_initial_delay_ms: u64,
#[serde(default = "default_fallback_max_delay")]
pub fallback_max_delay_ms: u64,
#[serde(default = "default_fallback_max_retries")]
pub fallback_max_retries: u32,
}
fn default_global_level() -> String {
"info".to_string()
}
fn default_global_format() -> String {
"{timestamp} [{level}] {target} - {message}".to_string()
}
fn default_true() -> bool {
true
}
fn default_fallback_initial_delay() -> u64 {
1000
}
fn default_fallback_max_delay() -> u64 {
60000
}
fn default_fallback_max_retries() -> u32 {
10
}
impl Default for GlobalConfig {
fn default() -> Self {
Self {
level: default_global_level(),
format: default_global_format(),
masking_enabled: default_true(),
auto_fallback: default_true(),
fallback_initial_delay_ms: default_fallback_initial_delay(),
fallback_max_delay_ms: default_fallback_max_delay(),
fallback_max_retries: default_fallback_max_retries(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConsoleSinkConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub colored: bool,
#[serde(default = "default_stderr_levels")]
pub stderr_levels: Vec<String>,
#[serde(default)]
pub masking_enabled: bool,
}
fn default_stderr_levels() -> Vec<String> {
vec!["error".to_string(), "warn".to_string()]
}
impl Default for ConsoleSinkConfig {
fn default() -> Self {
Self {
enabled: default_true(),
colored: default_true(),
stderr_levels: default_stderr_levels(),
masking_enabled: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileSinkConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_log_path")]
pub path: PathBuf,
#[serde(default = "default_max_size")]
pub max_size: String,
#[serde(default = "default_rotation_time")]
pub rotation_time: String,
#[serde(default = "default_keep_files")]
pub keep_files: u32,
#[serde(default = "default_true")]
pub compress: bool,
#[serde(default = "default_compression_level")]
pub compression_level: i32,
#[serde(default)]
pub encrypt: bool,
#[serde(default)]
pub encryption_key_env: Option<String>,
#[serde(default = "default_retention_days")]
pub retention_days: u32,
#[serde(default = "default_max_total_size")]
pub max_total_size: String,
#[serde(default = "default_cleanup_interval_minutes")]
pub cleanup_interval_minutes: u64,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_flush_interval_ms")]
pub flush_interval_ms: u64,
#[serde(default = "default_true")]
pub masking_enabled: bool,
}
fn default_log_path() -> PathBuf {
PathBuf::from("logs/app.log")
}
fn default_max_size() -> String {
"100MB".to_string()
}
fn default_rotation_time() -> String {
"daily".to_string()
}
fn default_keep_files() -> u32 {
30
}
fn default_compression_level() -> i32 {
3
}
fn default_retention_days() -> u32 {
30
}
fn default_max_total_size() -> String {
"1GB".to_string()
}
fn default_cleanup_interval_minutes() -> u64 {
60
}
fn default_batch_size() -> usize {
100
}
fn default_flush_interval_ms() -> u64 {
100
}
impl Default for FileSinkConfig {
fn default() -> Self {
Self {
enabled: default_true(),
path: default_log_path(),
max_size: default_max_size(),
rotation_time: default_rotation_time(),
keep_files: default_keep_files(),
compress: default_true(),
compression_level: default_compression_level(),
encrypt: false,
encryption_key_env: None,
retention_days: default_retention_days(),
max_total_size: default_max_total_size(),
cleanup_interval_minutes: default_cleanup_interval_minutes(),
batch_size: default_batch_size(),
flush_interval_ms: default_flush_interval_ms(),
masking_enabled: default_true(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum DatabaseDriver {
#[serde(rename = "postgres")]
#[default]
PostgreSQL,
#[serde(rename = "mysql")]
MySQL,
#[serde(rename = "sqlite")]
SQLite,
}
impl std::str::FromStr for DatabaseDriver {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"postgres" | "postgresql" => Ok(DatabaseDriver::PostgreSQL),
"mysql" => Ok(DatabaseDriver::MySQL),
"sqlite" | "sqlite3" => Ok(DatabaseDriver::SQLite),
_ => Err(()),
}
}
}
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"),
}
}
}
#[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> {
match s.to_lowercase().as_str() {
"monthly" | "month" => Ok(PartitionStrategy::Monthly),
"yearly" | "year" => Ok(PartitionStrategy::Yearly),
_ => Err(format!("Unknown partition strategy: {}", s)),
}
}
}
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)]
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)]
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 = "default_db_archive_format")]
pub archive_format: String,
#[serde(default)]
pub parquet_config: ParquetConfig,
}
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_db_archive_format() -> String {
"json".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: default_db_archive_format(),
parquet_config: ParquetConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ChannelStrategy {
#[serde(rename = "fixed")]
#[default]
Fixed,
#[serde(rename = "adaptive")]
Adaptive,
}
impl std::str::FromStr for ChannelStrategy {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"fixed" => Ok(ChannelStrategy::Fixed),
"adaptive" => Ok(ChannelStrategy::Adaptive),
_ => Err(format!("Unknown channel strategy: {}", s)),
}
}
}
impl std::fmt::Display for ChannelStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ChannelStrategy::Fixed => write!(f, "fixed"),
ChannelStrategy::Adaptive => write!(f, "adaptive"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_http_host")]
pub host: String,
#[serde(default = "default_http_port")]
pub port: u16,
#[serde(default = "default_http_metrics_path")]
pub metrics_path: String,
#[serde(default = "default_http_health_path")]
pub health_path: String,
#[serde(default)]
pub error_mode: HttpErrorMode,
#[serde(default)]
pub auth: Option<HttpAuthConfig>,
#[serde(default)]
pub ip_whitelist: Option<Vec<String>>,
}
fn default_http_host() -> String {
"127.0.0.1".to_string()
}
fn default_http_port() -> u16 {
9090
}
fn default_http_metrics_path() -> String {
"/metrics".to_string()
}
fn default_http_health_path() -> String {
"/health".to_string()
}
impl Default for HttpServerConfig {
fn default() -> Self {
Self {
enabled: false,
host: default_http_host(),
port: default_http_port(),
metrics_path: default_http_metrics_path(),
health_path: default_http_health_path(),
error_mode: HttpErrorMode::default(),
auth: None,
ip_whitelist: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HttpAuthConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_http_auth_token_env")]
pub token_env: String,
}
fn default_http_auth_token_env() -> String {
"INKLOG_HTTP_AUTH_TOKEN".to_string()
}
impl Default for HttpAuthConfig {
fn default() -> Self {
Self {
enabled: false,
token_env: default_http_auth_token_env(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum HttpErrorMode {
#[serde(rename = "warn")]
Warn,
#[serde(rename = "strict")]
#[default]
Strict,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerformanceConfig {
#[serde(default = "default_channel_capacity")]
pub channel_capacity: usize,
#[serde(default = "default_worker_threads")]
pub worker_threads: usize,
#[serde(default)]
pub channel_strategy: ChannelStrategy,
#[serde(default = "default_expand_threshold")]
pub expand_threshold_percent: u8,
#[serde(default = "default_shrink_threshold")]
pub shrink_threshold_percent: u8,
#[serde(default = "default_shrink_wait")]
pub shrink_wait_seconds: u64,
#[serde(default = "default_min_capacity")]
pub min_capacity: usize,
#[serde(default = "default_max_capacity")]
pub max_capacity: usize,
}
fn default_channel_capacity() -> usize {
10000
}
fn default_worker_threads() -> usize {
3
}
fn default_expand_threshold() -> u8 {
80
}
fn default_shrink_threshold() -> u8 {
20
}
fn default_shrink_wait() -> u64 {
30
}
fn default_min_capacity() -> usize {
1000
}
fn default_max_capacity() -> usize {
50000
}
impl Default for PerformanceConfig {
fn default() -> Self {
Self {
channel_capacity: default_channel_capacity(),
worker_threads: default_worker_threads(),
channel_strategy: ChannelStrategy::default(),
expand_threshold_percent: default_expand_threshold(),
shrink_threshold_percent: default_shrink_threshold(),
shrink_wait_seconds: default_shrink_wait(),
min_capacity: default_min_capacity(),
max_capacity: default_max_capacity(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use tempfile::tempdir;
#[test]
fn test_global_config_default() {
let global = GlobalConfig::default();
assert_eq!(global.level, "info");
assert!(global.auto_fallback);
assert!(global.fallback_initial_delay_ms > 0);
assert!(global.fallback_max_delay_ms > 0);
}
#[test]
fn test_global_config_setters() {
let global = GlobalConfig {
level: "debug".to_string(),
auto_fallback: false,
..Default::default()
};
assert_eq!(global.level, "debug");
assert!(!global.auto_fallback);
}
#[test]
fn test_performance_config_default() {
let perf = PerformanceConfig::default();
assert_eq!(perf.channel_capacity, 10000);
assert_eq!(perf.worker_threads, 3);
assert_eq!(perf.channel_strategy, ChannelStrategy::Fixed);
}
#[test]
fn test_performance_config_channel_strategy_fixed() {
let perf = PerformanceConfig {
channel_strategy: ChannelStrategy::Fixed,
channel_capacity: 5000,
..Default::default()
};
match perf.channel_strategy {
ChannelStrategy::Fixed => {}
ChannelStrategy::Adaptive => panic!("Expected Fixed strategy"),
}
assert_eq!(perf.channel_capacity, 5000);
}
#[test]
fn test_performance_config_channel_strategy_adaptive() {
let perf = PerformanceConfig {
channel_strategy: ChannelStrategy::Adaptive,
channel_capacity: 20000,
expand_threshold_percent: 70,
shrink_threshold_percent: 30,
shrink_wait_seconds: 60,
min_capacity: 2000,
max_capacity: 50000,
..Default::default()
};
match perf.channel_strategy {
ChannelStrategy::Adaptive => {}
ChannelStrategy::Fixed => panic!("Expected Adaptive strategy"),
}
assert_eq!(perf.expand_threshold_percent, 70);
assert_eq!(perf.shrink_threshold_percent, 30);
assert_eq!(perf.shrink_wait_seconds, 60);
assert_eq!(perf.min_capacity, 2000);
assert_eq!(perf.max_capacity, 50000);
}
#[test]
fn test_console_config_default() {
let console = ConsoleSinkConfig::default();
assert!(console.enabled);
assert!(!console.masking_enabled);
}
#[test]
fn test_console_config_custom() {
let console = ConsoleSinkConfig {
enabled: false,
colored: false,
stderr_levels: vec!["error".to_string(), "warn".to_string()],
masking_enabled: false,
};
assert!(!console.enabled);
assert!(!console.colored);
assert_eq!(console.stderr_levels.len(), 2);
}
#[test]
fn test_console_config_stderr_levels() {
let config = ConsoleSinkConfig::default();
assert_eq!(config.stderr_levels.len(), 2);
assert!(config.stderr_levels.contains(&"error".to_string()));
assert!(config.stderr_levels.contains(&"warn".to_string()));
}
#[test]
fn test_file_config_default() {
let file = FileSinkConfig::default();
assert!(file.enabled);
assert!(!file.max_size.is_empty());
assert!(!file.rotation_time.is_empty());
assert!(file.keep_files > 0);
assert!(file.retention_days > 0);
}
#[test]
fn test_file_config_rotation_times() {
let mut config = FileSinkConfig::default();
for time in ["hourly", "daily", "weekly", "monthly"] {
config.rotation_time = time.to_string();
assert_eq!(config.rotation_time, time);
}
}
#[test]
fn test_file_config_parse_size() {
let config = FileSinkConfig::default();
assert_eq!(config.max_size, "100MB");
assert_eq!(config.max_total_size, "1GB");
}
#[test]
fn test_file_config_batch_settings() {
let config = FileSinkConfig {
batch_size: 500,
flush_interval_ms: 50,
..Default::default()
};
assert_eq!(config.batch_size, 500);
assert_eq!(config.flush_interval_ms, 50);
}
#[test]
fn test_file_config_encryption_settings() {
let config = FileSinkConfig {
encrypt: true,
encryption_key_env: Some("CUSTOM_KEY_VAR".to_string()),
..Default::default()
};
assert!(config.encrypt);
assert_eq!(
config.encryption_key_env,
Some("CUSTOM_KEY_VAR".to_string())
);
}
#[test]
fn test_database_config_default() {
let db = DatabaseSinkConfig::default();
assert!(!db.enabled);
assert!(!db.url.is_empty());
assert_eq!(db.table_name, "logs");
assert!(db.batch_size > 0);
assert!(db.flush_interval_ms > 0);
}
#[test]
fn test_database_config_url_parsing() {
let config = DatabaseSinkConfig {
url: "postgres://user:pass@localhost:5432/logs".to_string(),
..Default::default()
};
assert!(config.url.starts_with("postgres://"));
assert!(config.url.contains("localhost"));
}
#[test]
fn test_database_config_batch_settings() {
let config = DatabaseSinkConfig {
batch_size: 1000,
flush_interval_ms: 500,
..Default::default()
};
assert_eq!(config.batch_size, 1000);
assert_eq!(config.flush_interval_ms, 500);
}
#[test]
fn test_file_config_path_operations() {
let config = FileSinkConfig {
path: PathBuf::from("/var/log/app.log"),
..Default::default()
};
assert!(config.path.is_absolute());
assert_eq!(
config.path.file_name().unwrap().to_string_lossy(),
"app.log"
);
}
#[test]
fn test_inklog_config_sinks_enabled() {
let config = InklogConfig {
console_sink: Some(ConsoleSinkConfig::default()),
..Default::default()
};
println!("console_sink: {:?}", config.console_sink);
println!("global: {:?}", config.global);
let sinks = config.sinks_enabled();
println!("sinks: {:?}", sinks);
assert!(
sinks.contains(&"console"),
"Expected console sink to be enabled, but got: {:?}",
sinks
);
}
#[test]
fn test_console_sink_config_default_values() {
let config = ConsoleSinkConfig::default();
assert!(config.enabled);
assert!(config.colored);
assert_eq!(config.stderr_levels.len(), 2);
}
#[test]
fn test_global_config_load_sync() {
let config = GlobalConfig::default();
assert_eq!(config.level, "info");
assert!(config.auto_fallback);
}
#[test]
fn test_validate_default_passes() {
let config = InklogConfig::default();
assert!(config.validate().is_ok(), "default config should validate");
}
#[test]
fn test_validate_zero_channel_capacity_fails() {
let config = InklogConfig {
performance: PerformanceConfig {
channel_capacity: 0,
..Default::default()
},
..Default::default()
};
let err = config.validate().expect_err("capacity=0 should fail");
assert!(
err.to_string().contains("channel_capacity"),
"error should mention channel_capacity, got: {err}"
);
}
#[test]
fn test_validate_zero_worker_threads_fails() {
let config = InklogConfig {
performance: PerformanceConfig {
worker_threads: 0,
..Default::default()
},
..Default::default()
};
let err = config.validate().expect_err("worker_threads=0 should fail");
assert!(
err.to_string().contains("worker_threads"),
"error should mention worker_threads, got: {err}"
);
}
#[test]
fn test_validate_both_zero_reports_capacity_first() {
let config = InklogConfig {
performance: PerformanceConfig {
channel_capacity: 0,
worker_threads: 0,
..Default::default()
},
..Default::default()
};
let err = config.validate().expect_err("both zero should fail");
assert!(err.to_string().contains("channel_capacity"));
}
#[test]
fn test_from_str_valid_toml() {
let toml = r#"
[global]
level = "debug"
format = "{timestamp} {message}"
[console_sink]
enabled = true
colored = false
"#;
let config: InklogConfig = toml.parse().expect("valid TOML should parse");
assert_eq!(config.global.level, "debug");
assert!(config.console_sink.is_some());
assert!(!config.console_sink.as_ref().unwrap().colored);
}
#[test]
fn test_from_str_empty_string_returns_defaults() {
let config: InklogConfig = "".parse().expect("empty TOML should parse to defaults");
assert_eq!(config.global.level, "info");
assert!(config.console_sink.is_some());
}
#[test]
fn test_from_str_invalid_toml_errors() {
let bad = "not = valid = toml = syntax";
let result: Result<InklogConfig, _> = bad.parse();
assert!(result.is_err(), "malformed TOML should error");
}
#[test]
fn test_from_str_partial_config_only_global() {
let toml = r#"
[global]
level = "warn"
"#;
let config: InklogConfig = toml.parse().expect("partial config should parse");
assert_eq!(config.global.level, "warn");
assert!(config.file_sink.is_none());
assert!(config.database_sink.is_none());
assert!(config.http_server.is_none());
}
#[test]
fn test_sinks_enabled_all_disabled() {
let config = InklogConfig {
console_sink: Some(ConsoleSinkConfig {
enabled: false,
..Default::default()
}),
file_sink: None,
database_sink: None,
http_server: None,
..Default::default()
};
assert!(
config.sinks_enabled().is_empty(),
"no sinks should be enabled"
);
}
#[test]
fn test_sinks_enabled_only_file() {
let config = InklogConfig {
console_sink: Some(ConsoleSinkConfig {
enabled: false,
..Default::default()
}),
file_sink: Some(FileSinkConfig {
enabled: true,
..Default::default()
}),
database_sink: None,
http_server: None,
..Default::default()
};
let sinks = config.sinks_enabled();
assert_eq!(sinks, vec!["file"]);
}
#[test]
fn test_sinks_enabled_console_and_database() {
let config = InklogConfig {
console_sink: Some(ConsoleSinkConfig {
enabled: true,
..Default::default()
}),
file_sink: Some(FileSinkConfig {
enabled: false,
..Default::default()
}),
database_sink: Some(DatabaseSinkConfig {
enabled: true,
..Default::default()
}),
http_server: None,
..Default::default()
};
let mut sinks = config.sinks_enabled();
sinks.sort();
assert_eq!(sinks, vec!["console", "database"]);
}
#[test]
#[serial]
fn test_from_search_paths_with_env_var_loads_file() {
let dir = tempdir().expect("failed to create tempdir");
let config_path = dir.path().join("custom_config.toml");
std::fs::write(
&config_path,
r#"
[global]
level = "trace"
"#,
)
.expect("failed to write config");
env::set_var("INKLOG_CONFIG_PATH", config_path.to_str().unwrap());
let config = InklogConfig::from_search_paths().expect("should load from env path");
env::remove_var("INKLOG_CONFIG_PATH");
assert_eq!(config.global.level, "trace");
}
#[test]
#[serial]
fn test_from_search_paths_missing_env_falls_back_to_default() {
env::remove_var("INKLOG_CONFIG_PATH");
let config = InklogConfig::from_search_paths().expect("should not error");
assert!(config.validate().is_ok());
}
#[test]
#[serial]
fn test_from_search_paths_malformed_toml_errors() {
let dir = tempdir().expect("failed to create tempdir");
let config_path = dir.path().join("bad_config.toml");
std::fs::write(&config_path, "not = valid = toml").expect("failed to write");
env::set_var("INKLOG_CONFIG_PATH", config_path.to_str().unwrap());
let result = InklogConfig::from_search_paths();
env::remove_var("INKLOG_CONFIG_PATH");
let err = result.expect_err("malformed TOML should error");
assert!(
err.to_string().contains("Failed to parse config file"),
"error should mention parse failure, got: {err}"
);
}
#[test]
#[serial]
fn test_load_sync_with_env_path() {
let dir = tempdir().expect("failed to create tempdir");
let config_path = dir.path().join("sync_config.toml");
std::fs::write(
&config_path,
r#"
[global]
level = "error"
"#,
)
.expect("failed to write");
env::set_var("INKLOG_CONFIG_PATH", config_path.to_str().unwrap());
let config = InklogConfig::load_sync().expect("load_sync should succeed");
env::remove_var("INKLOG_CONFIG_PATH");
assert_eq!(config.global.level, "error");
}
#[test]
#[serial]
fn test_load_with_env_overrides_global_level() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_GLOBAL_LEVEL", "debug");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_GLOBAL_LEVEL");
assert_eq!(config.global.level, "debug");
}
#[test]
#[serial]
fn test_load_with_env_overrides_performance_capacity() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_PERFORMANCE_CHANNEL_CAPACITY", "5000");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_PERFORMANCE_CHANNEL_CAPACITY");
assert_eq!(config.performance.channel_capacity, 5000);
}
#[test]
#[serial]
fn test_load_with_env_overrides_file_sink_enabled() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_FILE_SINK_ENABLED", "true");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_FILE_SINK_ENABLED");
let file = config
.file_sink
.expect("file_sink should be Some after env override");
assert!(file.enabled, "file_sink.enabled should be true");
}
#[test]
#[serial]
fn test_load_with_env_overrides_http_server_enabled() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_HTTP_SERVER_ENABLED", "true");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_HTTP_SERVER_ENABLED");
let http = config
.http_server
.expect("http_server should be Some after env override");
assert!(http.enabled);
}
#[test]
#[serial]
fn test_load_with_env_overrides_invalid_bool_ignored() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_GLOBAL_MASKING_ENABLED", "not_a_bool");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_GLOBAL_MASKING_ENABLED");
assert_eq!(
config.global.masking_enabled,
GlobalConfig::default().masking_enabled
);
}
#[test]
#[serial]
fn test_load_with_env_overrides_invalid_int_ignored() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_PERFORMANCE_CHANNEL_CAPACITY", "not_an_int");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_PERFORMANCE_CHANNEL_CAPACITY");
assert_eq!(
config.performance.channel_capacity,
PerformanceConfig::default().channel_capacity
);
}
#[test]
#[serial]
fn test_load_with_env_overrides_http_error_mode_strict() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_HTTP_SERVER_ENABLED", "true");
env::set_var("INKLOG_HTTP_SERVER_ERROR_MODE", "strict");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_HTTP_SERVER_ENABLED");
env::remove_var("INKLOG_HTTP_SERVER_ERROR_MODE");
let http = config.http_server.expect("http_server should be Some");
assert!(matches!(http.error_mode, HttpErrorMode::Strict));
}
#[test]
#[serial]
fn test_load_with_env_overrides_http_error_mode_warn() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_HTTP_SERVER_ENABLED", "true");
env::set_var("INKLOG_HTTP_SERVER_ERROR_MODE", "warn");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_HTTP_SERVER_ENABLED");
env::remove_var("INKLOG_HTTP_SERVER_ERROR_MODE");
let http = config.http_server.expect("http_server should be Some");
assert!(matches!(http.error_mode, HttpErrorMode::Warn));
}
#[test]
#[serial]
fn test_load_with_env_overrides_file_sink_path_and_max_size() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_FILE_SINK_ENABLED", "true");
env::set_var("INKLOG_FILE_SINK_PATH", "/tmp/test_app.log");
env::set_var("INKLOG_FILE_SINK_MAX_SIZE", "250MB");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_FILE_SINK_ENABLED");
env::remove_var("INKLOG_FILE_SINK_PATH");
env::remove_var("INKLOG_FILE_SINK_MAX_SIZE");
let file = config.file_sink.expect("file_sink should be Some");
assert_eq!(file.path, std::path::PathBuf::from("/tmp/test_app.log"));
assert_eq!(file.max_size, "250MB");
}
#[test]
#[serial]
fn test_load_with_env_overrides_http_server_host_port() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_HTTP_SERVER_ENABLED", "true");
env::set_var("INKLOG_HTTP_SERVER_HOST", "0.0.0.0");
env::set_var("INKLOG_HTTP_SERVER_PORT", "8080");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_HTTP_SERVER_ENABLED");
env::remove_var("INKLOG_HTTP_SERVER_HOST");
env::remove_var("INKLOG_HTTP_SERVER_PORT");
let http = config.http_server.expect("http_server should be Some");
assert_eq!(http.host, "0.0.0.0");
assert_eq!(http.port, 8080);
}
#[test]
#[serial]
fn test_load_with_env_overrides_global_format() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_GLOBAL_FORMAT", "{level} {message}");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_GLOBAL_FORMAT");
assert_eq!(config.global.format, "{level} {message}");
}
#[test]
#[serial]
fn test_load_with_env_overrides_global_auto_fallback() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_GLOBAL_AUTO_FALLBACK", "false");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_GLOBAL_AUTO_FALLBACK");
assert!(!config.global.auto_fallback);
}
#[test]
#[serial]
fn test_load_with_env_overrides_http_server_metrics_and_health_path() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_HTTP_SERVER_ENABLED", "true");
env::set_var("INKLOG_HTTP_SERVER_METRICS_PATH", "/custom_metrics");
env::set_var("INKLOG_HTTP_SERVER_HEALTH_PATH", "/custom_health");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_HTTP_SERVER_ENABLED");
env::remove_var("INKLOG_HTTP_SERVER_METRICS_PATH");
env::remove_var("INKLOG_HTTP_SERVER_HEALTH_PATH");
let http = config.http_server.expect("http_server should be Some");
assert_eq!(http.metrics_path, "/custom_metrics");
assert_eq!(http.health_path, "/custom_health");
}
#[test]
#[serial]
fn test_load_with_env_overrides_http_error_mode_unknown_keeps_default() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_HTTP_SERVER_ENABLED", "true");
env::set_var("INKLOG_HTTP_SERVER_ERROR_MODE", "unknown_mode");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_HTTP_SERVER_ENABLED");
env::remove_var("INKLOG_HTTP_SERVER_ERROR_MODE");
let http = config.http_server.expect("http_server should be Some");
assert!(
matches!(http.error_mode, HttpErrorMode::Strict),
"unknown mode should keep default Strict"
);
}
#[test]
#[serial]
fn test_load_with_env_overrides_performance_worker_threads() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_PERFORMANCE_WORKER_THREADS", "8");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_PERFORMANCE_WORKER_THREADS");
assert_eq!(config.performance.worker_threads, 8);
}
#[test]
#[serial]
fn test_load_with_env_overrides_performance_worker_threads_invalid_ignored() {
env::remove_var("INKLOG_CONFIG_PATH");
env::set_var("INKLOG_PERFORMANCE_WORKER_THREADS", "not_a_number");
let config = InklogConfig::load_with_env_overrides().expect("should load");
env::remove_var("INKLOG_PERFORMANCE_WORKER_THREADS");
assert_eq!(
config.performance.worker_threads,
PerformanceConfig::default().worker_threads
);
}
#[test]
fn test_database_driver_from_str_postgres() {
let driver: DatabaseDriver = "postgres".parse().expect("should parse");
assert_eq!(driver, DatabaseDriver::PostgreSQL);
let driver: DatabaseDriver = "PostgreSQL".parse().expect("should parse case-insensitive");
assert_eq!(driver, DatabaseDriver::PostgreSQL);
}
#[test]
fn test_database_driver_from_str_mysql() {
let driver: DatabaseDriver = "mysql".parse().expect("should parse");
assert_eq!(driver, DatabaseDriver::MySQL);
}
#[test]
fn test_database_driver_from_str_sqlite() {
let driver: DatabaseDriver = "sqlite".parse().expect("should parse");
assert_eq!(driver, DatabaseDriver::SQLite);
let driver: DatabaseDriver = "sqlite3".parse().expect("should parse");
assert_eq!(driver, DatabaseDriver::SQLite);
}
#[test]
fn test_database_driver_from_str_invalid() {
let result: Result<DatabaseDriver, _> = "oracle".parse();
assert!(result.is_err());
}
#[test]
fn test_database_driver_display() {
assert_eq!(format!("{}", DatabaseDriver::PostgreSQL), "postgres");
assert_eq!(format!("{}", DatabaseDriver::MySQL), "mysql");
assert_eq!(format!("{}", DatabaseDriver::SQLite), "sqlite");
}
#[test]
fn test_partition_strategy_from_str_monthly() {
let s: PartitionStrategy = "monthly".parse().expect("should parse");
assert_eq!(s, PartitionStrategy::Monthly);
let s: PartitionStrategy = "month".parse().expect("should parse");
assert_eq!(s, PartitionStrategy::Monthly);
}
#[test]
fn test_partition_strategy_from_str_yearly() {
let s: PartitionStrategy = "yearly".parse().expect("should parse");
assert_eq!(s, PartitionStrategy::Yearly);
let s: PartitionStrategy = "year".parse().expect("should parse");
assert_eq!(s, PartitionStrategy::Yearly);
}
#[test]
fn test_partition_strategy_from_str_invalid() {
let result: Result<PartitionStrategy, String> = "weekly".parse();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown partition strategy"));
}
#[test]
fn test_partition_strategy_display() {
assert_eq!(format!("{}", PartitionStrategy::Monthly), "monthly");
assert_eq!(format!("{}", PartitionStrategy::Yearly), "yearly");
}
#[test]
fn test_channel_strategy_from_str_fixed() {
let s: ChannelStrategy = "fixed".parse().expect("should parse");
assert_eq!(s, ChannelStrategy::Fixed);
}
#[test]
fn test_channel_strategy_from_str_adaptive() {
let s: ChannelStrategy = "adaptive".parse().expect("should parse");
assert_eq!(s, ChannelStrategy::Adaptive);
}
#[test]
fn test_channel_strategy_from_str_invalid() {
let result: Result<ChannelStrategy, String> = "dynamic".parse();
assert!(result.is_err());
assert!(result.unwrap_err().contains("Unknown channel strategy"));
}
#[test]
fn test_channel_strategy_display() {
assert_eq!(format!("{}", ChannelStrategy::Fixed), "fixed");
assert_eq!(format!("{}", ChannelStrategy::Adaptive), "adaptive");
}
#[test]
fn test_http_auth_config_default() {
let auth = HttpAuthConfig::default();
assert!(!auth.enabled);
assert_eq!(auth.token_env, "INKLOG_HTTP_AUTH_TOKEN");
}
#[test]
#[serial]
fn test_from_search_paths_unreadable_file_errors() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().expect("failed to create tempdir");
let config_path = dir.path().join("unreadable.toml");
std::fs::write(&config_path, "[global]\nlevel = \"info\"\n").expect("failed to write");
let mut perms = std::fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&config_path, perms).unwrap();
env::set_var("INKLOG_CONFIG_PATH", config_path.to_str().unwrap());
let result = InklogConfig::from_search_paths();
env::remove_var("INKLOG_CONFIG_PATH");
let mut perms = std::fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o644);
let _ = std::fs::set_permissions(&config_path, perms);
if result.is_ok() {
eprintln!("Skipping: running as root, file is readable despite 0o000 permissions");
return;
}
let err = result.unwrap_err();
assert!(
err.to_string().contains("Failed to read config file"),
"error should mention read failure, got: {err}"
);
}
}