1#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
2
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use std::sync::Arc;
7
8use motosan_agent_tool::{Tool, ToolContext, ToolDef, ToolResult};
9use serde_json::{json, Value};
10
11use crate::tools::ToolCtx;
12
13pub struct ReadTool {
14 ctx: Arc<ToolCtx>,
15}
16
17impl ReadTool {
18 pub fn new(ctx: Arc<ToolCtx>) -> Self {
19 Self { ctx }
20 }
21}
22
23impl Tool for ReadTool {
24 fn def(&self) -> ToolDef {
25 ToolDef {
26 name: "read".to_string(),
27 description: "Read the contents of a UTF-8 text file from disk.".to_string(),
28 input_schema: json!({
29 "type": "object",
30 "properties": {
31 "path": { "type": "string", "description": "Path to file (absolute or cwd-relative)." },
32 "offset": { "type": "integer", "description": "1-based starting line (optional)." },
33 "limit": { "type": "integer", "description": "Number of lines to read (default 2000)." }
34 },
35 "required": ["path"]
36 }),
37 }
38 }
39
40 fn call(
41 &self,
42 args: Value,
43 _ctx: &ToolContext,
44 ) -> Pin<Box<dyn Future<Output = ToolResult> + Send + '_>> {
45 const MAX_BYTES: u64 = 2 * 1024 * 1024;
46
47 let ctx = Arc::clone(&self.ctx);
48 Box::pin(async move {
49 let path = match args.get("path").and_then(|v| v.as_str()) {
50 Some(p) => PathBuf::from(p),
51 None => return ToolResult::error("missing 'path' argument"),
52 };
53 let abs = if path.is_absolute() {
54 path.clone()
55 } else {
56 ctx.cwd.join(&path)
57 };
58
59 let metadata = match tokio::fs::metadata(&abs).await {
60 Ok(m) => m,
61 Err(e) => {
62 return ToolResult::error(format!("failed to stat {}: {e}", abs.display()))
63 }
64 };
65 if metadata.len() > MAX_BYTES {
66 return ToolResult::error(format!(
67 "file {} too large ({} bytes > {} byte cap)",
68 abs.display(),
69 metadata.len(),
70 MAX_BYTES
71 ));
72 }
73
74 let bytes = match tokio::fs::read(&abs).await {
75 Ok(b) => b,
76 Err(e) => {
77 return ToolResult::error(format!("failed to read {}: {e}", abs.display()))
78 }
79 };
80 let text = match String::from_utf8(bytes) {
81 Ok(s) => s,
82 Err(_) => {
83 return ToolResult::error(format!(
84 "file {} appears to be binary (non-UTF-8); use `bash` + `head`/`xxd` instead",
85 abs.display()
86 ));
87 }
88 };
89
90 let offset = args
91 .get("offset")
92 .and_then(|v| v.as_u64())
93 .map(|n| n.saturating_sub(1) as usize)
94 .unwrap_or(0);
95 let limit = args
96 .get("limit")
97 .and_then(|v| v.as_u64())
98 .map(|n| n as usize)
99 .unwrap_or(2000);
100 let sliced = text
101 .split_inclusive('\n')
102 .skip(offset)
103 .take(limit)
104 .collect::<String>();
105
106 let canonical = tokio::fs::canonicalize(&abs)
107 .await
108 .unwrap_or_else(|_| abs.clone());
109 ctx.mark_read(&canonical).await;
110 ToolResult::text(sliced)
111 })
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use crate::permissions::NoOpPermissionGate;
119 use tempfile::tempdir;
120 use tokio::sync::mpsc;
121
122 fn test_ctx(cwd: &std::path::Path) -> Arc<ToolCtx> {
123 let (tx, _rx) = mpsc::channel(8);
124 Arc::new(ToolCtx::new(cwd, Arc::new(NoOpPermissionGate), tx))
125 }
126
127 #[tokio::test]
128 async fn reads_existing_file_and_records_in_read_set() {
129 let dir = tempdir().expect("tempdir");
130 let file = dir.path().join("hello.txt");
131 tokio::fs::write(&file, "hello, world\n")
132 .await
133 .expect("write");
134
135 let ctx = test_ctx(dir.path());
136 let tool = ReadTool::new(Arc::clone(&ctx));
137 let result = tool
138 .call(json!({ "path": "hello.txt" }), &ToolContext::default())
139 .await;
140
141 let debug = format!("{result:?}");
142 assert!(
143 debug.contains("hello, world"),
144 "unexpected ToolResult: {debug}"
145 );
146
147 let canonical = tokio::fs::canonicalize(&file).await.expect("canonicalize");
148 assert!(ctx.has_been_read(&canonical).await);
149 }
150
151 #[tokio::test]
152 async fn rejects_file_larger_than_2mb() {
153 let dir = tempdir().expect("tempdir");
154 let file = dir.path().join("big.bin");
155 let payload: Vec<u8> = std::iter::repeat_n(b'x', 2_500_000).collect();
156 tokio::fs::write(&file, &payload).await.expect("write");
157
158 let ctx = test_ctx(dir.path());
159 let tool = ReadTool::new(Arc::clone(&ctx));
160 let result = tool
161 .call(json!({ "path": "big.bin" }), &ToolContext::default())
162 .await;
163
164 let debug = format!("{result:?}");
165 assert!(debug.to_lowercase().contains("too large"), "got: {debug}");
166 }
167
168 #[tokio::test]
169 async fn rejects_binary_file() {
170 let dir = tempdir().expect("tempdir");
171 let file = dir.path().join("pic.bin");
172 tokio::fs::write(&file, [0xff_u8, 0xfe, 0xfd, 0xfc, 0x00, 0x01, 0x02])
173 .await
174 .expect("write");
175
176 let ctx = test_ctx(dir.path());
177 let tool = ReadTool::new(Arc::clone(&ctx));
178 let result = tool
179 .call(json!({ "path": "pic.bin" }), &ToolContext::default())
180 .await;
181
182 let debug = format!("{result:?}");
183 assert!(debug.to_lowercase().contains("binary"), "got: {debug}");
184 }
185
186 #[tokio::test]
187 async fn errors_on_missing_file() {
188 let dir = tempdir().expect("tempdir");
189 let ctx = test_ctx(dir.path());
190 let tool = ReadTool::new(Arc::clone(&ctx));
191 let result = tool
192 .call(
193 json!({ "path": "does_not_exist.txt" }),
194 &ToolContext::default(),
195 )
196 .await;
197
198 let debug = format!("{result:?}");
199 assert!(
200 debug.to_lowercase().contains("failed to stat"),
201 "got: {debug}"
202 );
203 }
204
205 #[tokio::test]
206 async fn respects_offset_and_limit() {
207 let dir = tempdir().expect("tempdir");
208 let file = dir.path().join("lines.txt");
209 let body: String = (1..=10).map(|n| format!("line{n}\n")).collect();
210 tokio::fs::write(&file, body).await.expect("write");
211
212 let ctx = test_ctx(dir.path());
213 let tool = ReadTool::new(Arc::clone(&ctx));
214 let result = tool
215 .call(
216 json!({ "path": "lines.txt", "offset": 3, "limit": 2 }),
217 &ToolContext::default(),
218 )
219 .await;
220
221 let debug = format!("{result:?}");
222 assert!(debug.contains("line3"), "missing line3: {debug}");
223 assert!(debug.contains("line4"), "missing line4: {debug}");
224 assert!(!debug.contains("line5"), "unexpected line5 leaked: {debug}");
225 }
226
227 #[tokio::test]
228 async fn preserves_crlf_and_trailing_newline() {
229 let dir = tempdir().expect("tempdir");
230 let file = dir.path().join("windows.txt");
231 tokio::fs::write(&file, b"line1\r\nline2\r\n")
232 .await
233 .expect("write");
234
235 let ctx = test_ctx(dir.path());
236 let tool = ReadTool::new(Arc::clone(&ctx));
237 let result = tool
238 .call(
239 json!({ "path": "windows.txt", "offset": 1, "limit": 2 }),
240 &ToolContext::default(),
241 )
242 .await;
243
244 let text = result.as_text().unwrap_or_default();
245 assert_eq!(text, "line1\r\nline2\r\n");
246 }
247
248 #[tokio::test]
249 async fn offset_zero_equals_offset_one_documented_behavior() {
250 let dir = tempdir().expect("tempdir");
251 let file = dir.path().join("lines.txt");
252 tokio::fs::write(&file, "a\nb\nc\n").await.expect("write");
253 let ctx = test_ctx(dir.path());
254 let tool = ReadTool::new(Arc::clone(&ctx));
255
256 let r0 = tool
257 .call(
258 json!({"path":"lines.txt","offset":0,"limit":2}),
259 &ToolContext::default(),
260 )
261 .await;
262 let r1 = tool
263 .call(
264 json!({"path":"lines.txt","offset":1,"limit":2}),
265 &ToolContext::default(),
266 )
267 .await;
268
269 assert_eq!(format!("{r0:?}"), format!("{r1:?}"));
270 }
271}