1use std::fs;
13use std::path::{Path, PathBuf};
14
15use async_trait::async_trait;
16use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
17use serde::{Deserialize, Serialize};
18use tracing::debug;
19
20use super::{compute_content_hash, FileReadRecord, SharedFileReadHistory};
21use crate::tools::base::{PermissionCheckResult, Tool};
22use crate::tools::context::{ToolContext, ToolOptions, ToolResult};
23use crate::tools::error::ToolError;
24
25pub const MAX_TEXT_FILE_SIZE: u64 = 10 * 1024 * 1024;
27
28pub const MAX_IMAGE_FILE_SIZE: u64 = 50 * 1024 * 1024;
30
31const IMAGE_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "svg"];
33
34const TEXT_EXTENSIONS: &[&str] = &[
36 "txt",
37 "md",
38 "rs",
39 "py",
40 "js",
41 "ts",
42 "jsx",
43 "tsx",
44 "json",
45 "yaml",
46 "yml",
47 "toml",
48 "xml",
49 "html",
50 "css",
51 "scss",
52 "less",
53 "sql",
54 "sh",
55 "bash",
56 "zsh",
57 "c",
58 "cpp",
59 "h",
60 "hpp",
61 "java",
62 "go",
63 "rb",
64 "php",
65 "swift",
66 "kt",
67 "scala",
68 "r",
69 "lua",
70 "pl",
71 "pm",
72 "ex",
73 "exs",
74 "erl",
75 "hrl",
76 "hs",
77 "ml",
78 "mli",
79 "fs",
80 "fsx",
81 "clj",
82 "cljs",
83 "lisp",
84 "el",
85 "vim",
86 "conf",
87 "ini",
88 "cfg",
89 "env",
90 "gitignore",
91 "dockerignore",
92 "makefile",
93 "cmake",
94 "gradle",
95];
96
97#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
99pub struct LineRange {
100 pub start: usize,
102 pub end: Option<usize>,
104}
105
106impl LineRange {
107 pub fn new(start: usize, end: Option<usize>) -> Self {
109 Self { start, end }
110 }
111
112 pub fn from_start(start: usize) -> Self {
114 Self { start, end: None }
115 }
116
117 pub fn lines(start: usize, count: usize) -> Self {
119 Self {
120 start,
121 end: Some(start + count - 1),
122 }
123 }
124}
125
126#[derive(Debug)]
137struct TextFileInfo {
138 path: PathBuf,
139 extension: String,
140 language: Option<String>,
141 file_category: String,
142 size_bytes: u64,
143 total_lines: usize,
144}
145
146#[derive(Debug)]
147pub struct ReadTool {
148 read_history: SharedFileReadHistory,
150 pdf_enabled: bool,
152}
153
154impl ReadTool {
155 pub fn new(read_history: SharedFileReadHistory) -> Self {
157 Self {
158 read_history,
159 pdf_enabled: false,
160 }
161 }
162
163 pub fn with_pdf_enabled(mut self, enabled: bool) -> Self {
165 self.pdf_enabled = enabled;
166 self
167 }
168
169 pub fn read_history(&self) -> &SharedFileReadHistory {
171 &self.read_history
172 }
173}
174
175impl ReadTool {
180 pub async fn read_text(
187 &self,
188 path: &Path,
189 range: Option<LineRange>,
190 context: &ToolContext,
191 ) -> Result<String, ToolError> {
192 let full_path = self.resolve_path(path, context);
193
194 if !full_path.exists() {
196 return Err(ToolError::execution_failed(format!(
197 "File not found: {}",
198 full_path.display()
199 )));
200 }
201
202 let metadata = fs::metadata(&full_path)?;
204 if metadata.len() > MAX_TEXT_FILE_SIZE {
205 return Err(ToolError::execution_failed(format!(
206 "File too large: {} bytes (max: {} bytes)",
207 metadata.len(),
208 MAX_TEXT_FILE_SIZE
209 )));
210 }
211
212 let content = fs::read(&full_path)?;
214 let text = String::from_utf8_lossy(&content);
215
216 self.record_file_read(&full_path, &content, &metadata)?;
218
219 let lines: Vec<&str> = text.lines().collect();
221 let total_lines = lines.len();
222
223 let (start, end) = match range {
224 Some(r) => {
225 let start = r.start.saturating_sub(1).min(total_lines);
226 let end = r.end.map(|e| e.min(total_lines)).unwrap_or(total_lines);
227 (start, end)
228 }
229 None => (0, total_lines),
230 };
231
232 let line_width = (end.max(1)).to_string().len();
234
235 let formatted: Vec<String> = lines[start..end]
236 .iter()
237 .enumerate()
238 .map(|(i, line)| {
239 let line_num = start + i + 1;
240 format!("{:>width$} | {}", line_num, line, width = line_width)
241 })
242 .collect();
243
244 debug!(
245 "Read text file: {} ({} lines, showing {}-{})",
246 full_path.display(),
247 total_lines,
248 start + 1,
249 end
250 );
251
252 Ok(formatted.join("\n"))
253 }
254
255 fn record_file_read(
257 &self,
258 path: &Path,
259 content: &[u8],
260 metadata: &fs::Metadata,
261 ) -> Result<(), ToolError> {
262 let hash = compute_content_hash(content);
263 let mtime = metadata.modified().ok();
264 let line_count = String::from_utf8_lossy(content).lines().count();
265
266 let mut record = FileReadRecord::new(path.to_path_buf(), hash, metadata.len())
267 .with_line_count(line_count);
268
269 if let Some(mt) = mtime {
270 record = record.with_mtime(mt);
271 }
272
273 self.read_history.write().unwrap().record_read(record);
274 Ok(())
275 }
276
277 fn resolve_path(&self, path: &Path, context: &ToolContext) -> PathBuf {
279 if path.is_absolute() {
280 path.to_path_buf()
281 } else {
282 context.working_directory.join(path)
283 }
284 }
285}
286
287impl ReadTool {
292 pub async fn read_image(
302 &self,
303 path: &Path,
304 context: &ToolContext,
305 ) -> Result<String, ToolError> {
306 let full_path = self.resolve_path(path, context);
307
308 if !full_path.exists() {
310 return Err(ToolError::execution_failed(format!(
311 "Image not found: {}",
312 full_path.display()
313 )));
314 }
315
316 let metadata = fs::metadata(&full_path)?;
318 if metadata.len() > MAX_IMAGE_FILE_SIZE {
319 return Err(ToolError::execution_failed(format!(
320 "Image too large: {} bytes (max: {} bytes)",
321 metadata.len(),
322 MAX_IMAGE_FILE_SIZE
323 )));
324 }
325
326 let image_result = crate::media::read_image_file_enhanced(&full_path)
328 .map_err(|e| ToolError::execution_failed(format!("Failed to read image: {}", e)))?;
329
330 let content = fs::read(&full_path)?;
332 self.record_file_read(&full_path, &content, &metadata)?;
333
334 let size_kb = (image_result.original_size as f64 / 1024.0).round() as u64;
336 let token_estimate = crate::media::estimate_image_tokens(&image_result.base64);
337
338 let mut output = Vec::new();
340 output.push(format!(
341 "[Enhanced Image Analysis: {}]",
342 full_path.display()
343 ));
344 output.push(format!("Format: {}", image_result.mime_type));
345 output.push(format!(
346 "Size: {} KB ({} bytes)",
347 size_kb, image_result.original_size
348 ));
349
350 if let Some(dims) = &image_result.dimensions {
351 if let (Some(w), Some(h)) = (dims.original_width, dims.original_height) {
352 output.push(format!("Original dimensions: {}x{}", w, h));
353 if let (Some(dw), Some(dh)) = (dims.display_width, dims.display_height) {
354 if dw != w || dh != h {
355 output.push(format!("Display dimensions: {}x{} (resized)", dw, dh));
356 }
357 }
358 }
359 }
360
361 output.push(format!("Estimated tokens: {}", token_estimate));
362
363 output.push(String::new());
365 output.push("AI Analysis Capabilities:".to_string());
366 output.push("- Content recognition and description".to_string());
367 output.push("- Text extraction (OCR)".to_string());
368 output.push("- Object and scene detection".to_string());
369 output.push("- Color analysis and composition".to_string());
370 output.push("- Technical diagram interpretation".to_string());
371 output.push("- Screenshot analysis and UI element identification".to_string());
372
373 debug!(
374 "Enhanced image read: {} ({} KB, {} tokens, {})",
375 full_path.display(),
376 size_kb,
377 token_estimate,
378 image_result.mime_type
379 );
380
381 Ok(format!(
383 "{}\n\nBase64 Data: data:{};base64,{}",
384 output.join("\n"),
385 image_result.mime_type,
386 image_result.base64
387 ))
388 }
389
390 pub fn is_image_file(path: &Path) -> bool {
392 let ext = path
393 .extension()
394 .and_then(|e| e.to_str())
395 .unwrap_or("")
396 .to_lowercase();
397 crate::media::is_supported_image_format(&ext)
398 }
399}
400
401impl ReadTool {
406 pub async fn read_pdf(&self, path: &Path, context: &ToolContext) -> Result<String, ToolError> {
420 if !self.pdf_enabled {
421 return Err(ToolError::execution_failed(
422 "PDF reading is not enabled. Enable it with ReadTool::with_pdf_enabled(true)",
423 ));
424 }
425
426 let full_path = self.resolve_path(path, context);
427
428 if !full_path.exists() {
430 return Err(ToolError::execution_failed(format!(
431 "PDF not found: {}",
432 full_path.display()
433 )));
434 }
435
436 let content = fs::read(&full_path)?;
438 let metadata = fs::metadata(&full_path)?;
439 self.record_file_read(&full_path, &content, &metadata)?;
440
441 let size_mb = (metadata.len() as f64 / 1_048_576.0 * 100.0).round() / 100.0;
443 let base64_content = BASE64.encode(&content);
444 let base64_length = base64_content.len();
445
446 let mut output = Vec::new();
448 output.push(format!("[Enhanced PDF Analysis: {}]", full_path.display()));
449 output.push(format!("Size: {} MB ({} bytes)", size_mb, metadata.len()));
450 output.push(format!("Base64 length: {} chars", base64_length));
451 output.push(String::new());
452
453 output.push("AI Analysis Capabilities:".to_string());
455 output.push("- Document structure analysis".to_string());
456 output.push("- Text extraction and content analysis".to_string());
457 output.push("- Table and form recognition".to_string());
458 output.push("- Image and diagram extraction".to_string());
459 output.push("- Layout and formatting analysis".to_string());
460 output.push("- Multi-page document processing".to_string());
461 output.push(String::new());
462
463 output.push("Processing Notes:".to_string());
465 output
466 .push("- PDF content will be processed as document blocks for AI analysis".to_string());
467 output.push("- Large PDFs may be processed in chunks for optimal performance".to_string());
468 output.push("- Text and visual elements will be analyzed together".to_string());
469
470 debug!(
471 "Enhanced PDF read: {} ({} MB, {} base64 chars)",
472 full_path.display(),
473 size_mb,
474 base64_length
475 );
476
477 Ok(format!("{}\n\nDocument ready for AI analysis.\nBase64 data available for multimodal processing.",
481 output.join("\n")))
482 }
483
484 pub fn is_pdf_file(path: &Path) -> bool {
486 let ext = path
487 .extension()
488 .and_then(|e| e.to_str())
489 .unwrap_or("")
490 .to_lowercase();
491 crate::media::is_pdf_extension(&ext)
492 }
493}
494
495impl ReadTool {
500 pub async fn read_svg(&self, path: &Path, context: &ToolContext) -> Result<String, ToolError> {
510 let full_path = self.resolve_path(path, context);
511
512 if !full_path.exists() {
514 return Err(ToolError::execution_failed(format!(
515 "SVG not found: {}",
516 full_path.display()
517 )));
518 }
519
520 let content = fs::read(&full_path)?;
522 let metadata = fs::metadata(&full_path)?;
523 let svg_text = String::from_utf8_lossy(&content);
524
525 self.record_file_read(&full_path, &content, &metadata)?;
527
528 let size_kb = (metadata.len() as f64 / 1024.0).round() as u64;
530
531 let mut output = Vec::new();
533 output.push(format!("[Enhanced SVG Analysis: {}]", full_path.display()));
534 output.push(format!("Size: {} KB ({} bytes)", size_kb, metadata.len()));
535 output.push("Content type: Scalable Vector Graphics".to_string());
536 output.push(String::new());
537
538 output.push("AI Analysis Capabilities:".to_string());
540 output.push("- Vector graphics structure analysis".to_string());
541 output.push("- Shape and path recognition".to_string());
542 output.push("- Text content extraction".to_string());
543 output.push("- Color scheme analysis".to_string());
544 output.push("- Diagram and flowchart interpretation".to_string());
545 output.push("- Icon and symbol recognition".to_string());
546 output.push(String::new());
547
548 output.push("SVG Content Preview:".to_string());
550 let lines: Vec<&str> = svg_text.lines().take(10).collect();
551 for (i, line) in lines.iter().enumerate() {
552 let trimmed = line.trim();
553 if !trimmed.is_empty() {
554 output.push(format!(" {}: {}", i + 1, trimmed));
555 }
556 }
557
558 if svg_text.lines().count() > 10 {
559 output.push(" ... (content truncated)".to_string());
560 }
561
562 debug!(
563 "Enhanced SVG read: {} ({} KB)",
564 full_path.display(),
565 size_kb
566 );
567
568 Ok(format!(
570 "{}\n\nFull SVG Content:\n{}",
571 output.join("\n"),
572 svg_text
573 ))
574 }
575
576 pub async fn read_notebook(
586 &self,
587 path: &Path,
588 context: &ToolContext,
589 ) -> Result<String, ToolError> {
590 let full_path = self.resolve_path(path, context);
591
592 let (content, metadata, notebook) = self.load_notebook_file(&full_path)?;
594
595 self.record_file_read(&full_path, &content, &metadata)?;
597
598 let cells = self.extract_notebook_cells(¬ebook)?;
600 let output = self.build_notebook_output(&full_path, &metadata, cells);
601
602 debug!(
603 "Enhanced notebook read: {} ({} cells)",
604 full_path.display(),
605 cells.len()
606 );
607
608 Ok(output.join("\n"))
609 }
610
611 fn add_notebook_header(
613 &self,
614 output: &mut Vec<String>,
615 full_path: &Path,
616 metadata: &fs::Metadata,
617 cells: &[serde_json::Value],
618 ) {
619 output.push(format!(
620 "[Enhanced Notebook Analysis: {}]",
621 full_path.display()
622 ));
623 output.push(format!(
624 "Size: {} KB",
625 (metadata.len() as f64 / 1024.0).round() as u64
626 ));
627 output.push(format!("Total cells: {}", cells.len()));
628
629 let (code_cells, markdown_cells, other_cells) = self.analyze_cell_types(cells);
631 output.push(format!(
632 "Code cells: {}, Markdown cells: {}, Other: {}",
633 code_cells, markdown_cells, other_cells
634 ));
635 output.push(String::new());
636 }
637
638 fn load_notebook_file(
640 &self,
641 full_path: &Path,
642 ) -> Result<(Vec<u8>, fs::Metadata, serde_json::Value), ToolError> {
643 if !full_path.exists() {
645 return Err(ToolError::execution_failed(format!(
646 "Notebook not found: {}",
647 full_path.display()
648 )));
649 }
650
651 let content = fs::read(full_path)?;
653 let metadata = fs::metadata(full_path)?;
654
655 let notebook: serde_json::Value = serde_json::from_slice(&content).map_err(|e| {
656 ToolError::execution_failed(format!("Failed to parse notebook JSON: {}", e))
657 })?;
658
659 Ok((content, metadata, notebook))
660 }
661
662 fn extract_notebook_cells<'a>(
664 &self,
665 notebook: &'a serde_json::Value,
666 ) -> Result<&'a Vec<serde_json::Value>, ToolError> {
667 notebook
668 .get("cells")
669 .and_then(|c| c.as_array())
670 .ok_or_else(|| ToolError::execution_failed("Invalid notebook format: missing cells"))
671 }
672
673 fn build_notebook_output(
675 &self,
676 full_path: &Path,
677 metadata: &fs::Metadata,
678 cells: &[serde_json::Value],
679 ) -> Vec<String> {
680 let mut output = Vec::new();
681
682 self.add_notebook_header(&mut output, full_path, metadata, cells);
684
685 self.add_analysis_capabilities(&mut output);
687
688 self.process_notebook_cells(&mut output, cells);
690
691 output
692 }
693
694 fn analyze_cell_types(&self, cells: &[serde_json::Value]) -> (usize, usize, usize) {
696 let mut code_cells = 0;
697 let mut markdown_cells = 0;
698 let mut other_cells = 0;
699
700 for cell in cells {
701 match cell
702 .get("cell_type")
703 .and_then(|t| t.as_str())
704 .unwrap_or("unknown")
705 {
706 "code" => code_cells += 1,
707 "markdown" => markdown_cells += 1,
708 _ => other_cells += 1,
709 }
710 }
711
712 (code_cells, markdown_cells, other_cells)
713 }
714
715 fn add_analysis_capabilities(&self, output: &mut Vec<String>) {
717 output.push("AI Analysis Capabilities:".to_string());
718 output.push("- Code execution flow analysis".to_string());
719 output.push("- Data visualization interpretation".to_string());
720 output.push("- Scientific computation analysis".to_string());
721 output.push("- Documentation and markdown processing".to_string());
722 output.push("- Output and result interpretation".to_string());
723 output.push("- Machine learning workflow analysis".to_string());
724 output.push(String::new());
725 }
726
727 fn process_notebook_cells(&self, output: &mut Vec<String>, cells: &[serde_json::Value]) {
729 for (i, cell) in cells.iter().enumerate() {
730 self.process_single_cell(output, cell, i + 1);
731 output.push(String::new());
732 }
733 }
734
735 fn process_single_cell(
737 &self,
738 output: &mut Vec<String>,
739 cell: &serde_json::Value,
740 cell_num: usize,
741 ) {
742 let cell_type = cell
743 .get("cell_type")
744 .and_then(|t| t.as_str())
745 .unwrap_or("unknown");
746
747 let source = cell
748 .get("source")
749 .map(|s| self.extract_cell_source(s))
750 .unwrap_or_default();
751
752 match cell_type {
753 "code" => {
754 output.push(format!("## Cell {} [Code Cell] 🐍", cell_num));
755 output.push("```python".to_string());
756 output.push(source);
757 output.push("```".to_string());
758
759 self.process_cell_outputs(output, cell);
761 }
762 "markdown" => {
763 output.push(format!("## Cell {} [Markdown Cell] 📝", cell_num));
764 output.push(source);
765 }
766 _ => {
767 output.push(format!("## Cell {} [{}] ❓", cell_num, cell_type));
768 output.push(source);
769 }
770 }
771 }
772
773 fn process_cell_outputs(&self, output: &mut Vec<String>, cell: &serde_json::Value) {
775 if let Some(outputs) = cell.get("outputs").and_then(|o| o.as_array()) {
776 if !outputs.is_empty() {
777 output.push("### Execution Output:".to_string());
778 for (out_idx, out) in outputs.iter().enumerate() {
779 if let Some(text) = self.extract_output_text(out) {
780 output.push(format!(
781 "#### Output {} [{}]:",
782 out_idx + 1,
783 out.get("output_type")
784 .and_then(|t| t.as_str())
785 .unwrap_or("result")
786 ));
787 output.push("```".to_string());
788 output.push(text);
789 output.push("```".to_string());
790 }
791 }
792 }
793 }
794 }
795
796 fn extract_cell_source(&self, source: &serde_json::Value) -> String {
798 match source {
799 serde_json::Value::String(s) => s.clone(),
800 serde_json::Value::Array(arr) => arr
801 .iter()
802 .filter_map(|v| v.as_str())
803 .collect::<Vec<_>>()
804 .join(""),
805 _ => String::new(),
806 }
807 }
808
809 fn extract_output_text(&self, output: &serde_json::Value) -> Option<String> {
811 if let Some(text) = output.get("text") {
813 return Some(self.extract_cell_source(text));
814 }
815
816 if let Some(data) = output.get("data") {
818 if let Some(text) = data.get("text/plain") {
819 return Some(self.extract_cell_source(text));
820 }
821 }
822
823 None
824 }
825
826 pub fn is_notebook_file(path: &Path) -> bool {
828 path.extension()
829 .and_then(|e| e.to_str())
830 .map(|e| e.to_lowercase() == "ipynb")
831 .unwrap_or(false)
832 }
833}
834
835#[async_trait]
840impl Tool for ReadTool {
841 fn name(&self) -> &str {
842 "read"
843 }
844
845 fn description(&self) -> &str {
846 "Enhanced multimodal file reader with intelligent analysis capabilities. \
847 Supports text files (with syntax highlighting and language detection), \
848 images (with metadata and AI analysis hints), PDF files (with document processing), \
849 SVG files (with vector graphics analysis), and Jupyter notebooks (with computational analysis). \
850 Automatically detects file type and provides structured information optimized for AI processing. \
851 Inspired by Claude Agent SDK for comprehensive file understanding and analysis."
852 }
853
854 fn input_schema(&self) -> serde_json::Value {
855 serde_json::json!({
856 "type": "object",
857 "properties": {
858 "path": {
859 "type": "string",
860 "description": "Path to the file to read (relative to working directory or absolute)"
861 },
862 "start_line": {
863 "type": "integer",
864 "description": "Start line number (1-indexed, for text files only)",
865 "minimum": 1
866 },
867 "end_line": {
868 "type": "integer",
869 "description": "End line number (1-indexed, inclusive, for text files only)",
870 "minimum": 1
871 }
872 },
873 "required": ["path"]
874 })
875 }
876
877 async fn execute(
878 &self,
879 params: serde_json::Value,
880 context: &ToolContext,
881 ) -> Result<ToolResult, ToolError> {
882 if context.is_cancelled() {
884 return Err(ToolError::Cancelled);
885 }
886
887 let path_str = params
889 .get("path")
890 .and_then(|v| v.as_str())
891 .ok_or_else(|| ToolError::invalid_params("Missing required parameter: path"))?;
892
893 let path = Path::new(path_str);
894
895 if Self::is_image_file(path) {
897 let content = self.read_image(path, context).await?;
898 return Ok(ToolResult::success(content)
899 .with_metadata("file_type", serde_json::json!("image"))
900 .with_metadata("analysis_type", serde_json::json!("enhanced_multimodal")));
901 }
902
903 if Self::is_pdf_file(path) {
904 let content = self.read_pdf(path, context).await?;
905 return Ok(ToolResult::success(content)
906 .with_metadata("file_type", serde_json::json!("pdf"))
907 .with_metadata("analysis_type", serde_json::json!("enhanced_document")));
908 }
909
910 if Self::is_svg_file(path) {
911 let content = self.read_svg(path, context).await?;
912 return Ok(ToolResult::success(content)
913 .with_metadata("file_type", serde_json::json!("svg"))
914 .with_metadata("analysis_type", serde_json::json!("enhanced_vector")));
915 }
916
917 if Self::is_notebook_file(path) {
918 let content = self.read_notebook(path, context).await?;
919 return Ok(ToolResult::success(content)
920 .with_metadata("file_type", serde_json::json!("notebook"))
921 .with_metadata("analysis_type", serde_json::json!("enhanced_computational")));
922 }
923
924 let range = self.extract_line_range(¶ms);
926 let content = self.read_text_enhanced(path, range, context).await?;
927
928 Ok(ToolResult::success(content)
929 .with_metadata("file_type", serde_json::json!("text"))
930 .with_metadata("analysis_type", serde_json::json!("enhanced_textual")))
931 }
932
933 async fn check_permissions(
934 &self,
935 params: &serde_json::Value,
936 context: &ToolContext,
937 ) -> PermissionCheckResult {
938 let path_str = match params.get("path").and_then(|v| v.as_str()) {
940 Some(p) => p,
941 None => return PermissionCheckResult::deny("Missing path parameter"),
942 };
943
944 let path = Path::new(path_str);
945 let full_path = self.resolve_path(path, context);
946
947 debug!("Permission check for read: {}", full_path.display());
950
951 PermissionCheckResult::allow()
952 }
953
954 fn options(&self) -> ToolOptions {
955 ToolOptions::new()
956 .with_max_retries(1)
957 .with_base_timeout(std::time::Duration::from_secs(30))
958 }
959}
960
961impl ReadTool {
962 fn extract_line_range(&self, params: &serde_json::Value) -> Option<LineRange> {
964 let start = params
965 .get("start_line")
966 .and_then(|v| v.as_u64())
967 .map(|v| v as usize);
968 let end = params
969 .get("end_line")
970 .and_then(|v| v.as_u64())
971 .map(|v| v as usize);
972
973 match (start, end) {
974 (Some(s), e) => Some(LineRange::new(s, e)),
975 (None, Some(e)) => Some(LineRange::new(1, Some(e))),
976 (None, None) => None,
977 }
978 }
979
980 pub async fn read_text_enhanced(
990 &self,
991 path: &Path,
992 range: Option<LineRange>,
993 context: &ToolContext,
994 ) -> Result<String, ToolError> {
995 let full_path = self.resolve_path(path, context);
996
997 let (content, metadata, text) = self.load_text_file(&full_path)?;
999
1000 self.record_file_read(&full_path, &content, &metadata)?;
1002
1003 let file_info = self.analyze_text_file(&full_path, &text, &metadata);
1005 let formatted_content = self.format_text_with_lines(&text, range);
1006 let output = self.build_text_analysis_output(&file_info, &formatted_content, range);
1007
1008 debug!(
1009 "Enhanced text read: {} ({} lines, {}, {})",
1010 full_path.display(),
1011 file_info.total_lines,
1012 file_info.file_category,
1013 file_info.language.unwrap_or_else(|| "unknown".to_string())
1014 );
1015
1016 Ok(output.join("\n"))
1017 }
1018
1019 fn analyze_text_file(&self, path: &Path, text: &str, metadata: &fs::Metadata) -> TextFileInfo {
1021 let extension = path
1022 .extension()
1023 .and_then(|e| e.to_str())
1024 .unwrap_or("")
1025 .to_lowercase();
1026
1027 let language = self.detect_programming_language(&extension, text);
1028 let file_category = self.categorize_file_type(&extension);
1029 let total_lines = text.lines().count();
1030
1031 TextFileInfo {
1032 path: path.to_path_buf(),
1033 extension,
1034 language,
1035 file_category,
1036 size_bytes: metadata.len(),
1037 total_lines,
1038 }
1039 }
1040
1041 fn load_text_file(
1043 &self,
1044 full_path: &Path,
1045 ) -> Result<(Vec<u8>, fs::Metadata, String), ToolError> {
1046 if !full_path.exists() {
1048 return Err(ToolError::execution_failed(format!(
1049 "File not found: {}",
1050 full_path.display()
1051 )));
1052 }
1053
1054 let metadata = fs::metadata(full_path)?;
1055 if metadata.len() > MAX_TEXT_FILE_SIZE {
1056 return Err(ToolError::execution_failed(format!(
1057 "File too large: {} bytes (max: {} bytes)",
1058 metadata.len(),
1059 MAX_TEXT_FILE_SIZE
1060 )));
1061 }
1062
1063 let content = fs::read(full_path)?;
1065 let text = String::from_utf8_lossy(&content).to_string();
1066
1067 Ok((content, metadata, text))
1068 }
1069
1070 fn format_text_with_lines(&self, text: &str, range: Option<LineRange>) -> Vec<String> {
1072 let lines: Vec<&str> = text.lines().collect();
1073 let total_lines = lines.len();
1074
1075 let (start, end) = match range {
1076 Some(r) => {
1077 let start = r.start.saturating_sub(1).min(total_lines);
1078 let end = r.end.map(|e| e.min(total_lines)).unwrap_or(total_lines);
1079 (start, end)
1080 }
1081 None => (0, total_lines),
1082 };
1083
1084 let line_width = (end.max(1)).to_string().len();
1085
1086 lines[start..end]
1087 .iter()
1088 .enumerate()
1089 .map(|(i, line)| {
1090 let line_num = start + i + 1;
1091 format!("{:>width$} | {}", line_num, line, width = line_width)
1092 })
1093 .collect()
1094 }
1095
1096 fn build_text_analysis_output(
1098 &self,
1099 file_info: &TextFileInfo,
1100 formatted_content: &[String],
1101 range: Option<LineRange>,
1102 ) -> Vec<String> {
1103 let mut output = Vec::new();
1104
1105 output.push(format!(
1107 "[Enhanced Text Analysis: {}]",
1108 file_info.path.display()
1109 ));
1110 output.push(format!(
1111 "File type: {} ({})",
1112 file_info.file_category, file_info.extension
1113 ));
1114 if let Some(lang) = &file_info.language {
1115 output.push(format!("Programming language: {}", lang));
1116 }
1117 output.push(format!(
1118 "Size: {} KB ({} bytes)",
1119 (file_info.size_bytes as f64 / 1024.0).round() as u64,
1120 file_info.size_bytes
1121 ));
1122
1123 let (start, end) = self.get_display_range(file_info.total_lines, range);
1125 output.push(format!(
1126 "Lines: {} total, showing {}-{}",
1127 file_info.total_lines, start, end
1128 ));
1129 output.push(String::new());
1130
1131 self.add_text_analysis_capabilities(&mut output, &file_info.file_category);
1133
1134 output.push("File Content:".to_string());
1136 output.extend_from_slice(formatted_content);
1137
1138 output
1139 }
1140
1141 fn get_display_range(&self, total_lines: usize, range: Option<LineRange>) -> (usize, usize) {
1143 match range {
1144 Some(r) => {
1145 let start = r.start.min(total_lines + 1);
1146 let end = r.end.map(|e| e.min(total_lines)).unwrap_or(total_lines);
1147 (start, end)
1148 }
1149 None => (1, total_lines),
1150 }
1151 }
1152
1153 fn add_text_analysis_capabilities(&self, output: &mut Vec<String>, file_category: &str) {
1155 output.push("AI Analysis Capabilities:".to_string());
1156 match file_category {
1157 "Source Code" => {
1158 output.push("- Code structure and syntax analysis".to_string());
1159 output.push("- Function and class identification".to_string());
1160 output.push("- Code quality and best practices review".to_string());
1161 output.push("- Bug detection and security analysis".to_string());
1162 output.push("- Documentation and comment analysis".to_string());
1163 }
1164 "Configuration" => {
1165 output.push("- Configuration structure analysis".to_string());
1166 output.push("- Setting validation and optimization".to_string());
1167 output.push("- Dependency and version management".to_string());
1168 output.push("- Security configuration review".to_string());
1169 }
1170 "Documentation" => {
1171 output.push("- Content structure and organization".to_string());
1172 output.push("- Writing quality and clarity analysis".to_string());
1173 output.push("- Link and reference validation".to_string());
1174 output.push("- Documentation completeness review".to_string());
1175 }
1176 _ => {
1177 output.push("- Content analysis and understanding".to_string());
1178 output.push("- Structure and format recognition".to_string());
1179 output.push("- Data extraction and processing".to_string());
1180 output.push("- Pattern recognition and insights".to_string());
1181 }
1182 }
1183 output.push(String::new());
1184 }
1185
1186 fn detect_programming_language(&self, extension: &str, content: &str) -> Option<String> {
1188 match extension {
1189 "rs" => Some("Rust".to_string()),
1190 "py" => Some("Python".to_string()),
1191 "js" => Some("JavaScript".to_string()),
1192 "ts" => Some("TypeScript".to_string()),
1193 "jsx" => Some("React JSX".to_string()),
1194 "tsx" => Some("React TSX".to_string()),
1195 "java" => Some("Java".to_string()),
1196 "c" => Some("C".to_string()),
1197 "cpp" | "cc" | "cxx" => Some("C++".to_string()),
1198 "h" | "hpp" => Some("C/C++ Header".to_string()),
1199 "go" => Some("Go".to_string()),
1200 "rb" => Some("Ruby".to_string()),
1201 "php" => Some("PHP".to_string()),
1202 "swift" => Some("Swift".to_string()),
1203 "kt" => Some("Kotlin".to_string()),
1204 "scala" => Some("Scala".to_string()),
1205 "sh" | "bash" | "zsh" => Some("Shell Script".to_string()),
1206 "sql" => Some("SQL".to_string()),
1207 "html" => Some("HTML".to_string()),
1208 "css" => Some("CSS".to_string()),
1209 "scss" | "sass" => Some("SCSS/Sass".to_string()),
1210 "xml" => Some("XML".to_string()),
1211 "json" => Some("JSON".to_string()),
1212 "yaml" | "yml" => Some("YAML".to_string()),
1213 "toml" => Some("TOML".to_string()),
1214 "md" => Some("Markdown".to_string()),
1215 _ => {
1216 if content.starts_with("#!/bin/bash") || content.starts_with("#!/bin/sh") {
1218 Some("Shell Script".to_string())
1219 } else if content.starts_with("#!/usr/bin/env python") {
1220 Some("Python".to_string())
1221 } else if content.starts_with("#!/usr/bin/env node") {
1222 Some("JavaScript".to_string())
1223 } else {
1224 None
1225 }
1226 }
1227 }
1228 }
1229
1230 fn categorize_file_type(&self, extension: &str) -> String {
1232 match extension {
1233 "rs" | "py" | "js" | "ts" | "jsx" | "tsx" | "java" | "c" | "cpp" | "cc" | "cxx"
1234 | "h" | "hpp" | "go" | "rb" | "php" | "swift" | "kt" | "scala" | "sh" | "bash"
1235 | "zsh" | "sql" => "Source Code".to_string(),
1236
1237 "json" | "yaml" | "yml" | "toml" | "xml" | "ini" | "cfg" | "conf" | "env" => {
1238 "Configuration".to_string()
1239 }
1240
1241 "md" | "txt" | "rst" | "adoc" => "Documentation".to_string(),
1242
1243 "html" | "css" | "scss" | "sass" | "less" => "Web Content".to_string(),
1244
1245 "csv" | "tsv" | "log" => "Data File".to_string(),
1246
1247 _ => "Text File".to_string(),
1248 }
1249 }
1250
1251 pub fn is_text_file(path: &Path) -> bool {
1253 match path.extension().and_then(|e| e.to_str()) {
1254 Some(ext) => {
1255 let ext_lower = ext.to_lowercase();
1256 if TEXT_EXTENSIONS.contains(&ext_lower.as_str()) {
1260 true
1261 } else if IMAGE_EXTENSIONS.contains(&ext_lower.as_str())
1262 || ext_lower == "pdf"
1263 || ext_lower == "ipynb"
1264 || ext_lower == "svg"
1265 {
1266 false
1267 } else {
1268 true }
1270 }
1271 None => true, }
1273 }
1274
1275 pub fn is_svg_file(path: &Path) -> bool {
1277 path.extension()
1278 .and_then(|e| e.to_str())
1279 .map(|e| e.to_lowercase() == "svg")
1280 .unwrap_or(false)
1281 }
1282}
1283
1284#[cfg(test)]
1289mod tests {
1290 use super::*;
1291 use std::io::Write;
1292 use tempfile::TempDir;
1293
1294 fn create_test_context(dir: &Path) -> ToolContext {
1295 ToolContext::new(dir.to_path_buf())
1296 .with_session_id("test-session")
1297 .with_user("test-user")
1298 }
1299
1300 fn create_read_tool() -> ReadTool {
1301 ReadTool::new(super::super::create_shared_history())
1302 }
1303
1304 #[test]
1305 fn test_line_range_new() {
1306 let range = LineRange::new(5, Some(10));
1307 assert_eq!(range.start, 5);
1308 assert_eq!(range.end, Some(10));
1309 }
1310
1311 #[test]
1312 fn test_line_range_from_start() {
1313 let range = LineRange::from_start(5);
1314 assert_eq!(range.start, 5);
1315 assert_eq!(range.end, None);
1316 }
1317
1318 #[test]
1319 fn test_line_range_lines() {
1320 let range = LineRange::lines(5, 10);
1321 assert_eq!(range.start, 5);
1322 assert_eq!(range.end, Some(14));
1323 }
1324
1325 #[test]
1326 fn test_is_image_file() {
1327 assert!(ReadTool::is_image_file(Path::new("test.png")));
1328 assert!(ReadTool::is_image_file(Path::new("test.jpg")));
1329 assert!(ReadTool::is_image_file(Path::new("test.JPEG")));
1330 assert!(ReadTool::is_image_file(Path::new("test.gif")));
1331 assert!(!ReadTool::is_image_file(Path::new("test.txt")));
1332 assert!(!ReadTool::is_image_file(Path::new("test.rs")));
1333 }
1334
1335 #[test]
1336 fn test_is_pdf_file() {
1337 assert!(ReadTool::is_pdf_file(Path::new("test.pdf")));
1338 assert!(ReadTool::is_pdf_file(Path::new("test.PDF")));
1339 assert!(!ReadTool::is_pdf_file(Path::new("test.txt")));
1340 }
1341
1342 #[test]
1343 fn test_is_notebook_file() {
1344 assert!(ReadTool::is_notebook_file(Path::new("test.ipynb")));
1345 assert!(ReadTool::is_notebook_file(Path::new("test.IPYNB")));
1346 assert!(!ReadTool::is_notebook_file(Path::new("test.py")));
1347 }
1348
1349 #[test]
1350 fn test_is_text_file() {
1351 assert!(ReadTool::is_text_file(Path::new("test.txt")));
1352 assert!(ReadTool::is_text_file(Path::new("test.rs")));
1353 assert!(ReadTool::is_text_file(Path::new("test.py")));
1354 assert!(ReadTool::is_text_file(Path::new("test.json")));
1355 assert!(ReadTool::is_text_file(Path::new("test.unknown")));
1357 }
1358
1359 #[tokio::test]
1360 async fn test_read_text_file() {
1361 let temp_dir = TempDir::new().unwrap();
1362 let file_path = temp_dir.path().join("test.txt");
1363
1364 let mut file = fs::File::create(&file_path).unwrap();
1366 writeln!(file, "Line 1").unwrap();
1367 writeln!(file, "Line 2").unwrap();
1368 writeln!(file, "Line 3").unwrap();
1369
1370 let tool = create_read_tool();
1371 let context = create_test_context(temp_dir.path());
1372
1373 let result = tool.read_text(&file_path, None, &context).await.unwrap();
1374
1375 assert!(result.contains("1 | Line 1"));
1376 assert!(result.contains("2 | Line 2"));
1377 assert!(result.contains("3 | Line 3"));
1378
1379 assert!(tool.read_history.read().unwrap().has_read(&file_path));
1381 }
1382
1383 #[tokio::test]
1384 async fn test_read_text_file_with_range() {
1385 let temp_dir = TempDir::new().unwrap();
1386 let file_path = temp_dir.path().join("test.txt");
1387
1388 let mut file = fs::File::create(&file_path).unwrap();
1390 for i in 1..=10 {
1391 writeln!(file, "Line {}", i).unwrap();
1392 }
1393
1394 let tool = create_read_tool();
1395 let context = create_test_context(temp_dir.path());
1396
1397 let range = LineRange::new(3, Some(5));
1398 let result = tool
1399 .read_text(&file_path, Some(range), &context)
1400 .await
1401 .unwrap();
1402
1403 assert!(result.contains("3 | Line 3"));
1404 assert!(result.contains("4 | Line 4"));
1405 assert!(result.contains("5 | Line 5"));
1406 assert!(!result.contains("Line 1"));
1407 assert!(!result.contains("Line 6"));
1408 }
1409
1410 #[tokio::test]
1411 async fn test_read_nonexistent_file() {
1412 let temp_dir = TempDir::new().unwrap();
1413 let file_path = temp_dir.path().join("nonexistent.txt");
1414
1415 let tool = create_read_tool();
1416 let context = create_test_context(temp_dir.path());
1417
1418 let result = tool.read_text(&file_path, None, &context).await;
1419 assert!(result.is_err());
1420 }
1421
1422 #[tokio::test]
1423 async fn test_read_image_file() {
1424 let temp_dir = TempDir::new().unwrap();
1425 let file_path = temp_dir.path().join("test.png");
1426
1427 let png_data: Vec<u8> = vec![
1429 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F,
1432 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00, 0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D,
1434 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
1436 ];
1437 fs::write(&file_path, &png_data).unwrap();
1438
1439 let tool = create_read_tool();
1440 let context = create_test_context(temp_dir.path());
1441
1442 let result = tool.read_image(&file_path, &context).await.unwrap();
1443
1444 assert!(result.contains("[Enhanced Image Analysis:"));
1446 assert!(result.contains("Base64 Data: data:image/png;base64,"));
1447 assert!(tool.read_history.read().unwrap().has_read(&file_path));
1448 }
1449
1450 #[tokio::test]
1451 async fn test_read_notebook_file() {
1452 let temp_dir = TempDir::new().unwrap();
1453 let file_path = temp_dir.path().join("test.ipynb");
1454
1455 let notebook = serde_json::json!({
1457 "cells": [
1458 {
1459 "cell_type": "code",
1460 "source": ["print('Hello')"],
1461 "outputs": []
1462 },
1463 {
1464 "cell_type": "markdown",
1465 "source": ["# Title"]
1466 }
1467 ],
1468 "metadata": {},
1469 "nbformat": 4,
1470 "nbformat_minor": 2
1471 });
1472 fs::write(&file_path, serde_json::to_string(¬ebook).unwrap()).unwrap();
1473
1474 let tool = create_read_tool();
1475 let context = create_test_context(temp_dir.path());
1476
1477 let result = tool.read_notebook(&file_path, &context).await.unwrap();
1478
1479 assert!(result.contains("[Enhanced Notebook Analysis:"));
1481 assert!(result.contains("Cell 1 [Code Cell]"));
1482 assert!(result.contains("print('Hello')"));
1483 assert!(result.contains("Cell 2 [Markdown Cell]"));
1484 assert!(result.contains("# Title"));
1485 }
1486
1487 #[tokio::test]
1488 async fn test_tool_execute_text() {
1489 let temp_dir = TempDir::new().unwrap();
1490 let file_path = temp_dir.path().join("test.txt");
1491 fs::write(&file_path, "Hello, World!").unwrap();
1492
1493 let tool = create_read_tool();
1494 let context = create_test_context(temp_dir.path());
1495 let params = serde_json::json!({
1496 "path": file_path.to_str().unwrap()
1497 });
1498
1499 let result = tool.execute(params, &context).await.unwrap();
1500
1501 assert!(result.is_success());
1502 assert!(result.output.unwrap().contains("Hello, World!"));
1503 assert_eq!(
1504 result.metadata.get("file_type"),
1505 Some(&serde_json::json!("text"))
1506 );
1507 }
1508
1509 #[tokio::test]
1510 async fn test_tool_execute_with_line_range() {
1511 let temp_dir = TempDir::new().unwrap();
1512 let file_path = temp_dir.path().join("test.txt");
1513
1514 let mut file = fs::File::create(&file_path).unwrap();
1515 for i in 1..=10 {
1516 writeln!(file, "Line {}", i).unwrap();
1517 }
1518
1519 let tool = create_read_tool();
1520 let context = create_test_context(temp_dir.path());
1521 let params = serde_json::json!({
1522 "path": file_path.to_str().unwrap(),
1523 "start_line": 2,
1524 "end_line": 4
1525 });
1526
1527 let result = tool.execute(params, &context).await.unwrap();
1528
1529 assert!(result.is_success());
1530 let output = result.output.unwrap();
1531 assert!(output.contains("Line 2"));
1532 assert!(output.contains("Line 3"));
1533 assert!(output.contains("Line 4"));
1534 assert!(!output.contains("Line 1"));
1535 assert!(!output.contains("Line 5"));
1536 }
1537
1538 #[tokio::test]
1539 async fn test_tool_execute_missing_path() {
1540 let temp_dir = TempDir::new().unwrap();
1541 let tool = create_read_tool();
1542 let context = create_test_context(temp_dir.path());
1543 let params = serde_json::json!({});
1544
1545 let result = tool.execute(params, &context).await;
1546 assert!(result.is_err());
1547 assert!(matches!(result.unwrap_err(), ToolError::InvalidParams(_)));
1548 }
1549
1550 #[test]
1551 fn test_tool_name() {
1552 let tool = create_read_tool();
1553 assert_eq!(tool.name(), "read");
1554 }
1555
1556 #[test]
1557 fn test_tool_description() {
1558 let tool = create_read_tool();
1559 assert!(!tool.description().is_empty());
1560 assert!(
1561 tool.description().contains("Enhanced") || tool.description().contains("multimodal")
1562 );
1563 }
1564
1565 #[test]
1566 fn test_tool_input_schema() {
1567 let tool = create_read_tool();
1568 let schema = tool.input_schema();
1569 assert_eq!(schema["type"], "object");
1570 assert!(schema["properties"]["path"].is_object());
1571 assert!(schema["properties"]["start_line"].is_object());
1572 assert!(schema["properties"]["end_line"].is_object());
1573 }
1574
1575 #[tokio::test]
1576 async fn test_check_permissions() {
1577 let temp_dir = TempDir::new().unwrap();
1578 let tool = create_read_tool();
1579 let context = create_test_context(temp_dir.path());
1580 let params = serde_json::json!({
1581 "path": "test.txt"
1582 });
1583
1584 let result = tool.check_permissions(¶ms, &context).await;
1585 assert!(result.is_allowed());
1586 }
1587
1588 #[tokio::test]
1589 async fn test_check_permissions_missing_path() {
1590 let temp_dir = TempDir::new().unwrap();
1591 let tool = create_read_tool();
1592 let context = create_test_context(temp_dir.path());
1593 let params = serde_json::json!({});
1594
1595 let result = tool.check_permissions(¶ms, &context).await;
1596 assert!(result.is_denied());
1597 }
1598}