pub mod daemon_log;
pub mod tamper_log;
use std::io::Write;
use std::path::{Path, PathBuf};
use tokio::sync::mpsc;
pub fn log_file_name(date: &chrono::NaiveDate) -> String {
format!("events-{}.jsonl", date.format("%Y-%m-%d"))
}
pub fn current_log_path(log_dir: &Path) -> PathBuf {
let today = chrono::Utc::now().date_naive();
log_dir.join(log_file_name(&today))
}
pub fn append_event_sync(path: &Path, event: &serde_json::Value) -> std::io::Result<()> {
let line = serde_json::to_string(event).map_err(std::io::Error::other)?;
append_line_sync(path, &line)
}
fn append_line_sync(path: &Path, line: &str) -> std::io::Result<()> {
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
writeln!(file, "{}", line)?;
Ok(())
}
#[derive(Clone)]
pub struct EventLogger {
tx: mpsc::Sender<String>,
}
pub struct EventLoggerHandle {
join_handle: tokio::task::JoinHandle<()>,
}
impl EventLogger {
pub fn new(log_dir: PathBuf) -> (Self, EventLoggerHandle) {
let (tx, rx) = mpsc::channel(1024);
let handle = EventLoggerHandle {
join_handle: tokio::spawn(writer_task(log_dir, rx)),
};
(Self { tx }, handle)
}
pub fn log(&self, event_json: String) {
if self.tx.try_send(event_json).is_err() {
tracing::warn!("event log channel full or closed, event dropped");
}
}
}
impl EventLoggerHandle {
pub async fn shutdown(self) {
let _ = self.join_handle.await;
}
}
async fn writer_task(log_dir: PathBuf, mut rx: mpsc::Receiver<String>) {
if let Err(e) = tokio::fs::create_dir_all(&log_dir).await {
tracing::error!(error = %e, "failed to create log directory");
return;
}
while let Some(json_line) = rx.recv().await {
let path = current_log_path(&log_dir);
if let Err(e) = append_line_sync(&path, &json_line) {
tracing::warn!(error = %e, "failed to append event to log");
}
}
tracing::debug!("event logger writer task shutting down");
}
pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> std::io::Result<u32> {
let cutoff = chrono::Utc::now().date_naive() - chrono::Duration::days(retention_days as i64);
let mut deleted = 0u32;
for entry in std::fs::read_dir(log_dir)? {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if let Some(date_str) = name_str
.strip_prefix("events-")
.and_then(|s| s.strip_suffix(".jsonl"))
{
if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
if date < cutoff {
std::fs::remove_file(entry.path())?;
deleted += 1;
}
}
}
}
Ok(deleted)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::BufRead;
use tempfile::TempDir;
fn make_date(year: i32, month: u32, day: u32) -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap()
}
#[test]
fn test_log_file_name_format() {
let date = make_date(2026, 4, 7);
assert_eq!(log_file_name(&date), "events-2026-04-07.jsonl");
}
#[test]
fn test_append_event_sync_writes_valid_json() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.jsonl");
let event = json!({"tool": "bash", "session": "abc123"});
append_event_sync(&path, &event).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
let line = content.lines().next().unwrap();
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert_eq!(parsed["tool"], "bash");
}
#[test]
fn test_append_event_sync_single_line() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.jsonl");
let event = json!({"key": "value"});
append_event_sync(&path, &event).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.ends_with('\n'), "file must end with newline");
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 1, "must produce exactly one line");
}
#[test]
fn test_append_event_sync_two_calls_two_lines() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("test.jsonl");
let event1 = json!({"seq": 1});
let event2 = json!({"seq": 2});
append_event_sync(&path, &event1).unwrap();
append_event_sync(&path, &event2).unwrap();
let file = std::fs::File::open(&path).unwrap();
let lines: Vec<_> = std::io::BufReader::new(file)
.lines()
.collect::<Result<_, _>>()
.unwrap();
assert_eq!(lines.len(), 2, "must produce exactly two lines");
let parsed1: serde_json::Value = serde_json::from_str(&lines[0]).unwrap();
let parsed2: serde_json::Value = serde_json::from_str(&lines[1]).unwrap();
assert_eq!(parsed1["seq"], 1);
assert_eq!(parsed2["seq"], 2);
}
#[test]
fn test_cleanup_old_logs_retention_zero_deletes_all() {
let dir = TempDir::new().unwrap();
let yesterday = chrono::Utc::now().date_naive() - chrono::Duration::days(1);
let two_days_ago = chrono::Utc::now().date_naive() - chrono::Duration::days(2);
std::fs::write(dir.path().join(log_file_name(&yesterday)), b"line\n").unwrap();
std::fs::write(dir.path().join(log_file_name(&two_days_ago)), b"line\n").unwrap();
let deleted = cleanup_old_logs(dir.path(), 0).unwrap();
assert_eq!(deleted, 2, "retention_days=0 must delete all past files");
let remaining: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.collect::<Result<_, _>>()
.unwrap();
assert!(remaining.is_empty(), "no files should remain");
}
#[test]
fn test_cleanup_old_logs_retention_keeps_recent() {
let dir = TempDir::new().unwrap();
let recent = chrono::Utc::now().date_naive() - chrono::Duration::days(5);
let old = chrono::Utc::now().date_naive() - chrono::Duration::days(40);
let recent_file = dir.path().join(log_file_name(&recent));
let old_file = dir.path().join(log_file_name(&old));
std::fs::write(&recent_file, b"line\n").unwrap();
std::fs::write(&old_file, b"line\n").unwrap();
let deleted = cleanup_old_logs(dir.path(), 30).unwrap();
assert_eq!(deleted, 1, "only the old file should be deleted");
assert!(recent_file.exists(), "recent file must not be deleted");
assert!(!old_file.exists(), "old file must be deleted");
}
#[tokio::test]
async fn test_event_logger_sends_and_appends() {
let dir = TempDir::new().unwrap();
let log_dir = dir.path().to_path_buf();
let (logger, handle) = EventLogger::new(log_dir.clone());
let event = json!({"tool": "write_file", "session": "sess_001"});
let event_str = serde_json::to_string(&event).unwrap();
logger.log(event_str);
drop(logger);
handle.shutdown().await;
let today = chrono::Utc::now().date_naive();
let log_path = log_dir.join(log_file_name(&today));
assert!(log_path.exists(), "log file must be created");
let content = std::fs::read_to_string(&log_path).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(content.lines().next().unwrap()).unwrap();
assert_eq!(parsed["tool"], "write_file");
}
}