1use super::path_security::PathGuard;
7use super::truncate::{self, TruncationOptions};
8use super::{AgentTool, AgentToolResult, ProgressCallback, ToolContext, ToolError};
9use async_trait::async_trait;
10use base64::Engine;
11use oxi_ai::{ContentBlock, ImageContent, TextContent};
12use serde_json::{json, Value};
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, Mutex};
15use tokio::fs;
16use tokio::io::AsyncReadExt;
17
18const BINARY_DETECT_BYTES: usize = 8192;
20
21const IMAGE_EXTENSIONS: &[(&str, &str)] = &[
23 ("jpg", "image/jpeg"),
24 ("jpeg", "image/jpeg"),
25 ("png", "image/png"),
26 ("gif", "image/gif"),
27 ("webp", "image/webp"),
28];
29
30pub struct ReadTool {
32 root_dir: Option<PathBuf>,
33 progress_callback: Arc<Mutex<Option<ProgressCallback>>>,
34}
35
36impl ReadTool {
37 pub fn new() -> Self {
39 Self {
40 root_dir: None,
41 progress_callback: Arc::new(Mutex::new(None)),
42 }
43 }
44
45 pub fn with_cwd(cwd: PathBuf) -> Self {
47 Self {
48 root_dir: Some(cwd),
49 progress_callback: Arc::new(Mutex::new(None)),
50 }
51 }
52
53 fn image_mime_type(path: &Path) -> Option<&'static str> {
56 let ext = path.extension()?.to_str()?.to_lowercase();
57 IMAGE_EXTENSIONS
58 .iter()
59 .find(|(e, _)| *e == ext)
60 .map(|(_, mime)| *mime)
61 }
62
63 fn is_binary(data: &[u8]) -> bool {
65 data.contains(&0)
66 }
67
68 async fn read_image(
70 path: &Path,
71 progress_cb: &Option<ProgressCallback>,
72 ) -> Result<AgentToolResult, ToolError> {
73 let display_path = path.display();
74
75 if let Some(cb) = progress_cb {
76 cb(format!("Reading image: {}", display_path));
77 }
78
79 let data = fs::read(path)
80 .await
81 .map_err(|e| format!("Cannot read image file: {}", e))?;
82
83 if let Some(cb) = progress_cb {
84 cb(format!("Read {} bytes, encoding as base64", data.len()));
85 }
86
87 let mime_type = Self::image_mime_type(path).unwrap_or("application/octet-stream");
88 let encoded = base64::engine::general_purpose::STANDARD.encode(&data);
89
90 let summary = format!(
92 "Image file: {} ({} bytes, {})",
93 display_path,
94 data.len(),
95 mime_type
96 );
97
98 let image_block = ContentBlock::Image(ImageContent::new(encoded, mime_type));
99 let text_block = ContentBlock::Text(TextContent::new(summary.clone()));
100
101 Ok(AgentToolResult::success(summary).with_content_blocks(vec![text_block, image_block]))
102 }
103
104 async fn read_text(
106 path: &Path,
107 offset: Option<usize>,
108 limit: Option<usize>,
109 progress_cb: &Option<ProgressCallback>,
110 ) -> Result<AgentToolResult, ToolError> {
111 let display_path = path.display();
112
113 let file_size = match fs::metadata(path).await {
115 Ok(meta) => meta.len(),
116 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
117 return Err(format!("File not found: {}", display_path));
118 }
119 Err(e) => {
120 return Err(format!("Cannot access file: {}", e));
121 }
122 };
123
124 if let Some(cb) = progress_cb {
125 cb(format!(
126 "Reading file: {} ({} bytes)",
127 display_path, file_size
128 ));
129 }
130
131 let mut file = fs::File::open(path)
133 .await
134 .map_err(|e| format!("Cannot open file: {}", e))?;
135
136 let mut detect_buf = vec![0u8; BINARY_DETECT_BYTES.min(file_size as usize)];
138 let n = file
139 .read(&mut detect_buf)
140 .await
141 .map_err(|e| format!("Cannot read file: {}", e))?;
142
143 if Self::is_binary(&detect_buf[..n]) {
144 return Ok(AgentToolResult::error(format!(
145 "File appears to be binary: {} ({} bytes). Cannot display as text.",
146 display_path, file_size
147 )));
148 }
149
150 let mut content = String::from_utf8_lossy(&detect_buf[..n]).into_owned();
152 let mut buffer = vec![0u8; 8192];
153 loop {
154 let n = file
155 .read(&mut buffer)
156 .await
157 .map_err(|e| format!("Cannot read file: {}", e))?;
158 if n == 0 {
159 break;
160 }
161 content.push_str(&String::from_utf8_lossy(&buffer[..n]));
162 }
163
164 if let Some(cb) = progress_cb {
165 cb(format!("Completed reading {} bytes", content.len()));
166 }
167
168 let all_lines: Vec<&str> = content.lines().collect();
170 let total_lines = all_lines.len();
171
172 let start_idx = offset
174 .map(|o| if o == 0 { 0 } else { o - 1 }) .unwrap_or(0);
176
177 if start_idx >= total_lines && total_lines > 0 {
178 return Ok(AgentToolResult::error(format!(
179 "Offset {} exceeds file length ({} lines). Use offset=1 to {}.",
180 offset.unwrap_or(1),
181 total_lines,
182 total_lines
183 )));
184 }
185
186 let effective_limit = limit.unwrap_or(usize::MAX);
187 let end_idx = if effective_limit > total_lines - start_idx {
188 total_lines
189 } else {
190 start_idx + effective_limit
191 };
192 let selected_lines = &all_lines[start_idx..end_idx];
193 let selected_count = selected_lines.len();
194
195 let (output_lines, truncated) = if limit.is_none() {
197 let trunc_opts = TruncationOptions::default();
198 let max_lines = trunc_opts.max_lines.unwrap_or(truncate::DEFAULT_MAX_LINES);
199 let max_bytes = trunc_opts.max_bytes.unwrap_or(truncate::DEFAULT_MAX_BYTES);
200
201 let mut byte_count: usize = 0;
203 let mut line_count: usize = 0;
204 for line in selected_lines {
205 let prefix_len = format!("{}", start_idx + line_count + 1).len() + 2; byte_count += prefix_len + line.len() + 1;
208 if line_count >= max_lines || byte_count > max_bytes {
209 break;
210 }
211 line_count += 1;
212 }
213
214 if line_count < selected_count {
215 (line_count, true)
216 } else {
217 (selected_count, false)
218 }
219 } else {
220 (selected_count, false)
221 };
222
223 let mut output = String::new();
225 for (i, line) in selected_lines.iter().enumerate().take(output_lines) {
226 let line_num = start_idx + i + 1; output.push_str(&format!("{:>6}\t{}", line_num, line));
228 if i < output_lines - 1 || !content.ends_with('\n') {
229 output.push('\n');
230 }
231 }
232
233 if truncated {
235 let next_offset = start_idx + output_lines + 1;
236 output.push_str(&format!(
237 "\n... [truncated: {} of {} lines shown. Use offset={} to continue]",
238 output_lines,
239 total_lines - start_idx,
240 next_offset
241 ));
242 }
243
244 if start_idx > 0 {
246 output = format!(
247 "Showing lines {}-{} of {}:\n",
248 start_idx + 1,
249 start_idx + output_lines,
250 total_lines
251 ) + &output;
252 }
253
254 Ok(AgentToolResult::success(output))
255 }
256}
257
258impl Default for ReadTool {
259 fn default() -> Self {
260 Self::new()
261 }
262}
263
264#[async_trait]
265impl AgentTool for ReadTool {
266 fn name(&self) -> &str {
267 "read"
268 }
269
270 fn label(&self) -> &str {
271 "Read File"
272 }
273
274 fn essential(&self) -> bool {
275 true
276 }
277 fn description(&self) -> &str {
278 "Read the contents of a file. Supports text files and images (jpg, png, gif, webp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When reading with offset, line numbering starts from 1."
279 }
280
281 fn parameters_schema(&self) -> Value {
282 json!({
283 "type": "object",
284 "properties": {
285 "path": {
286 "type": "string",
287 "description": "Path to the file to read (relative or absolute)"
288 },
289 "offset": {
290 "type": "number",
291 "description": "Line number to start reading from (1-indexed)"
292 },
293 "limit": {
294 "type": "number",
295 "description": "Maximum number of lines to read"
296 }
297 },
298 "required": ["path"]
299 })
300 }
301
302 async fn execute(
303 &self,
304 _tool_call_id: &str,
305 params: Value,
306 _signal: Option<tokio::sync::oneshot::Receiver<()>>,
307 ctx: &ToolContext,
308 ) -> Result<AgentToolResult, ToolError> {
309 let path_str = params
310 .get("path")
311 .and_then(|v: &Value| v.as_str())
312 .ok_or_else(|| "Missing required parameter: path".to_string())?;
313
314 let offset = params
315 .get("offset")
316 .and_then(|v| v.as_u64())
317 .map(|n| n as usize);
318
319 let limit = params
320 .get("limit")
321 .and_then(|v| v.as_u64())
322 .map(|n| n as usize);
323
324 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
326 let guard = PathGuard::new(root);
327 let validated = guard
328 .validate_traversal(Path::new(path_str))
329 .map_err(|e| e.to_string())?;
330 let path = validated.as_path();
331
332 match fs::metadata(path).await {
334 Ok(meta) if meta.is_dir() => {
335 return Err("Cannot read a directory, use read_dir instead".to_string());
336 }
337 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
338 return Err(format!("File not found: {}", path.display()));
339 }
340 Err(e) => {
341 return Err(format!("Cannot access file: {}", e));
342 }
343 _ => {}
344 }
345
346 let progress_cb = self
347 .progress_callback
348 .lock()
349 .expect("progress callback lock poisoned")
350 .clone();
351
352 if Self::image_mime_type(path).is_some() {
354 return Self::read_image(path, &progress_cb).await;
355 }
356
357 Self::read_text(path, offset, limit, &progress_cb).await
359 }
360
361 fn on_progress(&self, callback: ProgressCallback) {
362 let cb = self.progress_callback.clone();
363 let mut guard = cb.lock().expect("progress callback lock poisoned");
364 *guard = Some(callback);
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use std::io::Write as IoWrite;
372 use tempfile::NamedTempFile;
373
374 fn make_text_file(content: &str) -> NamedTempFile {
375 let mut f = NamedTempFile::new().unwrap();
376 f.write_all(content.as_bytes()).unwrap();
377 f.flush().unwrap();
378 f
379 }
380
381 #[tokio::test]
382 async fn test_read_simple_text() {
383 let f = make_text_file("hello\nworld\n");
384 let tool = ReadTool::new();
385 let params = json!({"path": f.path().to_str().unwrap()});
386 let result = tool
387 .execute("test", params, None, &ToolContext::default())
388 .await
389 .unwrap();
390 assert!(result.success);
391 assert!(result.output.contains("hello"));
392 assert!(result.output.contains("world"));
393 }
394
395 #[tokio::test]
396 async fn test_read_with_line_numbers() {
397 let f = make_text_file("line1\nline2\nline3\n");
398 let tool = ReadTool::new();
399 let params = json!({"path": f.path().to_str().unwrap()});
400 let result = tool
401 .execute("test", params, None, &ToolContext::default())
402 .await
403 .unwrap();
404 assert!(result.success);
405 assert!(result.output.contains("1"));
407 assert!(result.output.contains("2"));
408 assert!(result.output.contains("3"));
409 assert!(result.output.contains("\tline1"));
411 assert!(result.output.contains("\tline2"));
412 }
413
414 #[tokio::test]
415 async fn test_read_with_offset() {
416 let f = make_text_file("line1\nline2\nline3\nline4\nline5\n");
417 let tool = ReadTool::new();
418 let params = json!({"path": f.path().to_str().unwrap(), "offset": 3});
419 let result = tool
420 .execute("test", params, None, &ToolContext::default())
421 .await
422 .unwrap();
423 assert!(result.success);
424 assert!(result.output.contains("Showing lines 3-5 of 5"));
426 assert!(result.output.contains("\tline3"));
427 assert!(result.output.contains("\tline4"));
428 assert!(result.output.contains("\tline5"));
429 assert!(!result.output.contains("\tline1"));
431 assert!(!result.output.contains("\tline2"));
432 }
433
434 #[tokio::test]
435 async fn test_read_with_offset_and_limit() {
436 let f = make_text_file("line1\nline2\nline3\nline4\nline5\n");
437 let tool = ReadTool::new();
438 let params = json!({"path": f.path().to_str().unwrap(), "offset": 2, "limit": 2});
439 let result = tool
440 .execute("test", params, None, &ToolContext::default())
441 .await
442 .unwrap();
443 assert!(result.success);
444 assert!(result.output.contains("\tline2"));
445 assert!(result.output.contains("\tline3"));
446 assert!(!result.output.contains("\tline4"));
447 }
448
449 #[tokio::test]
450 async fn test_read_offset_beyond_file() {
451 let f = make_text_file("line1\nline2\n");
452 let tool = ReadTool::new();
453 let params = json!({"path": f.path().to_str().unwrap(), "offset": 999});
454 let result = tool
455 .execute("test", params, None, &ToolContext::default())
456 .await
457 .unwrap();
458 assert!(!result.success);
459 assert!(result.output.contains("exceeds file length"));
460 }
461
462 #[tokio::test]
463 async fn test_read_truncation_notice() {
464 let content: Vec<String> = (1..3000).map(|i| format!("line {}", i)).collect();
466 let f = make_text_file(&content.join("\n"));
467 let tool = ReadTool::new();
468 let params = json!({"path": f.path().to_str().unwrap()});
469 let result = tool
470 .execute("test", params, None, &ToolContext::default())
471 .await
472 .unwrap();
473 assert!(result.success);
474 assert!(result.output.contains("truncated"));
475 assert!(result.output.contains("Use offset="));
476 }
477
478 #[tokio::test]
479 async fn test_read_path_traversal_rejected() {
480 let tool = ReadTool::new();
481 let params = json!({"path": "../../etc/passwd"});
482 let result = tool
483 .execute("test", params, None, &ToolContext::default())
484 .await;
485 assert!(result.is_err());
486 assert!(result.unwrap_err().contains("Path traversal"));
487 }
488
489 #[tokio::test]
490 async fn test_read_nonexistent_file() {
491 let tool = ReadTool::new();
492 let params = json!({"path": "/nonexistent/path/file.txt"});
493 let result = tool
494 .execute("test", params, None, &ToolContext::default())
495 .await;
496 assert!(result.is_err() || !result.unwrap().success);
497 }
498
499 #[tokio::test]
500 async fn test_read_binary_detection() {
501 let mut f = NamedTempFile::new().unwrap();
502 f.write_all(b"hello\x00world\x00binary").unwrap();
504 f.flush().unwrap();
505 let tool = ReadTool::new();
506 let params = json!({"path": f.path().to_str().unwrap()});
507 let result = tool
508 .execute("test", params, None, &ToolContext::default())
509 .await
510 .unwrap();
511 assert!(!result.success);
512 assert!(result.output.contains("binary"));
513 }
514
515 #[tokio::test]
516 async fn test_read_image_file() {
517 let mut f = NamedTempFile::with_suffix(".png").unwrap();
518 f.write_all(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00])
520 .unwrap();
521 f.flush().unwrap();
522 let tool = ReadTool::new();
523 let params = json!({"path": f.path().to_str().unwrap()});
524 let result = tool
525 .execute("test", params, None, &ToolContext::default())
526 .await
527 .unwrap();
528 assert!(result.success);
529 assert!(result.output.contains("image/png"));
530 let blocks = result.content_blocks.unwrap();
532 assert!(blocks.iter().any(|b| matches!(b, ContentBlock::Image(_))));
533 }
534
535 #[tokio::test]
536 async fn test_read_image_jpg() {
537 let mut f = NamedTempFile::with_suffix(".jpg").unwrap();
538 f.write_all(b"\xFF\xD8\xFF\xE0").unwrap();
539 f.flush().unwrap();
540 let tool = ReadTool::new();
541 let params = json!({"path": f.path().to_str().unwrap()});
542 let result = tool
543 .execute("test", params, None, &ToolContext::default())
544 .await
545 .unwrap();
546 assert!(result.success);
547 assert!(result.output.contains("image/jpeg"));
548 let blocks = result.content_blocks.unwrap();
549 assert!(blocks.iter().any(|b| matches!(b, ContentBlock::Image(_))));
550 }
551
552 #[tokio::test]
553 async fn test_read_image_webp() {
554 let mut f = NamedTempFile::with_suffix(".webp").unwrap();
555 f.write_all(b"RIFF\x00\x00\x00\x00WEBP").unwrap();
556 f.flush().unwrap();
557 let tool = ReadTool::new();
558 let params = json!({"path": f.path().to_str().unwrap()});
559 let result = tool
560 .execute("test", params, None, &ToolContext::default())
561 .await
562 .unwrap();
563 assert!(result.success);
564 assert!(result.output.contains("image/webp"));
565 }
566
567 #[tokio::test]
568 async fn test_read_empty_file() {
569 let f = make_text_file("");
570 let tool = ReadTool::new();
571 let params = json!({"path": f.path().to_str().unwrap()});
572 let result = tool
573 .execute("test", params, None, &ToolContext::default())
574 .await
575 .unwrap();
576 assert!(result.success);
577 }
578
579 #[tokio::test]
580 async fn test_read_file_not_found() {
581 let tool = ReadTool::new();
582 let params = json!({"path": "/tmp/nonexistent_oxi_test_file_12345.txt"});
583 let result = tool
584 .execute("test", params, None, &ToolContext::default())
585 .await;
586 match result {
587 Err(e) => assert!(e.contains("File not found")),
588 Ok(r) => assert!(!r.success),
589 }
590 }
591
592 #[tokio::test]
593 async fn test_read_directory_error() {
594 let tool = ReadTool::new();
595 let params = json!({"path": "/tmp"});
596 let result = tool
597 .execute("test", params, None, &ToolContext::default())
598 .await;
599 match result {
600 Err(e) => assert!(e.contains("directory")),
601 Ok(r) => assert!(!r.success || r.output.contains("directory")),
602 }
603 }
604
605 #[test]
606 fn test_image_mime_type_detection() {
607 assert_eq!(
608 ReadTool::image_mime_type(Path::new("photo.jpg")),
609 Some("image/jpeg")
610 );
611 assert_eq!(
612 ReadTool::image_mime_type(Path::new("photo.jpeg")),
613 Some("image/jpeg")
614 );
615 assert_eq!(
616 ReadTool::image_mime_type(Path::new("icon.png")),
617 Some("image/png")
618 );
619 assert_eq!(
620 ReadTool::image_mime_type(Path::new("anim.gif")),
621 Some("image/gif")
622 );
623 assert_eq!(
624 ReadTool::image_mime_type(Path::new("img.webp")),
625 Some("image/webp")
626 );
627 assert_eq!(ReadTool::image_mime_type(Path::new("file.txt")), None);
628 assert_eq!(ReadTool::image_mime_type(Path::new("noext")), None);
629 }
630
631 #[test]
632 fn test_binary_detection() {
633 assert!(ReadTool::is_binary(b"hello\x00world"));
634 assert!(!ReadTool::is_binary(b"hello world\nfoo bar\n"));
635 assert!(!ReadTool::is_binary(b""));
636 assert!(!ReadTool::is_binary(b"pure ascii text"));
637 }
638}