use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use super::events::AuditEvent;
use crate::ipc::protocol::data_dir;
const AUDIT_FILE: &str = "detection.ndjson";
pub struct AuditLogger {
enabled: bool,
max_size_bytes: u64,
file_path: PathBuf,
writer: Option<BufWriter<File>>,
}
impl AuditLogger {
pub fn new(enabled: bool, max_size_bytes: u64) -> Self {
let file_path = data_dir().join("audit").join(AUDIT_FILE);
let writer = if enabled {
Self::open_writer(&file_path)
} else {
None
};
Self {
enabled,
max_size_bytes,
file_path,
writer,
}
}
pub fn log(&mut self, event: &AuditEvent) {
if !self.enabled {
return;
}
if self.writer.is_none() {
self.writer = Self::open_writer(&self.file_path);
}
let writer = match self.writer.as_mut() {
Some(w) => w,
None => return,
};
if let Ok(json) = serde_json::to_string(event) {
if writeln!(writer, "{}", json).is_ok() {
let _ = writer.flush();
} else {
self.writer = None;
}
}
self.maybe_rotate();
}
fn open_writer(file_path: &Path) -> Option<BufWriter<File>> {
if let Some(dir) = file_path.parent() {
if let Err(e) = fs::create_dir_all(dir) {
eprintln!("Failed to create audit directory: {}", e);
return None;
}
}
match OpenOptions::new().create(true).append(true).open(file_path) {
Ok(file) => Some(BufWriter::new(file)),
Err(e) => {
eprintln!("Failed to open audit log: {}", e);
None
}
}
}
fn maybe_rotate(&mut self) {
let metadata = match fs::metadata(&self.file_path) {
Ok(m) => m,
Err(_) => return,
};
if metadata.len() >= self.max_size_bytes {
self.writer = None;
let rotated = self.file_path.with_extension("ndjson.1");
let _ = fs::rename(&self.file_path, &rotated);
self.writer = Self::open_writer(&self.file_path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detectors::{DetectionConfidence, DetectionReason};
#[test]
fn test_disabled_logger_is_noop() {
let mut logger = AuditLogger::new(false, 1024);
let event = AuditEvent::AgentAppeared {
ts: 1234567890,
pane_id: "1".to_string(),
agent_type: "ClaudeCode".to_string(),
source: "capture_pane".to_string(),
initial_status: "idle".to_string(),
};
logger.log(&event);
}
#[test]
fn test_ndjson_output() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.ndjson");
let mut logger = AuditLogger {
enabled: true,
max_size_bytes: 10_485_760,
file_path: file_path.clone(),
writer: None,
};
let event = AuditEvent::AgentAppeared {
ts: 1234567890,
pane_id: "1".to_string(),
agent_type: "ClaudeCode".to_string(),
source: "capture_pane".to_string(),
initial_status: "idle".to_string(),
};
logger.log(&event);
let content = fs::read_to_string(&file_path).unwrap();
let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
assert_eq!(parsed["event"], "AgentAppeared");
assert_eq!(parsed["pane_id"], "1");
}
#[test]
fn test_rotation() {
let dir = tempfile::tempdir().unwrap();
let file_path = dir.path().join("test.ndjson");
let mut logger = AuditLogger {
enabled: true,
max_size_bytes: 100, file_path: file_path.clone(),
writer: None,
};
for i in 0..10 {
let event = AuditEvent::AgentAppeared {
ts: i,
pane_id: format!("pane-{}", i),
agent_type: "ClaudeCode".to_string(),
source: "capture_pane".to_string(),
initial_status: "idle".to_string(),
};
logger.log(&event);
}
let rotated = file_path.with_extension("ndjson.1");
assert!(rotated.exists(), "Rotated file should exist");
}
#[test]
fn test_state_changed_event_serialization() {
let event = AuditEvent::StateChanged {
ts: 1234567890,
pane_id: "5".to_string(),
agent_type: "ClaudeCode".to_string(),
source: "capture_pane".to_string(),
prev_status: "idle".to_string(),
new_status: "processing".to_string(),
reason: DetectionReason {
rule: "braille_spinner".to_string(),
confidence: DetectionConfidence::Medium,
matched_text: Some("⠋ Working".to_string()),
},
screen_context: None,
prev_state_duration_ms: None,
approval_type: None,
approval_details: None,
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "StateChanged");
assert_eq!(parsed["reason"]["rule"], "braille_spinner");
assert_eq!(parsed["reason"]["confidence"], "Medium");
assert!(parsed.get("approval_type").is_none());
assert!(parsed.get("approval_details").is_none());
}
#[test]
fn test_state_changed_with_approval_info() {
let event = AuditEvent::StateChanged {
ts: 1234567890,
pane_id: "5".to_string(),
agent_type: "ClaudeCode".to_string(),
source: "capture_pane".to_string(),
prev_status: "idle".to_string(),
new_status: "awaiting_approval".to_string(),
reason: DetectionReason {
rule: "proceed_prompt".to_string(),
confidence: DetectionConfidence::High,
matched_text: None,
},
screen_context: None,
prev_state_duration_ms: Some(5000),
approval_type: Some("user_question".to_string()),
approval_details: Some("Do you want to proceed?".to_string()),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["approval_type"], "user_question");
assert_eq!(parsed["approval_details"], "Do you want to proceed?");
}
}