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