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, or fetch an http(s) URL. 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. Use offset/limit for large files. For http(s) URLs, the page is fetched and returned as reader-mode markdown (static content only, no JavaScript rendering) — use `browse` for dynamic/JS pages, screenshots, or interaction. 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), an internal URL (issue://N, pr://owner/repo/N, skill://name/SKILL.md, agent://id, etc.), or a web URL (http://https://) which is fetched and converted to reader-mode markdown"
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 if path_str.starts_with("http://") || path_str.starts_with("https://") {
370 let fetch = super::read_http::fetch_url(path_str).await?;
371 let mut result = AgentToolResult::success(fetch.content);
372 result = result.with_metadata(json!({
373 "url": fetch.url,
374 "title": fetch.title,
375 "content_type": fetch.content_type,
376 }));
377 return Ok(result);
378 }
379
380 let root = self.root_dir.as_deref().unwrap_or(ctx.root());
382 let guard = PathGuard::new(root);
383 let validated = guard
384 .validate_traversal(Path::new(path_str))
385 .map_err(|e| e.to_string())?;
386 let path = validated.as_path();
387
388 match fs::metadata(path).await {
390 Ok(meta) if meta.is_dir() => {
391 return Err("Cannot read a directory, use read_dir instead".to_string());
392 }
393 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
394 return Err(format!("File not found: {}", path.display()));
395 }
396 Err(e) => {
397 return Err(format!("Cannot access file: {}", e));
398 }
399 _ => {}
400 }
401
402 #[allow(clippy::expect_used)]
406 let progress_cb = self
407 .progress_callback
408 .lock()
409 .expect("progress callback lock poisoned")
410 .clone();
411
412 if Self::image_mime_type(path).is_some() {
414 return Self::read_image(path, &progress_cb).await;
415 }
416
417 let snap = ctx.snapshot_store.as_ref().map(|s| {
419 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
420 (s.clone(), canonical)
421 });
422 Self::read_text(path, offset, limit, &progress_cb, snap).await
423 }
424
425 fn on_progress(&self, callback: ProgressCallback) {
426 let cb = self.progress_callback.clone();
427 #[allow(clippy::expect_used)]
430 let mut guard = cb.lock().expect("progress callback lock poisoned");
431 *guard = Some(callback);
432 }
433}
434
435#[cfg(test)]
436mod tests {
437 use super::*;
438 use std::io::Write as IoWrite;
439 use tempfile::NamedTempFile;
440
441 fn make_text_file(content: &str) -> NamedTempFile {
442 let mut f = NamedTempFile::new().unwrap();
443 f.write_all(content.as_bytes()).unwrap();
444 f.flush().unwrap();
445 f
446 }
447
448 #[tokio::test]
449 async fn test_read_simple_text() {
450 let f = make_text_file("hello\nworld\n");
451 let tool = ReadTool::new();
452 let params = json!({"path": f.path().to_str().unwrap()});
453 let result = tool
454 .execute("test", params, None, &ToolContext::default())
455 .await
456 .unwrap();
457 assert!(result.success);
458 assert!(result.output.contains("hello"));
459 assert!(result.output.contains("world"));
460 }
461
462 #[tokio::test]
463 async fn test_read_with_line_numbers() {
464 let f = make_text_file("line1\nline2\nline3\n");
465 let tool = ReadTool::new();
466 let params = json!({"path": f.path().to_str().unwrap()});
467 let result = tool
468 .execute("test", params, None, &ToolContext::default())
469 .await
470 .unwrap();
471 assert!(result.success);
472 assert!(result.output.contains("1"));
474 assert!(result.output.contains("2"));
475 assert!(result.output.contains("3"));
476 assert!(result.output.contains("\tline1"));
478 assert!(result.output.contains("\tline2"));
479 }
480
481 #[tokio::test]
482 async fn test_read_with_offset() {
483 let f = make_text_file("line1\nline2\nline3\nline4\nline5\n");
484 let tool = ReadTool::new();
485 let params = json!({"path": f.path().to_str().unwrap(), "offset": 3});
486 let result = tool
487 .execute("test", params, None, &ToolContext::default())
488 .await
489 .unwrap();
490 assert!(result.success);
491 assert!(result.output.contains("Showing lines 3-5 of 5"));
493 assert!(result.output.contains("\tline3"));
494 assert!(result.output.contains("\tline4"));
495 assert!(result.output.contains("\tline5"));
496 assert!(!result.output.contains("\tline1"));
498 assert!(!result.output.contains("\tline2"));
499 }
500
501 #[tokio::test]
502 async fn test_read_with_offset_and_limit() {
503 let f = make_text_file("line1\nline2\nline3\nline4\nline5\n");
504 let tool = ReadTool::new();
505 let params = json!({"path": f.path().to_str().unwrap(), "offset": 2, "limit": 2});
506 let result = tool
507 .execute("test", params, None, &ToolContext::default())
508 .await
509 .unwrap();
510 assert!(result.success);
511 assert!(result.output.contains("\tline2"));
512 assert!(result.output.contains("\tline3"));
513 assert!(!result.output.contains("\tline4"));
514 }
515
516 #[tokio::test]
517 async fn test_read_offset_beyond_file() {
518 let f = make_text_file("line1\nline2\n");
519 let tool = ReadTool::new();
520 let params = json!({"path": f.path().to_str().unwrap(), "offset": 999});
521 let result = tool
522 .execute("test", params, None, &ToolContext::default())
523 .await
524 .unwrap();
525 assert!(!result.success);
526 assert!(result.output.contains("exceeds file length"));
527 }
528
529 #[tokio::test]
530 async fn test_read_truncation_notice() {
531 let content: Vec<String> = (1..3000).map(|i| format!("line {}", i)).collect();
533 let f = make_text_file(&content.join("\n"));
534 let tool = ReadTool::new();
535 let params = json!({"path": f.path().to_str().unwrap()});
536 let result = tool
537 .execute("test", params, None, &ToolContext::default())
538 .await
539 .unwrap();
540 assert!(result.success);
541 assert!(result.output.contains("truncated"));
542 assert!(result.output.contains("Use offset="));
543 }
544
545 #[tokio::test]
546 async fn test_read_path_traversal_rejected() {
547 let tool = ReadTool::new();
548 let params = json!({"path": "../../etc/passwd"});
549 let result = tool
550 .execute("test", params, None, &ToolContext::default())
551 .await;
552 assert!(result.is_err());
553 assert!(result.unwrap_err().contains("Path traversal"));
554 }
555
556 #[tokio::test]
557 async fn test_read_nonexistent_file() {
558 let tool = ReadTool::new();
559 let params = json!({"path": "/nonexistent/path/file.txt"});
560 let result = tool
561 .execute("test", params, None, &ToolContext::default())
562 .await;
563 assert!(result.is_err() || !result.unwrap().success);
564 }
565
566 #[tokio::test]
567 async fn test_read_binary_detection() {
568 let mut f = NamedTempFile::new().unwrap();
569 f.write_all(b"hello\x00world\x00binary").unwrap();
571 f.flush().unwrap();
572 let tool = ReadTool::new();
573 let params = json!({"path": f.path().to_str().unwrap()});
574 let result = tool
575 .execute("test", params, None, &ToolContext::default())
576 .await
577 .unwrap();
578 assert!(!result.success);
579 assert!(result.output.contains("binary"));
580 }
581
582 #[tokio::test]
583 async fn test_read_image_file() {
584 let mut f = NamedTempFile::with_suffix(".png").unwrap();
585 f.write_all(&[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00])
587 .unwrap();
588 f.flush().unwrap();
589 let tool = ReadTool::new();
590 let params = json!({"path": f.path().to_str().unwrap()});
591 let result = tool
592 .execute("test", params, None, &ToolContext::default())
593 .await
594 .unwrap();
595 assert!(result.success);
596 assert!(result.output.contains("image/png"));
597 let blocks = result.content_blocks.unwrap();
599 assert!(blocks.iter().any(|b| matches!(b, ContentBlock::Image(_))));
600 }
601
602 #[tokio::test]
603 async fn test_read_image_jpg() {
604 let mut f = NamedTempFile::with_suffix(".jpg").unwrap();
605 f.write_all(b"\xFF\xD8\xFF\xE0").unwrap();
606 f.flush().unwrap();
607 let tool = ReadTool::new();
608 let params = json!({"path": f.path().to_str().unwrap()});
609 let result = tool
610 .execute("test", params, None, &ToolContext::default())
611 .await
612 .unwrap();
613 assert!(result.success);
614 assert!(result.output.contains("image/jpeg"));
615 let blocks = result.content_blocks.unwrap();
616 assert!(blocks.iter().any(|b| matches!(b, ContentBlock::Image(_))));
617 }
618
619 #[tokio::test]
620 async fn test_read_image_webp() {
621 let mut f = NamedTempFile::with_suffix(".webp").unwrap();
622 f.write_all(b"RIFF\x00\x00\x00\x00WEBP").unwrap();
623 f.flush().unwrap();
624 let tool = ReadTool::new();
625 let params = json!({"path": f.path().to_str().unwrap()});
626 let result = tool
627 .execute("test", params, None, &ToolContext::default())
628 .await
629 .unwrap();
630 assert!(result.success);
631 assert!(result.output.contains("image/webp"));
632 }
633
634 #[tokio::test]
635 async fn test_read_empty_file() {
636 let f = make_text_file("");
637 let tool = ReadTool::new();
638 let params = json!({"path": f.path().to_str().unwrap()});
639 let result = tool
640 .execute("test", params, None, &ToolContext::default())
641 .await
642 .unwrap();
643 assert!(result.success);
644 }
645
646 #[tokio::test]
647 async fn test_read_file_not_found() {
648 let tool = ReadTool::new();
649 let params = json!({"path": "/tmp/nonexistent_oxicode_test_file_12345.txt"});
650 let result = tool
651 .execute("test", params, None, &ToolContext::default())
652 .await;
653 match result {
654 Err(e) => assert!(e.contains("File not found")),
655 Ok(r) => assert!(!r.success),
656 }
657 }
658
659 #[tokio::test]
660 async fn test_read_directory_error() {
661 let tool = ReadTool::new();
662 let params = json!({"path": "/tmp"});
663 let result = tool
664 .execute("test", params, None, &ToolContext::default())
665 .await;
666 match result {
667 Err(e) => assert!(e.contains("directory")),
668 Ok(r) => assert!(!r.success || r.output.contains("directory")),
669 }
670 }
671
672 #[test]
673 fn test_image_mime_type_detection() {
674 assert_eq!(
675 ReadTool::image_mime_type(Path::new("photo.jpg")),
676 Some("image/jpeg")
677 );
678 assert_eq!(
679 ReadTool::image_mime_type(Path::new("photo.jpeg")),
680 Some("image/jpeg")
681 );
682 assert_eq!(
683 ReadTool::image_mime_type(Path::new("icon.png")),
684 Some("image/png")
685 );
686 assert_eq!(
687 ReadTool::image_mime_type(Path::new("anim.gif")),
688 Some("image/gif")
689 );
690 assert_eq!(
691 ReadTool::image_mime_type(Path::new("img.webp")),
692 Some("image/webp")
693 );
694 assert_eq!(ReadTool::image_mime_type(Path::new("file.txt")), None);
695 assert_eq!(ReadTool::image_mime_type(Path::new("noext")), None);
696 }
697
698 #[test]
699 fn test_binary_detection() {
700 assert!(ReadTool::is_binary(b"hello\x00world"));
701 assert!(!ReadTool::is_binary(b"hello world\nfoo bar\n"));
702 assert!(!ReadTool::is_binary(b""));
703 assert!(!ReadTool::is_binary(b"pure ascii text"));
704 }
705}