use std::{fmt, path::PathBuf};
use serde::Deserialize;
use crate::core::logging::{
LogConfig, LogFormat, LogWriterConfig, RollingFileConfig, RotationPolicy,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigFeatureWarning {
pub option: &'static str,
pub required_feature: &'static str,
pub message: String,
}
impl ConfigFeatureWarning {
pub fn ignored(option: &'static str, required_feature: &'static str) -> Self {
Self {
option,
required_feature,
message: format!(
"config option `{option}` requires Cargo feature `{required_feature}`; it will be ignored by this build"
),
}
}
}
impl fmt::Display for ConfigFeatureWarning {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.message)
}
}
pub fn emit_config_warnings(warnings: &[ConfigFeatureWarning]) {
for warning in warnings {
tracing::warn!(
target: "rs_zero::core::config",
option = warning.option,
required_feature = warning.required_feature,
"{}",
warning.message
);
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct ServiceConfig {
pub name: String,
pub mode: String,
pub log: LogSection,
}
impl Default for ServiceConfig {
fn default() -> Self {
Self {
name: "rs-zero".to_string(),
mode: "pro".to_string(),
log: LogSection::default(),
}
}
}
impl ServiceConfig {
pub fn log_config(&self) -> LogConfig {
self.log.to_log_config(&self.name)
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LogMode {
#[default]
Console,
File,
Volume,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum LogEncoding {
#[default]
Plain,
Json,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct LogSection {
pub mode: LogMode,
pub encoding: LogEncoding,
pub level: String,
pub path: PathBuf,
pub rotation: RotationPolicy,
pub compress: bool,
pub keep_days: u64,
pub max_backups: usize,
pub max_size_mb: u64,
pub ansi: bool,
}
impl Default for LogSection {
fn default() -> Self {
Self {
mode: LogMode::Console,
encoding: LogEncoding::Plain,
level: "info".to_string(),
path: PathBuf::from("logs"),
rotation: RotationPolicy::Daily,
compress: false,
keep_days: 0,
max_backups: 0,
max_size_mb: 0,
ansi: true,
}
}
}
impl LogSection {
pub fn to_log_config(&self, service_name: &str) -> LogConfig {
let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| self.level.clone());
let format = match self.encoding {
LogEncoding::Plain => LogFormat::Text,
LogEncoding::Json => LogFormat::Json,
};
let writer = match self.mode {
LogMode::Console => LogWriterConfig::Stdout,
LogMode::File => LogWriterConfig::RollingFile(self.rolling_file_config(service_name)),
LogMode::Volume => LogWriterConfig::RollingFile(
self.rolling_file_config(&volume_log_name(service_name)),
),
};
LogConfig {
filter,
ansi: self.ansi && matches!(self.mode, LogMode::Console),
format,
service: Some(service_name.to_string()),
writer,
..LogConfig::default()
}
}
fn rolling_file_config(&self, service_name: &str) -> RollingFileConfig {
let max_bytes = match self.rotation {
RotationPolicy::Size => Some(mib_to_bytes(if self.max_size_mb == 0 {
100
} else {
self.max_size_mb
})),
RotationPolicy::Daily => None,
};
RollingFileConfig {
path: self.path.join(format!("{service_name}.log")),
rotation: self.rotation,
max_bytes,
max_files: self.max_backups,
keep_days: (self.keep_days > 0).then_some(self.keep_days),
compress: self.compress,
}
}
}
fn mib_to_bytes(value: u64) -> u64 {
value.saturating_mul(1024).saturating_mul(1024)
}
fn volume_log_name(service_name: &str) -> String {
let host = std::env::var("HOSTNAME")
.or_else(|_| std::env::var("COMPUTERNAME"))
.unwrap_or_else(|_| "localhost".to_string());
format!("{host}-{service_name}")
}
#[cfg(test)]
mod tests {
use super::{ConfigFeatureWarning, LogEncoding, LogMode, LogSection, RotationPolicy};
use crate::core::LogWriterConfig;
#[test]
fn feature_warning_formats_ignored_option() {
let warning = ConfigFeatureWarning::ignored("middlewares.metrics", "observability");
assert_eq!(warning.option, "middlewares.metrics");
assert_eq!(warning.required_feature, "observability");
assert!(warning.to_string().contains("will be ignored"));
}
#[test]
fn maps_file_log_section_to_runtime_config() {
let section = LogSection {
mode: LogMode::File,
encoding: LogEncoding::Json,
path: "target/test-logs".into(),
rotation: RotationPolicy::Size,
max_size_mb: 1,
max_backups: 2,
compress: true,
..LogSection::default()
};
let config = section.to_log_config("svc");
assert_eq!(config.service.as_deref(), Some("svc"));
assert_eq!(config.format, crate::core::LogFormat::Json);
match config.writer {
LogWriterConfig::RollingFile(rolling) => {
assert_eq!(
rolling.path,
std::path::PathBuf::from("target/test-logs/svc.log")
);
assert_eq!(rolling.max_bytes, Some(1024 * 1024));
assert_eq!(rolling.max_files, 2);
assert!(rolling.compress);
}
other => panic!("unexpected writer: {other:?}"),
}
}
}