Skip to main content

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}