pub mod diagnostics;
pub mod filter;
pub mod formatter;
pub mod panic;
pub mod subscriber;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicU8, Ordering};
pub use diagnostics::{DiagnosticCounters, DiagnosticSnapshot};
pub use filter::ModuleFilter;
pub use formatter::ColoredFormatter;
use tracing::Level as TracingLevel;
static LOGGER: OnceLock<Logger> = OnceLock::new();
#[macro_export]
macro_rules! diag {
($f:expr) => {
#[cfg(feature = "diagnostics")]
{
if let Some(__d) = $crate::logger::Logger::diagnostics() {
let __f: fn(&$crate::logger::DiagnosticCounters) = $f;
__f(__d);
}
}
};
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum Level {
Trace = 0,
Debug = 1,
Info = 2,
Warn = 3,
Error = 4,
}
impl Level {
pub fn as_str(&self) -> &'static str {
match self {
Level::Trace => "trace",
Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warn",
Level::Error => "error",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"trace" => Some(Level::Trace),
"debug" => Some(Level::Debug),
"info" => Some(Level::Info),
"warn" => Some(Level::Warn),
"error" => Some(Level::Error),
_ => None,
}
}
pub fn to_tracing_level(&self) -> TracingLevel {
match self {
Level::Trace => TracingLevel::TRACE,
Level::Debug => TracingLevel::DEBUG,
Level::Info => TracingLevel::INFO,
Level::Warn => TracingLevel::WARN,
Level::Error => TracingLevel::ERROR,
}
}
}
#[derive(Debug, Clone)]
pub struct LoggerConfig {
pub level: Level,
pub color: bool,
pub timestamp: bool,
pub module: bool,
pub thread: bool,
pub file: Option<String>,
pub max_file_size: Option<u64>,
pub max_files: Option<usize>,
pub dev: bool,
}
impl Default for LoggerConfig {
fn default() -> Self {
Self {
level: Level::Info,
color: true,
timestamp: true,
module: true,
thread: false,
file: None,
max_file_size: Some(10 * 1024 * 1024), max_files: Some(5),
dev: false,
}
}
}
pub struct FileLogTarget {
pub dir: std::path::PathBuf,
pub file: std::path::PathBuf,
}
impl LoggerConfig {
pub fn resolve_file_target(&self) -> Option<FileLogTarget> {
use std::path::PathBuf;
if let Ok(dir) = std::env::var("BETTERTUI_LOG_DIR")
&& !dir.is_empty()
{
let dir = PathBuf::from(dir);
let file = subscriber::daily_log_path(&dir);
return Some(FileLogTarget { dir, file });
}
if let Some(explicit) = &self.file
&& !explicit.is_empty()
{
let file = PathBuf::from(explicit);
let dir = file.parent().map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."));
return Some(FileLogTarget { dir, file });
}
if self.dev {
let dir = subscriber::default_dev_log_dir();
let file = subscriber::daily_log_path(&dir);
return Some(FileLogTarget { dir, file });
}
None
}
}
#[derive(Debug, thiserror::Error)]
pub enum LoggerError {
#[error("Logger already initialized")]
AlreadyInitialized,
#[error("Logger not initialized")]
NotInitialized,
#[error("Failed to set global subscriber: {0}")]
SetGlobalSubscriber(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub struct Logger {
level: AtomicU8,
filter: parking_lot::RwLock<ModuleFilter>,
diagnostics: DiagnosticCounters,
#[allow(dead_code)]
config: LoggerConfig,
}
impl Logger {
pub fn init(config: LoggerConfig) -> Result<(), LoggerError> {
if LOGGER.get().is_some() {
return Err(LoggerError::AlreadyInitialized);
}
let level = AtomicU8::new(config.level as u8);
let filter = parking_lot::RwLock::new(ModuleFilter::default());
let diagnostics = DiagnosticCounters::new();
let logger = Logger { level, filter, diagnostics, config: config.clone() };
subscriber::init_subscriber(&config)?;
panic::install_panic_hook();
LOGGER.set(logger).map_err(|_| LoggerError::AlreadyInitialized)?;
tracing::info!(
level = ?config.level,
color = config.color,
"Logger initialized"
);
Ok(())
}
fn instance() -> Result<&'static Logger, LoggerError> {
LOGGER.get().ok_or(LoggerError::NotInitialized)
}
pub fn set_level(level: Level) {
if let Ok(logger) = Self::instance() {
logger.level.store(level as u8, Ordering::Relaxed);
tracing::info!(?level, "Log level changed");
}
}
pub fn get_level() -> Level {
Self::instance()
.map(|logger| {
let level_u8 = logger.level.load(Ordering::Relaxed);
match level_u8 {
0 => Level::Trace,
1 => Level::Debug,
2 => Level::Info,
3 => Level::Warn,
_ => Level::Error,
}
})
.unwrap_or(Level::Info)
}
pub fn set_module_filter(filter: ModuleFilter) {
if let Ok(logger) = Self::instance() {
*logger.filter.write() = filter;
tracing::info!("Module filter updated");
}
}
pub fn snapshot_diagnostics() -> DiagnosticSnapshot {
Self::instance().map(|logger| logger.diagnostics.snapshot()).unwrap_or_default()
}
pub fn diagnostics() -> Option<&'static DiagnosticCounters> {
LOGGER.get().map(|logger| &logger.diagnostics)
}
pub fn flush() {
tracing::debug!("Logger flush requested");
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn level_conversions() {
assert_eq!(Level::from_str("trace"), Some(Level::Trace));
assert_eq!(Level::from_str("DEBUG"), Some(Level::Debug));
assert_eq!(Level::from_str("Info"), Some(Level::Info));
assert_eq!(Level::from_str("warn"), Some(Level::Warn));
assert_eq!(Level::from_str("ERROR"), Some(Level::Error));
assert_eq!(Level::from_str("invalid"), None);
}
#[test]
fn level_ordering() {
assert!(Level::Trace < Level::Debug);
assert!(Level::Debug < Level::Info);
assert!(Level::Info < Level::Warn);
assert!(Level::Warn < Level::Error);
}
#[test]
fn level_as_str() {
assert_eq!(Level::Trace.as_str(), "trace");
assert_eq!(Level::Debug.as_str(), "debug");
assert_eq!(Level::Info.as_str(), "info");
assert_eq!(Level::Warn.as_str(), "warn");
assert_eq!(Level::Error.as_str(), "error");
}
#[test]
fn default_config() {
let config = LoggerConfig::default();
assert_eq!(config.level, Level::Info);
assert!(config.color);
assert!(config.timestamp);
assert!(config.module);
assert!(!config.thread);
assert!(!config.dev);
assert!(config.file.is_none());
}
#[test]
fn resolve_target_prod_no_path_is_none() {
unsafe { std::env::remove_var("BETTERTUI_LOG_DIR") };
let config = LoggerConfig { dev: false, file: None, ..Default::default() };
assert!(config.resolve_file_target().is_none());
}
#[test]
fn resolve_target_explicit_file_wins() {
unsafe { std::env::remove_var("BETTERTUI_LOG_DIR") };
let config = LoggerConfig { dev: false, file: Some("/tmp/btui/custom.log".into()), ..Default::default() };
let target = config.resolve_file_target().expect("explicit file → Some");
assert_eq!(target.file, std::path::PathBuf::from("/tmp/btui/custom.log"));
assert_eq!(target.dir, std::path::PathBuf::from("/tmp/btui"));
}
#[test]
fn resolve_target_dev_uses_repo_logs_dir() {
unsafe { std::env::remove_var("BETTERTUI_LOG_DIR") };
let config = LoggerConfig { dev: true, file: None, ..Default::default() };
let target = config.resolve_file_target().expect("dev → Some");
assert!(target.dir.ends_with("logs"), "dev dir should end with logs, got {:?}", target.dir);
let name = target.file.file_name().unwrap().to_str().unwrap();
assert!(name.starts_with("bettertui-") && name.ends_with(".log"));
}
}