use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use file_rotate::compression::Compression;
use file_rotate::suffix::AppendCount;
use file_rotate::{ContentLimit, FileRotate};
use tracing_appender::non_blocking::{NonBlocking, NonBlockingBuilder, WorkerGuard};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, fmt};
const DEFAULT_FILTER: &str = "hallouminate=info";
const LEGACY_LOG_PREFIX: &str = "hallouminate.";
const LEGACY_LOG_SUFFIX: &str = ".log";
pub fn init(config: &hallouminate_config::LoggingConfig) -> anyhow::Result<WorkerGuard> {
let (non_blocking, guard) = log_writer(&state_dir(), config)?;
let filter = EnvFilter::try_from_env("HALLOUMINATE_LOG")
.or_else(|_| EnvFilter::try_from_default_env())
.unwrap_or_else(|_| EnvFilter::new(DEFAULT_FILTER));
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(non_blocking).with_ansi(false))
.try_init()
.map_err(|e| anyhow::anyhow!("install tracing subscriber: {e}"))?;
Ok(guard)
}
fn log_writer(
dir: &Path,
config: &hallouminate_config::LoggingConfig,
) -> anyhow::Result<(NonBlocking, WorkerGuard)> {
let hallouminate_config::LoggingConfig {
max_file_bytes,
max_total_bytes,
} = *config;
let max_file_bytes = usize::try_from(max_file_bytes)
.map_err(|_| anyhow::anyhow!("logging.max_file_bytes exceeds this platform's usize"))?;
let total_slots = max_total_bytes / u64::try_from(max_file_bytes)?;
let rotated_files = usize::try_from(total_slots.saturating_sub(1))
.map_err(|_| anyhow::anyhow!("logging.max_total_bytes exceeds this platform's usize"))?;
std::fs::create_dir_all(dir)?;
reap_legacy_dated_logs(dir)?;
let appender = FileRotate::new(
dir.join("hallouminate.log"),
AppendCount::new(rotated_files),
ContentLimit::Bytes(max_file_bytes),
Compression::None,
None,
);
let (writer, guard) = NonBlockingBuilder::default().finish(appender);
Ok((writer, guard))
}
fn reap_legacy_dated_logs(dir: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
reap_legacy_dated_log(entry)?;
}
Ok(())
}
fn reap_legacy_dated_log(entry: std::fs::DirEntry) -> std::io::Result<()> {
let file_type = match entry.file_type() {
Ok(file_type) => file_type,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error),
};
if !file_type.is_file() {
return Ok(());
}
let name = entry.file_name();
let Some(name) = name.to_str() else {
return Ok(());
};
let Some(date) = name.strip_prefix(LEGACY_LOG_PREFIX) else {
return Ok(());
};
let Some(date) = date.strip_suffix(LEGACY_LOG_SUFFIX) else {
return Ok(());
};
if date.len() != 10 || NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err() {
return Ok(());
}
match std::fs::remove_file(entry.path()) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn state_dir() -> PathBuf {
hallouminate_config::xdg::xdg_path("XDG_STATE_HOME", "~/.local/state", &["hallouminate"])
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tracing_subscriber::fmt::MakeWriter;
#[test]
fn state_dir_ends_under_hallouminate_subdir() {
let dir = state_dir();
assert!(
dir.ends_with("hallouminate"),
"state dir must terminate in the app subdir: {dir:?}"
);
assert!(dir.is_absolute(), "state dir must be absolute: {dir:?}");
}
#[test]
fn log_writer_removes_only_legacy_dated_logs() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let legacy = [
dir.path().join("hallouminate.2026-07-04.log"),
dir.path().join("hallouminate.2026-07-12.log"),
];
let preserved = [
dir.path().join("hallouminate.2026-02-30.log"),
dir.path().join("hallouminate.not-a-date.log"),
dir.path().join("unrelated.log"),
];
for path in &legacy {
std::fs::write(path, b"log bytes")?;
}
for path in &preserved {
std::fs::write(path, b"log bytes")?;
}
let config = hallouminate_config::LoggingConfig {
max_file_bytes: 64,
max_total_bytes: 192,
};
let (_writer, guard) = log_writer(dir.path(), &config)?;
drop(guard);
for path in legacy {
assert!(!path.exists(), "legacy dated log survived: {path:?}");
}
for path in preserved {
assert!(path.exists(), "non-legacy file was removed: {path:?}");
}
Ok(())
}
#[test]
fn reaping_a_legacy_entry_already_removed_by_another_startup() -> anyhow::Result<()> {
let dir = tempfile::tempdir()?;
let path = dir.path().join("hallouminate.2026-07-04.log");
std::fs::write(&path, b"log bytes")?;
let entry = std::fs::read_dir(dir.path())?
.next()
.expect("legacy entry")?;
std::fs::remove_file(path)?;
reap_legacy_dated_log(entry)?;
Ok(())
}
#[test]
fn size_rotation_bounds_active_and_total_bytes() -> anyhow::Result<()> {
const MAX_FILE_BYTES: u64 = 64;
const MAX_TOTAL_BYTES: u64 = 192;
const BLOCK: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
const SENTINEL: &[u8] = b"drain-complete\n";
let dir = tempfile::tempdir()?;
let config = hallouminate_config::LoggingConfig {
max_file_bytes: MAX_FILE_BYTES,
max_total_bytes: MAX_TOTAL_BYTES,
};
let (writer, guard) = log_writer(dir.path(), &config)?;
let mut writer = writer.make_writer();
for _ in 0..10 {
writer.write_all(BLOCK)?;
}
writer.write_all(SENTINEL)?;
drop(writer);
drop(guard);
let mut files = Vec::new();
for entry in std::fs::read_dir(dir.path())? {
let entry = entry?;
if entry.file_type()?.is_file() {
let path = entry.path();
let bytes = std::fs::read(&path)?;
files.push((path, bytes));
}
}
assert!(!files.is_empty(), "appender must create a log file");
let mut active_index = None;
for (index, (_, bytes)) in files.iter().enumerate() {
let mut contains_sentinel = false;
for window in bytes.windows(SENTINEL.len()) {
if window == SENTINEL {
contains_sentinel = true;
break;
}
}
if contains_sentinel {
assert!(
active_index.is_none(),
"sentinel must occur in one log file"
);
active_index = Some(index);
}
}
let Some(active_index) = active_index else {
panic!("final sentinel must be drained before the guard returns");
};
let active_size = u64::try_from(files[active_index].1.len())?;
assert!(
active_size <= MAX_FILE_BYTES,
"active log is {active_size} bytes, above {MAX_FILE_BYTES}"
);
let mut rotated_files = 0;
let mut total_bytes = 0;
for (index, (path, bytes)) in files.iter().enumerate() {
let size = u64::try_from(bytes.len())?;
total_bytes += size;
if index != active_index {
rotated_files += 1;
assert!(
size <= MAX_FILE_BYTES,
"rotated log {path:?} is {size} bytes, above {MAX_FILE_BYTES}"
);
}
}
assert!(rotated_files > 0, "fixed writes must trigger size rotation");
assert!(
total_bytes <= MAX_TOTAL_BYTES,
"retained logs total {total_bytes} bytes, above {MAX_TOTAL_BYTES}"
);
Ok(())
}
#[test]
fn size_rotation_with_equal_limits_keeps_no_archives() -> anyhow::Result<()> {
const MAX_FILE_BYTES: u64 = 64;
const BLOCK: &[u8; 32] = b"0123456789abcdef0123456789abcdef";
let dir = tempfile::tempdir()?;
let config = hallouminate_config::LoggingConfig {
max_file_bytes: MAX_FILE_BYTES,
max_total_bytes: MAX_FILE_BYTES,
};
let (writer, guard) = log_writer(dir.path(), &config)?;
let mut writer = writer.make_writer();
for _ in 0..10 {
writer.write_all(BLOCK)?;
}
drop(writer);
drop(guard);
let mut total_bytes: u64 = 0;
let mut file_count = 0;
for entry in std::fs::read_dir(dir.path())? {
let entry = entry?;
if entry.file_type()?.is_file() {
file_count += 1;
total_bytes += entry.metadata()?.len();
}
}
assert_eq!(
file_count, 1,
"equal limits must retain only the active file"
);
assert!(
total_bytes <= MAX_FILE_BYTES,
"retained logs total {total_bytes} bytes, above {MAX_FILE_BYTES}"
);
Ok(())
}
}