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 const DEFAULT_LOG_DIRECTIVE: &str = "ai_memory=info";
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,
}
}
pub const URL_PASSWORD_MASK: &str = "****";
#[must_use]
pub fn redact_url_password(url: &str) -> String {
let Some(scheme_end) = url.find("://") else {
return url.to_string();
};
let authority_start = scheme_end + 3;
let rest = &url[authority_start..];
let authority_end = rest
.find(['/', '?', '#'])
.map_or(url.len(), |i| authority_start + i);
let authority = &url[authority_start..authority_end];
let Some(at_pos) = authority.rfind('@') else {
return url.to_string();
};
let userinfo = &authority[..at_pos];
let Some(colon_pos) = userinfo.find(':') else {
return url.to_string();
};
let mut out = String::with_capacity(url.len());
out.push_str(&url[..authority_start + colon_pos + 1]);
out.push_str(URL_PASSWORD_MASK);
out.push_str(&url[authority_start + at_pos..]);
out
}
#[must_use]
pub fn redact_urls_in_message(msg: &str) -> String {
let mut out = String::with_capacity(msg.len());
let mut rest = msg;
while let Some(sep) = rest.find("://") {
let mut scheme_start = sep;
while scheme_start > 0 {
let c = rest.as_bytes()[scheme_start - 1];
if c.is_ascii_alphanumeric() || c == b'+' || c == b'-' || c == b'.' {
scheme_start -= 1;
} else {
break;
}
}
out.push_str(&rest[..scheme_start]);
let url_end = rest[sep..]
.find(|c: char| {
c.is_ascii_whitespace()
|| matches!(
c,
'"' | '\'' | '`' | '{' | '}' | '(' | ')' | ',' | ';' | '<' | '>'
)
})
.map_or(rest.len(), |i| sep + i);
out.push_str(&redact_url_password(&rest[scheme_start..url_end]));
rest = &rest[url_end..];
}
out.push_str(rest);
out
}
#[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("@invalid@directive@".to_string()),
..Default::default()
};
let guard = init_file_logging(&cfg).unwrap();
assert!(guard.is_some());
}
#[test]
fn init_file_logging_fallback_filter_on_malformed_directive() {
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("my_target=not_a_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}");
}
#[test]
fn init_file_logging_second_call_does_not_panic() {
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()),
..Default::default()
};
let _first = init_file_logging(&cfg);
let second = init_file_logging(&cfg).expect("second init must not error");
assert!(
second.is_some(),
"second init still returns Some(guard); try_init failure goes to debug! not Err"
);
}
#[test]
fn init_file_logging_default_enabled_field_is_off() {
let cfg = LoggingConfig::default();
let guard = init_file_logging(&cfg).expect("disabled returns Ok(None)");
assert!(guard.is_none());
}
#[cfg(unix)]
#[test]
fn init_file_logging_propagates_ensure_dir_secure_failure() {
let _g = subscriber_lock();
let tmp = tempfile::tempdir().unwrap();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"file").unwrap();
let cfg = LoggingConfig {
enabled: Some(true),
path: Some(blocker.join("sub").to_string_lossy().into_owned()),
rotation: Some("never".to_string()),
..Default::default()
};
let res = init_file_logging(&cfg);
assert!(
res.is_err(),
"init_file_logging must propagate create_dir failure"
);
let err = res.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("creating log dir") || msg.contains("creating log directory"),
"expected wrapped context, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn resolve_log_dir_falls_back_to_platform_default_when_world_writable() {
use std::os::unix::fs::PermissionsExt;
let _g = subscriber_lock();
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 {
path: Some(bad.to_string_lossy().into_owned()),
..Default::default()
};
let p = resolve_log_dir(&cfg);
assert_ne!(p, bad);
assert!(p.to_string_lossy().contains("ai-memory"));
}
#[cfg(unix)]
#[test]
fn build_appender_returns_context_on_unwritable_dir() {
let tmp = tempfile::tempdir().unwrap();
let not_a_dir = tmp.path().join("not_a_dir_file");
std::fs::write(¬_a_dir, b"hello").unwrap();
let cfg = LoggingConfig {
rotation: Some("never".to_string()),
..Default::default()
};
let res = build_appender(¬_a_dir, &cfg);
if let Err(err) = res {
let msg = format!("{err:#}");
assert!(
msg.contains("building rolling appender"),
"expected wrapped context, got: {msg}"
);
}
}
#[test]
fn redact_masks_postgres_password() {
let url = "postgres://ai_memory:hunter2@db.internal:5432/ai_memory";
let redacted = redact_url_password(url);
assert_eq!(
redacted,
"postgres://ai_memory:****@db.internal:5432/ai_memory"
);
assert!(!redacted.contains("hunter2"));
}
#[test]
fn redact_masks_postgresql_scheme_too() {
let url = "postgresql://u:s3cr3t@h:5432/db";
let redacted = redact_url_password(url);
assert_eq!(redacted, "postgresql://u:****@h:5432/db");
}
#[test]
fn redact_without_password_is_unchanged() {
assert_eq!(
redact_url_password("postgres://user@host:5432/db"),
"postgres://user@host:5432/db"
);
assert_eq!(
redact_url_password("postgres://host:5432/db"),
"postgres://host:5432/db"
);
}
#[test]
fn redact_leaves_sqlite_paths_unchanged() {
assert_eq!(
redact_url_password("sqlite:///var/lib/ai-memory/mem.db"),
"sqlite:///var/lib/ai-memory/mem.db"
);
assert_eq!(
redact_url_password("/var/lib/ai-memory/mem.db"),
"/var/lib/ai-memory/mem.db"
);
}
#[test]
fn redact_handles_password_containing_at_and_colon() {
let url = "postgres://user:p@:ss@host/db";
let redacted = redact_url_password(url);
assert_eq!(redacted, "postgres://user:****@host/db");
assert!(!redacted.contains("p@:ss"));
}
#[test]
fn redact_does_not_touch_password_like_text_in_path_or_query() {
let url = "postgres://host/db?options=a:b@c";
assert_eq!(redact_url_password(url), url);
}
#[test]
fn redact_message_masks_embedded_url() {
let msg = "connect failed: invalid url postgres://admin:hunter2@db:5432/mem (timeout)";
let clean = redact_urls_in_message(msg);
assert!(!clean.contains("hunter2"), "password leaked: {clean}");
assert!(clean.contains("postgres://admin:****@db:5432/mem"));
assert!(clean.starts_with("connect failed: invalid url "));
assert!(clean.ends_with(" (timeout)"));
}
#[test]
fn redact_message_handles_multiple_urls() {
let msg = "from postgres://a:pw1@h1/db to postgres://b:pw2@h2/db";
let clean = redact_urls_in_message(msg);
assert!(!clean.contains("pw1") && !clean.contains("pw2"));
assert!(clean.contains("postgres://a:****@h1/db"));
assert!(clean.contains("postgres://b:****@h2/db"));
}
#[test]
fn redact_message_without_urls_is_identity() {
let msg = "plain diagnostic with no connection string";
assert_eq!(redact_urls_in_message(msg), msg);
}
}