algocline_core/recent_log.rs
1use std::collections::VecDeque;
2use std::sync::{Arc, Mutex};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5// ─── LogEntry ────────────────────────────────────────────────
6
7/// A single log entry captured from a running session.
8///
9/// Entries are produced by Lua `print()`, `alc.log()`, and engine-internal
10/// events, then accumulated in a per-session ring buffer (cap=20) for
11/// lightweight observability via `alc_status`.
12///
13/// # Fields
14///
15/// - `ts` — Unix milliseconds (i64) when the entry was recorded.
16/// - `level` — Severity string: `"info"`, `"warn"`, `"error"`, `"debug"`, etc.
17/// - `source` — Originator: `"alc.lua.print"`, `"alc.log"`, `"engine"`, etc.
18/// - `message` — Human-readable log text.
19#[derive(Debug, Clone, serde::Serialize)]
20pub struct LogEntry {
21 /// Unix milliseconds timestamp of when the entry was recorded.
22 pub ts: i64,
23 /// Severity level string (e.g. "info", "warn", "error", "debug").
24 pub level: String,
25 /// Originator identifier (e.g. "alc.lua.print", "alc.log", "engine").
26 pub source: String,
27 /// Human-readable log message.
28 pub message: String,
29}
30
31impl LogEntry {
32 /// Create a new `LogEntry` with the current wall-clock timestamp.
33 ///
34 /// # Arguments
35 ///
36 /// - `level` — Severity label string.
37 /// - `source` — Originator identifier string.
38 /// - `message` — Log message text.
39 ///
40 /// # Returns
41 ///
42 /// A new `LogEntry` with `ts` set to the current Unix millisecond
43 /// timestamp. If `SystemTime` is before the Unix epoch (broken wall
44 /// clock), `ts` is saturated to `0`.
45 pub fn new(
46 level: impl Into<String>,
47 source: impl Into<String>,
48 message: impl Into<String>,
49 ) -> Self {
50 // Note: duration_since can only fail if the wall clock predates
51 // UNIX_EPOCH (1970-01-01), which indicates a broken system clock.
52 // Saturating to zero is harmless for observability purposes.
53 let ts = SystemTime::now()
54 .duration_since(UNIX_EPOCH)
55 .unwrap_or_default()
56 .as_millis() as i64;
57 Self {
58 ts,
59 level: level.into(),
60 source: source.into(),
61 message: message.into(),
62 }
63 }
64}
65
66// ─── LogSink ─────────────────────────────────────────────────
67
68/// A shared, bounded ring-buffer sink for [`LogEntry`] items.
69///
70/// Wraps `Arc<Mutex<VecDeque<LogEntry>>>` and enforces a maximum capacity
71/// of 20 entries. Oldest entries are evicted when the cap is exceeded.
72///
73/// `LogSink` can be cloned cheaply (clones the `Arc`, not the buffer).
74/// It is intended to be passed to the Lua bridge so that log output from
75/// both `print()` and `alc.log()` is routed into the session's ring buffer.
76#[derive(Clone, Debug)]
77pub struct LogSink(Arc<Mutex<VecDeque<LogEntry>>>);
78
79/// Maximum number of entries retained in a [`LogSink`].
80pub const LOG_SINK_CAP: usize = 20;
81
82impl LogSink {
83 /// Create a new, empty `LogSink`.
84 pub fn new() -> Self {
85 Self(Arc::new(Mutex::new(VecDeque::with_capacity(
86 LOG_SINK_CAP + 1,
87 ))))
88 }
89
90 /// Push a new entry into the ring buffer, evicting the oldest if necessary.
91 ///
92 /// # Arguments
93 ///
94 /// - `entry` — The [`LogEntry`] to append.
95 ///
96 /// # Errors
97 ///
98 /// If the internal mutex is poisoned (only possible on OOM-induced panic),
99 /// the entry is silently dropped. This is the approved "observation/recording"
100 /// policy — log capture failure must not interrupt execution.
101 pub fn push(&self, entry: LogEntry) {
102 if let Ok(mut buf) = self.0.lock() {
103 buf.push_back(entry);
104 if buf.len() > LOG_SINK_CAP {
105 buf.pop_front();
106 }
107 }
108 }
109
110 /// Snapshot the current ring-buffer contents as a JSON array.
111 ///
112 /// # Returns
113 ///
114 /// A `serde_json::Value::Array` of serialized [`LogEntry`] objects,
115 /// in chronological order (oldest first). Returns an empty array if
116 /// the mutex is poisoned.
117 pub fn to_json(&self) -> serde_json::Value {
118 if let Ok(buf) = self.0.lock() {
119 let entries: Vec<serde_json::Value> = buf
120 .iter()
121 .map(|e| {
122 // LogEntry has only i64 / String fields; serde_json::to_value
123 // cannot fail in practice (only on OOM, which would already
124 // panic elsewhere). Falling back to Null preserves array
125 // length without introducing a silent drop.
126 serde_json::to_value(e).unwrap_or(serde_json::Value::Null)
127 })
128 .collect();
129 serde_json::Value::Array(entries)
130 } else {
131 serde_json::Value::Array(vec![])
132 }
133 }
134
135 /// Snapshot the current ring-buffer contents as a `Vec<LogEntry>`.
136 ///
137 /// Useful for programmatic access (e.g. in `SessionStatus::snapshot`).
138 /// Returns an empty vec if the mutex is poisoned.
139 pub fn entries(&self) -> Vec<LogEntry> {
140 if let Ok(buf) = self.0.lock() {
141 buf.iter().cloned().collect()
142 } else {
143 vec![]
144 }
145 }
146}
147
148impl Default for LogSink {
149 fn default() -> Self {
150 Self::new()
151 }
152}
153
154// ─── Tests ───────────────────────────────────────────────────
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 // T1: happy path — push entries and read them back
161 #[test]
162 fn log_sink_push_and_read() {
163 let sink = LogSink::new();
164 sink.push(LogEntry::new("info", "engine", "hello"));
165 sink.push(LogEntry::new("warn", "alc.log", "world"));
166
167 let entries = sink.entries();
168 assert_eq!(entries.len(), 2);
169 assert_eq!(entries[0].level, "info");
170 assert_eq!(entries[0].source, "engine");
171 assert_eq!(entries[0].message, "hello");
172 assert_eq!(entries[1].level, "warn");
173 assert_eq!(entries[1].message, "world");
174 }
175
176 // T2: boundary — cap=20 enforcement: 21st entry evicts the oldest
177 #[test]
178 fn log_sink_cap_evicts_oldest() {
179 let sink = LogSink::new();
180 for i in 0..=20u32 {
181 sink.push(LogEntry::new("info", "engine", format!("msg-{i}")));
182 }
183
184 let entries = sink.entries();
185 assert_eq!(entries.len(), LOG_SINK_CAP);
186 // The first entry should be msg-1 (msg-0 was evicted)
187 assert_eq!(entries[0].message, "msg-1");
188 // The last entry should be msg-20
189 assert_eq!(entries[LOG_SINK_CAP - 1].message, "msg-20");
190 }
191
192 // T2: boundary — empty sink
193 #[test]
194 fn log_sink_empty() {
195 let sink = LogSink::new();
196 assert!(sink.entries().is_empty());
197 let json = sink.to_json();
198 assert_eq!(json, serde_json::Value::Array(vec![]));
199 }
200
201 // T1: to_json serializes correctly
202 #[test]
203 fn log_sink_to_json_shape() {
204 let sink = LogSink::new();
205 sink.push(LogEntry::new("debug", "alc.lua.print", "test-msg"));
206
207 let json = sink.to_json();
208 let arr = json.as_array().unwrap();
209 assert_eq!(arr.len(), 1);
210 assert_eq!(arr[0]["level"], "debug");
211 assert_eq!(arr[0]["source"], "alc.lua.print");
212 assert_eq!(arr[0]["message"], "test-msg");
213 assert!(arr[0].get("ts").is_some());
214 }
215
216 // T1: clone shares the same underlying buffer
217 #[test]
218 fn log_sink_clone_shares_buffer() {
219 let sink = LogSink::new();
220 let sink2 = sink.clone();
221 sink.push(LogEntry::new("info", "engine", "shared"));
222 assert_eq!(sink2.entries().len(), 1);
223 }
224
225 // T3: exactly at cap boundary (20 entries) — no eviction yet
226 #[test]
227 fn log_sink_exactly_at_cap() {
228 let sink = LogSink::new();
229 for i in 0..20u32 {
230 sink.push(LogEntry::new("info", "engine", format!("msg-{i}")));
231 }
232 let entries = sink.entries();
233 assert_eq!(entries.len(), 20);
234 assert_eq!(entries[0].message, "msg-0");
235 assert_eq!(entries[19].message, "msg-19");
236 }
237}