use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use crate::config::LoggingConfig;
use crate::log_paths;
const DEFAULT_PREFIX: &str = "ai-memory.log";
pub fn init_file_logging(cfg: &LoggingConfig) -> Result<Option<WorkerGuard>> {
if !cfg.enabled.unwrap_or(false) {
return Ok(None);
}
let dir = resolve_log_dir(cfg);
log_paths::ensure_dir_secure(&dir)
.with_context(|| format!("creating log dir {}", dir.display()))?;
let appender = build_appender(&dir, cfg)?;
let (writer, guard) = tracing_appender::non_blocking(appender);
let level = cfg.level.as_deref().unwrap_or("info");
let filter = tracing_subscriber::EnvFilter::try_new(level).unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::try_new("info").expect("info is a valid filter")
});
let structured = cfg.structured.unwrap_or(false);
let res = if structured {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(writer)
.json()
.try_init()
} else {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(writer)
.try_init()
};
if let Err(e) = res {
tracing::debug!("file logging subscriber already initialised: {e}");
}
Ok(Some(guard))
}
#[must_use]
pub fn resolve_log_dir(cfg: &LoggingConfig) -> PathBuf {
log_paths::resolve_log_dir(None, cfg.path.as_deref())
.map(|r| r.path)
.unwrap_or_else(|_| log_paths::platform_default(log_paths::DirKind::Log).path)
}
pub fn resolve_log_dir_with_override(
cli_override: Option<&Path>,
cfg: &LoggingConfig,
) -> Result<log_paths::ResolvedDir> {
log_paths::resolve_log_dir(cli_override, cfg.path.as_deref())
}
pub fn build_appender(dir: &Path, cfg: &LoggingConfig) -> Result<RollingFileAppender> {
let rotation = rotation_for(cfg);
let max_files = cfg.max_files.unwrap_or(30);
let prefix = cfg
.filename_prefix
.clone()
.unwrap_or_else(|| DEFAULT_PREFIX.to_string());
RollingFileAppender::builder()
.filename_prefix(prefix)
.rotation(rotation)
.max_log_files(max_files)
.build(dir)
.with_context(|| format!("building rolling appender at {}", dir.display()))
}
fn rotation_for(cfg: &LoggingConfig) -> Rotation {
match cfg.rotation.as_deref().unwrap_or("daily") {
"minutely" => Rotation::MINUTELY,
"hourly" => Rotation::HOURLY,
"never" => Rotation::NEVER,
_ => Rotation::DAILY,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotation_for_default_is_daily() {
let cfg = LoggingConfig::default();
let r = rotation_for(&cfg);
assert!(format!("{r:?}").to_lowercase().contains("daily"));
}
#[test]
fn rotation_for_hourly() {
let cfg = LoggingConfig {
rotation: Some("hourly".to_string()),
..Default::default()
};
let r = rotation_for(&cfg);
assert!(format!("{r:?}").to_lowercase().contains("hourly"));
}
#[test]
fn resolve_log_dir_default_under_home() {
let cfg = LoggingConfig::default();
let p = resolve_log_dir(&cfg);
assert!(p.to_string_lossy().contains("ai-memory"));
}
#[test]
fn build_appender_creates_file_under_tmp() {
let tmp = tempfile::tempdir().unwrap();
let cfg = LoggingConfig {
enabled: Some(true),
path: Some(tmp.path().to_string_lossy().into_owned()),
rotation: Some("never".to_string()),
..Default::default()
};
let _appender = build_appender(tmp.path(), &cfg).unwrap();
assert!(tmp.path().is_dir());
}
#[test]
fn init_file_logging_returns_none_when_disabled() {
let cfg = LoggingConfig {
enabled: Some(false),
..Default::default()
};
let guard = init_file_logging(&cfg).unwrap();
assert!(guard.is_none());
}
fn subscriber_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap_or_else(|p| p.into_inner())
}
#[test]
fn init_file_logging_returns_guard_when_enabled() {
let _g = subscriber_lock();
let tmp = tempfile::tempdir().unwrap();
let cfg = LoggingConfig {
enabled: Some(true),
path: Some(tmp.path().to_string_lossy().into_owned()),
rotation: Some("never".to_string()),
level: Some("info".to_string()),
structured: Some(false),
..Default::default()
};
let guard = init_file_logging(&cfg).unwrap();
assert!(
guard.is_some(),
"init_file_logging must return a WorkerGuard when enabled"
);
assert!(tmp.path().is_dir());
drop(guard);
}
#[test]
fn init_file_logging_emits_structured_json_when_configured() {
let _g = subscriber_lock();
let tmp = tempfile::tempdir().unwrap();
let cfg = LoggingConfig {
enabled: Some(true),
path: Some(tmp.path().to_string_lossy().into_owned()),
rotation: Some("never".to_string()),
level: Some("info".to_string()),
structured: Some(true),
..Default::default()
};
let guard = init_file_logging(&cfg).unwrap();
assert!(guard.is_some(), "structured branch must produce a guard");
drop(guard);
}
#[test]
fn init_file_logging_accepts_invalid_level_falling_back_to_info() {
let _g = subscriber_lock();
let tmp = tempfile::tempdir().unwrap();
let cfg = LoggingConfig {
enabled: Some(true),
path: Some(tmp.path().to_string_lossy().into_owned()),
rotation: Some("never".to_string()),
level: Some("not-a-real-level".to_string()),
..Default::default()
};
let guard = init_file_logging(&cfg).unwrap();
assert!(guard.is_some());
}
#[test]
fn rotation_for_minutely() {
let cfg = LoggingConfig {
rotation: Some("minutely".to_string()),
..Default::default()
};
let r = rotation_for(&cfg);
assert!(format!("{r:?}").to_lowercase().contains("minutely"));
}
#[test]
fn rotation_for_never() {
let cfg = LoggingConfig {
rotation: Some("never".to_string()),
..Default::default()
};
let r = rotation_for(&cfg);
assert!(format!("{r:?}").to_lowercase().contains("never"));
}
#[test]
fn rotation_for_unknown_falls_back_to_daily() {
let cfg = LoggingConfig {
rotation: Some("garbage".to_string()),
..Default::default()
};
let r = rotation_for(&cfg);
assert!(format!("{r:?}").to_lowercase().contains("daily"));
}
#[test]
fn build_appender_honours_explicit_filename_prefix() {
let tmp = tempfile::tempdir().unwrap();
let cfg = LoggingConfig {
enabled: Some(true),
path: Some(tmp.path().to_string_lossy().into_owned()),
rotation: Some("never".to_string()),
filename_prefix: Some("custom-prefix".to_string()),
..Default::default()
};
let _appender = build_appender(tmp.path(), &cfg).unwrap();
}
#[test]
fn resolve_log_dir_with_override_uses_cli_layer() {
let tmp = tempfile::tempdir().unwrap();
let cfg = LoggingConfig::default();
let r = resolve_log_dir_with_override(Some(tmp.path()), &cfg).unwrap();
assert_eq!(r.path, tmp.path());
assert_eq!(r.source, log_paths::PathSource::CliFlag);
}
#[cfg(unix)]
#[test]
fn resolve_log_dir_with_override_propagates_world_writable_error() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let bad = tmp.path().join("worldwrite");
std::fs::create_dir(&bad).unwrap();
std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
let cfg = LoggingConfig::default();
let err = resolve_log_dir_with_override(Some(&bad), &cfg).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("world-writable"), "got: {msg}");
}
}