use anyhow::Result;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU8, Ordering};
pub const HUMAN: &str = "human";
pub const AGENT: &str = "agent";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Cli,
Tui,
Gui,
Mcp,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Cli => "cli",
Source::Tui => "tui",
Source::Gui => "gui",
Source::Mcp => "mcp",
}
}
fn from_u8(v: u8) -> Source {
match v {
1 => Source::Tui,
2 => Source::Gui,
3 => Source::Mcp,
_ => Source::Cli,
}
}
}
static SOURCE: AtomicU8 = AtomicU8::new(Source::Cli as u8);
pub fn set_source(s: Source) {
SOURCE.store(s as u8, Ordering::Relaxed);
}
pub fn source() -> Source {
Source::from_u8(SOURCE.load(Ordering::Relaxed))
}
fn unknown_source() -> String {
String::new()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Event {
pub ts: String,
pub actor: String,
pub actor_id: String,
#[serde(default = "unknown_source")]
pub source: 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(),
source: source().as_str().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"));
}
#[test]
fn events_are_stamped_with_the_current_source() {
set_source(Source::Tui);
assert_eq!(Event::human("unlock", None).source, "tui");
set_source(Source::Cli);
assert_eq!(Event::agent("claude", "get.allow", None).source, "cli");
}
#[test]
fn source_missing_from_old_logs_parses_as_unknown() {
let line =
r#"{"ts":"2026-01-01T00:00:00Z","actor":"human","actor_id":"nk","action":"unlock"}"#;
let e: Event = serde_json::from_str(line).unwrap();
assert_eq!(e.source, "");
}
}