use super::error::ToolExecError;
use super::sanitize::sanitize_content;
use std::fs::File;
use std::io::{self, BufRead, BufReader};
pub(crate) const BINARY_SNIFF_BYTES: usize = 8 * 1024;
pub(crate) const MAX_LINE_DISPLAY_BYTES: usize = 64 * 1024;
pub(crate) fn open_text_reader(path: &std::path::Path) -> Result<BufReader<File>, ToolExecError> {
let file = File::open(path)?;
let mut reader = BufReader::with_capacity(BINARY_SNIFF_BYTES, file);
let head = reader.fill_buf()?;
if let Some(pos) = head.iter().position(|&b| b == 0) {
return Err(ToolExecError(format!(
"'{}' appears to be a binary file (NUL byte at offset {pos}); \
read_file/read_file_range are for UTF-8 text files",
path.display()
)));
}
if let Err(e) = std::str::from_utf8(head)
&& e.error_len().is_some()
{
return Err(ToolExecError(format!(
"'{}' is not valid UTF-8 text (invalid byte sequence at offset {})",
path.display(),
e.valid_up_to()
)));
}
Ok(reader)
}
pub(crate) fn read_line_capped<R: BufRead>(
reader: &mut R,
buf: &mut Vec<u8>,
cap: usize,
) -> io::Result<bool> {
buf.clear();
loop {
let (consumed, done) = {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(true);
}
let remaining = cap.saturating_sub(buf.len());
if remaining == 0 {
return Ok(false);
}
let take = available.len().min(remaining);
let window = cap_slice(available, ..take)?;
match window.iter().position(|&b| b == b'\n') {
Some(idx) => {
buf.extend_from_slice(cap_slice(available, ..=idx)?);
(idx + 1, true)
}
None => {
buf.extend_from_slice(window);
(take, false)
}
}
};
reader.consume(consumed);
if done {
return Ok(true);
}
}
}
fn cap_slice(
available: &[u8],
range: impl std::slice::SliceIndex<[u8], Output = [u8]>,
) -> io::Result<&[u8]> {
available.get(range).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidData,
"internal invariant violated: fill_buf window slice out of bounds",
)
})
}
pub(crate) fn drain_rest_of_line<R: BufRead>(reader: &mut R) -> io::Result<u64> {
let mut drained: u64 = 0;
loop {
let (consumed, done) = {
let available = reader.fill_buf()?;
if available.is_empty() {
return Ok(drained);
}
match available.iter().position(|&b| b == b'\n') {
Some(idx) => (idx + 1, true),
None => (available.len(), false),
}
};
drained += consumed as u64;
reader.consume(consumed);
if done {
return Ok(drained);
}
}
}
pub(crate) struct StreamedLine {
pub line_number: u64,
pub content: Vec<u8>,
pub complete: bool,
pub start_offset: u64,
}
pub(crate) struct TextStream<R: BufRead> {
reader: R,
line_buf: Vec<u8>,
lines_read: u64,
total_bytes: u64,
finished: bool,
}
impl<R: BufRead> TextStream<R> {
pub(crate) fn new(reader: R) -> Self {
Self {
reader,
line_buf: Vec::with_capacity(MAX_LINE_DISPLAY_BYTES),
lines_read: 0,
total_bytes: 0,
finished: false,
}
}
pub(crate) fn total_lines(&self) -> u64 {
self.lines_read
}
pub(crate) fn total_bytes(&self) -> u64 {
self.total_bytes
}
}
impl<R: BufRead> Iterator for TextStream<R> {
type Item = io::Result<StreamedLine>;
fn next(&mut self) -> Option<Self::Item> {
if self.finished {
return None;
}
let complete =
match read_line_capped(&mut self.reader, &mut self.line_buf, MAX_LINE_DISPLAY_BYTES) {
Ok(complete) => complete,
Err(e) => {
self.finished = true;
return Some(Err(e));
}
};
if self.line_buf.is_empty() {
self.finished = true;
return None;
}
let start_offset = self.total_bytes;
let line_total = if complete {
self.line_buf.len() as u64
} else {
match drain_rest_of_line(&mut self.reader) {
Ok(drained) => self.line_buf.len() as u64 + drained,
Err(e) => {
self.finished = true;
return Some(Err(e));
}
}
};
self.total_bytes += line_total;
self.lines_read += 1;
Some(Ok(StreamedLine {
line_number: self.lines_read,
content: self.line_buf.clone(),
complete,
start_offset,
}))
}
}
pub(crate) struct OutputBudget {
max_bytes: usize,
shown_bytes: usize,
truncated: bool,
}
impl OutputBudget {
pub(crate) fn new(max_bytes: usize) -> Self {
Self {
max_bytes,
shown_bytes: 0,
truncated: false,
}
}
pub(crate) fn shown_bytes(&self) -> usize {
self.shown_bytes
}
pub(crate) fn is_truncated(&self) -> bool {
self.truncated
}
pub(crate) fn push_line(&mut self, out: &mut String, line: &str) -> bool {
let display_len = line.len() + 1;
if self.truncated || self.shown_bytes + display_len > self.max_bytes {
self.truncated = true;
return false;
}
out.push_str(line);
out.push('\n');
self.shown_bytes += display_len;
true
}
}
pub(crate) fn render_streamed_line(
line: &StreamedLine,
path: &std::path::Path,
numbered: bool,
) -> Result<String, ToolExecError> {
if let Some(pos) = line.content.iter().position(|&b| b == 0) {
return Err(ToolExecError(format!(
"'{}' appears to be a binary file (NUL byte at offset {})",
path.display(),
line.start_offset + pos as u64
)));
}
let line_str = match std::str::from_utf8(&line.content) {
Ok(s) => s,
Err(e) if !line.complete && e.error_len().is_none() => {
let prefix = line.content.get(..e.valid_up_to()).ok_or_else(|| {
ToolExecError(
"internal invariant violated: Utf8Error::valid_up_to() out of bounds".into(),
)
})?;
std::str::from_utf8(prefix).unwrap_or_default()
}
Err(e) => {
return Err(ToolExecError(format!(
"'{}' is not valid UTF-8 text (invalid byte sequence at offset {})",
path.display(),
line.start_offset + e.valid_up_to() as u64
)));
}
};
let mut display = line_str;
if let Some(stripped) = display.strip_suffix('\n') {
display = stripped;
}
if let Some(stripped) = display.strip_suffix('\r') {
display = stripped;
}
let display = sanitize_content(display);
let mut display_line = String::new();
if numbered {
display_line.push_str(&format!("{} | {display}", line.line_number));
} else {
display_line.push_str(&display);
}
if !line.complete {
display_line.push_str("\n...[line truncated: exceeds 64 KiB]");
}
Ok(display_line)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn text_stream_counts_lines_bytes_and_offsets() {
use std::io::Cursor;
let mut stream = TextStream::new(Cursor::new(b"a\nbb\nccc\n".to_vec()));
let lines: Vec<StreamedLine> = stream.by_ref().map(|l| l.unwrap()).collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].line_number, 1);
assert_eq!(lines[0].content, b"a\n");
assert_eq!(lines[0].start_offset, 0);
assert_eq!(lines[1].line_number, 2);
assert_eq!(lines[1].content, b"bb\n");
assert_eq!(lines[1].start_offset, 2);
assert_eq!(lines[2].line_number, 3);
assert_eq!(lines[2].content, b"ccc\n");
assert_eq!(lines[2].start_offset, 5);
assert_eq!(stream.total_lines(), 3);
assert_eq!(stream.total_bytes(), 9);
}
#[test]
fn text_stream_handles_over_cap_lines() {
use std::io::Cursor;
let content = vec![b'x'; 70 * 1024];
let mut stream = TextStream::new(Cursor::new(content.clone()));
let line = stream.next().unwrap().unwrap();
assert!(!line.complete);
assert_eq!(line.content.len(), MAX_LINE_DISPLAY_BYTES);
assert_eq!(stream.total_bytes(), content.len() as u64);
assert!(stream.next().is_none());
}
#[test]
fn output_budget_rejects_lines_past_cap() {
let mut out = String::new();
let mut budget = OutputBudget::new(10);
assert!(budget.push_line(&mut out, "abc")); assert!(budget.push_line(&mut out, "def")); assert!(!budget.push_line(&mut out, "ghi")); assert!(budget.is_truncated());
assert_eq!(budget.shown_bytes(), 8);
assert_eq!(out, "abc\ndef\n");
assert!(!budget.push_line(&mut out, "x"));
assert_eq!(out, "abc\ndef\n");
}
#[test]
fn render_streamed_line_rejects_binary_and_bad_utf8() {
let path = Path::new("f.txt");
let nul = StreamedLine {
line_number: 1,
content: b"ok\x00no".to_vec(),
complete: true,
start_offset: 0,
};
let err = render_streamed_line(&nul, path, false)
.unwrap_err()
.to_string();
assert!(err.contains("binary file"), "{err}");
let bad = StreamedLine {
line_number: 2,
content: b"ok\xff".to_vec(),
complete: true,
start_offset: 10,
};
let err = render_streamed_line(&bad, path, false)
.unwrap_err()
.to_string();
assert!(err.contains("not valid UTF-8"), "{err}");
assert!(err.contains("offset 12"), "{err}");
}
#[test]
fn render_streamed_line_normalizes_endings_and_numbers() {
let path = Path::new("f.txt");
let line = StreamedLine {
line_number: 3,
content: b"hi\r\n".to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(render_streamed_line(&line, path, true).unwrap(), "3 | hi");
assert_eq!(render_streamed_line(&line, path, false).unwrap(), "hi");
}
#[test]
fn render_streamed_line_handles_mid_char_cap_cut() {
let path = Path::new("f.txt");
let content = "€".repeat(21846); let line = StreamedLine {
line_number: 1,
content: content.into_bytes(),
complete: false,
start_offset: 0,
};
let out = render_streamed_line(&line, path, false).unwrap();
assert!(out.contains("...[line truncated: exceeds 64 KiB]"), "{out}");
std::str::from_utf8(out.as_bytes()).expect("output must be valid UTF-8");
}
#[test]
fn render_streamed_line_escapes_esc_and_bidi() {
let path = Path::new("f.txt");
let esc = StreamedLine {
line_number: 1,
content: b"x\x1b[31mred".to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(
render_streamed_line(&esc, path, false).unwrap(),
"x\\u{1b}[31mred"
);
let bidi = StreamedLine {
line_number: 1,
content: "ok\u{202e}evil".as_bytes().to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(
render_streamed_line(&bidi, path, false).unwrap(),
"ok\\u{202e}evil"
);
}
#[test]
fn render_streamed_line_keeps_tabs_and_cjk() {
let path = Path::new("f.txt");
let tabbed = StreamedLine {
line_number: 1,
content: b"a\tb".to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(render_streamed_line(&tabbed, path, false).unwrap(), "a\tb");
let cjk = StreamedLine {
line_number: 1,
content: "日本語".as_bytes().to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(render_streamed_line(&cjk, path, false).unwrap(), "日本語");
}
#[test]
fn render_streamed_line_numbered_path_sanitizes_content_only() {
let path = Path::new("f.txt");
let line = StreamedLine {
line_number: 7,
content: b"ok\x1b".to_vec(),
complete: true,
start_offset: 0,
};
assert_eq!(
render_streamed_line(&line, path, true).unwrap(),
"7 | ok\\u{1b}"
);
}
}