Skip to main content

mixtape_tools/filesystem/
read_multiple_files.rs

1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use futures::stream::{self, StreamExt};
4use std::path::PathBuf;
5
6/// Result for a single file read operation
7#[derive(Debug, Serialize, JsonSchema)]
8pub struct FileReadResult {
9    /// Path that was attempted
10    pub path: String,
11    /// Content of the file if successful
12    pub content: Option<String>,
13    /// Error message if failed
14    pub error: Option<String>,
15}
16
17/// Input for reading multiple files
18#[derive(Debug, Deserialize, JsonSchema)]
19pub struct ReadMultipleFilesInput {
20    /// List of file paths to read (relative to base path)
21    pub paths: Vec<PathBuf>,
22
23    /// Maximum number of files to read concurrently (default: 10)
24    #[serde(default = "default_concurrency")]
25    pub concurrency: usize,
26}
27
28fn default_concurrency() -> usize {
29    10
30}
31
32/// Tool for reading multiple files concurrently
33pub struct ReadMultipleFilesTool {
34    base_path: PathBuf,
35}
36
37impl Default for ReadMultipleFilesTool {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43impl ReadMultipleFilesTool {
44    /// Creates a new tool using the current working directory as the base path.
45    ///
46    /// Equivalent to `Default::default()`.
47    ///
48    /// # Panics
49    ///
50    /// Panics if the current working directory cannot be determined.
51    /// Use [`try_new`](Self::try_new) or [`with_base_path`](Self::with_base_path) instead.
52    pub fn new() -> Self {
53        Self {
54            base_path: std::env::current_dir().expect("Failed to get current working directory"),
55        }
56    }
57
58    /// Creates a new tool using the current working directory as the base path.
59    ///
60    /// Returns an error if the current working directory cannot be determined.
61    pub fn try_new() -> std::io::Result<Self> {
62        Ok(Self {
63            base_path: std::env::current_dir()?,
64        })
65    }
66
67    /// Creates a tool with a custom base directory.
68    ///
69    /// All file operations will be constrained to this directory.
70    pub fn with_base_path(base_path: PathBuf) -> Self {
71        Self { base_path }
72    }
73
74    async fn read_single_file(&self, path: PathBuf) -> FileReadResult {
75        let path_str = path.display().to_string();
76
77        match validate_path(&self.base_path, &path) {
78            Ok(validated_path) => match tokio::fs::read_to_string(&validated_path).await {
79                Ok(content) => FileReadResult {
80                    path: path_str,
81                    content: Some(content),
82                    error: None,
83                },
84                Err(e) => FileReadResult {
85                    path: path_str,
86                    content: None,
87                    error: Some(format!("Failed to read file: {}", e)),
88                },
89            },
90            Err(e) => FileReadResult {
91                path: path_str,
92                content: None,
93                error: Some(e.to_string()),
94            },
95        }
96    }
97}
98
99impl Tool for ReadMultipleFilesTool {
100    type Input = ReadMultipleFilesInput;
101
102    fn name(&self) -> &str {
103        "read_multiple_files"
104    }
105
106    fn description(&self) -> &str {
107        "Read multiple files concurrently. Returns results for all files, including errors for files that couldn't be read."
108    }
109
110    fn format_output_plain(&self, result: &ToolResult) -> String {
111        let output = result.as_text();
112        let lines: Vec<&str> = output.lines().collect();
113        if lines.is_empty() {
114            return output.to_string();
115        }
116
117        let mut out = String::new();
118        if let Some(header) = lines.first() {
119            if header.starts_with("Read ") {
120                out.push_str(&"─".repeat(50));
121                out.push_str(&format!("\n  {}\n", header));
122                out.push_str(&"─".repeat(50));
123                out.push('\n');
124            }
125        }
126
127        let mut in_file = false;
128        for line in lines.iter().skip(1) {
129            if let Some(path) = line.strip_prefix("✓ ") {
130                if in_file {
131                    out.push('\n');
132                }
133                out.push_str(&format!("[OK] {}\n", path));
134                in_file = true;
135            } else if let Some(path) = line.strip_prefix("✗ ") {
136                if in_file {
137                    out.push('\n');
138                }
139                out.push_str(&format!("[ERR] {}\n", path));
140                in_file = true;
141            } else if line.starts_with("Error:") {
142                out.push_str(&format!("      {}\n", line));
143            } else if !line.is_empty() {
144                out.push_str(&format!("      │ {}\n", line));
145            }
146        }
147        out
148    }
149
150    fn format_output_ansi(&self, result: &ToolResult) -> String {
151        let output = result.as_text();
152        let lines: Vec<&str> = output.lines().collect();
153        if lines.is_empty() {
154            return output.to_string();
155        }
156
157        let mut out = String::new();
158        if let Some(header) = lines.first() {
159            if header.starts_with("Read ") {
160                let mut success = 0;
161                let mut failed = 0;
162                if let Some(paren_start) = header.find('(') {
163                    let stats = &header[paren_start..];
164                    if let Some(s) = stats.split_whitespace().next() {
165                        success = s.trim_start_matches('(').parse().unwrap_or(0);
166                    }
167                    if let Some(f_idx) = stats.find("failed") {
168                        if let Some(num) = stats[..f_idx].split_whitespace().last() {
169                            failed = num.parse().unwrap_or(0);
170                        }
171                    }
172                }
173
174                out.push_str(&format!(
175                    "\x1b[2m{}\x1b[0m\n  \x1b[1mFiles Read\x1b[0m  ",
176                    "─".repeat(50)
177                ));
178                if success > 0 {
179                    out.push_str(&format!("\x1b[32m● {} ok\x1b[0m  ", success));
180                }
181                if failed > 0 {
182                    out.push_str(&format!("\x1b[31m● {} failed\x1b[0m", failed));
183                }
184                out.push_str(&format!("\n\x1b[2m{}\x1b[0m\n", "─".repeat(50)));
185            }
186        }
187
188        let mut in_file = false;
189        for line in lines.iter().skip(1) {
190            if let Some(path) = line.strip_prefix("✓ ") {
191                if in_file {
192                    out.push('\n');
193                }
194                out.push_str(&format!("\x1b[32m●\x1b[0m \x1b[36m{}\x1b[0m\n", path));
195                in_file = true;
196            } else if let Some(path) = line.strip_prefix("✗ ") {
197                if in_file {
198                    out.push('\n');
199                }
200                out.push_str(&format!("\x1b[31m●\x1b[0m \x1b[36m{}\x1b[0m\n", path));
201                in_file = true;
202            } else if line.starts_with("Error:") {
203                out.push_str(&format!("  \x1b[31m{}\x1b[0m\n", line));
204            } else if !line.is_empty() {
205                out.push_str(&format!("  \x1b[2m│\x1b[0m {}\n", line));
206            }
207        }
208        out
209    }
210
211    fn format_output_markdown(&self, result: &ToolResult) -> String {
212        let output = result.as_text();
213        let lines: Vec<&str> = output.lines().collect();
214        if lines.is_empty() {
215            return output.to_string();
216        }
217
218        let mut out = String::new();
219        if let Some(header) = lines.first() {
220            if header.starts_with("Read ") {
221                out.push_str(&format!("### {}\n\n", header));
222            }
223        }
224
225        let mut current_file: Option<&str> = None;
226        let mut content_lines: Vec<&str> = Vec::new();
227
228        for line in lines.iter().skip(1) {
229            let (is_file_line, is_success, path) = if let Some(p) = line.strip_prefix("✓ ") {
230                (true, true, p)
231            } else if let Some(p) = line.strip_prefix("✗ ") {
232                (true, false, p)
233            } else {
234                (false, false, "")
235            };
236
237            if is_file_line {
238                if current_file.is_some() {
239                    if !content_lines.is_empty() {
240                        out.push_str(&format!("```\n{}\n```\n\n", content_lines.join("\n")));
241                        content_lines.clear();
242                    } else {
243                        out.push('\n');
244                    }
245                }
246                out.push_str(&format!(
247                    "{} `{}`\n",
248                    if is_success { "✅" } else { "❌" },
249                    path
250                ));
251                current_file = Some(path);
252            } else if line.starts_with("Error:") {
253                out.push_str(&format!("> ⚠️ {}\n", line));
254            } else if !line.is_empty() {
255                content_lines.push(line);
256            }
257        }
258
259        if current_file.is_some() && !content_lines.is_empty() {
260            out.push_str(&format!("```\n{}\n```\n", content_lines.join("\n")));
261        }
262        out
263    }
264
265    async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
266        let concurrency = input.concurrency.min(50); // Cap at 50 to prevent resource exhaustion
267
268        let results: Vec<FileReadResult> = stream::iter(input.paths)
269            .map(|path| self.read_single_file(path))
270            .buffer_unordered(concurrency)
271            .collect()
272            .await;
273
274        let total = results.len();
275        let successful = results.iter().filter(|r| r.content.is_some()).count();
276        let failed = total - successful;
277
278        let mut content = format!(
279            "Read {} files ({} successful, {} failed):\n\n",
280            total, successful, failed
281        );
282
283        for result in &results {
284            match (&result.content, &result.error) {
285                (Some(file_content), None) => {
286                    let preview = if file_content.len() > 200 {
287                        format!(
288                            "{}... ({} bytes total)",
289                            &file_content[..200],
290                            file_content.len()
291                        )
292                    } else {
293                        file_content.clone()
294                    };
295                    content.push_str(&format!("✓ {}\n{}\n\n", result.path, preview));
296                }
297                (None, Some(error)) => {
298                    content.push_str(&format!("✗ {}\nError: {}\n\n", result.path, error));
299                }
300                _ => unreachable!(),
301            }
302        }
303
304        Ok(content.into())
305    }
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use std::fs;
312    use tempfile::TempDir;
313
314    #[test]
315    fn test_format_methods() {
316        let tool = ReadMultipleFilesTool::new();
317        let params = serde_json::json!({"paths": ["file1.txt", "file2.txt"]});
318
319        // All format methods should return non-empty strings
320        assert!(!tool.format_input_plain(&params).is_empty());
321        assert!(!tool.format_input_ansi(&params).is_empty());
322        assert!(!tool.format_input_markdown(&params).is_empty());
323
324        let result = ToolResult::from("Read 2 files");
325        assert!(!tool.format_output_plain(&result).is_empty());
326        assert!(!tool.format_output_ansi(&result).is_empty());
327        assert!(!tool.format_output_markdown(&result).is_empty());
328    }
329
330    // ===== Execution Tests =====
331
332    #[tokio::test]
333    async fn test_read_multiple_files() {
334        let temp_dir = TempDir::new().unwrap();
335        fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
336        fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();
337        fs::write(temp_dir.path().join("file3.txt"), "content3").unwrap();
338
339        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
340        let input = ReadMultipleFilesInput {
341            paths: vec![
342                PathBuf::from("file1.txt"),
343                PathBuf::from("file2.txt"),
344                PathBuf::from("file3.txt"),
345            ],
346            concurrency: 10,
347        };
348
349        let result = tool.execute(input).await.unwrap();
350        assert!(result.as_text().contains("3 successful, 0 failed"));
351        assert!(result.as_text().contains("content1"));
352        assert!(result.as_text().contains("content2"));
353        assert!(result.as_text().contains("content3"));
354    }
355
356    #[test]
357    fn test_tool_metadata() {
358        let tool: ReadMultipleFilesTool = Default::default();
359        assert_eq!(tool.name(), "read_multiple_files");
360        assert!(!tool.description().is_empty());
361
362        let tool2 = ReadMultipleFilesTool::new();
363        assert_eq!(tool2.name(), "read_multiple_files");
364    }
365
366    #[test]
367    fn test_try_new() {
368        let tool = ReadMultipleFilesTool::try_new();
369        assert!(tool.is_ok());
370    }
371
372    #[test]
373    fn test_default_concurrency() {
374        // Deserialize without specifying concurrency to trigger default_concurrency()
375        let input: ReadMultipleFilesInput = serde_json::from_value(serde_json::json!({
376            "paths": ["file.txt"]
377        }))
378        .unwrap();
379
380        assert_eq!(input.concurrency, 10, "Default concurrency should be 10");
381    }
382
383    #[tokio::test]
384    async fn test_read_multiple_files_with_errors() {
385        let temp_dir = TempDir::new().unwrap();
386        fs::write(temp_dir.path().join("exists.txt"), "content").unwrap();
387
388        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
389        let input = ReadMultipleFilesInput {
390            paths: vec![PathBuf::from("exists.txt"), PathBuf::from("missing.txt")],
391            concurrency: 10,
392        };
393
394        let result = tool.execute(input).await.unwrap();
395        assert!(result.as_text().contains("1 successful, 1 failed"));
396        assert!(result.as_text().contains("content"));
397        assert!(result.as_text().contains("✗ missing.txt"));
398    }
399
400    // ===== Coverage Gap Tests =====
401
402    #[tokio::test]
403    async fn test_concurrency_capped_at_50() {
404        // Test that concurrency is capped at 50 to prevent resource exhaustion
405        // even when a much larger value is requested
406        let temp_dir = TempDir::new().unwrap();
407
408        // Create 100 small files
409        for i in 0..100 {
410            fs::write(temp_dir.path().join(format!("file{}.txt", i)), "content").unwrap();
411        }
412
413        let paths: Vec<PathBuf> = (0..100)
414            .map(|i| PathBuf::from(format!("file{}.txt", i)))
415            .collect();
416
417        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
418        let input = ReadMultipleFilesInput {
419            paths,
420            concurrency: 10000, // Request absurdly high concurrency
421        };
422
423        // Should complete successfully without panicking or exhausting resources
424        let result = tool.execute(input).await.unwrap();
425        assert!(result.as_text().contains("100 successful, 0 failed"));
426    }
427
428    #[tokio::test]
429    async fn test_large_file_content_truncation() {
430        // Test that file content longer than 200 characters is truncated
431        // in the output with a byte count indicator
432        let temp_dir = TempDir::new().unwrap();
433        let large_content = "x".repeat(500);
434        fs::write(temp_dir.path().join("large.txt"), &large_content).unwrap();
435
436        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
437        let input = ReadMultipleFilesInput {
438            paths: vec![PathBuf::from("large.txt")],
439            concurrency: 10,
440        };
441
442        let result = tool.execute(input).await.unwrap();
443        let text = result.as_text();
444
445        // Should show truncation marker and total byte count
446        assert!(text.contains("... (500 bytes total)"));
447
448        // Verify content is actually truncated (contains first 200 chars but not beyond)
449        assert!(text.contains(&"x".repeat(200)));
450        assert!(!text.contains(&"x".repeat(300)));
451    }
452
453    #[tokio::test]
454    async fn test_path_validation_errors_reported() {
455        // Test that path validation failures (directory traversal attempts)
456        // are properly caught and reported in the results
457        let temp_dir = TempDir::new().unwrap();
458
459        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
460        let input = ReadMultipleFilesInput {
461            paths: vec![
462                PathBuf::from("../../etc/passwd"),
463                PathBuf::from("../../../secret.txt"),
464            ],
465            concurrency: 10,
466        };
467
468        let result = tool.execute(input).await.unwrap();
469        let text = result.as_text();
470
471        // Both paths should fail validation
472        assert!(text.contains("0 successful, 2 failed"));
473        assert!(text.contains("✗ ../../etc/passwd"));
474        assert!(text.contains("✗ ../../../secret.txt"));
475
476        // Error messages should indicate path validation failure
477        assert!(text.contains("escapes") || text.contains("Path"));
478    }
479
480    #[tokio::test]
481    async fn test_empty_file_list() {
482        // Test edge case of reading zero files - should not panic or produce
483        // invalid output
484        let temp_dir = TempDir::new().unwrap();
485
486        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
487        let input = ReadMultipleFilesInput {
488            paths: vec![],
489            concurrency: 10,
490        };
491
492        let result = tool.execute(input).await.unwrap();
493        let text = result.as_text();
494
495        assert!(text.contains("Read 0 files (0 successful, 0 failed)"));
496    }
497
498    #[tokio::test]
499    async fn test_formatter_handles_mixed_results() {
500        // Test that formatters correctly handle and display both successful
501        // and failed file reads with appropriate visual indicators
502        let temp_dir = TempDir::new().unwrap();
503        fs::write(temp_dir.path().join("exists.txt"), "content").unwrap();
504
505        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
506        let input = ReadMultipleFilesInput {
507            paths: vec![PathBuf::from("exists.txt"), PathBuf::from("missing.txt")],
508            concurrency: 10,
509        };
510
511        let result = tool.execute(input).await.unwrap();
512
513        // Test ANSI formatter includes color codes and stats
514        let ansi = tool.format_output_ansi(&result);
515        assert!(ansi.contains("\x1b[32m")); // Green for success
516        assert!(ansi.contains("\x1b[31m")); // Red for failure
517        assert!(ansi.contains("1 ok"));
518        assert!(ansi.contains("1 failed"));
519
520        // Test Markdown formatter uses appropriate emoji and code blocks
521        let markdown = tool.format_output_markdown(&result);
522        assert!(markdown.contains("✅"));
523        assert!(markdown.contains("❌"));
524        assert!(markdown.contains("```"));
525
526        // Test plain formatter
527        let plain = tool.format_output_plain(&result);
528        assert!(plain.contains("[OK]"));
529        assert!(plain.contains("[ERR]"));
530    }
531
532    #[tokio::test]
533    #[cfg(unix)]
534    async fn test_symlink_inside_base() {
535        // Test that symlinks pointing to files within the base directory
536        // are allowed and properly dereferenced
537        let temp_dir = TempDir::new().unwrap();
538        let real_file = temp_dir.path().join("real.txt");
539        let symlink = temp_dir.path().join("link.txt");
540
541        fs::write(&real_file, "real content").unwrap();
542        std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
543
544        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
545        let input = ReadMultipleFilesInput {
546            paths: vec![PathBuf::from("link.txt")],
547            concurrency: 10,
548        };
549
550        let result = tool.execute(input).await.unwrap();
551        let text = result.as_text();
552
553        assert!(text.contains("1 successful, 0 failed"));
554        assert!(text.contains("real content"));
555    }
556
557    #[tokio::test]
558    #[cfg(unix)]
559    async fn test_symlink_escaping_base_rejected() {
560        // Test that symlinks pointing outside the base directory are rejected
561        // for security reasons
562        let temp_dir = TempDir::new().unwrap();
563        let outside_dir = TempDir::new().unwrap();
564        let outside_file = outside_dir.path().join("secret.txt");
565        fs::write(&outside_file, "secret").unwrap();
566
567        let symlink = temp_dir.path().join("escape_link.txt");
568        std::os::unix::fs::symlink(&outside_file, &symlink).unwrap();
569
570        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
571        let input = ReadMultipleFilesInput {
572            paths: vec![PathBuf::from("escape_link.txt")],
573            concurrency: 10,
574        };
575
576        let result = tool.execute(input).await.unwrap();
577        let text = result.as_text();
578
579        // Should fail validation
580        assert!(text.contains("0 successful, 1 failed"));
581        assert!(text.contains("✗ escape_link.txt"));
582        assert!(text.contains("escapes"));
583    }
584
585    #[tokio::test]
586    async fn test_relative_path_with_dots() {
587        // Test that paths with . and .. components are properly validated
588        let temp_dir = TempDir::new().unwrap();
589        fs::create_dir(temp_dir.path().join("subdir")).unwrap();
590        fs::write(temp_dir.path().join("subdir/file.txt"), "content").unwrap();
591
592        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
593        let input = ReadMultipleFilesInput {
594            paths: vec![PathBuf::from("./subdir/../subdir/./file.txt")],
595            concurrency: 10,
596        };
597
598        let result = tool.execute(input).await.unwrap();
599        assert!(result.as_text().contains("1 successful, 0 failed"));
600        assert!(result.as_text().contains("content"));
601    }
602
603    #[tokio::test]
604    async fn test_batch_read_with_permission_errors() {
605        // Test handling of permission denied errors during batch reads
606        // Note: This test is platform-specific and may behave differently on Windows
607        #[cfg(unix)]
608        {
609            let temp_dir = TempDir::new().unwrap();
610            let unreadable = temp_dir.path().join("unreadable.txt");
611            fs::write(&unreadable, "secret").unwrap();
612
613            // Remove read permissions
614            let mut perms = fs::metadata(&unreadable).unwrap().permissions();
615            use std::os::unix::fs::PermissionsExt;
616            perms.set_mode(0o000);
617            fs::set_permissions(&unreadable, perms).unwrap();
618
619            let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
620            let input = ReadMultipleFilesInput {
621                paths: vec![PathBuf::from("unreadable.txt")],
622                concurrency: 10,
623            };
624
625            let result = tool.execute(input).await.unwrap();
626            let text = result.as_text();
627
628            // Should fail to read with permission error
629            assert!(text.contains("0 successful, 1 failed"));
630            assert!(text.contains("✗ unreadable.txt"));
631            assert!(text.contains("Failed to read file") || text.contains("Permission denied"));
632
633            // Clean up: restore permissions so temp_dir can be deleted
634            let mut perms = fs::metadata(&unreadable).unwrap().permissions();
635            perms.set_mode(0o644);
636            fs::set_permissions(&unreadable, perms).unwrap();
637        }
638    }
639
640    #[tokio::test]
641    async fn test_mixed_success_and_validation_errors() {
642        // Test that both successful reads and validation errors can occur
643        // in the same batch
644        let temp_dir = TempDir::new().unwrap();
645        fs::write(temp_dir.path().join("good1.txt"), "content1").unwrap();
646        fs::write(temp_dir.path().join("good2.txt"), "content2").unwrap();
647
648        let tool = ReadMultipleFilesTool::with_base_path(temp_dir.path().to_path_buf());
649        let input = ReadMultipleFilesInput {
650            paths: vec![
651                PathBuf::from("good1.txt"),
652                PathBuf::from("../../etc/passwd"),
653                PathBuf::from("good2.txt"),
654                PathBuf::from("missing.txt"),
655            ],
656            concurrency: 10,
657        };
658
659        let result = tool.execute(input).await.unwrap();
660        let text = result.as_text();
661
662        assert!(text.contains("2 successful, 2 failed"));
663        assert!(text.contains("✓ good1.txt"));
664        assert!(text.contains("✓ good2.txt"));
665        assert!(text.contains("✗ ../../etc/passwd"));
666        assert!(text.contains("✗ missing.txt"));
667    }
668}