use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoggingConfig {
pub enabled: bool,
pub app_name: String,
pub log_dir: Option<PathBuf>,
pub level: LogLevel,
}
impl LoggingConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn disabled() -> Self {
Self {
enabled: false,
..Default::default()
}
}
#[must_use]
pub fn with_app_name(mut self, name: impl Into<String>) -> Self {
self.app_name = name.into();
self
}
#[must_use]
pub fn with_log_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.log_dir = Some(path.into());
self
}
#[must_use]
pub fn with_level(mut self, level: LogLevel) -> Self {
self.level = level;
self
}
}
impl Default for LoggingConfig {
fn default() -> Self {
Self {
enabled: true,
app_name: "acton-ai".to_string(),
log_dir: None,
level: LogLevel::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum LogLevel {
Trace,
Debug,
#[default]
Info,
Warn,
Error,
}
impl LogLevel {
#[must_use]
pub fn to_filter(self) -> tracing_subscriber::filter::LevelFilter {
match self {
Self::Trace => tracing_subscriber::filter::LevelFilter::TRACE,
Self::Debug => tracing_subscriber::filter::LevelFilter::DEBUG,
Self::Info => tracing_subscriber::filter::LevelFilter::INFO,
Self::Warn => tracing_subscriber::filter::LevelFilter::WARN,
Self::Error => tracing_subscriber::filter::LevelFilter::ERROR,
}
}
}
pub struct LoggingGuard {
_guard: tracing_appender::non_blocking::WorkerGuard,
}
impl std::fmt::Debug for LoggingGuard {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LoggingGuard").finish_non_exhaustive()
}
}
static LOGGING_GUARD: std::sync::OnceLock<LoggingGuard> = std::sync::OnceLock::new();
fn store_logging_guard(guard: LoggingGuard) {
let _ = LOGGING_GUARD.set(guard);
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LoggingError {
pub kind: LoggingErrorKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LoggingErrorKind {
NoDataDir,
CreateDirFailed {
path: PathBuf,
reason: String,
},
SubscriberInitFailed {
reason: String,
},
}
impl LoggingError {
#[must_use]
pub fn new(kind: LoggingErrorKind) -> Self {
Self { kind }
}
#[must_use]
pub fn no_data_dir() -> Self {
Self::new(LoggingErrorKind::NoDataDir)
}
#[must_use]
pub fn create_dir_failed(path: PathBuf, reason: impl Into<String>) -> Self {
Self::new(LoggingErrorKind::CreateDirFailed {
path,
reason: reason.into(),
})
}
#[must_use]
pub fn subscriber_init_failed(reason: impl Into<String>) -> Self {
Self::new(LoggingErrorKind::SubscriberInitFailed {
reason: reason.into(),
})
}
#[must_use]
pub fn is_no_data_dir(&self) -> bool {
matches!(self.kind, LoggingErrorKind::NoDataDir)
}
}
impl fmt::Display for LoggingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.kind {
LoggingErrorKind::NoDataDir => {
write!(
f,
"could not determine XDG data directory; \
set XDG_DATA_HOME or use a custom log_dir"
)
}
LoggingErrorKind::CreateDirFailed { path, reason } => {
write!(
f,
"failed to create log directory '{}': {}; check permissions",
path.display(),
reason
)
}
LoggingErrorKind::SubscriberInitFailed { reason } => {
write!(
f,
"failed to initialize tracing subscriber: {}; \
a subscriber may already be set",
reason
)
}
}
}
}
impl std::error::Error for LoggingError {}
fn resolve_log_dir(config: &LoggingConfig) -> Result<PathBuf, LoggingError> {
if let Some(ref custom_dir) = config.log_dir {
return Ok(custom_dir.clone());
}
dirs::data_local_dir()
.map(|dir| dir.join("acton").join("logs"))
.ok_or_else(LoggingError::no_data_dir)
}
pub fn init_file_logging(config: &LoggingConfig) -> Result<Option<LoggingGuard>, LoggingError> {
if !config.enabled {
return Ok(None);
}
let log_dir = resolve_log_dir(config)?;
std::fs::create_dir_all(&log_dir)
.map_err(|e| LoggingError::create_dir_failed(log_dir.clone(), e.to_string()))?;
let file_appender =
tracing_appender::rolling::daily(&log_dir, format!("{}.log", config.app_name));
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let result = tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(false),
)
.with(config.level.to_filter())
.try_init();
match result {
Ok(()) => Ok(Some(LoggingGuard { _guard: guard })),
Err(e) => Err(LoggingError::subscriber_init_failed(e.to_string())),
}
}
pub fn init_and_store_logging(config: &LoggingConfig) -> Result<bool, LoggingError> {
if LOGGING_GUARD.get().is_some() {
return Ok(false);
}
match init_file_logging(config)? {
Some(guard) => {
store_logging_guard(guard);
Ok(true)
}
None => Ok(false),
}
}
pub fn get_log_dir(config: &LoggingConfig) -> Result<PathBuf, LoggingError> {
resolve_log_dir(config)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn logging_config_default_values() {
let config = LoggingConfig::default();
assert!(config.enabled);
assert_eq!(config.app_name, "acton-ai");
assert!(config.log_dir.is_none());
assert_eq!(config.level, LogLevel::Info);
}
#[test]
fn logging_config_new_equals_default() {
let new = LoggingConfig::new();
let default = LoggingConfig::default();
assert_eq!(new, default);
}
#[test]
fn logging_config_builder_pattern() {
let config = LoggingConfig::new()
.with_app_name("my-app")
.with_log_dir("/tmp/logs")
.with_level(LogLevel::Debug);
assert_eq!(config.app_name, "my-app");
assert_eq!(config.log_dir, Some(PathBuf::from("/tmp/logs")));
assert_eq!(config.level, LogLevel::Debug);
}
#[test]
fn logging_config_disabled() {
let config = LoggingConfig::disabled();
assert!(!config.enabled);
}
#[test]
fn log_level_default_is_info() {
let level = LogLevel::default();
assert_eq!(level, LogLevel::Info);
}
#[test]
fn log_level_to_filter_mapping() {
use tracing_subscriber::filter::LevelFilter;
assert_eq!(LogLevel::Trace.to_filter(), LevelFilter::TRACE);
assert_eq!(LogLevel::Debug.to_filter(), LevelFilter::DEBUG);
assert_eq!(LogLevel::Info.to_filter(), LevelFilter::INFO);
assert_eq!(LogLevel::Warn.to_filter(), LevelFilter::WARN);
assert_eq!(LogLevel::Error.to_filter(), LevelFilter::ERROR);
}
#[test]
fn logging_error_no_data_dir_display() {
let error = LoggingError::no_data_dir();
let message = error.to_string();
assert!(message.contains("XDG"));
assert!(message.contains("data directory"));
}
#[test]
fn logging_error_no_data_dir_is_no_data_dir() {
let error = LoggingError::no_data_dir();
assert!(error.is_no_data_dir());
}
#[test]
fn logging_error_create_dir_failed_display() {
let error = LoggingError::create_dir_failed(
PathBuf::from("/nonexistent/path"),
"permission denied",
);
let message = error.to_string();
assert!(message.contains("/nonexistent/path"));
assert!(message.contains("permission denied"));
}
#[test]
fn logging_error_subscriber_init_failed_display() {
let error = LoggingError::subscriber_init_failed("already initialized");
let message = error.to_string();
assert!(message.contains("already initialized"));
assert!(message.contains("subscriber"));
}
#[test]
fn logging_errors_are_clone() {
let error1 = LoggingError::no_data_dir();
let error2 = error1.clone();
assert_eq!(error1, error2);
}
#[test]
fn logging_errors_are_eq() {
let error1 = LoggingError::no_data_dir();
let error2 = LoggingError::no_data_dir();
assert_eq!(error1, error2);
let error3 = LoggingError::subscriber_init_failed("test");
assert_ne!(error1, error3);
}
#[test]
fn resolve_log_dir_uses_custom_when_provided() {
let config = LoggingConfig::default().with_log_dir("/custom/logs");
let resolved = resolve_log_dir(&config).unwrap();
assert_eq!(resolved, PathBuf::from("/custom/logs"));
}
#[test]
fn resolve_log_dir_uses_xdg_when_not_provided() {
let config = LoggingConfig::default();
if let Ok(resolved) = resolve_log_dir(&config) {
assert!(resolved.to_string_lossy().contains("acton"));
assert!(resolved.to_string_lossy().contains("logs"));
}
}
#[test]
fn init_file_logging_returns_none_when_disabled() {
let config = LoggingConfig::disabled();
let result = init_file_logging(&config);
assert!(result.is_ok());
assert!(result.unwrap().is_none());
}
#[test]
fn logging_config_serialization_roundtrip() {
let config = LoggingConfig::new()
.with_app_name("test-app")
.with_level(LogLevel::Debug);
let json = serde_json::to_string(&config).unwrap();
let deserialized: LoggingConfig = serde_json::from_str(&json).unwrap();
assert_eq!(config, deserialized);
}
#[test]
fn log_level_serialization_roundtrip() {
for level in [
LogLevel::Trace,
LogLevel::Debug,
LogLevel::Info,
LogLevel::Warn,
LogLevel::Error,
] {
let json = serde_json::to_string(&level).unwrap();
let deserialized: LogLevel = serde_json::from_str(&json).unwrap();
assert_eq!(level, deserialized);
}
}
}