use crate::config::Config;
use crate::paths::AppPaths;
use std::fs::{File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use tracing_subscriber::EnvFilter;
const LOG_FILE_NAME: &str = "larust.log";
pub(crate) fn init(config: &Config, paths: &AppPaths) {
let filter = build_filter(config);
match config.log_channel.as_str() {
"file" => match RotatingFileWriter::open(paths.logs().join(LOG_FILE_NAME), config) {
Ok(writer) => {
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_writer(move || writer.clone())
.try_init();
}
Err(error) => {
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
warn_log_file_open_failed(&error);
}
},
"stack" => match RotatingFileWriter::open(paths.logs().join(LOG_FILE_NAME), config) {
Ok(file) => {
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_writer(move || TeeWriter { file: file.clone() })
.try_init();
}
Err(error) => {
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
warn_log_file_open_failed(&error);
}
},
_ => {
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
}
}
}
fn warn_log_file_open_failed(error: &io::Error) {
tracing::warn!(
%error,
"couldn't open the log file - falling back to stdout only for this process; \
check storage/logs/ exists and is writable"
);
}
fn build_filter(config: &Config) -> EnvFilter {
if let Ok(filter) = EnvFilter::try_from_default_env() {
return filter;
}
if !config.log_level.is_empty() {
return EnvFilter::new(format!(
"{},sqlx=warn,tower_sessions=warn",
config.log_level
));
}
EnvFilter::new(if config.app_env == "local" {
"debug,sqlx=warn,tower_sessions=warn"
} else {
"info"
})
}
struct TeeWriter {
file: RotatingFileWriter,
}
impl Write for TeeWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let _ = io::stdout().write_all(buf);
self.file.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
let _ = io::stdout().flush();
self.file.flush()
}
}
#[derive(Clone)]
struct RotatingFileWriter(Arc<Mutex<RotatingFile>>);
impl RotatingFileWriter {
fn open(path: PathBuf, config: &Config) -> io::Result<Self> {
let file = RotatingFile::open(path, config.log_max_size, config.log_keep_files)?;
Ok(Self(Arc::new(Mutex::new(file))))
}
}
impl Write for RotatingFileWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.flush()
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RotatingFileWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
struct RotatingFile {
path: PathBuf,
max_size: u64,
keep_files: u32,
file: File,
current_size: u64,
}
impl RotatingFile {
fn open(path: PathBuf, max_size: u64, keep_files: u32) -> io::Result<Self> {
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();
Ok(Self {
path,
max_size,
keep_files,
file,
current_size,
})
}
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if self.current_size.saturating_add(buf.len() as u64) > self.max_size {
let _ = rotate(&self.path, self.keep_files).and_then(|()| {
self.file = OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&self.path)?;
self.current_size = 0;
Ok(())
});
}
let written = self.file.write(buf)?;
self.current_size += written as u64;
Ok(written)
}
fn flush(&mut self) -> io::Result<()> {
self.file.flush()
}
}
fn backup_path(path: &Path, generation: u32) -> PathBuf {
let mut name = path.as_os_str().to_os_string();
name.push(format!(".{generation}"));
PathBuf::from(name)
}
fn rotate(path: &Path, keep_files: u32) -> io::Result<()> {
if keep_files == 0 {
let _ = std::fs::remove_file(path);
return Ok(());
}
let _ = std::fs::remove_file(backup_path(path, keep_files));
for generation in (1..keep_files).rev() {
let from = backup_path(path, generation);
if from.exists() {
std::fs::rename(&from, backup_path(path, generation + 1))?;
}
}
std::fs::rename(path, backup_path(path, 1))
}
#[cfg(test)]
mod tests {
use super::*;
fn config_with(app_env: &str, log_level: &str) -> Config {
let mut config = Config::from_value(&serde_json::json!({})).unwrap();
config.app_env = app_env.to_string();
config.log_level = log_level.to_string();
config
}
fn directives(filter: &EnvFilter) -> Vec<String> {
filter.to_string().split(',').map(str::to_string).collect()
}
#[test]
fn build_filter_falls_back_to_the_legacy_local_default_with_no_log_level_set() {
let filter = build_filter(&config_with("local", ""));
let directives = directives(&filter);
for expected in ["debug", "sqlx=warn", "tower_sessions=warn"] {
assert!(
directives.contains(&expected.to_string()),
"expected {expected:?} in {directives:?}"
);
}
}
#[test]
fn build_filter_falls_back_to_info_outside_local_with_no_log_level_set() {
let filter = build_filter(&config_with("production", ""));
assert_eq!(filter.to_string(), "info");
}
#[test]
fn build_filter_uses_log_level_and_still_dampens_the_noisy_crates() {
let filter = build_filter(&config_with("production", "trace"));
let directives = directives(&filter);
for expected in ["trace", "sqlx=warn", "tower_sessions=warn"] {
assert!(
directives.contains(&expected.to_string()),
"expected {expected:?} in {directives:?}"
);
}
}
#[test]
fn backup_path_appends_a_generation_suffix_without_disturbing_the_real_extension() {
let path = PathBuf::from("/tmp/storage/logs/larust.log");
assert_eq!(
backup_path(&path, 1),
PathBuf::from("/tmp/storage/logs/larust.log.1")
);
assert_eq!(
backup_path(&path, 12),
PathBuf::from("/tmp/storage/logs/larust.log.12")
);
}
#[test]
fn rotating_file_rotates_once_max_size_is_exceeded() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("larust.log");
let mut file = RotatingFile::open(path.clone(), 10, 3).unwrap();
file.write(b"0123456789").unwrap(); assert!(!backup_path(&path, 1).exists());
file.write(b"more").unwrap(); assert!(backup_path(&path, 1).exists());
assert_eq!(std::fs::read(&path).unwrap(), b"more");
assert_eq!(std::fs::read(backup_path(&path, 1)).unwrap(), b"0123456789");
}
#[test]
fn rotating_file_shifts_backups_up_a_generation_each_time() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("larust.log");
let mut file = RotatingFile::open(path.clone(), 4, 3).unwrap();
file.write(b"aaaaa").unwrap(); file.write(b"bbbbb").unwrap();
assert_eq!(std::fs::read(backup_path(&path, 1)).unwrap(), b"aaaaa");
assert_eq!(std::fs::read(&path).unwrap(), b"bbbbb");
}
#[test]
fn rotating_file_deletes_outright_when_keep_files_is_zero() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("larust.log");
let mut file = RotatingFile::open(path.clone(), 4, 0).unwrap();
file.write(b"aaaaa").unwrap();
file.write(b"bbbbb").unwrap();
assert!(!backup_path(&path, 1).exists());
assert_eq!(std::fs::read(&path).unwrap(), b"bbbbb");
}
#[test]
fn rotating_file_never_keeps_more_backups_than_configured() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("larust.log");
let mut file = RotatingFile::open(path.clone(), 2, 2).unwrap();
for chunk in [b"aa", b"bb", b"cc", b"dd"] {
file.write(chunk).unwrap();
}
assert!(backup_path(&path, 1).exists());
assert!(backup_path(&path, 2).exists());
assert!(!backup_path(&path, 3).exists());
}
}