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!(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 assert!(output_str.contains("Red"));
226 assert!(output_str.contains("Bold"));
227 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 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 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 }
257
258 let output = writer.into_inner().unwrap();
259 let output_str = String::from_utf8_lossy(&output);
260 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}