#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
use serde_json::{json, Value};
use crate::tools::ToolCtx;
pub struct ReadTool {
ctx: Arc<ToolCtx>,
}
impl ReadTool {
pub fn new(ctx: Arc<ToolCtx>) -> Self {
Self { ctx }
}
}
impl Tool for ReadTool {
fn def(&self) -> ToolDef {
ToolDef {
name: "read".to_string(),
description: "Read the contents of a UTF-8 text file from disk.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Path to file (absolute or cwd-relative)." },
"offset": { "type": "integer", "description": "1-based starting line (optional)." },
"limit": { "type": "integer", "description": "Number of lines to read (default 2000)." }
},
"required": ["path"]
}),
}
}
fn call(
&self,
args: Value,
_ctx: &ToolContext,
) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
const MAX_BYTES: u64 = 2 * 1024 * 1024;
let ctx = Arc::clone(&self.ctx);
Box::pin(async move {
let path = match args.get("path").and_then(|v| v.as_str()) {
Some(p) => PathBuf::from(p),
None => return ToolResult::error("missing 'path' argument"),
};
let abs = if path.is_absolute() {
path.clone()
} else {
ctx.cwd.join(&path)
};
let metadata = match tokio::fs::metadata(&abs).await {
Ok(m) => m,
Err(e) => {
return ToolResult::error(format!("failed to stat {}: {e}", abs.display()))
}
};
if metadata.len() > MAX_BYTES {
return ToolResult::error(format!(
"file {} too large ({} bytes > {} byte cap)",
abs.display(),
metadata.len(),
MAX_BYTES
));
}
let bytes = match tokio::fs::read(&abs).await {
Ok(b) => b,
Err(e) => {
return ToolResult::error(format!("failed to read {}: {e}", abs.display()))
}
};
let text = match String::from_utf8(bytes) {
Ok(s) => s,
Err(_) => {
return ToolResult::error(format!(
"file {} appears to be binary (non-UTF-8); use `bash` + `head`/`xxd` instead",
abs.display()
));
}
};
let offset = args
.get("offset")
.and_then(|v| v.as_u64())
.map(|n| n.saturating_sub(1) as usize)
.unwrap_or(0);
let limit = args
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize)
.unwrap_or(2000);
let sliced = text
.split_inclusive('\n')
.skip(offset)
.take(limit)
.collect::<String>();
let canonical = tokio::fs::canonicalize(&abs)
.await
.unwrap_or_else(|_| abs.clone());
ctx.mark_read(&canonical).await;
ToolResult::text(sliced)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permissions::NoOpPermissionGate;
use tempfile::tempdir;
use tokio::sync::mpsc;
fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
let (tx, _rx) = mpsc::channel(8);
Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
}
#[tokio::test]
async fn reads_existing_file_and_records_in_read_set() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("hello.txt");
tokio::fs::write(&file, "hello, world\n")
.await
.expect("write");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let result = tool
.call(json!({ "path": "hello.txt" }), &ToolContext::default())
.await;
let debug = format!("{result:?}");
assert!(
debug.contains("hello, world"),
"unexpected ToolResult: {debug}"
);
let canonical = tokio::fs::canonicalize(&file).await.expect("canonicalize");
assert!(ctx.has_been_read(&canonical).await);
}
#[tokio::test]
async fn rejects_file_larger_than_2mb() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("big.bin");
let payload: Vec<u8> = std::iter::repeat_n(b'x', 2_500_000).collect();
tokio::fs::write(&file, &payload).await.expect("write");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let result = tool
.call(json!({ "path": "big.bin" }), &ToolContext::default())
.await;
let debug = format!("{result:?}");
assert!(debug.to_lowercase().contains("too large"), "got: {debug}");
}
#[tokio::test]
async fn rejects_binary_file() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("pic.bin");
tokio::fs::write(&file, [0xff_u8, 0xfe, 0xfd, 0xfc, 0x00, 0x01, 0x02])
.await
.expect("write");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let result = tool
.call(json!({ "path": "pic.bin" }), &ToolContext::default())
.await;
let debug = format!("{result:?}");
assert!(debug.to_lowercase().contains("binary"), "got: {debug}");
}
#[tokio::test]
async fn errors_on_missing_file() {
let dir = tempdir().expect("tempdir");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let result = tool
.call(
json!({ "path": "does_not_exist.txt" }),
&ToolContext::default(),
)
.await;
let debug = format!("{result:?}");
assert!(
debug.to_lowercase().contains("failed to stat"),
"got: {debug}"
);
}
#[tokio::test]
async fn respects_offset_and_limit() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("lines.txt");
let body: String = (1..=10).map(|n| format!("line{n}\n")).collect();
tokio::fs::write(&file, body).await.expect("write");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let result = tool
.call(
json!({ "path": "lines.txt", "offset": 3, "limit": 2 }),
&ToolContext::default(),
)
.await;
let debug = format!("{result:?}");
assert!(debug.contains("line3"), "missing line3: {debug}");
assert!(debug.contains("line4"), "missing line4: {debug}");
assert!(!debug.contains("line5"), "unexpected line5 leaked: {debug}");
}
#[tokio::test]
async fn preserves_crlf_and_trailing_newline() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("windows.txt");
tokio::fs::write(&file, b"line1\r\nline2\r\n")
.await
.expect("write");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let result = tool
.call(
json!({ "path": "windows.txt", "offset": 1, "limit": 2 }),
&ToolContext::default(),
)
.await;
let text = result.as_text().unwrap_or_default();
assert_eq!(text, "line1\r\nline2\r\n");
}
#[tokio::test]
async fn offset_zero_equals_offset_one_documented_behavior() {
let dir = tempdir().expect("tempdir");
let file = dir.path().join("lines.txt");
tokio::fs::write(&file, "a\nb\nc\n").await.expect("write");
let ctx = test_ctx(dir.path());
let tool = ReadTool::new(Arc::clone(&ctx));
let r0 = tool
.call(
json!({"path":"lines.txt","offset":0,"limit":2}),
&ToolContext::default(),
)
.await;
let r1 = tool
.call(
json!({"path":"lines.txt","offset":1,"limit":2}),
&ToolContext::default(),
)
.await;
assert_eq!(format!("{r0:?}"), format!("{r1:?}"));
}
}