use super::global::default_true;
use crate::support::processing::template::OutputFormat;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[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", alias = "pii_masking_enabled")]
pub masking_enabled: bool,
#[serde(default)]
pub output_format: OutputFormat,
}
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(),
output_format: OutputFormat::default(),
}
}
}
impl FileSinkConfig {
pub fn validate(&self) -> Result<(), String> {
if self.compression_level < 1 || self.compression_level > 22 {
return Err(format!(
"compression_level must be between 1 and 22, got {}",
self.compression_level
));
}
parse_config_size(&self.max_size)
.map_err(|e| format!("max_size \"{}\": {e}", self.max_size))?;
parse_config_size(&self.max_total_size)
.map_err(|e| format!("max_total_size \"{}\": {e}", self.max_total_size))?;
if self.encrypt {
match self.encryption_key_env.as_ref() {
None => {
return Err("encrypt is enabled but encryption_key_env is not set".to_string());
}
Some(env_name) if std::env::var(env_name).is_err() => {
return Err(format!(
"encrypt is enabled but environment variable \"{env_name}\" is not set"
));
}
Some(_) => {}
}
}
Ok(())
}
}
fn parse_config_size(size_str: &str) -> Result<u64, String> {
let size_str = size_str.trim().to_uppercase();
let (multiplier, suffix_len): (u64, usize) = if size_str.ends_with("TB") {
(1024 * 1024 * 1024 * 1024, 2)
} else if size_str.ends_with("GB") {
(1024 * 1024 * 1024, 2)
} else if size_str.ends_with("MB") {
(1024 * 1024, 2)
} else if size_str.ends_with("KB") {
(1024, 2)
} else if size_str.ends_with("B") {
(1, 1)
} else {
(1, 0)
};
let num_str = &size_str[..size_str.len() - suffix_len];
let num: u64 = num_str
.parse()
.map_err(|_| format!("\"{num_str}\" is not a valid size number"))?;
num.checked_mul(multiplier)
.ok_or_else(|| format!("\"{size_str}\" overflows u64"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_rejects_invalid_compression_level() {
let mut config = FileSinkConfig::default();
config.compression_level = 0;
assert!(config.validate().is_err());
config.compression_level = 23;
assert!(config.validate().is_err());
config.compression_level = 3;
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_rejects_encrypt_without_key_env() {
let mut config = FileSinkConfig::default();
config.encrypt = true;
config.encryption_key_env = None;
assert!(config.validate().is_err());
unsafe { std::env::remove_var("INKLOG_TEST_FILE_SINK_KEY_ENV") };
config.encryption_key_env = Some("INKLOG_TEST_FILE_SINK_KEY_ENV".to_string());
assert!(config.validate().is_err());
unsafe { std::env::set_var("INKLOG_TEST_FILE_SINK_KEY_ENV", "test-key") };
assert!(config.validate().is_ok());
unsafe { std::env::remove_var("INKLOG_TEST_FILE_SINK_KEY_ENV") };
}
#[test]
fn test_validate_rejects_invalid_size_strings() {
let mut config = FileSinkConfig::default();
config.max_size = "INVALID".to_string();
assert!(config.validate().is_err());
config.max_size = String::new();
assert!(config.validate().is_err());
config.max_size = "10XB".to_string();
assert!(config.validate().is_err());
config.max_size = "100MB".to_string();
config.max_total_size = "no-size".to_string();
assert!(config.validate().is_err());
config.max_total_size = "1GB".to_string();
assert!(config.validate().is_ok());
}
#[test]
fn test_validate_accepts_valid_size_strings() {
let mut config = FileSinkConfig::default();
for size in ["100", "512KB", "100MB", "1GB", "2TB", " 5MB "] {
config.max_size = size.to_string();
config.max_total_size = size.to_string();
assert!(
config.validate().is_ok(),
"size \"{size}\" should be accepted"
);
}
}
#[test]
fn test_parse_config_size() {
assert_eq!(parse_config_size("100").unwrap(), 100);
assert_eq!(parse_config_size("1KB").unwrap(), 1024);
assert_eq!(parse_config_size("100MB").unwrap(), 100 * 1024 * 1024);
assert_eq!(parse_config_size("1GB").unwrap(), 1024 * 1024 * 1024);
assert_eq!(parse_config_size(" 5MB ").unwrap(), 5 * 1024 * 1024);
assert!(parse_config_size("invalid").is_err());
assert!(parse_config_size("").is_err());
assert!(parse_config_size("99999999999999999999TB").is_err());
}
#[test]
fn test_deserialize_masking_enabled_legacy_and_alias_keys() {
let legacy: FileSinkConfig = toml::from_str("masking_enabled = false\n").unwrap();
assert!(!legacy.masking_enabled);
let renamed: FileSinkConfig = toml::from_str("pii_masking_enabled = false\n").unwrap();
assert!(!renamed.masking_enabled);
}
#[test]
fn test_serialize_emits_legacy_masking_enabled_key() {
let cfg = FileSinkConfig {
masking_enabled: false,
..Default::default()
};
let rendered = toml::to_string(&cfg).unwrap();
assert!(
rendered.contains("masking_enabled = false"),
"serialized TOML must keep the legacy key, got: {rendered}"
);
assert!(
!rendered.contains("pii_masking_enabled"),
"alias must not be emitted on serialization, got: {rendered}"
);
}
}