use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditRecord {
pub ts: DateTime<Utc>,
pub session_id: String,
pub agent: String,
pub profile: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub policy_path: Option<String>,
pub exit_code: i32,
pub duration_secs: u64,
pub tier: String,
pub net_mode: String,
pub blocked_count: u64,
pub events_total: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub report_path: Option<String>,
}
pub fn default_history_path() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.map(|home| home.join(".vetto").join("history.jsonl"))
}
pub fn record_session_history(record: &AuditRecord) -> Result<()> {
let Some(path) = default_history_path() else {
return Ok(());
};
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
append_record_to_file(&path, record)
}
pub fn append_record_to_file(path: &Path, record: &AuditRecord) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("open history file {}", path.display()))?;
let mut line = serde_json::to_string(record)?;
line.push('\n');
file.write_all(line.as_bytes())
.with_context(|| format!("write to history file {}", path.display()))?;
Ok(())
}
pub fn read_history(path: &Path) -> Result<Vec<AuditRecord>> {
if !path.exists() {
return Ok(Vec::new());
}
let file = File::open(path).with_context(|| format!("open {}", path.display()))?;
let reader = BufReader::new(file);
let mut records = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => continue,
};
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Ok(rec) = serde_json::from_str::<AuditRecord>(trimmed) {
records.push(rec);
}
}
Ok(records)
}
pub fn filter_records<'a>(
records: &'a [AuditRecord],
since: Option<DateTime<Utc>>,
agent_filter: Option<&str>,
query: Option<&str>,
) -> Vec<&'a AuditRecord> {
let agent_filter = agent_filter.map(|a| a.trim().to_ascii_lowercase());
let query = query.map(|q| q.trim().to_ascii_lowercase());
records
.iter()
.filter(|rec| {
if let Some(cutoff) = since {
if rec.ts < cutoff {
return false;
}
}
if let Some(ref agent) = agent_filter {
if !rec.agent.to_ascii_lowercase().contains(agent) {
return false;
}
}
if let Some(ref q) = query {
if q.is_empty() {
return true;
}
let in_policy = rec
.policy_path
.as_deref()
.unwrap_or("")
.to_ascii_lowercase()
.contains(q);
let in_profile = rec.profile.to_ascii_lowercase().contains(q);
let in_agent = rec.agent.to_ascii_lowercase().contains(q);
let in_session = rec.session_id.to_ascii_lowercase().contains(q);
if !in_policy && !in_profile && !in_agent && !in_session {
return false;
}
}
true
})
.collect()
}
pub fn parse_since_duration(s: &str) -> Result<DateTime<Utc>> {
let s = s.trim();
let now = Utc::now();
if let Some(rest) = s.strip_suffix('h') {
let hours: i64 = rest.parse().context("invalid hours in --since")?;
return Ok(now - Duration::hours(hours));
}
if let Some(rest) = s.strip_suffix('d') {
let days: i64 = rest.parse().context("invalid days in --since")?;
return Ok(now - Duration::days(days));
}
if let Some(rest) = s.strip_suffix('m') {
let mins: i64 = rest.parse().context("invalid minutes in --since")?;
return Ok(now - Duration::minutes(mins));
}
if let Some(rest) = s.strip_suffix('s') {
let secs: i64 = rest.parse().context("invalid seconds in --since")?;
return Ok(now - Duration::seconds(secs));
}
if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
return Ok(dt.with_timezone(&Utc));
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
if let Some(dt) = d.and_hms_opt(0, 0, 0) {
return Ok(DateTime::from_naive_utc_and_offset(dt, Utc));
}
}
anyhow::bail!("invalid --since value '{s}'; expected e.g. 24h, 7d, 30m, or YYYY-MM-DD")
}
pub fn run_audit(
since: Option<&str>,
agent: Option<&str>,
limit: Option<usize>,
query: Option<&str>,
json_output: bool,
) -> Result<()> {
let history_file = default_history_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve history file path"))?;
let records = read_history(&history_file)?;
let cutoff = match since {
Some(s) => Some(parse_since_duration(s)?),
None => None,
};
let mut filtered = filter_records(&records, cutoff, agent, query);
filtered.sort_by_key(|a| std::cmp::Reverse(a.ts));
if let Some(lim) = limit {
filtered.truncate(lim);
}
if json_output {
for record in filtered {
println!("{}", serde_json::to_string(record)?);
}
return Ok(());
}
if filtered.is_empty() {
println!(
"No matching audit history entries found in {}.",
history_file.display()
);
return Ok(());
}
println!(
"{:<19} {:<12} {:<10} {:<10} {:<4} {:<7} {:<8} {:<6}",
"TIMESTAMP", "SESSION ID", "AGENT", "PROFILE", "EXIT", "BLOCKED", "TIER", "DUR"
);
println!(
"{:-<19} {:-<12} {:-<10} {:-<10} {:-<4} {:-<7} {:-<8} {:-<6}",
"", "", "", "", "", "", "", ""
);
for r in filtered {
let ts_str = r.ts.format("%Y-%m-%d %H:%M:%S").to_string();
let dur_str = format!("{}s", r.duration_secs);
println!(
"{:<19} {:<12} {:<10} {:<10} {:<4} {:<7} {:<8} {:<6}",
ts_str,
truncate_str(&r.session_id, 12),
truncate_str(&r.agent, 10),
truncate_str(&r.profile, 10),
r.exit_code,
r.blocked_count,
truncate_str(&r.tier, 8),
dur_str,
);
}
Ok(())
}
fn truncate_str(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}…", &s[..max_len.saturating_sub(1)])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_since_supports_various_units() {
let now = Utc::now();
let dt_h = parse_since_duration("24h").expect("24h");
assert!(now - dt_h >= Duration::hours(23));
let dt_d = parse_since_duration("7d").expect("7d");
assert!(now - dt_d >= Duration::days(6));
let dt_m = parse_since_duration("30m").expect("30m");
assert!(now - dt_m >= Duration::minutes(29));
let dt_s = parse_since_duration("60s").expect("60s");
assert!(now - dt_s >= Duration::seconds(59));
assert!(parse_since_duration("invalid").is_err());
}
#[test]
fn filtering_records_matches_agent_and_query() {
let records = vec![
AuditRecord {
ts: Utc::now(),
session_id: "session-1".into(),
agent: "codex".into(),
profile: "default".into(),
policy_path: Some("/home/user/vetto.toml".into()),
exit_code: 0,
duration_secs: 10,
tier: "full".into(),
net_mode: "off".into(),
blocked_count: 0,
events_total: 10,
report_path: None,
},
AuditRecord {
ts: Utc::now() - Duration::hours(48),
session_id: "session-2".into(),
agent: "claude".into(),
profile: "strict".into(),
policy_path: None,
exit_code: 1,
duration_secs: 5,
tier: "fs-only".into(),
net_mode: "off".into(),
blocked_count: 2,
events_total: 8,
report_path: None,
},
];
let cutoff = Utc::now() - Duration::hours(24);
let recent = filter_records(&records, Some(cutoff), None, None);
assert_eq!(recent.len(), 1);
assert_eq!(recent[0].session_id, "session-1");
let claude_only = filter_records(&records, None, Some("claude"), None);
assert_eq!(claude_only.len(), 1);
assert_eq!(claude_only[0].agent, "claude");
let query_strict = filter_records(&records, None, None, Some("strict"));
assert_eq!(query_strict.len(), 1);
assert_eq!(query_strict[0].profile, "strict");
}
}