Skip to main content

ftui_runtime/
log_sink.rs

1#![forbid(unsafe_code)]
2
3//! Log sink for in-process output routing.
4//!
5//! The `LogSink` struct implements [`std::io::Write`] and forwards output to
6//! [`TerminalWriter::write_log`], ensuring that:
7//!
8//! 1. Output is line-buffered (to prevent torn lines).
9//! 2. Content is sanitized (escape sequences stripped) by default.
10//! 3. The One-Writer Rule is respected.
11//!
12//! # Usage
13//!
14//! ```ignore
15//! use ftui_runtime::log_sink::LogSink;
16//! use std::io::Write;
17//!
18//! // Assuming you have a mutable reference to TerminalWriter
19//! let mut sink = LogSink::new(&mut terminal_writer);
20//!
21//! // Now you can use it with any std::io::Write consumer
22//! writeln!(sink, "This log message is safe: \x1b[31mcolors stripped\x1b[0m").unwrap();
23//! ```
24
25use crate::TerminalWriter;
26use ftui_render::sanitize::sanitize;
27use std::io::{self, Write};
28
29/// A write adapter that routes output to the terminal's log scrollback.
30///
31/// Wraps a mutable reference to [`TerminalWriter`] and implements [`std::io::Write`].
32/// Buffers partial lines until a newline is encountered or `flush()` is called.
33pub struct LogSink<'a, W: Write> {
34    writer: &'a mut TerminalWriter<W>,
35    buffer: Vec<u8>,
36}
37
38impl<'a, W: Write> LogSink<'a, W> {
39    /// Create a new log sink wrapping the given terminal writer.
40    pub fn new(writer: &'a mut TerminalWriter<W>) -> Self {
41        Self {
42            writer,
43            buffer: Vec::with_capacity(1024),
44        }
45    }
46}
47
48impl<W: Write> Write for LogSink<'_, W> {
49    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
50        for &byte in buf {
51            if byte == b'\n' {
52                // Found a newline, flush the buffer
53                let line = String::from_utf8_lossy(&self.buffer);
54                let safe_line = sanitize(&line);
55
56                // Write line + newline to terminal writer
57                // We format manually to ensure we own the string if needed
58                self.writer.write_log(&format!("{}\n", safe_line))?;
59
60                self.buffer.clear();
61            } else {
62                self.buffer.push(byte);
63            }
64        }
65        Ok(buf.len())
66    }
67
68    fn flush(&mut self) -> io::Result<()> {
69        if !self.buffer.is_empty() {
70            // Flush remaining buffer as a partial line
71            let line = String::from_utf8_lossy(&self.buffer);
72            let safe_line = sanitize(&line);
73            self.writer.write_log(&safe_line)?;
74            self.buffer.clear();
75        }
76        self.writer.flush()
77    }
78}
79
80impl<W: Write> Drop for LogSink<'_, W> {
81    fn drop(&mut self) {
82        // Best-effort flush on drop.
83        // We ignore errors here because we can't propagate them and panicking in drop is bad.
84        let _ = self.flush();
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::terminal_writer::{ScreenMode, UiAnchor};
92    use ftui_core::terminal_capabilities::TerminalCapabilities;
93
94    // Helper to create a dummy writer
95    fn create_writer() -> TerminalWriter<Vec<u8>> {
96        TerminalWriter::new(
97            Vec::new(),
98            ScreenMode::Inline { ui_height: 5 },
99            UiAnchor::Bottom,
100            TerminalCapabilities::basic(),
101        )
102    }
103
104    #[test]
105    fn log_sink_buffers_lines() {
106        let mut writer = create_writer();
107        {
108            let mut sink = LogSink::new(&mut writer);
109            write!(sink, "Hello").unwrap();
110            // Not dropped yet, so buffer holds "Hello"
111        }
112        // Dropped now, should flush
113
114        let output = writer.into_inner().unwrap();
115        let output_str = String::from_utf8_lossy(&output);
116        // With Drop flush implemented, partial lines ARE written.
117        assert!(
118            output_str.contains("Hello"),
119            "partial content should be flushed on drop"
120        );
121    }
122
123    #[test]
124    fn log_sink_sanitizes_output() {
125        let mut writer = create_writer();
126        {
127            let mut sink = LogSink::new(&mut writer);
128            writeln!(sink, "Unsafe \x1b[31mred\x1b[0m text").unwrap();
129        }
130
131        // writer.flush() writes to the internal buffer
132        // We need to consume writer to check output
133        let output = writer.into_inner().unwrap();
134        let output_str = String::from_utf8_lossy(&output);
135
136        assert!(output_str.contains("Unsafe red text"));
137        assert!(!output_str.contains("\x1b[31m"));
138    }
139
140    #[test]
141    fn log_sink_flushes_partial_line() {
142        let mut writer = create_writer();
143        {
144            let mut sink = LogSink::new(&mut writer);
145            write!(sink, "Partial").unwrap();
146            sink.flush().unwrap();
147        }
148
149        let output = writer.into_inner().unwrap();
150        let output_str = String::from_utf8_lossy(&output);
151
152        assert!(output_str.contains("Partial"));
153    }
154
155    #[test]
156    fn log_sink_multiple_lines() {
157        let mut writer = create_writer();
158        {
159            let mut sink = LogSink::new(&mut writer);
160            writeln!(sink, "Line1").unwrap();
161            writeln!(sink, "Line2").unwrap();
162            writeln!(sink, "Line3").unwrap();
163        }
164
165        let output = writer.into_inner().unwrap();
166        let output_str = String::from_utf8_lossy(&output);
167
168        assert!(output_str.contains("Line1"));
169        assert!(output_str.contains("Line2"));
170        assert!(output_str.contains("Line3"));
171    }
172
173    #[test]
174    fn log_sink_empty_write() {
175        let mut writer = create_writer();
176        {
177            let mut sink = LogSink::new(&mut writer);
178            let n = sink.write(b"").unwrap();
179            assert_eq!(n, 0);
180        }
181    }
182
183    #[test]
184    fn log_sink_newline_only() {
185        let mut writer = create_writer();
186        {
187            let mut sink = LogSink::new(&mut writer);
188            sink.write_all(b"\n").unwrap();
189        }
190
191        let output = writer.into_inner().unwrap();
192        let output_str = String::from_utf8_lossy(&output);
193        // Should have written an empty sanitized line + newline
194        assert!(output_str.contains('\n'));
195    }
196
197    #[test]
198    fn log_sink_multiple_newlines_in_one_write() {
199        let mut writer = create_writer();
200        {
201            let mut sink = LogSink::new(&mut writer);
202            sink.write_all(b"A\nB\nC\n").unwrap();
203        }
204
205        let output = writer.into_inner().unwrap();
206        let output_str = String::from_utf8_lossy(&output);
207
208        assert!(output_str.contains('A'));
209        assert!(output_str.contains('B'));
210        assert!(output_str.contains('C'));
211    }
212
213    #[test]
214    fn log_sink_sanitizes_multiple_escapes() {
215        let mut writer = create_writer();
216        {
217            let mut sink = LogSink::new(&mut writer);
218            writeln!(sink, "\x1b[31mRed\x1b[0m \x1b[1mBold\x1b[0m").unwrap();
219        }
220
221        let output = writer.into_inner().unwrap();
222        let output_str = String::from_utf8_lossy(&output);
223
224        // The content "Red Bold" should appear (sanitized)
225        assert!(output_str.contains("Red"));
226        assert!(output_str.contains("Bold"));
227        // The original SGR sequences (31m, 0m, 1m) should be stripped from content
228        // Note: terminal writer adds its own cursor control sequences, so we check
229        // that the specific SGR codes from the input are not present
230        assert!(!output_str.contains("\x1b[31m"));
231        assert!(!output_str.contains("\x1b[1m"));
232    }
233
234    #[test]
235    fn log_sink_invalid_utf8_lossy() {
236        let mut writer = create_writer();
237        {
238            let mut sink = LogSink::new(&mut writer);
239            // Write some invalid UTF-8 bytes followed by a newline
240            sink.write_all(&[0xFF, 0xFE, b'\n']).unwrap();
241        }
242
243        let output = writer.into_inner().unwrap();
244        let output_str = String::from_utf8_lossy(&output);
245        // Should contain replacement characters, not panic
246        assert!(output_str.contains('\u{FFFD}') || !output_str.is_empty());
247    }
248
249    #[test]
250    fn log_sink_drop_without_flush_writes_partial() {
251        let mut writer = create_writer();
252        {
253            let mut sink = LogSink::new(&mut writer);
254            write!(sink, "NoNewline").unwrap();
255            // Drop without flush
256        }
257
258        let output = writer.into_inner().unwrap();
259        let output_str = String::from_utf8_lossy(&output);
260        // With Drop flush, partial line should appear
261        assert!(
262            output_str.contains("NoNewline"),
263            "partial line should be written on drop"
264        );
265    }
266
267    #[test]
268    fn log_sink_write_returns_full_length() {
269        let mut writer = create_writer();
270        let mut sink = LogSink::new(&mut writer);
271        let data = b"Hello World\n";
272        let n = sink.write(data).unwrap();
273        assert_eq!(n, data.len());
274    }
275}