use super::{auto_import_storage_ctx_if_stale, resolve_issue_id};
use crate::cli::{AuditCommands, AuditLabelArgs, AuditLogArgs, AuditRecordArgs, AuditSummaryArgs};
use crate::config;
use crate::error::{BeadsError, Result};
use crate::model::EventType;
use crate::output::{OutputContext, Theme};
use crate::sync::require_valid_sync_path;
use crate::util::id::{IdResolver, ResolverConfig};
use chrono::{DateTime, Utc};
use rich_rust::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::io::{self, BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
static ID_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
struct AuditEntry {
id: Option<String>,
kind: String,
created_at: Option<DateTime<Utc>>,
#[serde(skip_serializing_if = "Option::is_none")]
actor: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
issue_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
response: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
parent_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
label: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
extra: Option<serde_json::Map<String, serde_json::Value>>,
}
#[derive(Debug, Serialize)]
struct AuditRecordOutput {
id: String,
kind: String,
}
#[derive(Debug, Serialize)]
struct AuditLabelOutput {
id: String,
parent_id: String,
label: String,
}
#[derive(Debug, Serialize)]
struct AuditLogOutput {
issue_id: String,
events: Vec<AuditEventOutput>,
}
#[derive(Debug, Serialize)]
struct AuditEventOutput {
id: i64,
event_type: String,
actor: String,
timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
old_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
new_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
comment: Option<String>,
}
#[derive(Debug, Serialize)]
struct AuditSummaryOutput {
period_days: u32,
totals: AuditTotals,
actors: Vec<ActorSummary>,
}
#[derive(Debug, Serialize, Default)]
struct AuditTotals {
created: usize,
updated: usize,
closed: usize,
comments: usize,
total: usize,
}
#[derive(Debug, Serialize)]
struct ActorSummary {
actor: String,
created: usize,
updated: usize,
closed: usize,
comments: usize,
total: usize,
}
pub fn execute(
command: &AuditCommands,
_json: bool,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let beads_dir = config::discover_beads_dir_with_cli(cli)?;
let layer = config::load_config(&beads_dir, None, cli)?;
let actor = config::resolve_actor(&layer);
match command {
AuditCommands::Record(args) => record_entry(args, &beads_dir, &actor, ctx),
AuditCommands::Label(args) => label_entry(args, &beads_dir, &actor, ctx),
AuditCommands::Log(args) => execute_log(args, &beads_dir, cli, ctx),
AuditCommands::Summary(args) => execute_summary(args, &beads_dir, cli, ctx),
}
}
fn execute_log(
args: &AuditLogArgs,
beads_dir: &Path,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let (storage_ctx, issue_id) = open_routed_storage_for_issue_input(beads_dir, cli, &args.id)?;
let events = storage_ctx.storage.get_events(&issue_id, 0)?;
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_toon() {
let output = AuditLogOutput {
issue_id,
events: events.iter().map(map_event_to_output).collect(),
};
ctx.toon(&output);
return Ok(());
}
if ctx.is_json() {
let output = AuditLogOutput {
issue_id,
events: events.iter().map(map_event_to_output).collect(),
};
ctx.json_pretty(&output);
return Ok(());
}
if ctx.is_rich() {
render_audit_log_rich(&issue_id, &events, ctx);
} else {
render_audit_log_plain(&issue_id, &events);
}
Ok(())
}
fn open_routed_storage_for_issue_input(
local_beads_dir: &Path,
cli: &config::CliOverrides,
issue_input: &str,
) -> Result<(config::OpenStorageResult, String)> {
let route = config::routing::resolve_route(issue_input, local_beads_dir)?;
let mut route_cli = cli.clone();
if route.is_external {
route_cli.db = None;
}
let mut storage_ctx = config::open_storage_with_cli(&route.beads_dir, &route_cli)?;
auto_import_storage_ctx_if_stale(&mut storage_ctx, &route_cli)?;
let config_layer = storage_ctx.load_config(&route_cli)?;
let id_config = config::id_config_from_layer(&config_layer);
let resolver = IdResolver::new(ResolverConfig::with_prefix(id_config.prefix));
let issue_id = resolve_issue_id(&storage_ctx.storage, &resolver, issue_input)?;
Ok((storage_ctx, issue_id))
}
fn execute_summary(
args: &AuditSummaryArgs,
beads_dir: &Path,
cli: &config::CliOverrides,
ctx: &OutputContext,
) -> Result<()> {
let storage_ctx = config::open_storage_with_cli(beads_dir, cli)?;
let events = storage_ctx.storage.get_all_events(0)?;
let cutoff = Utc::now() - chrono::Duration::days(i64::from(args.days));
let filtered_events: Vec<_> = events
.into_iter()
.filter(|e| e.created_at >= cutoff)
.collect();
let mut actor_map: HashMap<String, ActorSummary> = HashMap::new();
let mut totals = AuditTotals::default();
for event in &filtered_events {
let entry = actor_map
.entry(event.actor.clone())
.or_insert_with(|| ActorSummary {
actor: event.actor.clone(),
created: 0,
updated: 0,
closed: 0,
comments: 0,
total: 0,
});
match event.event_type {
EventType::Created => {
entry.created += 1;
totals.created += 1;
}
EventType::Closed => {
entry.closed += 1;
totals.closed += 1;
}
EventType::Commented => {
entry.comments += 1;
totals.comments += 1;
}
_ => {
entry.updated += 1;
totals.updated += 1;
}
}
entry.total += 1;
totals.total += 1;
}
let mut actors: Vec<_> = actor_map.into_values().collect();
actors.sort_by_key(|b| std::cmp::Reverse(b.total));
if ctx.is_quiet() {
return Ok(());
}
if ctx.is_toon() {
let output = AuditSummaryOutput {
period_days: args.days,
totals,
actors,
};
ctx.toon(&output);
return Ok(());
}
if ctx.is_json() {
let output = AuditSummaryOutput {
period_days: args.days,
totals,
actors,
};
ctx.json_pretty(&output);
return Ok(());
}
if ctx.is_rich() {
render_audit_summary_rich(args.days, &totals, &actors, ctx);
} else {
render_audit_summary_plain(args.days, &totals, &actors);
}
Ok(())
}
fn map_event_to_output(event: &crate::model::Event) -> AuditEventOutput {
AuditEventOutput {
id: event.id,
event_type: event.event_type.as_str().to_string(),
actor: event.actor.clone(),
timestamp: event.created_at,
old_value: event.old_value.clone(),
new_value: event.new_value.clone(),
comment: event.comment.clone(),
}
}
fn record_entry(
args: &AuditRecordArgs,
beads_dir: &Path,
actor: &str,
ctx: &OutputContext,
) -> Result<()> {
let use_stdin = args.stdin;
let mut entry = if use_stdin {
let mut input = String::new();
io::stdin()
.take(10 * 1024 * 1024)
.read_to_string(&mut input)?;
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(BeadsError::validation(
"stdin",
"expected JSON input but stdin was empty",
));
}
let mut entry: AuditEntry = serde_json::from_str(trimmed)?;
if let Some(override_actor) = clean_actor(actor) {
entry.actor = Some(override_actor);
}
entry
} else {
let kind = args
.kind
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| BeadsError::validation("kind", "required"))?
.to_string();
AuditEntry {
id: None,
kind,
created_at: None,
actor: clean_actor(actor),
issue_id: clean_opt(args.issue_id.as_deref()),
model: clean_opt(args.model.as_deref()),
prompt: clean_opt(args.prompt.as_deref()),
response: clean_opt(args.response.as_deref()),
error: clean_opt(args.error.as_deref()),
tool_name: clean_opt(args.tool_name.as_deref()),
exit_code: args.exit_code,
parent_id: None,
label: None,
reason: None,
extra: None,
}
};
let id = append_entry(beads_dir, &mut entry)?;
let output = AuditRecordOutput {
id: id.clone(),
kind: entry.kind.clone(),
};
if ctx.is_toon() {
ctx.toon(&output);
} else if ctx.is_json() {
ctx.json_pretty(&output);
} else if !ctx.is_quiet() {
println!("{id}");
}
Ok(())
}
fn label_entry(
args: &AuditLabelArgs,
beads_dir: &Path,
actor: &str,
ctx: &OutputContext,
) -> Result<()> {
let label = args
.label
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| BeadsError::validation("label", "required"))?
.to_string();
let parent_id = args.entry_id.trim();
if parent_id.is_empty() {
return Err(BeadsError::validation("entry_id", "required"));
}
if !audit_entry_exists(beads_dir, parent_id)? {
return Err(BeadsError::validation(
"entry_id",
format!("Audit entry '{parent_id}' not found"),
));
}
let mut entry = AuditEntry {
id: None,
kind: "label".to_string(),
created_at: None,
actor: clean_actor(actor),
issue_id: None,
model: None,
prompt: None,
response: None,
error: None,
tool_name: None,
exit_code: None,
parent_id: Some(parent_id.to_string()),
label: Some(label.clone()),
reason: clean_opt(args.reason.as_deref()),
extra: None,
};
let id = append_entry(beads_dir, &mut entry)?;
let output = AuditLabelOutput {
id: id.clone(),
parent_id: parent_id.to_string(),
label,
};
if ctx.is_toon() {
ctx.toon(&output);
} else if ctx.is_json() {
ctx.json_pretty(&output);
} else if !ctx.is_quiet() {
println!("{id}");
}
Ok(())
}
#[allow(dead_code)]
fn no_fields_provided(args: &AuditRecordArgs) -> bool {
is_empty_opt(args.kind.as_deref())
&& is_empty_opt(args.issue_id.as_deref())
&& is_empty_opt(args.model.as_deref())
&& is_empty_opt(args.prompt.as_deref())
&& is_empty_opt(args.response.as_deref())
&& is_empty_opt(args.tool_name.as_deref())
&& is_empty_opt(args.error.as_deref())
&& args.exit_code.is_none()
}
fn is_empty_opt(value: Option<&str>) -> bool {
value.is_none_or(|v| v.trim().is_empty())
}
fn clean_opt(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|v| !v.is_empty())
.map(str::to_string)
}
fn clean_actor(actor: &str) -> Option<String> {
let trimmed = actor.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
fn append_entry(beads_dir: &Path, entry: &mut AuditEntry) -> Result<String> {
let path = ensure_interactions_file(beads_dir)?;
let kind = entry.kind.trim();
if kind.is_empty() {
return Err(BeadsError::validation("kind", "required"));
}
entry.kind = kind.to_string();
if entry.id.as_ref().is_none_or(|id| id.trim().is_empty()) {
entry.id = Some(new_audit_id());
}
if entry.created_at.is_none() {
entry.created_at = Some(Utc::now());
}
let mut line = serde_json::to_vec(&entry)?;
line.push(b'\n');
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)?;
file.write_all(&line)?;
file.sync_all()?;
Ok(entry.id.as_ref().expect("id set before append").clone())
}
fn interactions_file_path(beads_dir: &Path) -> PathBuf {
beads_dir.join("interactions.jsonl")
}
fn ensure_interactions_file(beads_dir: &Path) -> Result<PathBuf> {
if !beads_dir.exists() {
return Err(BeadsError::NotInitialized);
}
fs::create_dir_all(beads_dir)?;
let path = interactions_file_path(beads_dir);
require_valid_sync_path(&path, beads_dir)?;
if !path.exists() {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)?;
}
Ok(path)
}
fn audit_entry_exists(beads_dir: &Path, entry_id: &str) -> Result<bool> {
#[derive(Deserialize)]
struct PartialId {
id: Option<String>,
}
let entry_id = entry_id.trim();
if entry_id.is_empty() {
return Err(BeadsError::validation("entry_id", "required"));
}
let path = interactions_file_path(beads_dir);
require_valid_sync_path(&path, beads_dir)?;
let file = match fs::File::open(&path) {
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(false),
Err(err) => return Err(err.into()),
};
let reader = BufReader::new(file);
let id_pattern = format!("\"{}\"", entry_id);
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
if !line.contains(&id_pattern) {
continue;
}
match serde_json::from_str::<PartialId>(&line) {
Ok(entry) => {
if entry.id.as_deref() == Some(entry_id) {
return Ok(true);
}
}
Err(e) => {
tracing::debug!(
error = %e,
"Skipping malformed audit entry during existence check"
);
}
}
}
Ok(false)
}
fn new_audit_id() -> String {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_nanos());
let counter = ID_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let mut hasher = Sha256::new();
hasher.update(nanos.to_le_bytes());
hasher.update(counter.to_le_bytes());
hasher.update(pid.to_le_bytes());
let digest = hasher.finalize();
let bytes = &digest[..8];
format!(
"int-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7]
)
}
fn render_audit_log_rich(issue_id: &str, events: &[crate::model::Event], ctx: &OutputContext) {
let _console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append("\n");
for event in events {
let time_str = event.created_at.format("%Y-%m-%d %H:%M").to_string();
content.append_styled(&time_str, theme.dimmed.clone());
content.append(" ");
content.append_styled(&format!("@{:<10}", event.actor), theme.accent.clone());
content.append(" ");
let type_style = event_type_style(&event.event_type, theme);
content.append_styled(&format!("{:<15}", event.event_type.as_str()), type_style);
content.append("\n");
let mut details = String::new();
if let Some(old) = &event.old_value {
if let Some(new) = &event.new_value {
details.push_str(&format!(" {old} → {new}"));
} else {
details.push_str(&format!(" Removed: {old}"));
}
} else if let Some(new) = &event.new_value {
details.push_str(&format!(" Set: {new}"));
}
if !details.is_empty() {
content.append_styled(&details, theme.dimmed.clone());
content.append("\n");
}
if let Some(comment) = &event.comment {
content.append_styled(&format!(" \"{comment}\"\n"), theme.comment.clone());
}
content.append("\n");
}
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(
format!("Audit Log: {}", issue_id),
theme.panel_title.clone(),
))
.box_style(theme.box_style);
ctx.render(&panel);
}
fn render_audit_log_plain(issue_id: &str, events: &[crate::model::Event]) {
println!("Audit Log: {}", issue_id);
println!("{}", "-".repeat(40));
for event in events {
println!(
"{} @{:<10} {}",
event.created_at.format("%Y-%m-%d %H:%M"),
event.actor,
event.event_type.as_str()
);
if let Some(old) = &event.old_value {
if let Some(new) = &event.new_value {
println!(" {} -> {}", old, new);
} else {
println!(" Removed: {}", old);
}
} else if let Some(new) = &event.new_value {
println!(" Set: {}", new);
}
if let Some(comment) = &event.comment {
println!(" \"{}\"", comment);
}
println!();
}
}
fn render_audit_summary_rich(
days: u32,
totals: &AuditTotals,
actors: &[ActorSummary],
ctx: &OutputContext,
) {
let _console = Console::default();
let theme = ctx.theme();
let width = ctx.width();
let mut content = Text::new("");
content.append_styled(
&format!(
"{:<15} {:>8} {:>8} {:>8} {:>8} {:>8}\n",
"Actor", "Created", "Updated", "Closed", "Comments", "Total"
),
theme.table_header.clone(),
);
content.append_styled(&format!("{}\n", "─".repeat(60)), theme.dimmed.clone());
for actor in actors {
content.append_styled(&format!("{:<15}", actor.actor), theme.accent.clone());
content.append(&format!(
" {:>8} {:>8} {:>8} {:>8} ",
actor.created, actor.updated, actor.closed, actor.comments
));
content.append_styled(&format!("{:>8}\n", actor.total), theme.emphasis.clone());
}
content.append("\n");
content.append_styled(&format!("{:<15}", "TOTAL"), theme.table_header.clone());
content.append_styled(
&format!(
" {:>8} {:>8} {:>8} {:>8} {:>8}",
totals.created, totals.updated, totals.closed, totals.comments, totals.total
),
theme.emphasis.clone(),
);
let panel = Panel::from_rich_text(&content, width)
.title(Text::styled(
format!("Audit Summary (last {} days)", days),
theme.panel_title.clone(),
))
.box_style(theme.box_style);
ctx.render(&panel);
}
fn render_audit_summary_plain(days: u32, totals: &AuditTotals, actors: &[ActorSummary]) {
println!("Audit Summary (last {} days)", days);
println!(
"{:<15} {:>8} {:>8} {:>8} {:>8} {:>8}",
"Actor", "Created", "Updated", "Closed", "Comments", "Total"
);
println!("{}", "-".repeat(65));
for actor in actors {
println!(
"{:<15} {:>8} {:>8} {:>8} {:>8} {:>8}",
actor.actor, actor.created, actor.updated, actor.closed, actor.comments, actor.total
);
}
println!("{}", "-".repeat(65));
println!(
"{:<15} {:>8} {:>8} {:>8} {:>8} {:>8}",
"TOTAL", totals.created, totals.updated, totals.closed, totals.comments, totals.total
);
}
fn event_type_style(event_type: &EventType, theme: &Theme) -> rich_rust::Style {
use rich_rust::Color;
match event_type {
EventType::Created => Style::new().color(Color::parse("green").unwrap()),
EventType::Closed => Style::new().color(Color::parse("blue").unwrap()),
EventType::Updated => Style::new().color(Color::parse("yellow").unwrap()),
EventType::Commented => theme.dimmed.clone(),
_ => Style::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn temp_beads_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
let beads_dir = dir.path().join(".beads");
fs::create_dir_all(&beads_dir).expect("create beads dir");
dir
}
fn base_entry(kind: &str) -> AuditEntry {
AuditEntry {
id: None,
kind: kind.to_string(),
created_at: None,
actor: None,
issue_id: None,
model: None,
prompt: None,
response: None,
error: None,
tool_name: None,
exit_code: None,
parent_id: None,
label: None,
reason: None,
extra: None,
}
}
#[test]
fn test_append_preserves_order() {
let dir = temp_beads_dir();
let beads_dir = dir.path().join(".beads");
let mut entry_a = base_entry("llm_call");
let id_a = append_entry(&beads_dir, &mut entry_a).expect("append A");
let mut entry_b = base_entry("tool_call");
let id_b = append_entry(&beads_dir, &mut entry_b).expect("append B");
let contents =
fs::read_to_string(beads_dir.join("interactions.jsonl")).expect("read interactions");
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(first["id"], id_a);
assert_eq!(second["id"], id_b);
}
#[test]
fn test_record_output_shape() {
let output = AuditRecordOutput {
id: "int-1a2b3c4d".to_string(),
kind: "llm_call".to_string(),
};
let json = serde_json::to_value(output).unwrap();
assert_eq!(json["id"], "int-1a2b3c4d");
assert_eq!(json["kind"], "llm_call");
}
#[test]
fn test_label_output_shape() {
let output = AuditLabelOutput {
id: "int-2b3c4d5e".to_string(),
parent_id: "int-aaaa1111".to_string(),
label: "good".to_string(),
};
let json = serde_json::to_value(output).unwrap();
assert_eq!(json["id"], "int-2b3c4d5e");
assert_eq!(json["parent_id"], "int-aaaa1111");
assert_eq!(json["label"], "good");
}
#[test]
fn test_audit_entry_exists_finds_existing_parent() {
let dir = temp_beads_dir();
let beads_dir = dir.path().join(".beads");
let mut entry = base_entry("llm_call");
let entry_id = append_entry(&beads_dir, &mut entry).expect("append interaction");
assert!(audit_entry_exists(&beads_dir, &entry_id).expect("lookup existing entry"));
assert!(!audit_entry_exists(&beads_dir, "int-missing").expect("lookup missing entry"));
}
#[cfg(unix)]
#[test]
fn test_ensure_interactions_file_rejects_symlink_escape() {
use std::os::unix::fs::symlink;
let dir = temp_beads_dir();
let beads_dir = dir.path().join(".beads");
let outside_dir = TempDir::new().expect("outside tempdir");
let outside_path = outside_dir.path().join("captured.jsonl");
fs::write(&outside_path, "outside").expect("write outside file");
symlink(&outside_path, beads_dir.join("interactions.jsonl")).expect("create symlink");
let err = ensure_interactions_file(&beads_dir).unwrap_err();
match err {
BeadsError::Config(_) => {}
other => panic!("unexpected error: {other:?}"),
}
}
}