use std::path::Path;
use serde::{Deserialize, Serialize};
use tracing_appender::non_blocking::DEFAULT_BUFFERED_LINES_LIMIT;
pub const DEFAULT_BUFFER_SIZE_LINES: usize = DEFAULT_BUFFERED_LINES_LIMIT;
const MAX_BUFFER_SIZE_LINES: u32 = 10_000_000;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct LoggingConfig {
pub output: LogOutput,
pub file_path: Option<String>,
#[serde(default = "default_non_blocking")]
pub non_blocking: bool,
pub buffer_size: Option<u32>,
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
output: LogOutput::default(),
file_path: None,
non_blocking: default_non_blocking(),
buffer_size: None,
}
}
}
const fn default_non_blocking() -> bool {
true
}
impl LoggingConfig {
pub fn validate(&self) -> Result<(), String> {
if let Some(buffer_size) = self.buffer_size {
if buffer_size == 0 {
return Err("runtime.logging.buffer_size must be > 0 when set".to_owned());
}
if buffer_size > MAX_BUFFER_SIZE_LINES {
return Err(format!(
"runtime.logging.buffer_size ({buffer_size}) exceeds maximum ({MAX_BUFFER_SIZE_LINES})"
));
}
}
match self.output {
LogOutput::Stdout | LogOutput::Stderr => {
if self.file_path.is_some() {
return Err("runtime.logging.file_path is only valid when output is file".to_owned());
}
},
LogOutput::File => {
let Some(path) = self.file_path.as_deref() else {
return Err("runtime.logging.file_path is required when output is file".to_owned());
};
if path.is_empty() {
return Err("runtime.logging.file_path must not be empty".to_owned());
}
if Path::new(path).file_name().is_none() {
return Err(format!("runtime.logging.file_path '{path}' must include a file name"));
}
},
}
Ok(())
}
#[must_use]
pub fn effective_buffer_size_lines(&self) -> usize {
self.buffer_size.map_or(DEFAULT_BUFFER_SIZE_LINES, |n| n as usize)
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum LogOutput {
#[default]
Stdout,
Stderr,
File,
}
#[cfg(test)]
#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
#[allow(clippy::unwrap_used, clippy::expect_used, reason = "tests")]
mod tests {
use super::*;
#[test]
fn defaults_match_proposal() {
let cfg = LoggingConfig::default();
assert_eq!(cfg.output, LogOutput::Stdout);
assert!(cfg.file_path.is_none());
assert!(cfg.non_blocking);
assert!(cfg.buffer_size.is_none());
assert_eq!(cfg.effective_buffer_size_lines(), DEFAULT_BUFFER_SIZE_LINES);
}
#[test]
fn file_path_required_for_file_output() {
let cfg = LoggingConfig {
output: LogOutput::File,
..LoggingConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("file_path is required"), "{err}");
}
#[test]
fn buffer_size_zero_rejected() {
let cfg = LoggingConfig {
buffer_size: Some(0),
..LoggingConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("buffer_size must be > 0"), "{err}");
}
#[test]
fn buffer_size_exceeding_maximum_rejected() {
let cfg = LoggingConfig {
buffer_size: Some(u32::MAX),
..LoggingConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("exceeds maximum"), "{err}");
}
#[test]
fn buffer_size_at_maximum_accepted() {
let cfg = LoggingConfig {
buffer_size: Some(MAX_BUFFER_SIZE_LINES),
..LoggingConfig::default()
};
cfg.validate().unwrap();
}
#[test]
fn empty_file_path_rejected() {
let cfg = LoggingConfig {
output: LogOutput::File,
file_path: Some(String::new()),
..LoggingConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("file_path must not be empty"), "{err}");
}
#[test]
fn file_path_rejected_for_stdout() {
let cfg = LoggingConfig {
file_path: Some("/tmp/praxis.log".to_owned()),
..LoggingConfig::default()
};
let err = cfg.validate().unwrap_err();
assert!(err.contains("file_path is only valid when output is file"), "{err}");
}
}