use std::path::Path;
use std::time::Duration;
use chrono::{NaiveDate, Utc};
use crate::cli::commands::shared;
use crate::cli::{EventsAction, GlobalArgs};
use crate::config;
use crate::error::OlError;
use crate::observability::verdict_display_str;
use crate::runtime::audit_log::{log_path_for, Record};
use crate::ui::output::OutputConfig;
pub async fn run(g: &GlobalArgs, action: EventsAction) -> Result<(), OlError> {
match action {
EventsAction::Tail { follow, tail } => tail_cmd(g, follow, tail).await,
}
}
pub async fn tail_cmd(g: &GlobalArgs, follow: bool, tail_n: usize) -> Result<(), OlError> {
let out = OutputConfig::resolve(g);
let log_dir = config::provider_dir().join("logs");
let today = Utc::now().date_naive();
let today_path = log_path_for(&log_dir, today);
if !today_path.exists() && !follow {
out.print_info(&format!(
"No audit log at {} — has the daemon run today?",
today_path.display()
));
return Ok(());
}
if today_path.exists() {
if out.is_machine() {
shared::print_tail(&today_path, tail_n).await?;
} else {
for raw in shared::read_last_lines(&today_path, tail_n).await? {
println!("{}", render_line(&raw, out.color));
}
}
}
if !follow {
return Ok(());
}
follow_with_rotation(&log_dir, today, &out).await
}
async fn follow_with_rotation(
log_dir: &Path,
starting_date: NaiveDate,
out: &OutputConfig,
) -> Result<(), OlError> {
use tokio::io::AsyncBufReadExt;
let mut current_date = starting_date;
let mut current_path = log_path_for(log_dir, current_date);
let mut reader = open_at_end_or_wait(¤t_path).await;
let mut line = String::new();
loop {
let now_date = Utc::now().date_naive();
if now_date != current_date {
current_date = now_date;
current_path = log_path_for(log_dir, current_date);
reader = open_at_end_or_wait(¤t_path).await;
}
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => {
tokio::time::sleep(Duration::from_millis(250)).await;
}
Ok(_) => {
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
emit_followed_line(&line, out);
}
Err(_) => {
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
}
}
async fn open_at_end_or_wait(path: &Path) -> tokio::io::BufReader<tokio::fs::File> {
use tokio::io::AsyncSeekExt;
loop {
if let Ok(mut f) = tokio::fs::File::open(path).await {
let _ = f.seek(std::io::SeekFrom::End(0)).await;
return tokio::io::BufReader::new(f);
}
tokio::time::sleep(Duration::from_millis(250)).await;
}
}
fn emit_followed_line(raw: &str, out: &OutputConfig) {
if out.is_machine() {
println!("{raw}");
} else {
println!("{}", render_line(raw, out.color));
}
}
fn render_line(raw: &str, ansi: bool) -> String {
match serde_json::from_str::<Record>(raw) {
Ok(rec) => render_record(&rec, ansi),
Err(_) => raw.to_string(),
}
}
fn render_record(rec: &Record, ansi: bool) -> String {
let verdict = verdict_display_str(rec.verdict_hint.as_deref(), ansi);
format!(
"{ts} {binding} {event} outcome={outcome} verdict={verdict} processing_ms={pms} tool_ms={tms}",
ts = rec.timestamp.to_rfc3339(),
binding = rec.binding_id,
event = rec.event_id,
outcome = rec.outcome,
verdict = verdict,
pms = rec.processing_ms,
tms = rec.tool_ms,
)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
#[test]
fn rotation_picks_tomorrow_file_after_midnight() {
let dir = std::env::temp_dir().join("openlatch-test-logs");
let today = NaiveDate::from_ymd_opt(2026, 5, 12).unwrap();
let tomorrow = today + Duration::days(1);
let today_path = log_path_for(&dir, today);
let tomorrow_path = log_path_for(&dir, tomorrow);
assert!(today_path
.to_string_lossy()
.ends_with("runtime-2026-05-12.jsonl"));
assert!(tomorrow_path
.to_string_lossy()
.ends_with("runtime-2026-05-13.jsonl"));
assert_ne!(today_path, tomorrow_path);
}
#[test]
fn render_record_includes_verdict_token() {
let rec = Record::now("evt_abc", "bnd_xyz", "delivered")
.with_verdict(Some("allow".into()), Some(10))
.with_timings(8, 2);
let line = render_record(&rec, false);
assert!(line.contains("evt_abc"));
assert!(line.contains("bnd_xyz"));
assert!(line.contains("outcome=delivered"));
assert!(line.contains("verdict=allow"));
assert!(line.contains("processing_ms=8"));
assert!(line.contains("tool_ms=2"));
}
#[test]
fn render_record_deny_is_red_when_ansi() {
let rec = Record::now("evt_abc", "bnd_xyz", "delivered")
.with_verdict(Some("deny".into()), Some(95));
let line = render_record(&rec, true);
assert!(line.contains("\x1b[31m"));
}
}