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