1use anyhow::Result;
13use chrono::{NaiveDate, Utc};
14use std::fs;
15use std::path::{Path, PathBuf};
16use tokio_util::sync::CancellationToken;
17use tracing_appender::rolling::{RollingFileAppender, Rotation};
18use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
19
20use crate::constants::{
21 DEFAULT_LOG_MAX_FILES, DEFAULT_LOG_RETENTION_DAYS, LOG_DIR_NAME, LOG_FILE_NAME,
22};
23
24#[derive(Debug)]
26pub enum LoggerInitResult {
27 FileLogging,
29 ConsoleOnly,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum LogLevel {
36 Error,
37 Warn,
38 Info,
39 Debug,
40 Trace,
41}
42
43impl LogLevel {
44 pub fn parse(s: &str) -> Option<Self> {
46 match s.to_lowercase().as_str() {
47 "error" => Some(LogLevel::Error),
48 "warn" | "warning" => Some(LogLevel::Warn),
49 "info" => Some(LogLevel::Info),
50 "debug" => Some(LogLevel::Debug),
51 "trace" => Some(LogLevel::Trace),
52 _ => None,
53 }
54 }
55
56 pub fn as_str(&self) -> &'static str {
58 match self {
59 LogLevel::Error => "error",
60 LogLevel::Warn => "warn",
61 LogLevel::Info => "info",
62 LogLevel::Debug => "debug",
63 LogLevel::Trace => "trace",
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
70pub struct LogRotationConfig {
71 pub max_files: usize,
73 pub retention_days: i64,
75}
76
77impl LogRotationConfig {
78 pub fn from_env() -> Self {
80 Self {
81 max_files: std::env::var("CODESEARCH_LOG_MAX_FILES")
82 .ok()
83 .and_then(|s| s.parse().ok())
84 .unwrap_or(DEFAULT_LOG_MAX_FILES),
85 retention_days: std::env::var("CODESEARCH_LOG_RETENTION_DAYS")
86 .ok()
87 .and_then(|s| s.parse().ok())
88 .unwrap_or(DEFAULT_LOG_RETENTION_DAYS as i64),
89 }
90 }
91}
92
93pub fn get_log_dir(db_path: &Path) -> PathBuf {
95 db_path.join(LOG_DIR_NAME)
96}
97
98pub fn ensure_log_dir(log_dir: &Path) -> Result<()> {
100 if !log_dir.exists() {
101 fs::create_dir_all(log_dir)?;
102 tracing::debug!("Created log directory: {:?}", log_dir);
103 }
104 Ok(())
105}
106
107fn parse_log_date(file_name: &str) -> Option<NaiveDate> {
112 let suffix = file_name.strip_prefix(&format!("{}.", LOG_FILE_NAME))?;
114 NaiveDate::parse_from_str(suffix, "%Y-%m-%d").ok()
115}
116
117pub fn cleanup_old_logs(log_dir: &Path, config: &LogRotationConfig) -> Result<()> {
123 if !log_dir.exists() {
124 return Ok(());
125 }
126
127 let today = Utc::now().date_naive();
128
129 let mut dated_files: Vec<(NaiveDate, PathBuf)> = Vec::new();
131
132 for entry in fs::read_dir(log_dir)? {
133 let entry = entry?;
134 let path = entry.path();
135
136 if !path.is_file() {
137 continue;
138 }
139
140 if let Some(file_name) = path.file_name().and_then(|n| n.to_str()) {
141 if let Some(date) = parse_log_date(file_name) {
142 dated_files.push((date, path));
143 }
144 }
145 }
146
147 dated_files.sort_by_key(|(date, _)| *date);
149
150 let mut removed_count = 0u32;
151
152 dated_files.retain(|(date, path)| {
154 let age_days = (today - *date).num_days();
155 if age_days > config.retention_days {
156 if let Err(e) = fs::remove_file(path) {
157 tracing::warn!("Failed to remove old log file {:?}: {}", path, e);
158 } else {
159 tracing::debug!("Removed old log file {:?} (age: {} days)", path, age_days);
160 removed_count += 1;
161 }
162 false } else {
164 true }
166 });
167
168 if dated_files.len() > config.max_files {
170 let excess = dated_files.len() - config.max_files;
171 for (_, path) in dated_files.iter().take(excess) {
172 if let Err(e) = fs::remove_file(path) {
173 tracing::warn!("Failed to remove excess log file {:?}: {}", path, e);
174 } else {
175 tracing::debug!("Removed excess log file {:?}", path);
176 removed_count += 1;
177 }
178 }
179 }
180
181 if removed_count > 0 {
182 tracing::info!(
183 "Log cleanup: removed {} file(s) (retention={}d, max_files={})",
184 removed_count,
185 config.retention_days,
186 config.max_files
187 );
188 }
189
190 Ok(())
191}
192
193pub fn init_logger(db_path: &Path, log_level: LogLevel, quiet: bool) -> Result<LoggerInitResult> {
208 let log_dir = get_log_dir(db_path);
209 ensure_log_dir(&log_dir)?;
210
211 let config = LogRotationConfig::from_env();
212
213 let file_appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, LOG_FILE_NAME);
216
217 let filter_str = format!(
220 "{level},tantivy=warn,arroy=warn,ort=warn,h2=warn,hyper=warn,tower=warn",
221 level = log_level.as_str()
222 );
223 let env_filter =
224 EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&filter_str));
225
226 let subscriber = tracing_subscriber::registry().with(env_filter);
227
228 if quiet {
229 let result = subscriber
231 .with(
232 fmt::layer()
233 .with_writer(file_appender)
234 .with_ansi(false)
235 .with_target(true)
236 .with_thread_ids(false),
237 )
238 .try_init();
239
240 if let Err(e) = result {
241 eprintln!(
242 "Logger: subscriber already set ({}), file logging not active",
243 e
244 );
245 return Ok(LoggerInitResult::ConsoleOnly);
246 }
247 } else {
248 let result = subscriber
250 .with(
251 fmt::layer()
252 .with_writer(std::io::stderr)
253 .with_ansi(true)
254 .with_target(true)
255 .with_thread_ids(false),
256 )
257 .with(
258 fmt::layer()
259 .with_writer(file_appender)
260 .with_ansi(false)
261 .with_target(true)
262 .with_thread_ids(false),
263 )
264 .try_init();
265
266 if let Err(e) = result {
267 eprintln!(
268 "Logger: subscriber already set ({}), file logging not active",
269 e
270 );
271 return Ok(LoggerInitResult::ConsoleOnly);
272 }
273 }
274
275 tracing::info!(
276 "Logger initialized: level={}, log_dir={:?}, max_files={}, retention_days={}",
277 log_level.as_str(),
278 log_dir,
279 config.max_files,
280 config.retention_days,
281 );
282
283 Ok(LoggerInitResult::FileLogging)
284}
285
286pub fn start_cleanup_task(
291 log_dir: PathBuf,
292 config: LogRotationConfig,
293 cancel_token: CancellationToken,
294) -> tokio::task::JoinHandle<()> {
295 tokio::spawn(async move {
296 let cleanup_interval_hours: u64 = std::env::var("CODESEARCH_LOG_CLEANUP_INTERVAL_HOURS")
297 .ok()
298 .and_then(|s| s.parse().ok())
299 .unwrap_or(24);
300
301 let interval = std::time::Duration::from_secs(cleanup_interval_hours * 3600);
302
303 tracing::info!(
304 "Log cleanup task started: interval={}h, retention_days={}, max_files={}",
305 cleanup_interval_hours,
306 config.retention_days,
307 config.max_files,
308 );
309
310 loop {
311 tokio::select! {
312 _ = tokio::time::sleep(interval) => {
313 if let Err(e) = cleanup_old_logs(&log_dir, &config) {
314 tracing::error!("Failed to cleanup old logs: {}", e);
315 }
316 }
317 _ = cancel_token.cancelled() => {
318 tracing::info!("Log cleanup task stopped");
319 break;
320 }
321 }
322 }
323 })
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use std::fs::File;
330 use std::io::Write;
331 use tempfile::TempDir;
332
333 #[test]
334 fn test_log_level_parse() {
335 assert_eq!(LogLevel::parse("error"), Some(LogLevel::Error));
336 assert_eq!(LogLevel::parse("ERROR"), Some(LogLevel::Error));
337 assert_eq!(LogLevel::parse("warn"), Some(LogLevel::Warn));
338 assert_eq!(LogLevel::parse("warning"), Some(LogLevel::Warn));
339 assert_eq!(LogLevel::parse("info"), Some(LogLevel::Info));
340 assert_eq!(LogLevel::parse("debug"), Some(LogLevel::Debug));
341 assert_eq!(LogLevel::parse("trace"), Some(LogLevel::Trace));
342 assert_eq!(LogLevel::parse("invalid"), None);
343 }
344
345 #[test]
346 fn test_log_level_as_str() {
347 assert_eq!(LogLevel::Error.as_str(), "error");
348 assert_eq!(LogLevel::Warn.as_str(), "warn");
349 assert_eq!(LogLevel::Info.as_str(), "info");
350 assert_eq!(LogLevel::Debug.as_str(), "debug");
351 assert_eq!(LogLevel::Trace.as_str(), "trace");
352 }
353
354 #[test]
355 fn test_log_rotation_config_from_env() {
356 let config = LogRotationConfig::from_env();
357 assert!(config.max_files > 0);
358 assert!(config.retention_days > 0);
359 }
360
361 #[test]
362 fn test_get_log_dir() {
363 let db_path = PathBuf::from("/test/db");
364 let log_dir = get_log_dir(&db_path);
365 assert_eq!(log_dir, PathBuf::from("/test/db/logs"));
366 }
367
368 #[test]
369 fn test_parse_log_date() {
370 assert_eq!(
371 parse_log_date("codesearch.log.2026-02-09"),
372 Some(NaiveDate::from_ymd_opt(2026, 2, 9).unwrap())
373 );
374 assert_eq!(parse_log_date("codesearch.log"), None);
375 assert_eq!(parse_log_date("codesearch.log.1"), None);
376 assert_eq!(parse_log_date("other.log.2026-02-09"), None);
377 }
378
379 #[test]
380 fn test_cleanup_old_logs_by_retention() {
381 let temp_dir = TempDir::new().unwrap();
382 let log_dir = temp_dir.path();
383
384 let today = Utc::now().date_naive();
386 let recent_name = format!("{}.{}", LOG_FILE_NAME, today.format("%Y-%m-%d"));
387 let recent_path = log_dir.join(&recent_name);
388 let mut f = File::create(&recent_path).unwrap();
389 write!(f, "recent log").unwrap();
390
391 let old_date = today - chrono::Duration::days(10);
393 let old_name = format!("{}.{}", LOG_FILE_NAME, old_date.format("%Y-%m-%d"));
394 let old_path = log_dir.join(&old_name);
395 let mut f = File::create(&old_path).unwrap();
396 write!(f, "old log").unwrap();
397
398 let config = LogRotationConfig {
399 max_files: 100, retention_days: 5,
401 };
402
403 cleanup_old_logs(log_dir, &config).unwrap();
404
405 assert!(recent_path.exists(), "Recent log file should be retained");
407 assert!(!old_path.exists(), "Old log file should be removed");
409 }
410
411 #[test]
412 fn test_cleanup_old_logs_by_max_files() {
413 let temp_dir = TempDir::new().unwrap();
414 let log_dir = temp_dir.path();
415
416 let today = Utc::now().date_naive();
417
418 let mut paths = Vec::new();
420 for i in 0..5 {
421 let date = today - chrono::Duration::days(i);
422 let name = format!("{}.{}", LOG_FILE_NAME, date.format("%Y-%m-%d"));
423 let path = log_dir.join(&name);
424 let mut f = File::create(&path).unwrap();
425 write!(f, "log day {}", i).unwrap();
426 paths.push(path);
427 }
428
429 let config = LogRotationConfig {
430 max_files: 3,
431 retention_days: 30, };
433
434 cleanup_old_logs(log_dir, &config).unwrap();
435
436 assert!(paths[0].exists(), "Today's log should remain");
438 assert!(paths[1].exists(), "Yesterday's log should remain");
439 assert!(paths[2].exists(), "2 days ago log should remain");
440 assert!(!paths[3].exists(), "3 days ago log should be removed");
442 assert!(!paths[4].exists(), "4 days ago log should be removed");
443 }
444
445 #[test]
446 fn test_cleanup_empty_dir() {
447 let temp_dir = TempDir::new().unwrap();
448 let config = LogRotationConfig {
449 max_files: 5,
450 retention_days: 5,
451 };
452 assert!(cleanup_old_logs(temp_dir.path(), &config).is_ok());
454 }
455
456 #[test]
457 fn test_cleanup_nonexistent_dir() {
458 let config = LogRotationConfig {
459 max_files: 5,
460 retention_days: 5,
461 };
462 assert!(cleanup_old_logs(Path::new("/nonexistent/path"), &config).is_ok());
464 }
465}