1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5const MAX_LOG_LINE: usize = 4096;
6
7fn sanitize(s: &str) -> String {
8 s.chars()
9 .filter(|&c| c.is_ascii_graphic() || c == ' ' || c == '\t')
10 .take(256)
11 .collect()
12}
13
14#[derive(Debug)]
15pub enum LogEvent {
16 Parse { run_id: String, ok: bool },
17 Validate { run_id: String, ok: bool },
18 SafetyGate { run_id: String, blocked: bool, rules: String },
19 Resolve { run_id: String, refs_total: usize, refs_unresolved: usize },
20 Expand { run_id: String, packet_type: String },
21 Run { run_id: String, status: String },
22}
23
24pub fn log_event(path: &Path, event: &LogEvent) -> std::io::Result<()> {
25 if let Some(parent) = path.parent() {
26 std::fs::create_dir_all(parent)?;
27 }
28
29 let timestamp = chrono_now();
30 let line = format_line(timestamp, event);
31
32 let mut file = OpenOptions::new()
33 .create(true)
34 .append(true)
35 .open(path)?;
36 writeln!(file, "{}", line)?;
37 Ok(())
38}
39
40fn chrono_now() -> String {
41 use std::time::{SystemTime, UNIX_EPOCH};
43 let dur = SystemTime::now()
44 .duration_since(UNIX_EPOCH)
45 .unwrap_or_default();
46 let secs = dur.as_secs();
47 let millis = dur.subsec_millis();
48 let s = secs % 86400;
49 let h = s / 3600;
50 let m = (s % 3600) / 60;
51 let s = s % 60;
52 format!("{:02}:{:02}:{:02}.{:03}", h, m, s, millis)
53}
54
55fn format_line(timestamp: String, event: &LogEvent) -> String {
56 let line = match event {
57 LogEvent::Parse { run_id, ok } => {
58 format!("[{}] PARSE {} {}", timestamp, if *ok { "OK" } else { "FAIL" }, sanitize(run_id))
59 }
60 LogEvent::Validate { run_id, ok } => {
61 format!("[{}] VALIDATE {} {}", timestamp, if *ok { "OK" } else { "FAIL" }, sanitize(run_id))
62 }
63 LogEvent::SafetyGate { run_id, blocked, rules } => {
64 format!(
65 "[{}] SAFETY {} {} rules=[{}]",
66 timestamp,
67 if *blocked { "BLOCKED" } else { "PASS" },
68 sanitize(run_id),
69 sanitize(rules),
70 )
71 }
72 LogEvent::Resolve { run_id, refs_total, refs_unresolved } => {
73 format!(
74 "[{}] RESOLVE {} refs={} unresolved={}",
75 timestamp, sanitize(run_id), refs_total, refs_unresolved
76 )
77 }
78 LogEvent::Expand { run_id, packet_type } => {
79 format!("[{}] EXPAND {} type={}", timestamp, sanitize(run_id), sanitize(packet_type))
80 }
81 LogEvent::Run { run_id, status } => {
82 format!("[{}] RUN {} status={}", timestamp, sanitize(run_id), sanitize(status))
83 }
84 };
85 if line.len() > MAX_LOG_LINE {
86 line[..MAX_LOG_LINE].to_string()
87 } else {
88 line
89 }
90}