Files
storkit/server/src/log_buffer.rs

137 lines
3.9 KiB
Rust
Raw Normal View History

//! Bounded in-memory ring buffer for server log output.
//!
//! Use the [`slog!`] macro as a drop-in replacement for `eprintln!`. It writes
//! to stderr (same as before) and simultaneously appends the line to the global
//! ring buffer, making it retrievable via the `get_server_logs` MCP tool.
use std::collections::VecDeque;
use std::sync::{Mutex, OnceLock};
const CAPACITY: usize = 1000;
pub struct LogBuffer {
lines: Mutex<VecDeque<String>>,
}
impl LogBuffer {
fn new() -> Self {
Self {
lines: Mutex::new(VecDeque::with_capacity(CAPACITY)),
}
}
/// Append a log line, evicting the oldest entry when at capacity.
pub fn push(&self, line: String) {
if let Ok(mut buf) = self.lines.lock() {
if buf.len() >= CAPACITY {
buf.pop_front();
}
buf.push_back(line);
}
}
/// Return up to `count` recent lines, optionally filtered by a substring.
/// Lines are returned in chronological order (oldest first).
pub fn get_recent(&self, count: usize, filter: Option<&str>) -> Vec<String> {
let buf = match self.lines.lock() {
Ok(b) => b,
Err(_) => return vec![],
};
let filtered: Vec<&String> = buf
.iter()
.filter(|line| filter.is_none_or(|f| line.contains(f)))
.collect();
let start = filtered.len().saturating_sub(count);
filtered[start..].iter().map(|s| (*s).clone()).collect()
}
}
static GLOBAL: OnceLock<LogBuffer> = OnceLock::new();
/// Access the process-wide log ring buffer.
pub fn global() -> &'static LogBuffer {
GLOBAL.get_or_init(LogBuffer::new)
}
/// Write a formatted message to stderr **and** capture it in the ring buffer.
///
/// Usage is identical to `eprintln!`:
/// ```ignore
/// slog!("agent {} started", name);
/// ```
#[macro_export]
macro_rules! slog {
($($arg:tt)*) => {{
let _line = format!($($arg)*);
eprintln!("{}", _line);
$crate::log_buffer::global().push(_line);
}};
}
#[cfg(test)]
mod tests {
use super::*;
fn fresh_buffer() -> LogBuffer {
LogBuffer::new()
}
#[test]
fn push_and_retrieve() {
let buf = fresh_buffer();
buf.push("line one".into());
buf.push("line two".into());
let recent = buf.get_recent(10, None);
assert_eq!(recent, vec!["line one", "line two"]);
}
#[test]
fn evicts_oldest_at_capacity() {
let buf = LogBuffer {
lines: Mutex::new(VecDeque::with_capacity(CAPACITY)),
};
// Fill past capacity
for i in 0..=CAPACITY {
buf.push(format!("line {i}"));
}
let recent = buf.get_recent(CAPACITY + 1, None);
// Should have exactly CAPACITY lines
assert_eq!(recent.len(), CAPACITY);
// The oldest (line 0) should have been evicted
assert!(!recent.iter().any(|l| l == "line 0"));
// The newest should be present
assert!(recent.iter().any(|l| l == &format!("line {CAPACITY}")));
}
#[test]
fn filter_by_substring() {
let buf = fresh_buffer();
buf.push("watcher started".into());
buf.push("mcp call received".into());
buf.push("watcher event".into());
let filtered = buf.get_recent(100, Some("watcher"));
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0], "watcher started");
assert_eq!(filtered[1], "watcher event");
}
#[test]
fn count_limits_results() {
let buf = fresh_buffer();
for i in 0..10 {
buf.push(format!("line {i}"));
}
let recent = buf.get_recent(3, None);
assert_eq!(recent.len(), 3);
// Most recent 3
assert_eq!(recent, vec!["line 7", "line 8", "line 9"]);
}
#[test]
fn empty_buffer_returns_empty() {
let buf = fresh_buffer();
assert!(buf.get_recent(10, None).is_empty());
}
}