use super::{
MAX_TOOL_OUTPUT_BYTES, OutputBudget, TextStream, ToolExecError, open_text_reader,
render_streamed_line, resolve_path,
};
use schemars::JsonSchema;
use serde::Deserialize;
use std::path::Path;
use tracing::debug;
const MAX_READ_FILE_RANGE_LINES: usize = 500;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadFileRangeArgs {
pub path: String,
pub start_line: usize,
pub max_lines: usize,
}
pub(crate) fn execute_read_file_range_tool(
args: &ReadFileRangeArgs,
working_dir: Option<&Path>,
) -> Result<String, ToolExecError> {
if args.path.trim().is_empty() {
return Err(ToolExecError(
"missing required string argument: path".to_string(),
));
}
if args.start_line == 0 {
return Err(ToolExecError("start_line must be >= 1".to_string()));
}
if args.max_lines == 0 {
return Err(ToolExecError("max_lines must be >= 1".to_string()));
}
if args.max_lines > MAX_READ_FILE_RANGE_LINES {
return Err(ToolExecError(format!(
"max_lines must be <= {MAX_READ_FILE_RANGE_LINES}"
)));
}
let resolved = resolve_path(&args.path, working_dir);
let mut stream = TextStream::new(open_text_reader(&resolved)?);
let start = args.start_line as u64;
let max = args.max_lines as u64;
let mut out = String::new();
let mut budget = OutputBudget::new(MAX_TOOL_OUTPUT_BYTES);
let mut lines_shown: u64 = 0;
for line in &mut stream {
let line = line?;
let in_window = line.line_number >= start && lines_shown < max;
if !in_window || budget.is_truncated() {
continue;
}
let display_line = render_streamed_line(&line, &resolved, true)?;
if budget.push_line(&mut out, &display_line) {
lines_shown += 1;
}
}
let total_lines = stream.total_lines();
let total_bytes = stream.total_bytes();
if args.start_line as u64 > total_lines {
return Err(ToolExecError(format!(
"start_line {} is past end of file; file has {} lines",
args.start_line, total_lines
)));
}
let requested_end = total_lines.min(start.saturating_add(max - 1));
let header = if budget.is_truncated() && lines_shown == 0 {
format!(
"path: {}\nlines: none of {} (first line exceeds output budget)\n\n",
resolved.display(),
total_lines
)
} else {
let shown_end = if budget.is_truncated() {
start + lines_shown - 1
} else {
requested_end
};
format!(
"path: {}\nlines: {}-{} of {}\n\n",
resolved.display(),
args.start_line,
shown_end,
total_lines
)
};
if budget.is_truncated() {
let returned_bytes = budget.shown_bytes() + header.len() + 1;
out.push_str(&format!(
"\n...[truncated: showing {returned_bytes} of {total_bytes} bytes \
({lines_shown} of {total_lines} lines) — use a smaller range]"
));
}
debug!(
path = %resolved.display(),
start_line = args.start_line,
max_lines = args.max_lines,
total_lines,
lines_shown,
truncated = budget.is_truncated(),
"read_file_range completed"
);
Ok(format!("{header}{out}"))
}
pub fn describe_read_file_range_invocation(args: &ReadFileRangeArgs) -> String {
format!(
"Reading file `{}` from line {} (max {} lines).",
args.path, args.start_line, args.max_lines
)
}
pub(crate) struct ReadFileRange;
define_tool!(
ReadFileRange,
"read_file_range",
"Read a line range from a UTF-8 text file in the local workspace. Rejects binary files; max 500 lines per call.",
ReadFileRangeArgs,
execute_read_file_range_tool,
"core",
describe_read_file_range_invocation
);
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn run(content: &str, start_line: usize, max_lines: usize) -> Result<String, ToolExecError> {
run_bytes(content.as_bytes(), start_line, max_lines)
}
fn run_bytes(
content: &[u8],
start_line: usize,
max_lines: usize,
) -> Result<String, ToolExecError> {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(content).unwrap();
execute_read_file_range_tool(
&ReadFileRangeArgs {
path: file.path().display().to_string(),
start_line,
max_lines,
},
None,
)
}
#[test]
fn reads_numbered_line_chunks() {
let out = run("alpha\nbeta\ngamma\ndelta\n", 2, 2).unwrap();
assert!(out.contains("lines: 2-3 of 4"), "{out}");
assert!(out.contains("2 | beta"), "{out}");
assert!(out.contains("3 | gamma"), "{out}");
}
#[test]
fn clamps_to_eof() {
let out = run("alpha\nbeta\ngamma\n", 2, 10).unwrap();
assert!(out.contains("lines: 2-3 of 3"), "{out}");
assert!(out.contains("2 | beta"), "{out}");
assert!(out.contains("3 | gamma"), "{out}");
}
#[test]
fn rejects_start_line_past_eof() {
let err = run("alpha\nbeta\n", 5, 1).unwrap_err();
assert!(err.to_string().contains("past end of file"), "{err}");
}
#[test]
fn rejects_start_line_zero() {
let err = run("alpha\n", 0, 1).unwrap_err();
assert!(err.to_string().contains("start_line must be >= 1"), "{err}");
}
#[test]
fn rejects_max_lines_zero() {
let err = run("alpha\n", 1, 0).unwrap_err();
assert!(err.to_string().contains("max_lines must be >= 1"), "{err}");
}
#[test]
fn rejects_excessive_max_lines() {
let err = run("alpha\n", 1, 501).unwrap_err();
assert!(
err.to_string().contains("max_lines must be <= 500"),
"{err}"
);
}
#[test]
fn accepts_max_lines_at_cap() {
let content = (1..=500)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n")
+ "\n";
let out = run(&content, 1, 500).unwrap();
assert!(out.contains("lines: 1-500 of 500"), "{out}");
}
#[test]
fn rejects_binary_file() {
let err = run_bytes(b"\x00\x01\x02binary", 1, 5).unwrap_err();
assert!(err.to_string().contains("binary file"), "{err}");
}
#[test]
fn rejects_nul_past_sniff_head_in_range() {
let mut content = vec![b'a'; 9 * 1024];
content.extend_from_slice(b"\n\x00nul\n");
let err = run_bytes(&content, 1, 5).unwrap_err();
assert!(err.to_string().contains("binary file"), "{err}");
}
#[test]
fn rejects_invalid_utf8_in_range() {
let err = run_bytes(b"ok\n\xff\xfe\n", 1, 5).unwrap_err();
assert!(err.to_string().contains("not valid UTF-8"), "{err}");
}
#[test]
fn rejects_invalid_utf8_past_sniff_head_in_range() {
let mut content = vec![b'a'; 9 * 1024];
content.extend_from_slice(b"\n\xff\xfe\n");
let err = run_bytes(&content, 2, 5).unwrap_err();
assert!(err.to_string().contains("not valid UTF-8"), "{err}");
}
#[test]
fn ignores_invalid_utf8_outside_requested_range() {
let mut content = vec![b'a'; 9 * 1024];
content.extend_from_slice(b"\n\xff\xfe\nok\n");
let out = run_bytes(&content, 3, 1).unwrap();
assert!(out.contains("3 | ok"), "{out}");
assert!(out.contains("lines: 3-3 of 3"), "{out}");
}
#[test]
fn marks_over_cap_line_truncated() {
let big = "x".repeat(100 * 1024);
let out = run(&big, 1, 5).unwrap();
assert!(out.contains("...[line truncated: exceeds 64 KiB]"), "{out}");
}
#[test]
fn reports_truncation_when_lines_exceed_byte_budget() {
let content = (0..300)
.map(|i| format!("{i:>3} {}", "y".repeat(496)))
.collect::<Vec<_>>()
.join("\n");
let out = run(&content, 1, 500).unwrap();
assert!(out.contains("...[truncated: showing"), "{out}");
assert!(out.contains("of 300 lines)"), "{out}");
}
#[test]
fn truncation_report_counts_header_bytes() {
let content = (0..300)
.map(|i| format!("{i:>3} {}", "y".repeat(496)))
.collect::<Vec<_>>()
.join("\n");
let out = run(&content, 1, 500).unwrap();
let marker_prefix = "...[truncated: showing ";
let marker_tail = out.rsplit(marker_prefix).next().expect("marker present");
let shown: usize = marker_tail.split(" of ").next().unwrap().parse().unwrap();
let returned = out.len() - marker_tail.len() - marker_prefix.len();
assert_eq!(shown, returned, "reported bytes != returned bytes: {out}");
}
#[test]
fn describe_invocation() {
let args = ReadFileRangeArgs {
path: "src/lib.rs".into(),
start_line: 10,
max_lines: 50,
};
assert_eq!(
super::describe_read_file_range_invocation(&args),
"Reading file `src/lib.rs` from line 10 (max 50 lines)."
);
}
}