Skip to main content

nntp_proxy/tui/
log_capture.rs

1//! In-memory log capture for TUI display
2
3use std::collections::VecDeque;
4use std::io::{self, Write};
5use std::mem;
6use std::sync::{Arc, Mutex};
7
8/// Maximum number of log lines to keep in memory
9const MAX_LOG_LINES: usize = 1000;
10
11/// Shared log buffer accessible by both tracing subscriber and TUI
12#[derive(Clone)]
13pub struct LogBuffer {
14    lines: Arc<Mutex<VecDeque<String>>>,
15}
16
17impl LogBuffer {
18    /// Create a new log buffer
19    #[must_use]
20    pub fn new() -> Self {
21        Self {
22            lines: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_LOG_LINES))),
23        }
24    }
25
26    /// Add a log line to the buffer
27    pub fn push(&self, line: String) {
28        if let Ok(mut lines) = self.lines.lock() {
29            if lines.len() >= MAX_LOG_LINES {
30                lines.pop_front();
31            }
32            lines.push_back(line);
33        }
34    }
35
36    /// Get recent log lines (clones strings)
37    #[must_use]
38    pub fn recent_lines(&self, count: usize) -> Vec<String> {
39        self.lines.lock().map_or_else(
40            |_| Vec::new(),
41            |lines| lines.iter().rev().take(count).rev().cloned().collect(),
42        )
43    }
44
45    /// Get all log lines (clones strings)
46    #[must_use]
47    pub fn all_lines(&self) -> Vec<String> {
48        self.lines
49            .lock()
50            .map_or_else(|_| Vec::new(), |lines| lines.iter().cloned().collect())
51    }
52
53    /// Access log lines with a closure (zero-allocation, holds lock briefly)
54    ///
55    /// The closure receives the deque and skip count for the last N visible lines.
56    /// Returns the result of the closure, or None if lock is poisoned.
57    pub fn with_recent_lines<F, R>(&self, count: usize, f: F) -> Option<R>
58    where
59        F: FnOnce(&VecDeque<String>, usize) -> R,
60    {
61        self.lines.lock().ok().map(|lines| {
62            let skip = lines.len().saturating_sub(count);
63            f(&lines, skip)
64        })
65    }
66}
67
68impl Default for LogBuffer {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74/// Writer that appends to `LogBuffer`
75pub struct LogWriter {
76    buffer: LogBuffer,
77    line_buffer: String,
78}
79
80impl LogWriter {
81    /// Create a new log writer
82    #[must_use]
83    pub fn new(buffer: LogBuffer) -> Self {
84        Self {
85            buffer,
86            line_buffer: String::with_capacity(256),
87        }
88    }
89}
90
91impl Write for LogWriter {
92    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
93        let s =
94            std::str::from_utf8(buf).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
95
96        for c in s.chars() {
97            if c == '\n' {
98                // Complete line - push to buffer
99                if !self.line_buffer.is_empty() {
100                    // Move string out, replacing with empty (zero-copy)
101                    self.buffer.push(mem::take(&mut self.line_buffer));
102                }
103            } else {
104                self.line_buffer.push(c);
105            }
106        }
107
108        Ok(buf.len())
109    }
110
111    fn flush(&mut self) -> io::Result<()> {
112        // Flush any remaining content
113        if !self.line_buffer.is_empty() {
114            // Move string out, replacing with empty (zero-copy)
115            self.buffer.push(mem::take(&mut self.line_buffer));
116        }
117        Ok(())
118    }
119}
120
121/// `MakeWriter` implementation for `tracing_subscriber`
122pub struct LogMakeWriter {
123    buffer: LogBuffer,
124}
125
126impl LogMakeWriter {
127    /// Create a new `MakeWriter` for the given buffer
128    #[must_use]
129    pub const fn new(buffer: LogBuffer) -> Self {
130        Self { buffer }
131    }
132}
133
134impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogMakeWriter {
135    type Writer = LogWriter;
136
137    fn make_writer(&'a self) -> Self::Writer {
138        LogWriter::new(self.buffer.clone())
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::io::Write;
146
147    #[test]
148    fn test_log_buffer_push_and_retrieve() {
149        let buffer = LogBuffer::new();
150        buffer.push("Line 1".to_string());
151        buffer.push("Line 2".to_string());
152        buffer.push("Line 3".to_string());
153
154        let lines = buffer.all_lines();
155        assert_eq!(lines.len(), 3);
156        assert_eq!(lines[0], "Line 1");
157        assert_eq!(lines[2], "Line 3");
158    }
159
160    #[test]
161    fn test_log_buffer_capacity_limit() {
162        let buffer = LogBuffer::new();
163
164        // Add more than MAX_LOG_LINES
165        for i in 0..1500 {
166            buffer.push(format!("Line {i}"));
167        }
168
169        let lines = buffer.all_lines();
170        assert_eq!(lines.len(), MAX_LOG_LINES);
171        // Oldest lines should be dropped
172        assert_eq!(lines[0], "Line 500");
173    }
174
175    #[test]
176    fn test_log_buffer_recent_lines() {
177        let buffer = LogBuffer::new();
178        for i in 0..10 {
179            buffer.push(format!("Line {i}"));
180        }
181
182        let recent = buffer.recent_lines(3);
183        assert_eq!(recent.len(), 3);
184        assert_eq!(recent[0], "Line 7");
185        assert_eq!(recent[1], "Line 8");
186        assert_eq!(recent[2], "Line 9");
187    }
188
189    #[test]
190    fn test_log_writer_splits_lines() {
191        let buffer = LogBuffer::new();
192        let mut writer = LogWriter::new(buffer.clone());
193
194        writer.write_all(b"Line 1\nLine 2\nLine 3\n").unwrap();
195        writer.flush().unwrap();
196
197        let lines = buffer.all_lines();
198        assert_eq!(lines.len(), 3);
199        assert_eq!(lines[0], "Line 1");
200        assert_eq!(lines[1], "Line 2");
201        assert_eq!(lines[2], "Line 3");
202    }
203
204    #[test]
205    fn test_log_writer_partial_lines() {
206        let buffer = LogBuffer::new();
207        let mut writer = LogWriter::new(buffer.clone());
208
209        writer.write_all(b"Partial ").unwrap();
210        writer.write_all(b"line\n").unwrap();
211        writer.flush().unwrap();
212
213        let lines = buffer.all_lines();
214        assert_eq!(lines.len(), 1);
215        assert_eq!(lines[0], "Partial line");
216    }
217
218    #[test]
219    fn test_log_writer_flush_incomplete() {
220        let buffer = LogBuffer::new();
221        let mut writer = LogWriter::new(buffer.clone());
222
223        writer.write_all(b"No newline").unwrap();
224        writer.flush().unwrap();
225
226        let lines = buffer.all_lines();
227        assert_eq!(lines.len(), 1);
228        assert_eq!(lines[0], "No newline");
229    }
230}