Skip to main content

algocline_core/
recent_log.rs

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