1use std::path::{Path, PathBuf};
30
31use anyhow::{Context, Result};
32use tracing_appender::non_blocking::WorkerGuard;
33use tracing_appender::rolling::{RollingFileAppender, Rotation};
34
35use crate::config::LoggingConfig;
36use crate::log_paths;
37
38const DEFAULT_PREFIX: &str = "ai-memory.log";
41
42pub fn init_file_logging(cfg: &LoggingConfig) -> Result<Option<WorkerGuard>> {
51 if !cfg.enabled.unwrap_or(false) {
52 return Ok(None);
53 }
54 let dir = resolve_log_dir(cfg);
55 log_paths::ensure_dir_secure(&dir)
56 .with_context(|| format!("creating log dir {}", dir.display()))?;
57 let appender = build_appender(&dir, cfg)?;
58 let (writer, guard) = tracing_appender::non_blocking(appender);
59 let level = cfg.level.as_deref().unwrap_or("info");
63 let filter = tracing_subscriber::EnvFilter::try_new(level).unwrap_or_else(|_| {
64 tracing_subscriber::EnvFilter::try_new("info").expect("info is a valid filter")
65 });
66 let structured = cfg.structured.unwrap_or(false);
67 let res = if structured {
68 tracing_subscriber::fmt()
69 .with_env_filter(filter)
70 .with_writer(writer)
71 .json()
72 .try_init()
73 } else {
74 tracing_subscriber::fmt()
75 .with_env_filter(filter)
76 .with_writer(writer)
77 .try_init()
78 };
79 if let Err(e) = res {
80 tracing::debug!("file logging subscriber already initialised: {e}");
81 }
82 Ok(Some(guard))
83}
84
85#[must_use]
95pub fn resolve_log_dir(cfg: &LoggingConfig) -> PathBuf {
96 log_paths::resolve_log_dir(None, cfg.path.as_deref())
97 .map(|r| r.path)
98 .unwrap_or_else(|_| log_paths::platform_default(log_paths::DirKind::Log).path)
99}
100
101pub fn resolve_log_dir_with_override(
108 cli_override: Option<&Path>,
109 cfg: &LoggingConfig,
110) -> Result<log_paths::ResolvedDir> {
111 log_paths::resolve_log_dir(cli_override, cfg.path.as_deref())
112}
113
114pub fn build_appender(dir: &Path, cfg: &LoggingConfig) -> Result<RollingFileAppender> {
118 let rotation = rotation_for(cfg);
119 let max_files = cfg.max_files.unwrap_or(30);
120 let prefix = cfg
121 .filename_prefix
122 .clone()
123 .unwrap_or_else(|| DEFAULT_PREFIX.to_string());
124
125 RollingFileAppender::builder()
126 .filename_prefix(prefix)
127 .rotation(rotation)
128 .max_log_files(max_files)
129 .build(dir)
130 .with_context(|| format!("building rolling appender at {}", dir.display()))
131}
132
133fn rotation_for(cfg: &LoggingConfig) -> Rotation {
134 match cfg.rotation.as_deref().unwrap_or("daily") {
135 "minutely" => Rotation::MINUTELY,
136 "hourly" => Rotation::HOURLY,
137 "never" => Rotation::NEVER,
138 _ => Rotation::DAILY,
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn rotation_for_default_is_daily() {
148 let cfg = LoggingConfig::default();
149 let r = rotation_for(&cfg);
151 assert!(format!("{r:?}").to_lowercase().contains("daily"));
152 }
153
154 #[test]
155 fn rotation_for_hourly() {
156 let cfg = LoggingConfig {
157 rotation: Some("hourly".to_string()),
158 ..Default::default()
159 };
160 let r = rotation_for(&cfg);
161 assert!(format!("{r:?}").to_lowercase().contains("hourly"));
162 }
163
164 #[test]
165 fn resolve_log_dir_default_under_home() {
166 let cfg = LoggingConfig::default();
167 let p = resolve_log_dir(&cfg);
168 assert!(p.to_string_lossy().contains("ai-memory"));
171 }
172
173 #[test]
174 fn build_appender_creates_file_under_tmp() {
175 let tmp = tempfile::tempdir().unwrap();
176 let cfg = LoggingConfig {
177 enabled: Some(true),
178 path: Some(tmp.path().to_string_lossy().into_owned()),
179 rotation: Some("never".to_string()),
180 ..Default::default()
181 };
182 let _appender = build_appender(tmp.path(), &cfg).unwrap();
183 assert!(tmp.path().is_dir());
186 }
187
188 #[test]
189 fn init_file_logging_returns_none_when_disabled() {
190 let cfg = LoggingConfig {
191 enabled: Some(false),
192 ..Default::default()
193 };
194 let guard = init_file_logging(&cfg).unwrap();
195 assert!(guard.is_none());
196 }
197
198 fn subscriber_lock() -> std::sync::MutexGuard<'static, ()> {
201 static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
202 LOCK.get_or_init(|| std::sync::Mutex::new(()))
203 .lock()
204 .unwrap_or_else(|p| p.into_inner())
205 }
206
207 #[test]
208 fn init_file_logging_returns_guard_when_enabled() {
209 let _g = subscriber_lock();
210 let tmp = tempfile::tempdir().unwrap();
211 let cfg = LoggingConfig {
212 enabled: Some(true),
213 path: Some(tmp.path().to_string_lossy().into_owned()),
214 rotation: Some("never".to_string()),
215 level: Some("info".to_string()),
216 structured: Some(false),
217 ..Default::default()
218 };
219 let guard = init_file_logging(&cfg).unwrap();
223 assert!(
224 guard.is_some(),
225 "init_file_logging must return a WorkerGuard when enabled"
226 );
227 assert!(tmp.path().is_dir());
228 drop(guard);
231 }
232
233 #[test]
234 fn init_file_logging_emits_structured_json_when_configured() {
235 let _g = subscriber_lock();
236 let tmp = tempfile::tempdir().unwrap();
237 let cfg = LoggingConfig {
238 enabled: Some(true),
239 path: Some(tmp.path().to_string_lossy().into_owned()),
240 rotation: Some("never".to_string()),
241 level: Some("info".to_string()),
242 structured: Some(true),
243 ..Default::default()
244 };
245 let guard = init_file_logging(&cfg).unwrap();
246 assert!(guard.is_some(), "structured branch must produce a guard");
247 drop(guard);
248 }
249
250 #[test]
251 fn init_file_logging_accepts_invalid_level_falling_back_to_info() {
252 let _g = subscriber_lock();
253 let tmp = tempfile::tempdir().unwrap();
254 let cfg = LoggingConfig {
255 enabled: Some(true),
256 path: Some(tmp.path().to_string_lossy().into_owned()),
257 rotation: Some("never".to_string()),
258 level: Some("not-a-real-level".to_string()),
260 ..Default::default()
261 };
262 let guard = init_file_logging(&cfg).unwrap();
264 assert!(guard.is_some());
265 }
266
267 #[test]
268 fn rotation_for_minutely() {
269 let cfg = LoggingConfig {
270 rotation: Some("minutely".to_string()),
271 ..Default::default()
272 };
273 let r = rotation_for(&cfg);
274 assert!(format!("{r:?}").to_lowercase().contains("minutely"));
275 }
276
277 #[test]
278 fn rotation_for_never() {
279 let cfg = LoggingConfig {
280 rotation: Some("never".to_string()),
281 ..Default::default()
282 };
283 let r = rotation_for(&cfg);
284 assert!(format!("{r:?}").to_lowercase().contains("never"));
285 }
286
287 #[test]
288 fn rotation_for_unknown_falls_back_to_daily() {
289 let cfg = LoggingConfig {
290 rotation: Some("garbage".to_string()),
291 ..Default::default()
292 };
293 let r = rotation_for(&cfg);
294 assert!(format!("{r:?}").to_lowercase().contains("daily"));
295 }
296
297 #[test]
298 fn build_appender_honours_explicit_filename_prefix() {
299 let tmp = tempfile::tempdir().unwrap();
300 let cfg = LoggingConfig {
301 enabled: Some(true),
302 path: Some(tmp.path().to_string_lossy().into_owned()),
303 rotation: Some("never".to_string()),
304 filename_prefix: Some("custom-prefix".to_string()),
305 ..Default::default()
306 };
307 let _appender = build_appender(tmp.path(), &cfg).unwrap();
309 }
310
311 #[test]
312 fn resolve_log_dir_with_override_uses_cli_layer() {
313 let tmp = tempfile::tempdir().unwrap();
314 let cfg = LoggingConfig::default();
315 let r = resolve_log_dir_with_override(Some(tmp.path()), &cfg).unwrap();
316 assert_eq!(r.path, tmp.path());
317 assert_eq!(r.source, log_paths::PathSource::CliFlag);
318 }
319
320 #[cfg(unix)]
321 #[test]
322 fn resolve_log_dir_with_override_propagates_world_writable_error() {
323 use std::os::unix::fs::PermissionsExt;
324 let tmp = tempfile::tempdir().unwrap();
325 let bad = tmp.path().join("worldwrite");
326 std::fs::create_dir(&bad).unwrap();
327 std::fs::set_permissions(&bad, std::fs::Permissions::from_mode(0o777)).unwrap();
328 let cfg = LoggingConfig::default();
329 let err = resolve_log_dir_with_override(Some(&bad), &cfg).unwrap_err();
330 let msg = format!("{err}");
331 assert!(msg.contains("world-writable"), "got: {msg}");
332 }
333}