use std::path::{Path, PathBuf};
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
pub const DEFAULT_MAX_LOG_FILES: usize = 14;
#[derive(Debug, Clone)]
pub struct LogOptions {
pub dir: PathBuf,
pub file_name_prefix: String,
pub max_files: usize,
pub default_level: String,
}
impl LogOptions {
pub fn new(dir: impl Into<PathBuf>) -> Self {
Self {
dir: dir.into(),
file_name_prefix: "bamboo".to_string(),
max_files: DEFAULT_MAX_LOG_FILES,
default_level: "info".to_string(),
}
}
}
pub fn init_logging_with_home(home: &Path, debug: bool) {
init_logging_with_options(options_for_home(home, debug));
}
fn options_for_home(home: &Path, debug: bool) -> LogOptions {
let mut opts = LogOptions::new(home.join("logs"));
opts.default_level = level_for(debug).to_string();
opts
}
fn build_appender(
opts: &LogOptions,
) -> Result<RollingFileAppender, tracing_appender::rolling::InitError> {
if let Err(e) = std::fs::create_dir_all(&opts.dir) {
eprintln!(
"warning: could not create log directory {}: {e}",
opts.dir.display()
);
}
RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(&opts.file_name_prefix)
.filename_suffix("log")
.max_log_files(opts.max_files)
.build(&opts.dir)
}
pub fn init_logging_with_options(opts: LogOptions) {
let default_level = opts.default_level.clone();
let make_filter = move || {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level.clone()))
};
match build_appender(&opts) {
Ok(file_writer) => {
let stdout_layer = fmt::layer().with_target(true);
let file_layer = fmt::layer()
.with_target(true)
.with_ansi(false)
.with_writer(file_writer);
let _ = tracing_subscriber::registry()
.with(make_filter())
.with(stdout_layer)
.with(file_layer)
.try_init();
}
Err(e) => {
eprintln!("warning: file logging disabled ({e}); using stdout only");
let _ = fmt()
.with_target(true)
.with_env_filter(make_filter())
.try_init();
}
}
}
pub fn init_logging(debug: bool) {
let _ = fmt()
.with_target(true)
.with_env_filter(
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new(level_for(debug))),
)
.try_init();
}
fn level_for(debug: bool) -> &'static str {
if debug {
"debug"
} else {
"info"
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
use tracing_subscriber::fmt::MakeWriter;
#[test]
fn level_for_maps_build_profile() {
assert_eq!(level_for(true), "debug");
assert_eq!(level_for(false), "info");
}
#[test]
fn log_options_new_uses_shared_defaults() {
let opts = LogOptions::new("/tmp/example");
assert_eq!(opts.dir, PathBuf::from("/tmp/example"));
assert_eq!(opts.file_name_prefix, "bamboo");
assert_eq!(opts.max_files, DEFAULT_MAX_LOG_FILES);
assert_eq!(opts.default_level, "info");
}
#[test]
fn options_for_home_places_logs_under_home_and_sets_level() {
let debug = options_for_home(Path::new("/srv/data"), true);
assert_eq!(debug.dir, PathBuf::from("/srv/data/logs"));
assert_eq!(debug.default_level, "debug");
let release = options_for_home(Path::new("/srv/data"), false);
assert_eq!(release.default_level, "info");
}
#[test]
fn build_appender_creates_dir_and_writes_dated_file() {
let tmp = tempdir().expect("tempdir");
let dir = tmp.path().join("nested").join("logs");
let opts = LogOptions {
dir: dir.clone(),
file_name_prefix: "unit-test".to_string(),
max_files: 5,
default_level: "info".to_string(),
};
let appender = build_appender(&opts).expect("appender builds");
assert!(dir.exists(), "log directory should be created");
{
let mut writer = appender.make_writer();
writeln!(writer, "hello-from-test").expect("write line");
writer.flush().expect("flush");
}
drop(appender);
let entries: Vec<_> = std::fs::read_dir(&dir)
.expect("read log dir")
.filter_map(Result::ok)
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(entries.len(), 1, "exactly one log file, got {entries:?}");
let name = &entries[0];
assert!(
name.starts_with("unit-test.") && name.ends_with(".log"),
"filename should be `<prefix>.<date>.log`, got {name}"
);
let contents =
std::fs::read_to_string(dir.join(name)).expect("read back log file contents");
assert!(
contents.contains("hello-from-test"),
"log file should contain the written line, got: {contents:?}"
);
}
#[test]
fn init_logging_with_options_creates_dir_and_is_idempotent() {
let tmp = tempdir().expect("tempdir");
let dir = tmp.path().join("logs");
let opts = LogOptions {
dir: dir.clone(),
file_name_prefix: "idem".to_string(),
max_files: 2,
default_level: "info".to_string(),
};
init_logging_with_options(opts.clone());
init_logging_with_options(opts);
assert!(dir.exists());
}
}