use std::fs::{File, OpenOptions};
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use tracing::debug;
pub struct LogfileBackend {
path: PathBuf,
writer: Mutex<BufWriter<File>>,
rotate_size: u64,
rotate_keep: usize,
bytes_written: Mutex<u64>,
}
impl LogfileBackend {
pub fn new(path: impl Into<PathBuf>, rotate_size: u64, rotate_keep: usize) -> io::Result<Self> {
let path = path.into();
let file = open_append(&path)?;
let initial_size = file.metadata().map(|m| m.len()).unwrap_or(0);
Ok(Self {
path,
writer: Mutex::new(BufWriter::new(file)),
rotate_size,
rotate_keep,
bytes_written: Mutex::new(initial_size),
})
}
pub fn write_line(&self, line: &str) -> io::Result<()> {
let mut writer = self.writer.lock().unwrap();
let mut bytes = self.bytes_written.lock().unwrap();
writer.write_all(line.as_bytes())?;
writer.write_all(b"\n")?;
writer.flush()?;
*bytes += line.len() as u64 + 1;
if self.rotate_size > 0 && *bytes >= self.rotate_size {
drop(writer);
drop(bytes);
self.rotate()?;
}
Ok(())
}
fn rotate(&self) -> io::Result<()> {
for i in (1..self.rotate_keep).rev() {
let from = rotated_path(&self.path, i);
let to = rotated_path(&self.path, i + 1);
if from.exists() {
std::fs::rename(&from, &to)?;
}
}
let oldest = rotated_path(&self.path, self.rotate_keep);
if oldest.exists() {
std::fs::remove_file(&oldest)?;
}
if self.path.exists() {
std::fs::rename(&self.path, rotated_path(&self.path, 1))?;
}
let file = open_append(&self.path)?;
let mut writer = self.writer.lock().unwrap();
*writer = BufWriter::new(file);
let mut bytes = self.bytes_written.lock().unwrap();
*bytes = 0;
debug!(path = %self.path.display(), "log file rotated");
Ok(())
}
}
fn rotated_path(base: &Path, n: usize) -> PathBuf {
let mut p = base.as_os_str().to_owned();
p.push(format!(".{n}"));
PathBuf::from(p)
}
fn open_append(path: &Path) -> io::Result<File> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
OpenOptions::new().create(true).append(true).open(path)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StdlogOutput {
Stdout,
Stderr,
}
pub struct StdlogBackend {
output: StdlogOutput,
json: bool,
}
impl StdlogBackend {
pub fn new(output: StdlogOutput, json: bool) -> Self {
Self { output, json }
}
pub fn write_entry(&self, level: &str, message: &str) {
let line = if self.json {
let ts = chrono_now();
format!(
r#"{{"timestamp":"{ts}","level":"{level}","message":{}}}"#,
json_escape(message)
)
} else {
format!("[{level}] {message}")
};
match self.output {
StdlogOutput::Stdout => {
let _ = writeln!(io::stdout(), "{line}");
}
StdlogOutput::Stderr => {
let _ = writeln!(io::stderr(), "{line}");
}
}
}
}
fn chrono_now() -> String {
use std::time::SystemTime;
let d = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = d.as_secs();
format!("{secs}")
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
out.push_str(&format!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logfile_write_and_rotate() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.log");
let backend = LogfileBackend::new(&path, 50, 3).unwrap();
for i in 0..10 {
backend
.write_line(&format!("line {i} with some padding"))
.unwrap();
}
assert!(path.exists());
assert!(rotated_path(&path, 1).exists());
}
#[test]
fn stdlog_json_format() {
let backend = StdlogBackend::new(StdlogOutput::Stderr, true);
backend.write_entry("info", "test message with \"quotes\"");
}
#[test]
fn json_escape_special_chars() {
assert_eq!(json_escape("hello"), "\"hello\"");
assert_eq!(json_escape("a\"b"), "\"a\\\"b\"");
assert_eq!(json_escape("a\nb"), "\"a\\nb\"");
}
}