use super::{looks_binary, Tool, ToolContext, ToolError, MAX_FILE_BYTES, MAX_TOOL_OUTPUT_BYTES};
use async_trait::async_trait;
use serde::Deserialize;
use std::fs::File;
use std::io::Read;
const DEFAULT_LIMIT: usize = 200;
const MAX_LIMIT: usize = 2000;
#[derive(Debug, Deserialize)]
struct ReadFileInput {
path: String,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
pub(crate) struct ReadFileTool;
#[async_trait]
impl Tool for ReadFileTool {
fn name(&self) -> &'static str {
"read_file"
}
fn description(&self) -> &'static str {
"Read a text file from the workspace. Returns up to 64 KiB. Use offset+limit \
(line-based) to page through larger files. Binary or >1 MiB files are refused."
}
fn input_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path relative to the workspace root." },
"offset": { "type": "integer", "minimum": 0, "description": "0-based line to start at." },
"limit": { "type": "integer", "minimum": 1, "description": "Max lines to return." }
},
"required": ["path"],
"additionalProperties": false
})
}
async fn run(&self, ctx: &ToolContext, input: serde_json::Value) -> Result<String, ToolError> {
let args: ReadFileInput =
serde_json::from_value(input).map_err(|e| ToolError::InvalidArgument(e.to_string()))?;
let offset = args.offset.unwrap_or(0);
let limit = args.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT);
let abs = ctx.resolve(&args.path)?;
let meta = std::fs::metadata(&abs).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ToolError::NotFound(args.path.clone()),
_ => ToolError::Io(e.to_string()),
})?;
if !meta.is_file() {
return Err(ToolError::InvalidArgument(format!(
"{} is not a regular file",
args.path
)));
}
if meta.len() > MAX_FILE_BYTES {
return Err(ToolError::TooLarge);
}
let mut sniff = [0u8; 8192];
let n = {
let mut f = File::open(&abs).map_err(|e| ToolError::Io(e.to_string()))?;
f.read(&mut sniff)
.map_err(|e| ToolError::Io(e.to_string()))?
};
if looks_binary(&sniff[..n]) {
return Err(ToolError::Binary);
}
let bytes = std::fs::read(&abs).map_err(|e| ToolError::Io(e.to_string()))?;
let content = String::from_utf8(bytes).map_err(|_| ToolError::Binary)?;
let mut all: Vec<&str> = content.split('\n').collect();
if matches!(all.last(), Some(&"")) && content.ends_with('\n') {
all.pop();
}
let total_lines = all.len();
if offset >= total_lines && total_lines > 0 {
return Err(ToolError::InvalidArgument(format!(
"offset {offset} exceeds line count {total_lines}"
)));
}
if total_lines == 0 {
return Ok(format!(
"{}:0-0 (of 0)\n",
rel_path_string(&abs, &ctx.workspace_root)
));
}
let end = (offset + limit).min(total_lines);
let slice = &all[offset..end];
let rel = rel_path_string(&abs, &ctx.workspace_root);
let mut out = String::new();
out.push_str(&format!(
"{}:{}-{} (of {})\n",
rel,
offset + 1,
end,
total_lines
));
for (i, line) in slice.iter().enumerate() {
let n = offset + i + 1;
out.push_str(&format!("{n:>6}\u{2192} {line}\n"));
}
Ok(truncate_to_budget(out, total_lines.saturating_sub(end)))
}
}
fn rel_path_string(abs: &std::path::Path, root: &std::path::Path) -> String {
let rel = abs.strip_prefix(root).unwrap_or(abs);
super::path_to_forward_slash(rel)
}
fn truncate_to_budget(s: String, lines_after_slice: usize) -> String {
if s.len() <= MAX_TOOL_OUTPUT_BYTES {
return s;
}
let cut = s[..MAX_TOOL_OUTPUT_BYTES]
.rfind('\n')
.unwrap_or(MAX_TOOL_OUTPUT_BYTES);
let dropped_in_slice = s[cut..].matches('\n').count();
let mut out = s[..cut].to_string();
out.push_str(&format!(
"\n... (truncated, {} more lines)",
dropped_in_slice + lines_after_slice
));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chat::tools::ToolContext;
use std::fs;
use tempfile::TempDir;
fn ctx_for(td: &TempDir) -> ToolContext {
ToolContext::new(td.path()).unwrap()
}
#[tokio::test]
async fn happy_path_reads_full_file() {
let td = TempDir::new().unwrap();
fs::write(td.path().join("a.txt"), "alpha\nbeta\ngamma\n").unwrap();
let tool = ReadFileTool;
let out = tool
.run(&ctx_for(&td), serde_json::json!({ "path": "a.txt" }))
.await
.unwrap();
assert!(out.starts_with("a.txt:1-3 (of 3)\n"), "got: {out}");
assert!(out.contains(" 1\u{2192} alpha"));
assert!(out.contains(" 2\u{2192} beta"));
assert!(out.contains(" 3\u{2192} gamma"));
}
#[tokio::test]
async fn offset_and_limit_slice() {
let td = TempDir::new().unwrap();
let body = (1..=10)
.map(|n| format!("line{n}"))
.collect::<Vec<_>>()
.join("\n");
fs::write(td.path().join("b.txt"), body).unwrap();
let tool = ReadFileTool;
let out = tool
.run(
&ctx_for(&td),
serde_json::json!({ "path": "b.txt", "offset": 2, "limit": 3 }),
)
.await
.unwrap();
assert!(out.starts_with("b.txt:3-5 (of 10)\n"), "got: {out}");
assert!(out.contains(" 3\u{2192} line3"));
assert!(out.contains(" 5\u{2192} line5"));
assert!(!out.contains("line6"));
}
#[tokio::test]
async fn offset_past_end_errors() {
let td = TempDir::new().unwrap();
fs::write(td.path().join("c.txt"), "only one line\n").unwrap();
let tool = ReadFileTool;
let err = tool
.run(
&ctx_for(&td),
serde_json::json!({ "path": "c.txt", "offset": 50 }),
)
.await
.unwrap_err();
match err {
ToolError::InvalidArgument(msg) => {
assert!(msg.contains("offset 50"));
assert!(msg.contains("line count 1"));
}
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[tokio::test]
async fn rejects_binary_files() {
let td = TempDir::new().unwrap();
fs::write(td.path().join("bin.dat"), [0u8, 1, 2, 3, 0, 9]).unwrap();
let tool = ReadFileTool;
let err = tool
.run(&ctx_for(&td), serde_json::json!({ "path": "bin.dat" }))
.await
.unwrap_err();
assert!(matches!(err, ToolError::Binary));
}
#[tokio::test]
async fn rejects_too_large_files() {
let td = TempDir::new().unwrap();
let big = vec![b'a'; (MAX_FILE_BYTES as usize) + 16];
fs::write(td.path().join("big.txt"), big).unwrap();
let tool = ReadFileTool;
let err = tool
.run(&ctx_for(&td), serde_json::json!({ "path": "big.txt" }))
.await
.unwrap_err();
assert!(matches!(err, ToolError::TooLarge));
}
}