use std::io::Write;
use std::path::{Path, PathBuf};
use chrono::Utc;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct Audit {
log_path: PathBuf,
}
impl Audit {
pub fn new(data_dir: &Path) -> Self {
Self {
log_path: data_dir.join("audit.log"),
}
}
pub fn path(&self) -> &Path {
&self.log_path
}
pub fn record(&self, entry: AuditEntry) {
if let Err(e) = self.try_record(&entry) {
tracing::warn!(
error = %e,
path = %self.log_path.display(),
action = %entry.action,
"audit log write failed (ignored)"
);
}
}
fn try_record(&self, entry: &AuditEntry) -> std::io::Result<()> {
if let Some(parent) = self.log_path.parent() {
std::fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(entry).unwrap_or_else(|_| "{}".into());
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.log_path)?;
writeln!(file, "{line}")?;
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AuditEntry {
pub timestamp: chrono::DateTime<Utc>,
pub action: String,
pub detail: serde_json::Value,
}
impl AuditEntry {
pub fn new(action: impl Into<String>, detail: serde_json::Value) -> Self {
Self {
timestamp: Utc::now(),
action: action.into(),
detail,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicU64, Ordering};
static AUDIT_TMP_NONCE: AtomicU64 = AtomicU64::new(0);
fn tmp_dir() -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let seq = AUDIT_TMP_NONCE.fetch_add(1, Ordering::Relaxed);
let p = std::env::temp_dir().join(format!(
"anamnesis-audit-{nonce}-{pid}-{seq}",
pid = std::process::id()
));
std::fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn writes_one_jsonl_per_record() {
let dir = tmp_dir();
let audit = Audit::new(&dir);
audit.record(AuditEntry::new(
"import",
json!({"adapter": "claude-code", "records": 12}),
));
audit.record(AuditEntry::new(
"search",
json!({"query": "vim", "hits": 3}),
));
let body = std::fs::read_to_string(audit.path()).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
let first: AuditEntry = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first.action, "import");
assert_eq!(first.detail["adapter"], "claude-code");
let second: AuditEntry = serde_json::from_str(lines[1]).unwrap();
assert_eq!(second.action, "search");
}
#[test]
fn appends_when_log_exists() {
let dir = tmp_dir();
let audit = Audit::new(&dir);
audit.record(AuditEntry::new("first", json!({})));
audit.record(AuditEntry::new("second", json!({})));
audit.record(AuditEntry::new("third", json!({})));
let body = std::fs::read_to_string(audit.path()).unwrap();
assert_eq!(body.lines().count(), 3);
}
#[test]
fn creates_parent_directory_lazily() {
let dir = tmp_dir().join("nested/sub/dir");
assert!(!dir.exists());
let audit = Audit::new(&dir);
audit.record(AuditEntry::new("late", json!({})));
assert!(audit.path().exists(), "audit.log should have been created");
}
#[test]
fn missing_directory_does_not_propagate_error() {
let audit = Audit::new(Path::new(
"/nonexistent-anamnesis-path/that/cannot/be/created",
));
audit.record(AuditEntry::new("safe", json!({})));
}
}