1use std::collections::HashMap;
8use std::future::Future;
9use std::path::Path;
10use std::pin::Pin;
11use std::sync::Arc;
12
13use tokio::fs;
14use tokio::io::AsyncReadExt;
15
16use super::types::{
17 DisplayConfig, DisplayResult, Executable, ResultContentType, ToolContext, ToolType,
18};
19use crate::permissions::{GrantTarget, PermissionLevel, PermissionRegistry, PermissionRequest};
20
21pub const READ_FILE_TOOL_NAME: &str = "read_file";
23
24pub const READ_FILE_TOOL_DESCRIPTION: &str = r#"Reads a file from the local filesystem.
26
27Usage:
28- The file_path parameter must be an absolute path, not a relative path
29- By default, it reads up to 2000 lines starting from the beginning of the file
30- You can optionally specify a line offset and limit for reading large files in chunks
31- Any lines longer than 2000 characters will be truncated
32- Results are returned with line numbers starting at 1
33- Binary files cannot be read and will return an error"#;
34
35pub const READ_FILE_TOOL_SCHEMA: &str = r#"{
37 "type": "object",
38 "properties": {
39 "file_path": {
40 "type": "string",
41 "description": "The absolute path to the file to read"
42 },
43 "offset": {
44 "type": "integer",
45 "description": "The line number to start reading from (0-based). Defaults to 0."
46 },
47 "limit": {
48 "type": "integer",
49 "description": "The maximum number of lines to read. Defaults to 2000."
50 }
51 },
52 "required": ["file_path"]
53}"#;
54
55const DEFAULT_READ_LIMIT: usize = 2000;
57
58const MAX_LINE_LENGTH: usize = 2000;
60
61const MAX_BYTES: usize = 50 * 1024;
63
64const BINARY_EXTENSIONS: &[&str] = &[
66 ".zip", ".tar", ".gz", ".tgz", ".bz2", ".xz", ".7z", ".rar", ".exe", ".dll", ".so", ".dylib",
67 ".a", ".lib", ".o", ".obj", ".class", ".jar", ".war", ".pyc", ".pyo", ".wasm", ".bin", ".dat",
68 ".db", ".sqlite", ".sqlite3", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
69 ".tiff", ".mp3", ".mp4", ".avi", ".mov", ".mkv", ".wav", ".flac", ".ogg", ".pdf", ".doc",
70 ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp",
71];
72
73pub struct ReadFileTool {
75 permission_registry: Arc<PermissionRegistry>,
76}
77
78impl ReadFileTool {
79 pub fn new(permission_registry: Arc<PermissionRegistry>) -> Self {
81 Self {
82 permission_registry,
83 }
84 }
85
86 fn build_permission_request(tool_use_id: &str, path: &str) -> PermissionRequest {
87 let reason = "Read file contents";
88
89 PermissionRequest::new(
90 tool_use_id,
91 GrantTarget::path(path, false),
92 PermissionLevel::Read,
93 format!("Read file: {}", path),
94 )
95 .with_reason(reason)
96 .with_tool(READ_FILE_TOOL_NAME)
97 }
98}
99
100fn is_binary_extension(path: &Path) -> bool {
102 path.extension()
103 .and_then(|ext| ext.to_str())
104 .map(|ext| {
105 let ext_lower = format!(".{}", ext.to_lowercase());
106 BINARY_EXTENSIONS.contains(&ext_lower.as_str())
107 })
108 .unwrap_or(false)
109}
110
111fn is_binary_content(bytes: &[u8]) -> bool {
113 if bytes.is_empty() {
114 return false;
115 }
116
117 let check_size = bytes.len().min(4096);
118 let sample = &bytes[..check_size];
119
120 if sample.contains(&0) {
122 return true;
123 }
124
125 let non_printable_count = sample
127 .iter()
128 .filter(|&&b| b < 9 || (b > 13 && b < 32))
129 .count();
130
131 (non_printable_count as f64 / sample.len() as f64) > 0.3
133}
134
135async fn find_similar_files(path: &Path) -> Vec<String> {
137 let Some(dir) = path.parent() else {
138 return Vec::new();
139 };
140
141 let Some(filename) = path.file_name().and_then(|n| n.to_str()) else {
142 return Vec::new();
143 };
144
145 let filename_lower = filename.to_lowercase();
146
147 let Ok(mut entries) = fs::read_dir(dir).await else {
148 return Vec::new();
149 };
150
151 let mut suggestions = Vec::new();
152
153 while let Ok(Some(entry)) = entries.next_entry().await {
154 let entry_name = entry.file_name();
155 let Some(entry_str) = entry_name.to_str() else {
156 continue;
157 };
158
159 let entry_lower = entry_str.to_lowercase();
160
161 if (entry_lower.contains(&filename_lower) || filename_lower.contains(&entry_lower))
163 && let Some(full_path) = entry.path().to_str()
164 {
165 suggestions.push(full_path.to_string());
166 }
167
168 if suggestions.len() >= 3 {
169 break;
170 }
171 }
172
173 suggestions
174}
175
176impl Executable for ReadFileTool {
177 fn name(&self) -> &str {
178 READ_FILE_TOOL_NAME
179 }
180
181 fn description(&self) -> &str {
182 READ_FILE_TOOL_DESCRIPTION
183 }
184
185 fn input_schema(&self) -> &str {
186 READ_FILE_TOOL_SCHEMA
187 }
188
189 fn tool_type(&self) -> ToolType {
190 ToolType::FileRead
191 }
192
193 fn execute(
194 &self,
195 context: ToolContext,
196 input: HashMap<String, serde_json::Value>,
197 ) -> Pin<Box<dyn Future<Output = Result<String, String>> + Send>> {
198 let permission_registry = self.permission_registry.clone();
199
200 Box::pin(async move {
201 let file_path = input
203 .get("file_path")
204 .and_then(|v| v.as_str())
205 .ok_or_else(|| "Missing required 'file_path' parameter".to_string())?;
206
207 let path = Path::new(file_path);
208
209 if !path.is_absolute() {
211 return Err(format!(
212 "file_path must be an absolute path, got: {}",
213 file_path
214 ));
215 }
216
217 if !path.exists() {
219 let suggestions = find_similar_files(path).await;
220 if suggestions.is_empty() {
221 return Err(format!("File not found: {}", file_path));
222 } else {
223 return Err(format!(
224 "File not found: {}\n\nDid you mean one of these?\n{}",
225 file_path,
226 suggestions.join("\n")
227 ));
228 }
229 }
230
231 if path.is_dir() {
233 return Err(format!(
234 "Cannot read directory: {}. Use a file listing tool instead.",
235 file_path
236 ));
237 }
238
239 if !context.permissions_pre_approved {
241 let permission_request =
242 ReadFileTool::build_permission_request(&context.tool_use_id, file_path);
243 let response_rx = permission_registry
244 .request_permission(
245 context.session_id,
246 permission_request,
247 context.turn_id.clone(),
248 )
249 .await
250 .map_err(|e| format!("Failed to request permission: {}", e))?;
251
252 let response = response_rx
253 .await
254 .map_err(|_| "Permission request was cancelled".to_string())?;
255
256 if !response.granted {
257 let reason = response
258 .message
259 .unwrap_or_else(|| "User denied".to_string());
260 return Err(format!(
261 "Permission denied to read '{}': {}",
262 file_path, reason
263 ));
264 }
265 }
266
267 if is_binary_extension(path) {
269 return Err(format!("Cannot read binary file: {}", file_path));
270 }
271
272 let offset = input
274 .get("offset")
275 .and_then(|v| v.as_i64())
276 .map(|v| v.max(0) as usize)
277 .unwrap_or(0);
278
279 let limit = input
280 .get("limit")
281 .and_then(|v| v.as_i64())
282 .map(|v| v.max(1) as usize)
283 .unwrap_or(DEFAULT_READ_LIMIT);
284
285 let mut file = fs::File::open(path)
287 .await
288 .map_err(|e| format!("Failed to open file: {}", e))?;
289
290 let metadata = file
291 .metadata()
292 .await
293 .map_err(|e| format!("Failed to read file metadata: {}", e))?;
294
295 let file_size = metadata.len() as usize;
297 if file_size > 0 {
298 let check_size = file_size.min(4096);
299 let mut check_buffer = vec![0u8; check_size];
300 file.read_exact(&mut check_buffer)
301 .await
302 .map_err(|e| format!("Failed to read file: {}", e))?;
303
304 if is_binary_content(&check_buffer) {
305 return Err(format!("Cannot read binary file: {}", file_path));
306 }
307 }
308
309 let content = fs::read_to_string(path)
311 .await
312 .map_err(|e| format!("Failed to read file as text: {}", e))?;
313
314 let lines: Vec<&str> = content.lines().collect();
315 let total_lines = lines.len();
316
317 let start = offset.min(total_lines);
319 let end = (start + limit).min(total_lines);
320
321 let mut output_lines = Vec::new();
322 let mut total_bytes = 0;
323 let mut truncated_by_bytes = false;
324
325 for (idx, line) in lines[start..end].iter().enumerate() {
326 let line_num = start + idx + 1; let display_line = if line.len() > MAX_LINE_LENGTH {
330 format!("{}...", &line[..MAX_LINE_LENGTH])
331 } else {
332 line.to_string()
333 };
334
335 let formatted = format!("{:05}| {}", line_num, display_line);
336 let line_bytes = formatted.len() + 1; if total_bytes + line_bytes > MAX_BYTES {
339 truncated_by_bytes = true;
340 break;
341 }
342
343 output_lines.push(formatted);
344 total_bytes += line_bytes;
345 }
346
347 let last_read_line = start + output_lines.len();
348 let has_more_lines = total_lines > last_read_line;
349
350 let mut output = String::from("<file>\n");
352 output.push_str(&output_lines.join("\n"));
353
354 if truncated_by_bytes {
355 output.push_str(&format!(
356 "\n\n(Output truncated at {} bytes. Use 'offset' parameter to read beyond line {})",
357 MAX_BYTES, last_read_line
358 ));
359 } else if has_more_lines {
360 output.push_str(&format!(
361 "\n\n(File has {} total lines. Use 'offset' parameter to read beyond line {})",
362 total_lines, last_read_line
363 ));
364 } else {
365 output.push_str(&format!("\n\n(End of file - {} total lines)", total_lines));
366 }
367
368 output.push_str("\n</file>");
369
370 Ok(output)
371 })
372 }
373
374 fn display_config(&self) -> DisplayConfig {
375 DisplayConfig {
376 display_name: "Read File".to_string(),
377 display_title: Box::new(|input| {
378 input
379 .get("file_path")
380 .and_then(|v| v.as_str())
381 .map(|p| {
382 Path::new(p)
384 .file_name()
385 .and_then(|n| n.to_str())
386 .unwrap_or(p)
387 .to_string()
388 })
389 .unwrap_or_default()
390 }),
391 display_content: Box::new(|_input, result| {
392 let content = result
394 .strip_prefix("<file>\n")
395 .and_then(|s| s.split("\n\n(").next())
396 .unwrap_or(result);
397
398 let lines: Vec<&str> = content.lines().take(20).collect();
399 let preview = lines.join("\n");
400 let is_truncated = content.lines().count() > 20;
401
402 DisplayResult {
403 content: preview,
404 content_type: ResultContentType::PlainText,
405 is_truncated,
406 full_length: content.lines().count(),
407 }
408 }),
409 }
410 }
411
412 fn compact_summary(&self, input: &HashMap<String, serde_json::Value>, result: &str) -> String {
413 let filename = input
414 .get("file_path")
415 .and_then(|v| v.as_str())
416 .map(|p| {
417 Path::new(p)
418 .file_name()
419 .and_then(|n| n.to_str())
420 .unwrap_or(p)
421 })
422 .unwrap_or("unknown");
423
424 let truncated = result.contains("Use 'offset' parameter");
425 let status = if truncated { "partial" } else { "complete" };
426
427 format!("[ReadFile: {} ({})]", filename, status)
428 }
429
430 fn required_permissions(
431 &self,
432 context: &ToolContext,
433 input: &HashMap<String, serde_json::Value>,
434 ) -> Option<Vec<PermissionRequest>> {
435 let file_path = input.get("file_path").and_then(|v| v.as_str())?;
437
438 let path = Path::new(file_path);
439
440 if !path.is_absolute() {
442 return None;
443 }
444
445 let permission_request =
447 ReadFileTool::build_permission_request(&context.tool_use_id, file_path);
448
449 Some(vec![permission_request])
450 }
451}
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456 use std::io::Write;
457 use std::sync::Arc;
458 use tempfile::NamedTempFile;
459 use tokio::sync::mpsc;
460
461 use crate::controller::types::ControllerEvent;
462 use crate::permissions::{Grant, PermissionPanelResponse, PermissionRegistry};
463
464 fn create_test_registry() -> (Arc<PermissionRegistry>, mpsc::Receiver<ControllerEvent>) {
465 let (event_tx, event_rx) = mpsc::channel(10);
466 (Arc::new(PermissionRegistry::new(event_tx)), event_rx)
467 }
468
469 fn create_session_grant(request: &PermissionRequest) -> PermissionPanelResponse {
471 PermissionPanelResponse {
472 granted: true,
473 grant: Some(Grant::new(request.target.clone(), request.required_level)),
474 message: None,
475 }
476 }
477
478 #[test]
479 fn test_is_binary_extension() {
480 assert!(is_binary_extension(Path::new("/tmp/file.zip")));
481 assert!(is_binary_extension(Path::new("/tmp/file.exe")));
482 assert!(is_binary_extension(Path::new("/tmp/file.png")));
483 assert!(is_binary_extension(Path::new("/tmp/file.PDF"))); assert!(!is_binary_extension(Path::new("/tmp/file.rs")));
485 assert!(!is_binary_extension(Path::new("/tmp/file.txt")));
486 assert!(!is_binary_extension(Path::new("/tmp/file.json")));
487 }
488
489 #[test]
490 fn test_is_binary_content() {
491 assert!(!is_binary_content(b"Hello, world!\nThis is text."));
493 assert!(!is_binary_content(b""));
494
495 assert!(is_binary_content(&[0x00, 0x01, 0x02, 0x03]));
497
498 let binary_like: Vec<u8> = (0..100).map(|i| if i % 2 == 0 { 1 } else { 65 }).collect();
500 assert!(is_binary_content(&binary_like));
501 }
502
503 #[tokio::test]
504 async fn test_read_file_success() {
505 let mut temp_file = NamedTempFile::new().unwrap();
506 writeln!(temp_file, "Line 1").unwrap();
507 writeln!(temp_file, "Line 2").unwrap();
508 writeln!(temp_file, "Line 3").unwrap();
509
510 let (registry, mut _event_rx) = create_test_registry();
511 let tool = ReadFileTool::new(registry.clone());
512
513 let file_path = temp_file.path().to_str().unwrap().to_string();
514
515 let permission_request = ReadFileTool::build_permission_request("pre_grant", &file_path);
517 let rx = registry
518 .request_permission(1, permission_request.clone(), None)
519 .await
520 .unwrap();
521 registry
522 .respond_to_request("pre_grant", create_session_grant(&permission_request))
523 .await
524 .unwrap();
525 let _ = rx.await;
526
527 let context = ToolContext {
528 session_id: 1,
529 tool_use_id: "test".to_string(),
530 turn_id: None,
531 permissions_pre_approved: false,
532 };
533
534 let mut input = HashMap::new();
535 input.insert(
536 "file_path".to_string(),
537 serde_json::Value::String(file_path),
538 );
539
540 let result = tool.execute(context, input).await;
541 assert!(result.is_ok());
542
543 let output = result.unwrap();
544 assert!(output.contains("<file>"));
545 assert!(output.contains("00001| Line 1"));
546 assert!(output.contains("00002| Line 2"));
547 assert!(output.contains("00003| Line 3"));
548 assert!(output.contains("</file>"));
549 }
550
551 #[tokio::test]
552 async fn test_read_file_with_offset() {
553 let mut temp_file = NamedTempFile::new().unwrap();
554 for i in 1..=10 {
555 writeln!(temp_file, "Line {}", i).unwrap();
556 }
557
558 let (registry, mut _event_rx) = create_test_registry();
559 let tool = ReadFileTool::new(registry.clone());
560
561 let file_path = temp_file.path().to_str().unwrap().to_string();
562
563 let permission_request = ReadFileTool::build_permission_request("pre_grant", &file_path);
565 let rx = registry
566 .request_permission(1, permission_request.clone(), None)
567 .await
568 .unwrap();
569 registry
570 .respond_to_request("pre_grant", create_session_grant(&permission_request))
571 .await
572 .unwrap();
573 let _ = rx.await;
574
575 let context = ToolContext {
576 session_id: 1,
577 tool_use_id: "test".to_string(),
578 turn_id: None,
579 permissions_pre_approved: false,
580 };
581
582 let mut input = HashMap::new();
583 input.insert(
584 "file_path".to_string(),
585 serde_json::Value::String(file_path),
586 );
587 input.insert("offset".to_string(), serde_json::Value::Number(5.into()));
588 input.insert("limit".to_string(), serde_json::Value::Number(3.into()));
589
590 let result = tool.execute(context, input).await;
591 assert!(result.is_ok());
592
593 let output = result.unwrap();
594 assert!(output.contains("00006| Line 6"));
595 assert!(output.contains("00007| Line 7"));
596 assert!(output.contains("00008| Line 8"));
597 assert!(!output.contains("00005| Line 5"));
598 assert!(!output.contains("00009| Line 9"));
599 }
600
601 #[tokio::test]
602 async fn test_read_file_not_found() {
603 let (registry, _event_rx) = create_test_registry();
604 let tool = ReadFileTool::new(registry);
605 let context = ToolContext {
606 session_id: 1,
607 tool_use_id: "test".to_string(),
608 turn_id: None,
609 permissions_pre_approved: false,
610 };
611
612 let mut input = HashMap::new();
613 input.insert(
614 "file_path".to_string(),
615 serde_json::Value::String("/nonexistent/path/file.txt".to_string()),
616 );
617
618 let result = tool.execute(context, input).await;
619 assert!(result.is_err());
620 assert!(result.unwrap_err().contains("File not found"));
621 }
622
623 #[tokio::test]
624 async fn test_read_file_relative_path_rejected() {
625 let (registry, _event_rx) = create_test_registry();
626 let tool = ReadFileTool::new(registry);
627 let context = ToolContext {
628 session_id: 1,
629 tool_use_id: "test".to_string(),
630 turn_id: None,
631 permissions_pre_approved: false,
632 };
633
634 let mut input = HashMap::new();
635 input.insert(
636 "file_path".to_string(),
637 serde_json::Value::String("relative/path/file.txt".to_string()),
638 );
639
640 let result = tool.execute(context, input).await;
641 assert!(result.is_err());
642 assert!(result.unwrap_err().contains("must be an absolute path"));
643 }
644
645 #[tokio::test]
646 async fn test_read_binary_extension_rejected() {
647 let (registry, mut _event_rx) = create_test_registry();
648 let tool = ReadFileTool::new(registry.clone());
649 let context = ToolContext {
650 session_id: 1,
651 tool_use_id: "test".to_string(),
652 turn_id: None,
653 permissions_pre_approved: false,
654 };
655
656 let temp_dir = tempfile::tempdir().unwrap();
658 let binary_path = temp_dir.path().join("test.exe");
659 std::fs::write(&binary_path, b"fake binary").unwrap();
660
661 let file_path = binary_path.to_str().unwrap().to_string();
662
663 let permission_request = ReadFileTool::build_permission_request("pre_grant", &file_path);
665 let rx = registry
666 .request_permission(1, permission_request.clone(), None)
667 .await
668 .unwrap();
669 registry
670 .respond_to_request("pre_grant", create_session_grant(&permission_request))
671 .await
672 .unwrap();
673 let _ = rx.await;
674
675 let mut input = HashMap::new();
676 input.insert(
677 "file_path".to_string(),
678 serde_json::Value::String(file_path),
679 );
680
681 let result = tool.execute(context, input).await;
682 assert!(result.is_err());
683 assert!(result.unwrap_err().contains("Cannot read binary file"));
684 }
685
686 #[test]
687 fn test_compact_summary() {
688 let (registry, _event_rx) = create_test_registry();
689 let tool = ReadFileTool::new(registry);
690
691 let mut input = HashMap::new();
692 input.insert(
693 "file_path".to_string(),
694 serde_json::Value::String("/path/to/file.rs".to_string()),
695 );
696
697 let complete_result = "<file>\n00001| code\n\n(End of file - 1 total lines)\n</file>";
698 assert_eq!(
699 tool.compact_summary(&input, complete_result),
700 "[ReadFile: file.rs (complete)]"
701 );
702
703 let partial_result =
704 "<file>\n00001| code\n\n(Use 'offset' parameter to read beyond line 2000)\n</file>";
705 assert_eq!(
706 tool.compact_summary(&input, partial_result),
707 "[ReadFile: file.rs (partial)]"
708 );
709 }
710
711 #[test]
712 fn test_build_permission_request() {
713 let request =
714 ReadFileTool::build_permission_request("test-id", "/home/user/project/file.rs");
715 assert_eq!(request.description, "Read file: /home/user/project/file.rs");
716 assert_eq!(request.reason, Some("Read file contents".to_string()));
717 assert_eq!(
718 request.target,
719 GrantTarget::path("/home/user/project/file.rs", false)
720 );
721 assert_eq!(request.required_level, PermissionLevel::Read);
722 }
723}