1#![forbid(unsafe_code)]
2
3use crate::TerminalWriter;
26use ftui_render::sanitize::sanitize;
27use std::io::{self, Write};
28
29pub 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 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 let line = String::from_utf8_lossy(&self.buffer);
54 let safe_line = sanitize(&line);
55
56 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 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 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 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 }
112 let output = writer.into_inner().unwrap();
115 let output_str = String::from_utf8_lossy(&output);
116 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 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 assert!(
197 !output_str.contains('\n'),
198 "overlay log emission must not contain raw newline bytes"
199 );
200 }
201
202 #[test]
203 fn log_sink_multiple_newlines_in_one_write() {
204 let mut writer = create_writer();
205 {
206 let mut sink = LogSink::new(&mut writer);
207 sink.write_all(b"A\nB\nC\n").unwrap();
208 }
209
210 let output = writer.into_inner().unwrap();
211 let output_str = String::from_utf8_lossy(&output);
212
213 assert!(output_str.contains('A'));
214 assert!(output_str.contains('B'));
215 assert!(output_str.contains('C'));
216 }
217
218 #[test]
219 fn log_sink_sanitizes_multiple_escapes() {
220 let mut writer = create_writer();
221 {
222 let mut sink = LogSink::new(&mut writer);
223 writeln!(sink, "\x1b[31mRed\x1b[0m \x1b[1mBold\x1b[0m").unwrap();
224 }
225
226 let output = writer.into_inner().unwrap();
227 let output_str = String::from_utf8_lossy(&output);
228
229 assert!(output_str.contains("Red"));
231 assert!(output_str.contains("Bold"));
232 assert!(!output_str.contains("\x1b[31m"));
236 assert!(!output_str.contains("\x1b[1m"));
237 }
238
239 #[test]
240 fn log_sink_invalid_utf8_lossy() {
241 let mut writer = create_writer();
242 {
243 let mut sink = LogSink::new(&mut writer);
244 sink.write_all(&[0xFF, 0xFE, b'\n']).unwrap();
246 }
247
248 let output = writer.into_inner().unwrap();
249 let output_str = String::from_utf8_lossy(&output);
250 assert!(output_str.contains('\u{FFFD}') || !output_str.is_empty());
252 }
253
254 #[test]
255 fn log_sink_drop_without_flush_writes_partial() {
256 let mut writer = create_writer();
257 {
258 let mut sink = LogSink::new(&mut writer);
259 write!(sink, "NoNewline").unwrap();
260 }
262
263 let output = writer.into_inner().unwrap();
264 let output_str = String::from_utf8_lossy(&output);
265 assert!(
267 output_str.contains("NoNewline"),
268 "partial line should be written on drop"
269 );
270 }
271
272 #[test]
273 fn log_sink_write_returns_full_length() {
274 let mut writer = create_writer();
275 let mut sink = LogSink::new(&mut writer);
276 let data = b"Hello World\n";
277 let n = sink.write(data).unwrap();
278 assert_eq!(n, data.len());
279 }
280}