mixtape_tools/filesystem/
read_multiple_files.rs1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use futures::stream::{self, StreamExt};
4use std::path::PathBuf;
5
6#[derive(Debug, Serialize, JsonSchema)]
8pub struct FileReadResult {
9 pub path: String,
11 pub content: Option<String>,
13 pub error: Option<String>,
15}
16
17#[derive(Debug, Deserialize, JsonSchema)]
19pub struct ReadMultipleFilesInput {
20 pub paths: Vec<PathBuf>,
22
23 #[serde(default = "default_concurrency")]
25 pub concurrency: usize,
26}
27
28fn default_concurrency() -> usize {
29 10
30}
31
32pub 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 pub fn new() -> Self {
53 Self {
54 base_path: std::env::current_dir().expect("Failed to get current working directory"),
55 }
56 }
57
58 pub fn try_new() -> std::io::Result<Self> {
62 Ok(Self {
63 base_path: std::env::current_dir()?,
64 })
65 }
66
67 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); 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 assert!(!tool.format_input_plain(¶ms).is_empty());
321 assert!(!tool.format_input_ansi(¶ms).is_empty());
322 assert!(!tool.format_input_markdown(¶ms).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 #[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 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 #[tokio::test]
403 async fn test_concurrency_capped_at_50() {
404 let temp_dir = TempDir::new().unwrap();
407
408 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, };
422
423 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 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 assert!(text.contains("... (500 bytes total)"));
447
448 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 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 assert!(text.contains("0 successful, 2 failed"));
473 assert!(text.contains("✗ ../../etc/passwd"));
474 assert!(text.contains("✗ ../../../secret.txt"));
475
476 assert!(text.contains("escapes") || text.contains("Path"));
478 }
479
480 #[tokio::test]
481 async fn test_empty_file_list() {
482 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 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 let ansi = tool.format_output_ansi(&result);
515 assert!(ansi.contains("\x1b[32m")); assert!(ansi.contains("\x1b[31m")); assert!(ansi.contains("1 ok"));
518 assert!(ansi.contains("1 failed"));
519
520 let markdown = tool.format_output_markdown(&result);
522 assert!(markdown.contains("✅"));
523 assert!(markdown.contains("❌"));
524 assert!(markdown.contains("```"));
525
526 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 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 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 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 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 #[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 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 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 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 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}