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 log_dir = config::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(())
}
#[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 mut all_events: Vec<LogEvent> = Vec::new();
for (_, path) in &log_files {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
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 timestamp = entry
.get("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if let Some(since_dt) = since {
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(×tamp) {
if ts.with_timezone(&chrono::Utc) < *since_dt {
continue;
}
}
}
let event_type = entry
.get("event_type")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let tool_name = entry
.get("raw_event")
.and_then(|re| re.get("tool_name"))
.or_else(|| entry.get("tool_name"))
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let verdict = entry
.get("verdict")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let latency_ms = entry
.get("latency_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
all_events.push(LogEvent {
timestamp,
event_type,
tool_name,
verdict,
latency_ms,
});
}
}
if all_events.len() > limit {
let start = all_events.len() - limit;
Ok(all_events.into_iter().skip(start).collect())
} else {
Ok(all_events)
}
}
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("timestamp")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let event_type = entry
.get("event_type")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let tool_name = entry
.get("raw_event")
.and_then(|re| re.get("tool_name"))
.or_else(|| entry.get("tool_name"))
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let verdict = entry
.get("verdict")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let latency_ms = entry
.get("latency_ms")
.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_truncate() {
assert_eq!(truncate("hello", 10), "hello");
assert_eq!(truncate("toolname_long", 8).chars().count(), 8);
}
}