use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};
use std::fmt::Write;
use std::io::BufRead;
use super::{Tool, ToolOutput};
pub struct ReadTool;
const MAX_READ_LINES: usize = 2_000;
#[derive(Deserialize)]
struct Params {
file_path: String,
#[serde(default)]
offset: Option<usize>,
#[serde(default)]
limit: Option<usize>,
}
#[async_trait]
impl Tool for ReadTool {
fn name(&self) -> &str {
"Read"
}
fn description(&self) -> &str {
"Read a file from the filesystem. Returns content with line numbers."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "Absolute path to the file to read"
},
"offset": {
"type": "integer",
"description": "Line number to start reading from (1-indexed)"
},
"limit": {
"type": "integer",
"description": "Number of lines to read"
}
},
"required": ["file_path"]
})
}
fn is_read_only(&self) -> bool {
true
}
fn summarize(&self, input: &Value) -> String {
let path = input["file_path"].as_str().unwrap_or("?");
match input["offset"].as_u64() {
Some(offset) => format!("{path} (from line {offset})"),
None => path.to_string(),
}
}
async fn execute(
&self,
input: Value,
cancel: tokio_util::sync::CancellationToken,
) -> Result<ToolOutput> {
let params: Params = serde_json::from_value(input)?;
tokio::task::spawn_blocking(move || read_file(params, cancel)).await?
}
}
fn read_file(params: Params, cancel: tokio_util::sync::CancellationToken) -> Result<ToolOutput> {
if cancel.is_cancelled() {
return Ok(interrupted_output());
}
let path = expand_tilde(¶ms.file_path);
if !path.exists() {
return Ok(ToolOutput {
content: format!("File does not exist: {}", params.file_path),
is_error: true,
});
}
if !path.is_file() {
return Ok(ToolOutput {
content: format!("Not a file: {}", params.file_path),
is_error: true,
});
}
let reader = std::io::BufReader::new(std::fs::File::open(&path)?);
let start = params.offset.unwrap_or(1).max(1);
let limit = params.limit.unwrap_or(MAX_READ_LINES).min(MAX_READ_LINES);
let mut total_lines = 0;
let mut selected_lines = 0;
let mut result = String::new();
for line in reader.lines() {
if cancel.is_cancelled() {
return Ok(interrupted_output());
}
let line = line?;
total_lines += 1;
if total_lines < start {
continue;
}
if selected_lines >= limit {
break;
}
let _ = writeln!(result, "{total_lines}\t{line}");
selected_lines += 1;
}
if total_lines < start {
return Ok(ToolOutput {
content: format!(
"Offset {} is beyond end of file ({} lines)",
params.offset.unwrap_or(1),
total_lines
),
is_error: true,
});
}
Ok(ToolOutput {
content: result,
is_error: false,
})
}
fn interrupted_output() -> ToolOutput {
ToolOutput {
content: "Interrupted by user.".to_string(),
is_error: true,
}
}
pub fn expand_tilde(path: &str) -> std::path::PathBuf {
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
std::path::PathBuf::from(path)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tokio_util::sync::CancellationToken;
#[tokio::test]
async fn read_existing_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "line one").unwrap();
writeln!(tmp, "line two").unwrap();
writeln!(tmp, "line three").unwrap();
let tool = ReadTool;
let result = tool
.execute(
json!({"file_path": tmp.path().to_str().unwrap()}),
CancellationToken::new(),
)
.await
.unwrap();
assert!(!result.is_error);
assert!(result.content.contains("line one"));
assert!(result.content.contains("line two"));
assert!(result.content.contains("1\t"));
}
#[tokio::test]
async fn read_with_offset_and_limit() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
for i in 1..=10 {
writeln!(tmp, "line {i}").unwrap();
}
let tool = ReadTool;
let result = tool
.execute(
json!({
"file_path": tmp.path().to_str().unwrap(),
"offset": 3,
"limit": 2
}),
CancellationToken::new(),
)
.await
.unwrap();
assert!(!result.is_error);
assert!(result.content.contains("line 3"));
assert!(result.content.contains("line 4"));
assert!(!result.content.contains("line 5"));
}
#[tokio::test]
async fn read_nonexistent_file() {
let tool = ReadTool;
let result = tool
.execute(
json!({"file_path": "/tmp/definitely_does_not_exist_12345"}),
CancellationToken::new(),
)
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("does not exist"));
}
#[tokio::test]
async fn offset_beyond_end_returns_error_instead_of_panicking() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
writeln!(tmp, "only line").unwrap();
let result = ReadTool
.execute(
json!({"file_path": tmp.path(), "offset": 99}),
CancellationToken::new(),
)
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("beyond end of file"));
}
#[tokio::test]
async fn explicit_limit_is_bounded() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
for line in 1..=2_100 {
writeln!(tmp, "line {line}").unwrap();
}
let result = ReadTool
.execute(
json!({"file_path": tmp.path(), "limit": 10_000}),
CancellationToken::new(),
)
.await
.unwrap();
assert!(!result.is_error);
assert_eq!(result.content.lines().count(), MAX_READ_LINES);
assert!(!result.content.contains("line 2001"));
}
#[tokio::test]
async fn cancelled_read_stops_before_io() {
let cancel = CancellationToken::new();
cancel.cancel();
let result = ReadTool
.execute(json!({"file_path": "/etc/hosts"}), cancel)
.await
.unwrap();
assert!(result.is_error);
assert!(result.content.contains("Interrupted"));
}
#[test]
fn expand_tilde_works() {
let path = expand_tilde("~/test.txt");
assert!(!path.to_str().unwrap().contains('~'));
assert!(path.to_str().unwrap().contains("test.txt"));
}
#[test]
fn expand_tilde_absolute_unchanged() {
let path = expand_tilde("/tmp/test.txt");
assert_eq!(path.to_str().unwrap(), "/tmp/test.txt");
}
}