use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const HUMAN: &str = "human";
pub const AGENT: &str = "agent";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub ts: String,
pub actor: String,
pub actor_id: String,
pub action: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
impl Event {
fn new(actor: &str, actor_id: &str, action: &str, target: Option<&str>) -> Self {
Self {
ts: Utc::now().to_rfc3339(),
actor: actor.to_string(),
actor_id: actor_id.to_string(),
action: action.to_string(),
target: target.map(|t| t.to_string()),
}
}
pub fn human(action: &str, target: Option<&str>) -> Self {
Self::new(HUMAN, ¤t_user(), action, target)
}
pub fn agent(caller: &str, action: &str, target: Option<&str>) -> Self {
Self::new(AGENT, caller, action, target)
}
pub fn timestamp(&self) -> Option<DateTime<Utc>> {
DateTime::parse_from_rfc3339(&self.ts)
.ok()
.map(|t| t.with_timezone(&Utc))
}
}
pub fn current_user() -> String {
std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_else(|_| HUMAN.to_string())
}
fn usage_path(vault_dir: &Path) -> PathBuf {
vault_dir.join("usage.log")
}
fn ensure_gitignored(vault_dir: &Path) {
const NEEDED: [&str; 3] = [".session", "audit.log", "usage.log"];
let gi = vault_dir.join(".gitignore");
let existing = std::fs::read_to_string(&gi).unwrap_or_default();
let have: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
let missing: Vec<&str> = NEEDED
.iter()
.copied()
.filter(|n| !have.contains(n))
.collect();
if missing.is_empty() {
return;
}
let mut content = existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
for n in missing {
content.push_str(n);
content.push('\n');
}
let _ = std::fs::write(&gi, content);
}
pub fn record(vault_dir: &Path, event: &Event) -> Result<()> {
let path = usage_path(vault_dir);
if !path.exists() {
ensure_gitignored(vault_dir);
}
let mut line = serde_json::to_string(event)?;
line.push('\n');
use std::io::Write;
#[cfg(unix)]
let mut f = {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.create(true)
.append(true)
.mode(0o600)
.open(&path)?
};
#[cfg(not(unix))]
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
f.write_all(line.as_bytes())?;
Ok(())
}
pub fn human(vault_dir: &Path, action: &str, target: Option<&str>) {
let _ = record(vault_dir, &Event::human(action, target));
}
pub fn agent(vault_dir: &Path, caller: &str, action: &str, target: Option<&str>) {
let _ = record(vault_dir, &Event::agent(caller, action, target));
}
pub fn recent(vault_dir: &Path, limit: usize) -> Vec<Event> {
let Ok(content) = std::fs::read_to_string(usage_path(vault_dir)) else {
return Vec::new();
};
let mut events: Vec<Event> = content
.lines()
.filter_map(|l| serde_json::from_str::<Event>(l.trim()).ok())
.collect();
events.reverse();
events.truncate(limit);
events
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn human_and_agent_events_record_and_read_newest_first() {
let dir = TempDir::new().unwrap();
let vd = dir.path();
human(vd, "unlock", None);
human(vd, "secret.reveal", Some("STRIPE_KEY"));
agent(vd, "claude", "get.allow", Some("DB_URL"));
let events = recent(vd, 10);
assert_eq!(events.len(), 3);
assert_eq!(events[0].actor, AGENT);
assert_eq!(events[0].actor_id, "claude");
assert_eq!(events[0].action, "get.allow");
assert_eq!(events[0].target.as_deref(), Some("DB_URL"));
assert_eq!(events[2].action, "unlock");
assert!(events[2].target.is_none());
}
#[test]
fn recent_limit_caps_results() {
let dir = TempDir::new().unwrap();
for _ in 0..5 {
human(dir.path(), "secret.list", None);
}
assert_eq!(recent(dir.path(), 3).len(), 3);
}
#[test]
fn recent_on_missing_log_is_empty() {
let dir = TempDir::new().unwrap();
assert!(recent(dir.path(), 10).is_empty());
}
#[test]
fn first_event_adds_usage_log_to_a_stale_gitignore() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(".gitignore"), ".session\naudit.log\n").unwrap();
human(dir.path(), "unlock", None);
let gi = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
assert!(gi.contains("usage.log"), "usage.log must be ignored: {gi}");
assert!(gi.contains(".session") && gi.contains("audit.log"));
}
#[test]
fn target_is_omitted_from_json_when_none() {
let e = Event::human("lock", None);
let json = serde_json::to_string(&e).unwrap();
assert!(!json.contains("target"));
}
}