use crate::cli::output::{OutputConfig, OutputFormat};
use crate::cli::LogsArgs;
use crate::config;
use crate::error::{OlError, ERR_INVALID_CONFIG};
pub fn run_logs(args: &LogsArgs, output: &OutputConfig) -> Result<(), OlError> {
let openlatch_dir = config::openlatch_dir();
if args.tamper {
return run_tamper_logs(&openlatch_dir, args, output);
}
let log_dir = openlatch_dir.join("logs");
if !log_dir.exists() {
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({"events": [], "count": 0}));
} else if !output.quiet {
eprintln!("No log directory found. Run 'openlatch init' to set up.");
}
return Ok(());
}
if args.follow {
follow_logs(&log_dir, output)?;
return Ok(());
}
let since = args.since.as_deref().map(parse_since_filter).transpose()?;
let events = collect_events(&log_dir, since.as_ref(), args.lines)?;
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"events": events,
"count": events.len(),
}));
} else if !output.quiet {
print_event_table(&events, output);
}
Ok(())
}
fn run_tamper_logs(
openlatch_dir: &std::path::Path,
args: &LogsArgs,
output: &OutputConfig,
) -> Result<(), OlError> {
let path = openlatch_dir.join("tamper.jsonl");
if !path.exists() {
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({"events": [], "count": 0}));
} else if !output.quiet {
eprintln!(
"No tamper events recorded. The reconciler writes here when it detects drift in hook entries."
);
}
return Ok(());
}
let since = args.since.as_deref().map(parse_since_filter).transpose()?;
let content = std::fs::read_to_string(&path)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot read tamper log: {e}")))?;
let mut events: Vec<serde_json::Value> = Vec::new();
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
if let Some(cutoff) = since {
if let Some(ts) = v
.get("ocsf")
.and_then(|h| h.get("time"))
.and_then(|t| t.as_str())
{
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(ts) {
if parsed.with_timezone(&chrono::Utc) < cutoff {
continue;
}
}
}
}
events.push(v);
}
if events.len() > args.lines {
let start = events.len() - args.lines;
events = events.split_off(start);
}
if output.format == OutputFormat::Json {
output.print_json(&serde_json::json!({
"events": events,
"count": events.len(),
}));
} else if !output.quiet {
print_tamper_table(&events);
}
Ok(())
}
fn print_tamper_table(events: &[serde_json::Value]) {
if events.is_empty() {
eprintln!("No tamper events found.");
return;
}
eprintln!(
"{:<20} {:<22} {:<20} {:<12} HEAL",
"TIME", "DETECTION", "HOOK EVENT", "RELATED"
);
for e in events {
let time = e
.get("ocsf")
.and_then(|h| h.get("time"))
.and_then(|v| v.as_str())
.unwrap_or("-");
let ts_display = if time.len() >= 19 {
time[..19].replace('T', " ")
} else {
time.to_string()
};
let payload = e.get("tamper");
let detection = payload
.and_then(|p| p.get("detection_method"))
.and_then(|v| v.as_str())
.unwrap_or("-");
let hook_event = payload
.and_then(|p| p.get("hook_event"))
.and_then(|v| v.as_str())
.unwrap_or("-");
let related = payload
.and_then(|p| p.get("related_event_id"))
.and_then(|v| v.as_str())
.map(|s| truncate(s, 12))
.unwrap_or_else(|| "-".to_string());
let heal = payload
.and_then(|p| p.get("heal"))
.and_then(|h| h.get("outcome"))
.and_then(|v| v.as_str())
.unwrap_or("pending");
eprintln!(
"{:<20} {:<22} {:<20} {:<12} {}",
ts_display,
truncate(detection, 22),
truncate(hook_event, 20),
related,
heal,
);
}
}
#[derive(Debug, serde::Serialize)]
struct LogEvent {
timestamp: String,
event_type: String,
tool_name: String,
verdict: String,
latency_ms: u64,
}
fn parse_since_filter(s: &str) -> Result<chrono::DateTime<chrono::Utc>, OlError> {
let now = chrono::Utc::now();
if let Some(rest) = s.strip_suffix('d') {
let days: i64 = rest.parse().map_err(|_| {
OlError::new(ERR_INVALID_CONFIG, format!("Invalid --since value: '{s}'"))
.with_suggestion("Use formats like '2d', '4h', '30m', or 'YYYY-MM-DD'")
})?;
return Ok(now - chrono::Duration::days(days));
}
if let Some(rest) = s.strip_suffix('h') {
let hours: i64 = rest.parse().map_err(|_| {
OlError::new(ERR_INVALID_CONFIG, format!("Invalid --since value: '{s}'"))
.with_suggestion("Use formats like '2d', '4h', '30m', or 'YYYY-MM-DD'")
})?;
return Ok(now - chrono::Duration::hours(hours));
}
if let Some(rest) = s.strip_suffix('m') {
let minutes: i64 = rest.parse().map_err(|_| {
OlError::new(ERR_INVALID_CONFIG, format!("Invalid --since value: '{s}'"))
.with_suggestion("Use formats like '2d', '4h', '30m', or 'YYYY-MM-DD'")
})?;
return Ok(now - chrono::Duration::minutes(minutes));
}
if let Ok(date) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = date
.and_hms_opt(0, 0, 0)
.expect("midnight is always valid")
.and_utc();
return Ok(dt);
}
Err(
OlError::new(ERR_INVALID_CONFIG, format!("Invalid --since value: '{s}'"))
.with_suggestion("Use formats like '2d', '4h', '30m', or 'YYYY-MM-DD'"),
)
}
fn collect_events(
log_dir: &std::path::Path,
since: Option<&chrono::DateTime<chrono::Utc>>,
limit: usize,
) -> Result<Vec<LogEvent>, OlError> {
let today = chrono::Local::now().date_naive();
let mut log_files: Vec<(chrono::NaiveDate, std::path::PathBuf)> = std::fs::read_dir(log_dir)
.map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot read log directory: {e}"),
)
})?
.filter_map(|entry| {
let entry = entry.ok()?;
let name = entry.file_name();
let name = name.to_str()?;
let date_str = name.strip_prefix("events-")?.strip_suffix(".jsonl")?;
let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok()?;
Some((date, entry.path()))
})
.collect();
if let Some(since_dt) = since {
let since_date = since_dt.date_naive();
log_files.retain(|(date, _)| *date >= since_date);
} else {
log_files.retain(|(date, _)| *date == today);
}
log_files.sort_by_key(|(date, _)| *date);
let threshold: Option<chrono::DateTime<chrono::Utc>> = since.copied().or_else(|| {
today
.and_hms_opt(0, 0, 0)
.and_then(|ndt| ndt.and_local_timezone(chrono::Local).single())
.map(|dt| dt.with_timezone(&chrono::Utc))
});
let mut all_events: Vec<LogEvent> = Vec::new();
for (_, path) in &log_files {
append_events_from_file(path, threshold.as_ref(), false, &mut all_events);
}
let fallback_path = log_dir.join("fallback.jsonl");
if fallback_path.exists() {
append_events_from_file(&fallback_path, threshold.as_ref(), true, &mut all_events);
}
all_events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
if all_events.len() > limit {
let start = all_events.len() - limit;
Ok(all_events.into_iter().skip(start).collect())
} else {
Ok(all_events)
}
}
fn append_events_from_file(
path: &std::path::Path,
threshold: Option<&chrono::DateTime<chrono::Utc>>,
is_fallback: bool,
out: &mut Vec<LogEvent>,
) {
let Ok(content) = std::fs::read_to_string(path) else {
return;
};
for line in content.lines() {
if line.trim().is_empty() {
continue;
}
let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let mut event = parse_log_entry(&entry);
if let Some(cutoff) = threshold {
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&event.timestamp) {
if ts.with_timezone(&chrono::Utc) < *cutoff {
continue;
}
}
}
if is_fallback && event.verdict == "-" {
event.verdict = "offline".to_string();
}
out.push(event);
}
}
fn print_event_table(events: &[LogEvent], _output: &OutputConfig) {
if events.is_empty() {
eprintln!("No events found.");
return;
}
eprintln!(
"{:<20} {:<18} {:<14} {:<8} LATENCY",
"TIMESTAMP", "EVENT TYPE", "TOOL NAME", "VERDICT"
);
for event in events {
let ts_display = if event.timestamp.len() >= 19 {
event.timestamp[..19].replace('T', " ")
} else {
event.timestamp.clone()
};
eprintln!(
"{:<20} {:<18} {:<14} {:<8} {}ms",
ts_display,
truncate(&event.event_type, 18),
truncate(&event.tool_name, 14),
truncate(&event.verdict, 8),
event.latency_ms,
);
}
}
fn follow_logs(log_dir: &std::path::Path, output: &OutputConfig) -> Result<(), OlError> {
let mut current_date = chrono::Local::now().date_naive();
let mut current_file = log_dir.join(format!("events-{current_date}.jsonl"));
let mut position: u64 = 0;
if !output.quiet && output.format == OutputFormat::Human {
eprintln!(
"{:<20} {:<18} {:<14} {:<8} LATENCY",
"TIMESTAMP", "EVENT TYPE", "TOOL NAME", "VERDICT"
);
}
loop {
let today = chrono::Local::now().date_naive();
if today != current_date {
current_date = today;
current_file = log_dir.join(format!("events-{current_date}.jsonl"));
position = 0;
}
if let Ok(mut file) = std::fs::File::open(¤t_file) {
use std::io::{Read, Seek, SeekFrom};
let _ = file.seek(SeekFrom::Start(position));
let mut buf = String::new();
let _ = file.read_to_string(&mut buf);
let new_pos = file.stream_position().unwrap_or(position);
for line in buf.lines() {
if line.trim().is_empty() {
continue;
}
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
let event = parse_log_entry(&entry);
if output.format == OutputFormat::Json {
output.print_json(&entry);
} else if !output.quiet {
let ts_display = if event.timestamp.len() >= 19 {
event.timestamp[..19].replace('T', " ")
} else {
event.timestamp.clone()
};
eprintln!(
"{:<20} {:<18} {:<14} {:<8} {}ms",
ts_display,
truncate(&event.event_type, 18),
truncate(&event.tool_name, 14),
truncate(&event.verdict, 8),
event.latency_ms,
);
}
}
}
position = new_pos;
}
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
fn parse_log_entry(entry: &serde_json::Value) -> LogEvent {
let timestamp = entry
.get("time")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let event_type = entry
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let tool_name = entry
.get("data")
.and_then(|d| d.get("tool_name"))
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let verdict = entry
.get("olverdict")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let latency_ms = entry
.get("ollatencyms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
LogEvent {
timestamp,
event_type,
tool_name,
verdict,
latency_ms,
}
}
fn truncate(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 test_parse_since_days() {
let result = parse_since_filter("2d");
assert!(result.is_ok());
let threshold = result.unwrap();
let now = chrono::Utc::now();
let diff = now - threshold;
assert!(diff.num_hours() >= 47 && diff.num_hours() <= 49);
}
#[test]
fn test_parse_since_hours() {
let result = parse_since_filter("4h");
assert!(result.is_ok());
let threshold = result.unwrap();
let now = chrono::Utc::now();
let diff = now - threshold;
assert!(diff.num_hours() >= 3 && diff.num_hours() <= 5);
}
#[test]
fn test_parse_since_minutes() {
let result = parse_since_filter("30m");
assert!(result.is_ok());
let threshold = result.unwrap();
let now = chrono::Utc::now();
let diff = now - threshold;
assert!(diff.num_minutes() >= 29 && diff.num_minutes() <= 31);
}
#[test]
fn test_parse_since_iso_date() {
let result = parse_since_filter("2026-04-05");
assert!(result.is_ok());
let threshold = result.unwrap();
assert_eq!(threshold.format("%Y-%m-%d").to_string(), "2026-04-05");
}
#[test]
fn test_parse_since_invalid() {
let result = parse_since_filter("invalid");
assert!(result.is_err());
}
#[test]
fn test_collect_events_merges_fallback_with_offline_tag() {
let tmp = tempfile::tempdir().unwrap();
let log_dir = tmp.path();
let today = chrono::Local::now().date_naive();
let today_file = log_dir.join(format!("events-{today}.jsonl"));
let processed = serde_json::json!({
"time": format!("{today}T10:00:00Z"),
"type": "pre_tool_use",
"data": {"tool_name": "Bash"},
"olverdict": "allow",
"ollatencyms": 2,
});
let offline = serde_json::json!({
"time": format!("{today}T09:00:00Z"),
"type": "pre_tool_use",
"data": {"tool_name": "Read"},
});
std::fs::write(&today_file, format!("{processed}\n")).unwrap();
std::fs::write(log_dir.join("fallback.jsonl"), format!("{offline}\n")).unwrap();
let events = collect_events(log_dir, None, 100).unwrap();
assert_eq!(events.len(), 2);
assert_eq!(events[0].tool_name, "Read");
assert_eq!(events[0].verdict, "offline");
assert_eq!(events[1].tool_name, "Bash");
assert_eq!(events[1].verdict, "allow");
}
#[test]
fn test_parse_log_entry_cloudevents_shape() {
let entry = serde_json::json!({
"specversion": "1.0",
"id": "evt_abc",
"source": "claude-code",
"type": "pre_tool_use",
"time": "2026-04-16T17:53:07Z",
"data": {"tool_name": "Bash", "tool_input": {"command": "ls"}},
"olverdict": "allow",
"ollatencyms": 7,
});
let ev = parse_log_entry(&entry);
assert_eq!(ev.timestamp, "2026-04-16T17:53:07Z");
assert_eq!(ev.event_type, "pre_tool_use");
assert_eq!(ev.tool_name, "Bash");
assert_eq!(ev.verdict, "allow");
assert_eq!(ev.latency_ms, 7);
}
#[test]
fn test_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("toolname_long", 8).chars().count(), 8);
}
#[test]
fn test_tamper_logs_missing_file_is_not_an_error() {
let tmp = tempfile::tempdir().unwrap();
let args = crate::cli::LogsArgs {
follow: false,
since: None,
lines: 20,
tamper: true,
};
let output = OutputConfig {
format: OutputFormat::Json,
verbose: false,
debug: false,
quiet: true,
color: false,
};
run_tamper_logs(tmp.path(), &args, &output).unwrap();
}
#[test]
fn test_tamper_logs_reads_jsonl_entries() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("tamper.jsonl");
let detected = serde_json::json!({
"ocsf": {"class_uid": 2004, "activity_id": 2, "type_uid": 200402, "severity_id": 3, "time": "2026-04-17T10:00:00Z"},
"tamper": {
"event_id": "01234567-89ab-7000-8000-000000000001",
"entry_id": "entry-a",
"agent_type": "claude-code",
"settings_path_hash": "sha256:x",
"hook_event": "PreToolUse",
"detection_method": "hmac_mismatch",
"heal": {"outcome": "pending", "attempt": 0, "circuit": "closed"}
}
});
let healed = serde_json::json!({
"ocsf": {"class_uid": 2004, "activity_id": 2, "type_uid": 200402, "severity_id": 3, "time": "2026-04-17T10:00:01Z"},
"tamper": {
"event_id": "01234567-89ab-7000-8000-000000000002",
"entry_id": "entry-a",
"agent_type": "claude-code",
"settings_path_hash": "sha256:x",
"hook_event": "PreToolUse",
"detection_method": "hmac_mismatch",
"heal": {"outcome": "succeeded", "attempt": 1, "circuit": "closed"},
"related_event_id": "01234567-89ab-7000-8000-000000000001"
}
});
std::fs::write(&path, format!("{detected}\n{healed}\n")).unwrap();
let args = crate::cli::LogsArgs {
follow: false,
since: None,
lines: 20,
tamper: true,
};
let output = OutputConfig {
format: OutputFormat::Json,
verbose: false,
debug: false,
quiet: true,
color: false,
};
run_tamper_logs(tmp.path(), &args, &output).unwrap();
}
}