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