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