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;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ReadFileArgs {
pub path: String,
}
pub(crate) fn execute_read_file_tool(
args: &ReadFileArgs,
working_dir: Option<&Path>,
) -> Result<String, ToolExecError> {
if args.path.trim().is_empty() {
return Err(ToolExecError(
"missing required string argument: path".to_string(),
));
}
let resolved = resolve_path(&args.path, working_dir);
let mut stream = TextStream::new(open_text_reader(&resolved)?);
let mut out = String::new();
let mut budget = OutputBudget::new(MAX_TOOL_OUTPUT_BYTES);
for line in &mut stream {
let line = line?;
if budget.is_truncated() {
continue;
}
let display_line = render_streamed_line(&line, &resolved, false)?;
budget.push_line(&mut out, &display_line);
}
if budget.is_truncated() {
let returned_bytes = budget.shown_bytes() + 1;
out.push_str(&format!(
"\n...[truncated: showing {returned_bytes} of {} bytes; file has {} line(s) — \
use read_file_range for the rest]",
stream.total_bytes(),
stream.total_lines()
));
}
debug!(
path = %resolved.display(),
total_lines = stream.total_lines(),
total_bytes = stream.total_bytes(),
shown_bytes = budget.shown_bytes(),
truncated = budget.is_truncated(),
"read_file completed"
);
Ok(out)
}
pub fn describe_read_file_invocation(args: &ReadFileArgs) -> String {
format!("Reading file `{}`.", args.path)
}
pub(crate) struct ReadFile;
define_tool!(
ReadFile,
"read_file",
"Read a UTF-8 text file from the local workspace. Rejects binary files; output is capped and truncation reports totals.",
ReadFileArgs,
execute_read_file_tool,
"core",
describe_read_file_invocation
);
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn run(content: &str) -> Result<String, ToolExecError> {
run_bytes(content.as_bytes())
}
fn run_bytes(content: &[u8]) -> Result<String, ToolExecError> {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(content).unwrap();
execute_read_file_tool(
&ReadFileArgs {
path: file.path().display().to_string(),
},
None,
)
}
#[test]
fn reads_plain_text_file() {
let out = run("alpha\nbeta\ngamma\n").unwrap();
assert_eq!(out, "alpha\nbeta\ngamma\n");
}
#[test]
fn reads_file_without_trailing_newline() {
let out = run("alpha\nbeta").unwrap();
assert_eq!(out, "alpha\nbeta\n");
}
#[test]
fn normalizes_crlf_line_endings() {
let out = run("alpha\r\nbeta\r\n").unwrap();
assert_eq!(out, "alpha\nbeta\n");
}
#[test]
fn empty_file_returns_empty_output() {
let out = run("").unwrap();
assert_eq!(out, "");
}
#[test]
fn rejects_missing_path() {
let err = execute_read_file_tool(&ReadFileArgs { path: " ".into() }, None).unwrap_err();
assert!(err.to_string().contains("missing required"));
}
#[test]
fn rejects_binary_file() {
let err = run_bytes(b"\x89PNG\r\n\x1a\n\x00\x00").unwrap_err();
assert!(err.to_string().contains("binary file"), "{err}");
}
#[test]
fn rejects_nul_past_sniff_head() {
let mut content = vec![b'a'; 9 * 1024];
content.push(0);
let err = run_bytes(&content).unwrap_err();
assert!(err.to_string().contains("binary file"), "{err}");
}
#[test]
fn rejects_invalid_utf8() {
let err = run_bytes(b"ok\n\xff\xfe").unwrap_err();
assert!(err.to_string().contains("not valid UTF-8"), "{err}");
}
#[test]
fn rejects_invalid_utf8_past_sniff_head() {
let mut content = vec![b'a'; 9 * 1024];
content.extend_from_slice(b"\xff\xfe");
let err = run_bytes(&content).unwrap_err();
assert!(err.to_string().contains("not valid UTF-8"), "{err}");
}
#[test]
fn reports_totals_when_output_truncated() {
let content = (0..3000)
.map(|i| format!("{i:>8} {}", "x".repeat(90)))
.collect::<Vec<_>>()
.join("\n");
let out = run(&content).unwrap();
assert!(out.contains("...[truncated: showing"), "{out}");
assert!(out.contains("of 299999 bytes"), "{out}");
assert!(out.contains("file has 3000 line(s)"), "{out}");
}
#[test]
fn marks_over_cap_line_truncated() {
let big = "x".repeat(100 * 1024);
let out = run(&big).unwrap();
assert!(out.contains("...[line truncated: exceeds 64 KiB]"), "{out}");
}
#[test]
fn reads_lines_after_over_cap_line() {
let mut content = vec![b'x'; 100 * 1024];
content.extend_from_slice(b"\nbeta\n");
let out = run_bytes(&content).unwrap();
assert!(out.contains("...[line truncated: exceeds 64 KiB]"), "{out}");
assert!(out.ends_with("beta\n"), "missing trailing line: {out:?}");
}
#[test]
fn describe_invocation() {
let args = ReadFileArgs {
path: "src/main.rs".into(),
};
assert_eq!(
super::describe_read_file_invocation(&args),
"Reading file `src/main.rs`."
);
}
}