use chrono::{DateTime, Utc};
use console::Style;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogLineData {
pub timestamp: String,
pub event_type: String,
pub agent_id: String,
pub message: String,
}
pub fn format_log_line(entry: &LogLineData, use_color: bool) -> String {
let tag = format!("[{}]", entry.event_type.to_uppercase());
let styled_tag = if use_color {
let style = style_for_type(&entry.event_type);
style.apply_to(&tag).to_string()
} else {
tag
};
format!(
"{} {:12} {} {}",
entry.timestamp, styled_tag, entry.agent_id, entry.message
)
}
pub fn format_log_json(entry: &LogLineData) -> String {
serde_json::to_string(entry).unwrap_or_default()
}
pub fn parse_since(value: &str) -> Option<DateTime<Utc>> {
if let Some(duration) = parse_duration_shorthand(value) {
return Some(Utc::now() - duration);
}
value.parse::<DateTime<Utc>>().ok()
}
pub fn parse_until(value: &str) -> Option<DateTime<Utc>> {
value.parse::<DateTime<Utc>>().ok()
}
fn parse_duration_shorthand(value: &str) -> Option<chrono::Duration> {
let value = value.trim();
if value.len() < 2 {
return None;
}
let (num_str, suffix) = value.split_at(value.len() - 1);
let num: i64 = num_str.parse().ok()?;
match suffix {
"s" => Some(chrono::Duration::seconds(num)),
"m" => Some(chrono::Duration::minutes(num)),
"h" => Some(chrono::Duration::hours(num)),
"d" => Some(chrono::Duration::days(num)),
_ => None,
}
}
pub fn is_within_time_range(
entry_timestamp: &str,
since: Option<&DateTime<Utc>>,
until: Option<&DateTime<Utc>>,
) -> bool {
let entry_dt = match entry_timestamp.parse::<DateTime<Utc>>() {
Ok(dt) => dt,
Err(_) => return true, };
if let Some(s) = since {
if entry_dt < *s {
return false;
}
}
if let Some(u) = until {
if entry_dt > *u {
return false;
}
}
true
}
pub fn style_for_type(event_type: &str) -> Style {
match event_type {
"violation" => Style::new().red().bold(),
"approval" => Style::new().yellow(),
"budget" => Style::new().cyan(),
_ => Style::new().white(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_types_get_distinct_styles() {
let _ = style_for_type("violation");
let _ = style_for_type("approval");
let _ = style_for_type("budget");
}
#[test]
fn unknown_type_returns_white_style() {
let _ = style_for_type("tool_call");
let _ = style_for_type("unknown_future_type");
}
fn sample_entry() -> LogLineData {
LogLineData {
timestamp: "2026-04-30T10:00:00Z".to_string(),
event_type: "violation".to_string(),
agent_id: "aa001".to_string(),
message: "policy denied tool call".to_string(),
}
}
#[test]
fn format_log_line_no_color_contains_all_fields() {
let line = format_log_line(&sample_entry(), false);
assert!(line.contains("2026-04-30T10:00:00Z"));
assert!(line.contains("[VIOLATION]"));
assert!(line.contains("aa001"));
assert!(line.contains("policy denied tool call"));
}
#[test]
fn format_log_line_with_color_does_not_panic() {
let _ = format_log_line(&sample_entry(), true);
}
#[test]
fn format_log_json_produces_valid_json() {
let json = format_log_json(&sample_entry());
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event_type"], "violation");
assert_eq!(parsed["agent_id"], "aa001");
}
#[test]
fn parse_since_duration_minutes() {
let dt = parse_since("30m").unwrap();
let diff = Utc::now() - dt;
assert!((diff.num_seconds() - 1800).abs() < 5);
}
#[test]
fn parse_since_duration_hours() {
let dt = parse_since("2h").unwrap();
let diff = Utc::now() - dt;
assert!((diff.num_seconds() - 7200).abs() < 5);
}
#[test]
fn parse_since_iso_timestamp() {
let dt = parse_since("2026-04-30T10:00:00Z").unwrap();
assert_eq!(dt.to_rfc3339(), "2026-04-30T10:00:00+00:00");
}
#[test]
fn parse_since_invalid_returns_none() {
assert!(parse_since("invalid").is_none());
assert!(parse_since("").is_none());
}
#[test]
fn parse_until_iso_timestamp() {
let dt = parse_until("2026-04-30T12:00:00Z").unwrap();
assert_eq!(dt.to_rfc3339(), "2026-04-30T12:00:00+00:00");
}
#[test]
fn is_within_range_no_bounds() {
assert!(is_within_time_range("2026-04-30T10:00:00Z", None, None));
}
#[test]
fn is_within_range_since_filter() {
let since = "2026-04-30T10:00:00Z".parse().unwrap();
assert!(is_within_time_range("2026-04-30T11:00:00Z", Some(&since), None));
assert!(!is_within_time_range("2026-04-30T09:00:00Z", Some(&since), None));
}
#[test]
fn is_within_range_until_filter() {
let until = "2026-04-30T12:00:00Z".parse().unwrap();
assert!(is_within_time_range("2026-04-30T11:00:00Z", None, Some(&until)));
assert!(!is_within_time_range("2026-04-30T13:00:00Z", None, Some(&until)));
}
#[test]
fn is_within_range_both_bounds() {
let since = "2026-04-30T10:00:00Z".parse().unwrap();
let until = "2026-04-30T12:00:00Z".parse().unwrap();
assert!(is_within_time_range("2026-04-30T11:00:00Z", Some(&since), Some(&until)));
assert!(!is_within_time_range(
"2026-04-30T09:00:00Z",
Some(&since),
Some(&until)
));
assert!(!is_within_time_range(
"2026-04-30T13:00:00Z",
Some(&since),
Some(&until)
));
}
}