use std::path::{Path, PathBuf};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::EnvFilter;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
Pretty,
Json,
}
pub fn parse_log_format(value: Option<&str>) -> LogFormat {
match value {
Some(v) if v.eq_ignore_ascii_case("json") => LogFormat::Json,
_ => LogFormat::Pretty,
}
}
pub fn parse_rotation(value: Option<&str>) -> Rotation {
match value {
Some(v) if v.eq_ignore_ascii_case("hourly") => Rotation::HOURLY,
Some(v) if v.eq_ignore_ascii_case("never") => Rotation::NEVER,
_ => Rotation::DAILY,
}
}
pub fn log_dir_from_exe(exe_path: &Path) -> PathBuf {
exe_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("logs")
}
pub fn build_env_filter(level: Option<&str>) -> EnvFilter {
level
.and_then(|spec| EnvFilter::try_new(spec).ok())
.unwrap_or_else(|| EnvFilter::new("info"))
}
#[derive(Debug, Clone, PartialEq)]
pub struct LogSettings {
pub level: Option<String>,
pub dir: PathBuf,
pub format: LogFormat,
pub rotation: Rotation,
}
pub fn resolve_log_settings(
cli_level: Option<String>,
cli_dir: Option<PathBuf>,
cli_format: Option<String>,
cli_rotation: Option<String>,
config: &crate::config::LogConfig,
default_dir: &Path,
) -> LogSettings {
let level = cli_level.or_else(|| config.level.clone());
let dir = cli_dir
.or_else(|| config.dir.clone().map(PathBuf::from))
.unwrap_or_else(|| default_dir.to_path_buf());
let format = parse_log_format(cli_format.as_deref().or(config.format.as_deref()));
let rotation = parse_rotation(cli_rotation.as_deref().or(config.rotation.as_deref()));
LogSettings {
level,
dir,
format,
rotation,
}
}
pub fn init_tracing(settings: &LogSettings) -> anyhow::Result<WorkerGuard> {
use std::io::IsTerminal;
init_tracing_with_stdout(settings, std::io::stdout().is_terminal())
}
fn init_tracing_with_stdout(
settings: &LogSettings,
attach_stdout: bool,
) -> anyhow::Result<WorkerGuard> {
use tracing_subscriber::Layer;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
std::fs::create_dir_all(&settings.dir)?;
let file_appender = RollingFileAppender::new(
settings.rotation.clone(),
&settings.dir,
"opcda-bridge-gateway.log",
);
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let filter = build_env_filter(settings.level.as_deref());
let file_layer = match settings.format {
LogFormat::Json => tracing_subscriber::fmt::layer()
.json()
.with_writer(non_blocking)
.boxed(),
LogFormat::Pretty => tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_writer(non_blocking)
.boxed(),
};
let stdout_layer = attach_stdout.then(tracing_subscriber::fmt::layer);
tracing_subscriber::registry()
.with(filter)
.with(file_layer)
.with(stdout_layer)
.try_init()?;
Ok(guard)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_log_format_json() {
assert_eq!(parse_log_format(Some("json")), LogFormat::Json);
assert_eq!(parse_log_format(Some("JSON")), LogFormat::Json);
}
#[test]
fn test_parse_log_format_pretty() {
assert_eq!(parse_log_format(Some("pretty")), LogFormat::Pretty);
}
#[test]
fn test_parse_log_format_unknown_defaults_to_pretty() {
assert_eq!(parse_log_format(Some("yaml")), LogFormat::Pretty);
}
#[test]
fn test_parse_log_format_none_defaults_to_pretty() {
assert_eq!(parse_log_format(None), LogFormat::Pretty);
}
#[test]
fn test_parse_rotation_hourly() {
assert_eq!(parse_rotation(Some("hourly")), Rotation::HOURLY);
assert_eq!(parse_rotation(Some("HOURLY")), Rotation::HOURLY);
}
#[test]
fn test_parse_rotation_never() {
assert_eq!(parse_rotation(Some("never")), Rotation::NEVER);
}
#[test]
fn test_parse_rotation_daily() {
assert_eq!(parse_rotation(Some("daily")), Rotation::DAILY);
}
#[test]
fn test_parse_rotation_unknown_defaults_to_daily() {
assert_eq!(parse_rotation(Some("weekly")), Rotation::DAILY);
}
#[test]
fn test_parse_rotation_none_defaults_to_daily() {
assert_eq!(parse_rotation(None), Rotation::DAILY);
}
#[test]
fn test_log_dir_from_exe_with_parent() {
let dir = log_dir_from_exe(Path::new("/usr/local/bin/opcda-bridge-gateway"));
assert_eq!(dir, PathBuf::from("/usr/local/bin/logs"));
}
#[test]
fn test_log_dir_from_exe_no_parent() {
let dir = log_dir_from_exe(Path::new("/"));
assert_eq!(dir, PathBuf::from("./logs"));
}
#[test]
fn test_build_env_filter_explicit_level() {
let filter = build_env_filter(Some("debug"));
assert!(format!("{filter}").contains("debug"));
}
#[test]
fn test_build_env_filter_invalid_falls_back_to_info() {
let filter = build_env_filter(Some("level=notalevel"));
assert!(format!("{filter}").contains("info"));
}
#[test]
fn test_build_env_filter_none_defaults_to_info() {
let filter = build_env_filter(None);
assert!(format!("{filter}").contains("info"));
}
#[test]
fn test_resolve_log_settings_cli_wins() {
let config = crate::config::LogConfig {
level: Some("warn".to_string()),
dir: Some("/config/dir".to_string()),
format: Some("json".to_string()),
rotation: Some("hourly".to_string()),
};
let settings = resolve_log_settings(
Some("debug".to_string()),
Some(PathBuf::from("/cli/dir")),
Some("pretty".to_string()),
Some("never".to_string()),
&config,
Path::new("/default/dir"),
);
assert_eq!(settings.level, Some("debug".to_string()));
assert_eq!(settings.dir, PathBuf::from("/cli/dir"));
assert_eq!(settings.format, LogFormat::Pretty);
assert_eq!(settings.rotation, Rotation::NEVER);
}
#[test]
fn test_resolve_log_settings_config_wins_over_default() {
let config = crate::config::LogConfig {
level: Some("warn".to_string()),
dir: Some("/config/dir".to_string()),
format: Some("json".to_string()),
rotation: Some("hourly".to_string()),
};
let settings =
resolve_log_settings(None, None, None, None, &config, Path::new("/default/dir"));
assert_eq!(settings.level, Some("warn".to_string()));
assert_eq!(settings.dir, PathBuf::from("/config/dir"));
assert_eq!(settings.format, LogFormat::Json);
assert_eq!(settings.rotation, Rotation::HOURLY);
}
#[test]
fn test_resolve_log_settings_defaults() {
let settings = resolve_log_settings(
None,
None,
None,
None,
&crate::config::LogConfig::default(),
Path::new("/default/dir"),
);
assert_eq!(settings.level, None);
assert_eq!(settings.dir, PathBuf::from("/default/dir"));
assert_eq!(settings.format, LogFormat::Pretty);
assert_eq!(settings.rotation, Rotation::DAILY);
}
#[test]
fn test_init_tracing_with_stdout_covers_json_and_pretty_layers() {
let dir = tempfile::tempdir().unwrap();
let json_settings = LogSettings {
level: Some("debug".to_string()),
dir: dir.path().to_path_buf(),
format: LogFormat::Json,
rotation: Rotation::NEVER,
};
let pretty_settings = LogSettings {
level: None,
dir: dir.path().to_path_buf(),
format: LogFormat::Pretty,
rotation: Rotation::DAILY,
};
let _ = init_tracing_with_stdout(&json_settings, true);
let _ = init_tracing_with_stdout(&pretty_settings, false);
}
#[test]
fn test_init_tracing_wrapper_detects_terminal() {
let dir = tempfile::tempdir().unwrap();
let settings = LogSettings {
level: None,
dir: dir.path().to_path_buf(),
format: LogFormat::Pretty,
rotation: Rotation::NEVER,
};
let _ = init_tracing(&settings);
}
}