use std::fs;
use std::path::Path;
use chrono::{DateTime, Utc};
use crate::profile::errors::ProfileError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuditAction {
Query { result_summary: String },
Skip { reason: String },
}
#[derive(Debug, Clone)]
pub struct AuditEntry {
pub timestamp: DateTime<Utc>,
pub action: AuditAction,
pub subject: String,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Default)]
pub struct AuditLog {
entries: Vec<AuditEntry>,
}
impl AuditLog {
pub fn new() -> Self {
Self::default()
}
pub fn record_query(&mut self, sql: &str, duration_ms: u64, result_summary: impl Into<String>) {
self.entries.push(AuditEntry {
timestamp: Utc::now(),
action: AuditAction::Query {
result_summary: result_summary.into(),
},
subject: sql.to_string(),
duration_ms,
});
}
pub fn record_skip(&mut self, subject: &str, reason: impl Into<String>) {
self.entries.push(AuditEntry {
timestamp: Utc::now(),
action: AuditAction::Skip {
reason: reason.into(),
},
subject: subject.to_string(),
duration_ms: 0,
});
}
pub fn entries(&self) -> &[AuditEntry] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn query_count(&self) -> usize {
self.entries
.iter()
.filter(|e| matches!(e.action, AuditAction::Query { .. }))
.count()
}
pub fn skip_count(&self) -> usize {
self.entries
.iter()
.filter(|e| matches!(e.action, AuditAction::Skip { .. }))
.count()
}
pub fn render(&self) -> String {
let mut out = String::new();
for e in &self.entries {
let ts = e.timestamp.to_rfc3339();
match &e.action {
AuditAction::Query { result_summary } => {
out.push_str(&format!(
"{ts} QUERY {} → {result_summary} ({}ms)\n",
e.subject, e.duration_ms
));
}
AuditAction::Skip { reason } => {
out.push_str(&format!("{ts} SKIP {} — {reason}\n", e.subject));
}
}
}
out
}
pub fn write_to_file(&self, path: &Path) -> Result<(), ProfileError> {
fs::write(path, self.render())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_audit_log_records_queries_and_skips() {
let mut log = AuditLog::new();
assert!(log.is_empty());
log.record_query("SELECT COUNT(*) FROM \"users\"", 1, "100 rows");
log.record_skip(
"\"users\".\"password_hash\"",
"sensitive column (pattern: password)",
);
assert_eq!(log.len(), 2);
assert_eq!(log.query_count(), 1);
assert_eq!(log.skip_count(), 1);
}
#[test]
fn test_audit_render_contains_query_and_skip_lines() {
let mut log = AuditLog::new();
log.record_query("SELECT COUNT(*) FROM \"users\"", 2, "100 rows");
log.record_skip(
"\"users\".\"reset_token\"",
"sensitive column (pattern: token)",
);
let text = log.render();
assert!(text.contains("QUERY"));
assert!(text.contains("SELECT COUNT(*) FROM \"users\""));
assert!(text.contains("→ 100 rows"));
assert!(text.contains("SKIP"));
assert!(text.contains("reset_token"));
assert!(text.contains("pattern: token"));
}
#[test]
fn test_audit_write_to_file_roundtrips() {
let mut log = AuditLog::new();
log.record_query("SELECT COUNT(*) FROM \"orders\"", 3, "354281 rows");
let dir = std::env::temp_dir();
let path = dir.join(format!("seedgen-audit-test-{}.log", std::process::id()));
log.write_to_file(&path).expect("write audit log");
let read = std::fs::read_to_string(&path).expect("read audit log");
assert!(read.contains("354281 rows"));
let _ = std::fs::remove_file(&path);
}
}