use crate::SyslogFacility;
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub enum OutputConfig {
#[default]
Stdout,
Stderr,
File {
path: PathBuf,
rotation: Option<FileRotation>,
},
Syslog { facility: SyslogFacility },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileRotation {
pub max_size: u64,
pub max_files: u32,
pub compress: bool,
}
impl Default for FileRotation {
fn default() -> Self {
Self {
max_size: 10 * 1024 * 1024, max_files: 5,
compress: false,
}
}
}
impl FileRotation {
pub fn with_max_size_mb(mb: u64) -> Self {
Self {
max_size: mb * 1024 * 1024,
..Default::default()
}
}
pub fn max_files(mut self, count: u32) -> Self {
self.max_files = count;
self
}
pub fn compressed(mut self) -> Self {
self.compress = true;
self
}
}
pub trait LogOutput: Send {
fn write(&mut self, line: &str) -> io::Result<()>;
fn flush(&mut self) -> io::Result<()>;
}
pub struct StdoutOutput {
handle: io::Stdout,
}
impl StdoutOutput {
pub fn new() -> Self {
Self {
handle: io::stdout(),
}
}
}
impl Default for StdoutOutput {
fn default() -> Self {
Self::new()
}
}
impl LogOutput for StdoutOutput {
fn write(&mut self, line: &str) -> io::Result<()> {
writeln!(self.handle, "{}", line)
}
fn flush(&mut self) -> io::Result<()> {
self.handle.flush()
}
}
pub struct StderrOutput {
handle: io::Stderr,
}
impl StderrOutput {
pub fn new() -> Self {
Self {
handle: io::stderr(),
}
}
}
impl Default for StderrOutput {
fn default() -> Self {
Self::new()
}
}
impl LogOutput for StderrOutput {
fn write(&mut self, line: &str) -> io::Result<()> {
writeln!(self.handle, "{}", line)
}
fn flush(&mut self) -> io::Result<()> {
self.handle.flush()
}
}
pub struct FileOutput {
path: PathBuf,
writer: BufWriter<File>,
rotation: Option<FileRotation>,
current_size: u64,
}
impl FileOutput {
pub fn open(path: impl AsRef<Path>, rotation: Option<FileRotation>) -> io::Result<Self> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
let current_size = file.metadata()?.len();
let writer = BufWriter::new(file);
Ok(Self {
path,
writer,
rotation,
current_size,
})
}
fn maybe_rotate(&mut self) -> io::Result<()> {
let rotation = match &self.rotation {
Some(r) if self.current_size >= r.max_size => r.clone(),
_ => return Ok(()),
};
self.writer.flush()?;
for i in (1..rotation.max_files).rev() {
let old_path = rotated_path(&self.path, i);
let new_path = rotated_path(&self.path, i + 1);
if old_path.exists() {
if i + 1 >= rotation.max_files {
std::fs::remove_file(&old_path)?;
} else {
std::fs::rename(&old_path, &new_path)?;
}
}
}
let rotated = rotated_path(&self.path, 1);
std::fs::rename(&self.path, &rotated)?;
if rotation.compress {
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
self.writer = BufWriter::new(file);
self.current_size = 0;
Ok(())
}
}
impl LogOutput for FileOutput {
fn write(&mut self, line: &str) -> io::Result<()> {
self.maybe_rotate()?;
let bytes = line.as_bytes();
self.writer.write_all(bytes)?;
self.writer.write_all(b"\n")?;
self.current_size += bytes.len() as u64 + 1;
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
self.writer.flush()
}
}
fn rotated_path(base: &Path, index: u32) -> PathBuf {
let stem = base.file_stem().unwrap_or_default().to_string_lossy();
let ext = base
.extension()
.map(|e| e.to_string_lossy())
.unwrap_or_default();
let new_name = if ext.is_empty() {
format!("{}.{}", stem, index)
} else {
format!("{}.{}.{}", stem, index, ext)
};
base.with_file_name(new_name)
}
#[cfg(unix)]
pub struct SyslogOutput {
socket: std::os::unix::net::UnixDatagram,
}
#[cfg(unix)]
impl SyslogOutput {
pub fn connect() -> io::Result<Self> {
let socket = std::os::unix::net::UnixDatagram::unbound()?;
let paths = ["/dev/log", "/var/run/syslog", "/var/run/log"];
for path in &paths {
if std::path::Path::new(path).exists() {
socket.connect(path)?;
return Ok(Self { socket });
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"No syslog socket found",
))
}
}
#[cfg(unix)]
impl LogOutput for SyslogOutput {
fn write(&mut self, line: &str) -> io::Result<()> {
self.socket.send(line.as_bytes())?;
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(not(unix))]
pub struct SyslogOutput;
#[cfg(not(unix))]
impl SyslogOutput {
pub fn connect() -> io::Result<Self> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"Syslog not supported on this platform",
))
}
}
#[cfg(not(unix))]
impl LogOutput for SyslogOutput {
fn write(&mut self, _line: &str) -> io::Result<()> {
Ok(())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
pub fn create_output(config: &OutputConfig) -> io::Result<Box<dyn LogOutput>> {
match config {
OutputConfig::Stdout => Ok(Box::new(StdoutOutput::new())),
OutputConfig::Stderr => Ok(Box::new(StderrOutput::new())),
OutputConfig::File { path, rotation } => {
Ok(Box::new(FileOutput::open(path, rotation.clone())?))
}
OutputConfig::Syslog { .. } => Ok(Box::new(SyslogOutput::connect()?)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_stdout_output() {
let mut output = StdoutOutput::new();
output.write("test log line").unwrap();
output.flush().unwrap();
}
#[test]
fn test_file_output() {
let temp_dir = TempDir::new().unwrap();
let log_path = temp_dir.path().join("test.log");
let mut output = FileOutput::open(&log_path, None).unwrap();
output.write("line 1").unwrap();
output.write("line 2").unwrap();
output.flush().unwrap();
let content = std::fs::read_to_string(&log_path).unwrap();
assert!(content.contains("line 1"));
assert!(content.contains("line 2"));
}
#[test]
fn test_file_rotation() {
let temp_dir = TempDir::new().unwrap();
let log_path = temp_dir.path().join("test.log");
let rotation = FileRotation {
max_size: 50, max_files: 3,
compress: false,
};
let mut output = FileOutput::open(&log_path, Some(rotation)).unwrap();
for i in 0..10 {
output.write(&format!("This is line number {}", i)).unwrap();
}
output.flush().unwrap();
assert!(log_path.exists());
let rotated_1 = temp_dir.path().join("test.1.log");
assert!(rotated_1.exists());
}
#[test]
fn test_rotated_path() {
let base = Path::new("/var/log/hdds.log");
assert_eq!(rotated_path(base, 1), PathBuf::from("/var/log/hdds.1.log"));
assert_eq!(rotated_path(base, 5), PathBuf::from("/var/log/hdds.5.log"));
let no_ext = Path::new("/var/log/hdds");
assert_eq!(rotated_path(no_ext, 1), PathBuf::from("/var/log/hdds.1"));
}
}