1use crate::filesystem::validate_path;
2use crate::prelude::*;
3use std::future::Future;
4use std::path::PathBuf;
5use std::pin::Pin;
6use tokio::fs;
7
8#[derive(Debug, Deserialize, JsonSchema)]
10pub struct ListDirectoryInput {
11 pub path: PathBuf,
13
14 #[serde(default = "default_depth")]
16 pub depth: usize,
17
18 #[serde(default)]
21 pub max_lines: Option<usize>,
22}
23
24fn default_depth() -> usize {
25 2
26}
27
28const HARD_MAX_LINES: usize = 10_000;
30
31#[derive(Debug)]
33struct EntryInfo {
34 name: String,
35 is_dir: bool,
36 size: Option<u64>,
37 children: Vec<EntryInfo>,
38 child_count: usize, }
40
41pub struct ListDirectoryTool {
43 base_path: PathBuf,
44}
45
46impl Default for ListDirectoryTool {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52impl ListDirectoryTool {
53 pub fn new() -> Self {
62 Self {
63 base_path: std::env::current_dir().expect("Failed to get current working directory"),
64 }
65 }
66
67 pub fn try_new() -> std::io::Result<Self> {
71 Ok(Self {
72 base_path: std::env::current_dir()?,
73 })
74 }
75
76 pub fn with_base_path(base_path: PathBuf) -> Self {
80 Self { base_path }
81 }
82
83 fn scan_directory<'a>(
85 &'a self,
86 path: PathBuf,
87 current_depth: usize,
88 max_depth: usize,
89 ) -> Pin<Box<dyn Future<Output = std::result::Result<Vec<EntryInfo>, ToolError>> + Send + 'a>>
90 {
91 Box::pin(async move {
92 let mut read_dir = fs::read_dir(&path)
93 .await
94 .map_err(|e| ToolError::from(format!("Failed to read directory: {}", e)))?;
95
96 let mut dir_entries = Vec::new();
97 while let Some(entry) = read_dir
98 .next_entry()
99 .await
100 .map_err(|e| ToolError::from(format!("Failed to read directory entry: {}", e)))?
101 {
102 dir_entries.push(entry);
103 }
104
105 dir_entries.sort_by_key(|e| e.file_name());
106
107 let mut entries = Vec::new();
108 for entry in dir_entries {
109 let file_name = entry.file_name();
110 let file_name_str = file_name.to_string_lossy().to_string();
111
112 if file_name_str == ".git" {
113 continue;
114 }
115
116 let metadata = entry
117 .metadata()
118 .await
119 .map_err(|e| ToolError::from(format!("Failed to read metadata: {}", e)))?;
120
121 if metadata.is_dir() {
122 let (children, child_count) = if current_depth < max_depth {
123 let children = self
124 .scan_directory(entry.path(), current_depth + 1, max_depth)
125 .await?;
126 let count = children.iter().map(|c| 1 + c.child_count).sum();
127 (children, count)
128 } else {
129 let mut count = 0;
131 if let Ok(mut rd) = fs::read_dir(entry.path()).await {
132 while let Ok(Some(_)) = rd.next_entry().await {
133 count += 1;
134 }
135 }
136 (vec![], count)
137 };
138
139 entries.push(EntryInfo {
140 name: file_name_str,
141 is_dir: true,
142 size: None,
143 children,
144 child_count,
145 });
146 } else {
147 entries.push(EntryInfo {
148 name: file_name_str,
149 is_dir: false,
150 size: Some(metadata.len()),
151 children: vec![],
152 child_count: 0,
153 });
154 }
155 }
156
157 Ok(entries)
158 })
159 }
160
161 fn format_entries(entries: &[EntryInfo], prefix: &str, budget: usize) -> (Vec<String>, usize) {
163 if budget == 0 || entries.is_empty() {
164 return (vec![], 0);
165 }
166
167 let mut output = Vec::new();
168 let mut used = 0;
169 let remaining_budget = budget;
170
171 let num_entries = entries.len();
174 let budget_per_entry = (remaining_budget / num_entries).max(1);
175
176 for (i, entry) in entries.iter().enumerate() {
177 if used >= budget {
178 let remaining = entries.len() - i;
179 output.push(format!("{}[MORE] ... {} more entries", prefix, remaining));
180 used += 1;
181 break;
182 }
183
184 let entry_budget = if i == entries.len() - 1 {
185 budget.saturating_sub(used)
187 } else {
188 budget_per_entry.min(budget.saturating_sub(used))
189 };
190
191 if entry.is_dir {
192 output.push(format!("{}[DIR] {}/", prefix, entry.name));
193 used += 1;
194
195 if entry_budget > 1 && !entry.children.is_empty() {
196 let child_prefix = format!("{} ", prefix);
197 let child_budget = entry_budget - 1; let (child_output, child_used) =
200 Self::format_entries(&entry.children, &child_prefix, child_budget);
201 output.extend(child_output);
202 used += child_used;
203 } else if !entry.children.is_empty() || entry.child_count > 0 {
204 let count = if entry.children.is_empty() {
206 entry.child_count
207 } else {
208 entry.children.len()
209 };
210 if count > 0 && used < budget {
211 output.push(format!("{} [MORE] ... {} items", prefix, count));
212 used += 1;
213 }
214 }
215 } else {
216 let size = entry.size.unwrap_or(0);
217 let size_str = if size < 1024 {
218 format!("{} B", size)
219 } else if size < 1024 * 1024 {
220 format!("{:.1} KB", size as f64 / 1024.0)
221 } else {
222 format!("{:.1} MB", size as f64 / (1024.0 * 1024.0))
223 };
224 output.push(format!("{}[FILE] {} ({})", prefix, entry.name, size_str));
225 used += 1;
226 }
227 }
228
229 (output, used)
230 }
231}
232
233impl Tool for ListDirectoryTool {
234 type Input = ListDirectoryInput;
235
236 fn name(&self) -> &str {
237 "list_directory"
238 }
239
240 fn description(&self) -> &str {
241 "List the contents of a directory recursively up to a specified depth. Shows files and subdirectories with sizes."
242 }
243
244 async fn execute(&self, input: Self::Input) -> std::result::Result<ToolResult, ToolError> {
245 let path = validate_path(&self.base_path, &input.path)
246 .map_err(|e| ToolError::from(e.to_string()))?;
247
248 if !path.is_dir() {
249 return Err(format!("{} is not a directory", input.path.display()).into());
250 }
251
252 if let Some(max) = input.max_lines {
254 if max > HARD_MAX_LINES {
255 return Err(format!(
256 "max_lines ({}) exceeds maximum allowed value ({})",
257 max, HARD_MAX_LINES
258 )
259 .into());
260 }
261 }
262
263 let entries = self.scan_directory(path, 0, input.depth).await?;
265
266 let budget = input.max_lines.unwrap_or(HARD_MAX_LINES);
268 let (formatted, _used) = Self::format_entries(&entries, "", budget);
269
270 let content = if formatted.is_empty() {
271 format!("Directory {} is empty", input.path.display())
272 } else {
273 format!(
274 "Contents of {} (depth={}):\n{}",
275 input.path.display(),
276 input.depth,
277 formatted.join("\n")
278 )
279 };
280
281 Ok(content.into())
282 }
283
284 fn format_output_plain(&self, result: &ToolResult) -> String {
285 let output = result.as_text();
286 let mut lines: Vec<&str> = output.lines().collect();
287 if lines.is_empty() {
288 return output.to_string();
289 }
290
291 let header = lines.remove(0);
292 let mut out = String::new();
293 out.push_str(header);
294 out.push('\n');
295
296 let entries: Vec<(usize, &str)> = lines
297 .iter()
298 .map(|line| {
299 let depth = line.len() - line.trim_start().len();
300 (depth / 2, line.trim())
301 })
302 .collect();
303
304 for (i, (depth, content)) in entries.iter().enumerate() {
305 let is_last_at_depth = entries
306 .iter()
307 .skip(i + 1)
308 .find(|(d, _)| *d <= *depth)
309 .map(|(d, _)| *d < *depth)
310 .unwrap_or(true);
311
312 let mut prefix = String::new();
313 for d in 0..*depth {
314 let has_more = entries.iter().skip(i + 1).any(|(dd, _)| *dd == d);
315 prefix.push_str(if has_more { "│ " } else { " " });
316 }
317
318 let connector = if is_last_at_depth {
319 "└── "
320 } else {
321 "├── "
322 };
323
324 let formatted = if content.starts_with("[DIR]") {
325 format!(
326 "{} {}",
327 connector,
328 content.trim_start_matches("[DIR]").trim()
329 )
330 } else if content.starts_with("[FILE]") {
331 let rest = content.trim_start_matches("[FILE]").trim();
332 if let Some(paren_idx) = rest.rfind(" (") {
333 format!(
334 "{} {} ({})",
335 connector,
336 &rest[..paren_idx],
337 &rest[paren_idx + 2..rest.len() - 1]
338 )
339 } else {
340 format!("{} {}", connector, rest)
341 }
342 } else {
343 format!("{} {}", connector, content)
344 };
345
346 out.push_str(&prefix);
347 out.push_str(&formatted);
348 out.push('\n');
349 }
350 out
351 }
352
353 fn format_output_ansi(&self, result: &ToolResult) -> String {
354 let output = result.as_text();
355 let mut lines: Vec<&str> = output.lines().collect();
356 if lines.is_empty() {
357 return output.to_string();
358 }
359
360 let header = lines.remove(0);
361 let mut out = format!("\x1b[1m{}\x1b[0m\n", header);
362
363 let entries: Vec<(usize, &str)> = lines
364 .iter()
365 .map(|line| {
366 let depth = line.len() - line.trim_start().len();
367 (depth / 2, line.trim())
368 })
369 .collect();
370
371 for (i, (depth, content)) in entries.iter().enumerate() {
372 let is_last_at_depth = entries
373 .iter()
374 .skip(i + 1)
375 .find(|(d, _)| *d <= *depth)
376 .map(|(d, _)| *d < *depth)
377 .unwrap_or(true);
378
379 let mut prefix = String::new();
380 for d in 0..*depth {
381 let has_more = entries.iter().skip(i + 1).any(|(dd, _)| *dd == d);
382 prefix.push_str(if has_more {
383 "\x1b[2m│\x1b[0m "
384 } else {
385 " "
386 });
387 }
388
389 let connector = if is_last_at_depth {
390 "\x1b[2m└──\x1b[0m "
391 } else {
392 "\x1b[2m├──\x1b[0m "
393 };
394
395 let formatted = if content.starts_with("[DIR]") {
396 let name = content.trim_start_matches("[DIR]").trim();
397 format!("{}\x1b[1;34m{}\x1b[0m", connector, name)
398 } else if content.starts_with("[FILE]") {
399 let rest = content.trim_start_matches("[FILE]").trim();
400 if let Some(paren_idx) = rest.rfind(" (") {
401 let name = &rest[..paren_idx];
402 let size = &rest[paren_idx + 2..rest.len() - 1];
403 format!(
404 "{}{} \x1b[2m{}\x1b[0m",
405 connector,
406 colorize_filename(name),
407 size
408 )
409 } else {
410 format!("{}{}", connector, colorize_filename(rest))
411 }
412 } else if content.starts_with("...") {
413 format!("{}\x1b[2m{}\x1b[0m", connector, content)
414 } else {
415 format!("{}{}", connector, content)
416 };
417
418 out.push_str(&prefix);
419 out.push_str(&formatted);
420 out.push('\n');
421 }
422 out
423 }
424
425 fn format_output_markdown(&self, result: &ToolResult) -> String {
426 format!("```\n{}\n```", self.format_output_plain(result))
427 }
428}
429
430fn colorize_filename(name: &str) -> String {
432 let ext = name.rsplit('.').next().unwrap_or("");
433 match ext.to_lowercase().as_str() {
434 "rs" | "py" | "js" | "ts" | "go" | "c" | "cpp" | "h" | "java" | "rb" | "php" => {
436 format!("\x1b[32m{}\x1b[0m", name)
437 }
438 "json" | "yaml" | "yml" | "toml" | "xml" | "ini" | "cfg" | "conf" => {
440 format!("\x1b[33m{}\x1b[0m", name)
441 }
442 "md" | "txt" | "rst" | "doc" | "pdf" => {
444 format!("\x1b[36m{}\x1b[0m", name)
445 }
446 "zip" | "tar" | "gz" | "bz2" | "xz" | "rar" | "7z" => {
448 format!("\x1b[31m{}\x1b[0m", name)
449 }
450 "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => {
452 format!("\x1b[35m{}\x1b[0m", name)
453 }
454 "sh" | "bash" | "zsh" | "exe" | "bin" => {
456 format!("\x1b[1;32m{}\x1b[0m", name)
457 }
458 _ if name.ends_with(".lock") || name.ends_with("-lock.json") => {
460 format!("\x1b[2m{}\x1b[0m", name)
461 }
462 _ => name.to_string(),
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use std::fs;
470 use tempfile::TempDir;
471
472 #[tokio::test]
477 async fn test_list_directory_basic() {
478 let temp_dir = TempDir::new().unwrap();
479 fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
480 fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
481 fs::create_dir(temp_dir.path().join("subdir")).unwrap();
482
483 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
484 let input = ListDirectoryInput {
485 path: PathBuf::from("."),
486 depth: 1,
487 max_lines: None,
488 };
489
490 let result = tool.execute(input).await.unwrap();
491 let output = result.as_text();
492
493 assert!(output.contains("file1.txt"));
494 assert!(output.contains("file2.txt"));
495 assert!(output.contains("subdir"));
496 }
497
498 #[test]
503 fn test_tool_metadata() {
504 let tool: ListDirectoryTool = Default::default();
505 assert_eq!(tool.name(), "list_directory");
506 assert!(!tool.description().is_empty());
507
508 let tool2 = ListDirectoryTool::new();
509 assert_eq!(tool2.name(), "list_directory");
510 }
511
512 #[test]
513 fn test_try_new() {
514 let tool = ListDirectoryTool::try_new();
515 assert!(tool.is_ok());
516 }
517
518 #[tokio::test]
523 async fn test_empty_directory() {
524 let temp_dir = TempDir::new().unwrap();
525
526 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
527 let input = ListDirectoryInput {
528 path: PathBuf::from("."),
529 depth: 1,
530 max_lines: None,
531 };
532
533 let result = tool.execute(input).await.unwrap();
534 assert!(result.as_text().contains("empty"));
535 }
536
537 #[tokio::test]
538 async fn test_skips_git_directory() {
539 let temp_dir = TempDir::new().unwrap();
540 fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
541 fs::create_dir(temp_dir.path().join(".git")).unwrap();
542 fs::write(temp_dir.path().join(".git/config"), "git config").unwrap();
543
544 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
545 let input = ListDirectoryInput {
546 path: PathBuf::from("."),
547 depth: 2,
548 max_lines: None,
549 };
550
551 let result = tool.execute(input).await.unwrap();
552 let output = result.as_text();
553
554 assert!(output.contains("file.txt"), "Should show regular files");
555 assert!(!output.contains(".git"), "Should skip .git directory");
556 }
557
558 #[tokio::test]
559 async fn test_respects_depth_limit() {
560 let temp_dir = TempDir::new().unwrap();
561 fs::create_dir_all(temp_dir.path().join("a/b/c/d")).unwrap();
562 fs::write(temp_dir.path().join("a/b/c/d/deep.txt"), "deep").unwrap();
563
564 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
565 let input = ListDirectoryInput {
566 path: PathBuf::from("."),
567 depth: 2,
568 max_lines: None,
569 };
570
571 let result = tool.execute(input).await.unwrap();
572 let output = result.as_text();
573
574 assert!(output.contains("a/"), "Should show first level");
575 assert!(output.contains("b/"), "Should show second level");
576 assert!(
577 !output.contains("deep.txt"),
578 "Should not show files beyond depth limit"
579 );
580 }
581
582 #[tokio::test]
583 async fn test_sorts_entries_alphabetically() {
584 let temp_dir = TempDir::new().unwrap();
585 fs::write(temp_dir.path().join("zebra.txt"), "").unwrap();
586 fs::write(temp_dir.path().join("alpha.txt"), "").unwrap();
587 fs::write(temp_dir.path().join("beta.txt"), "").unwrap();
588
589 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
590 let input = ListDirectoryInput {
591 path: PathBuf::from("."),
592 depth: 1,
593 max_lines: None,
594 };
595
596 let result = tool.execute(input).await.unwrap();
597 let output = result.as_text();
598
599 let alpha_pos = output.find("alpha.txt").unwrap();
600 let beta_pos = output.find("beta.txt").unwrap();
601 let zebra_pos = output.find("zebra.txt").unwrap();
602
603 assert!(
604 alpha_pos < beta_pos && beta_pos < zebra_pos,
605 "Entries should be sorted alphabetically"
606 );
607 }
608
609 #[tokio::test]
614 async fn test_size_formatting() {
615 let temp_dir = TempDir::new().unwrap();
616
617 fs::write(temp_dir.path().join("tiny.txt"), "hi").unwrap(); fs::write(temp_dir.path().join("medium.txt"), "x".repeat(2048)).unwrap(); fs::write(
621 temp_dir.path().join("large.txt"),
622 "x".repeat(1024 * 1024 + 1),
623 )
624 .unwrap(); let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
627 let input = ListDirectoryInput {
628 path: PathBuf::from("."),
629 depth: 1,
630 max_lines: None,
631 };
632
633 let result = tool.execute(input).await.unwrap();
634 let output = result.as_text();
635
636 assert!(output.contains("2 B"), "Should show bytes for tiny files");
637 assert!(output.contains("KB"), "Should show KB for medium files");
638 assert!(output.contains("MB"), "Should show MB for large files");
639 }
640
641 #[tokio::test]
646 async fn test_max_lines_limits_output() {
647 let temp_dir = TempDir::new().unwrap();
648 for i in 0..50 {
649 fs::write(temp_dir.path().join(format!("file{:03}.txt", i)), "x").unwrap();
650 }
651
652 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
653 let input = ListDirectoryInput {
654 path: PathBuf::from("."),
655 depth: 1,
656 max_lines: Some(10),
657 };
658
659 let result = tool.execute(input).await.unwrap();
660 let output = result.as_text();
661
662 let file_count = output.matches("[FILE]").count();
663 assert!(file_count <= 10, "Should respect max_lines limit");
664 assert!(output.contains("[MORE]"), "Should indicate truncation");
665 }
666
667 #[tokio::test]
668 async fn test_max_lines_none_returns_all() {
669 let temp_dir = TempDir::new().unwrap();
670 for i in 0..100 {
671 fs::write(temp_dir.path().join(format!("file{:03}.txt", i)), "x").unwrap();
672 }
673
674 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
675 let input = ListDirectoryInput {
676 path: PathBuf::from("."),
677 depth: 1,
678 max_lines: None, };
680
681 let result = tool.execute(input).await.unwrap();
682 let output = result.as_text();
683
684 let file_count = output.matches("[FILE]").count();
685 assert_eq!(
686 file_count, 100,
687 "Should show all files when max_lines is None"
688 );
689 assert!(!output.contains("[MORE]"), "Should not truncate");
690 }
691
692 #[tokio::test]
693 async fn test_max_lines_boundary_cases() {
694 let temp_dir = TempDir::new().unwrap();
695 for i in 0..20 {
696 fs::write(temp_dir.path().join(format!("file{:02}.txt", i)), "x").unwrap();
697 }
698
699 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
700
701 let input = ListDirectoryInput {
703 path: PathBuf::from("."),
704 depth: 1,
705 max_lines: Some(20),
706 };
707 let result = tool.execute(input).await.unwrap();
708 assert!(
709 !result.as_text().contains("[MORE]"),
710 "Should not truncate at exact boundary"
711 );
712
713 let input = ListDirectoryInput {
715 path: PathBuf::from("."),
716 depth: 1,
717 max_lines: Some(19),
718 };
719 let result = tool.execute(input).await.unwrap();
720 assert!(
721 result.as_text().contains("[MORE]"),
722 "Should truncate when over limit"
723 );
724 }
725
726 #[tokio::test]
731 async fn test_fair_allocation_across_directories() {
732 let temp_dir = TempDir::new().unwrap();
733
734 for d in 0..5 {
736 let dir_path = temp_dir.path().join(format!("dir{}", d));
737 fs::create_dir(&dir_path).unwrap();
738 for f in 0..50 {
739 fs::write(dir_path.join(format!("file{:02}.txt", f)), "x").unwrap();
740 }
741 }
742
743 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
744 let input = ListDirectoryInput {
745 path: PathBuf::from("."),
746 depth: 2,
747 max_lines: Some(30),
748 };
749
750 let result = tool.execute(input).await.unwrap();
751 let output = result.as_text();
752
753 let dir_count = output.matches("[DIR]").count();
755 assert_eq!(dir_count, 5, "All directories should be visible");
756
757 let file_count = output.matches("[FILE]").count();
759 assert!(
760 file_count >= 5,
761 "Should show files from multiple directories"
762 );
763 }
764
765 #[tokio::test]
766 async fn test_asymmetric_directories() {
767 let temp_dir = TempDir::new().unwrap();
768
769 let big = temp_dir.path().join("dir_big");
771 fs::create_dir(&big).unwrap();
772 for f in 0..100 {
773 fs::write(big.join(format!("f{:03}.txt", f)), "x").unwrap();
774 }
775
776 let small = temp_dir.path().join("dir_small");
778 fs::create_dir(&small).unwrap();
779 fs::write(small.join("a.txt"), "x").unwrap();
780 fs::write(small.join("b.txt"), "x").unwrap();
781
782 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
783 let input = ListDirectoryInput {
784 path: PathBuf::from("."),
785 depth: 2,
786 max_lines: Some(20),
787 };
788
789 let result = tool.execute(input).await.unwrap();
790 let output = result.as_text();
791
792 assert!(output.contains("dir_big/"));
794 assert!(output.contains("dir_small/"));
795
796 assert!(output.contains("a.txt"));
798 assert!(output.contains("b.txt"));
799 }
800
801 #[tokio::test]
806 async fn test_rejects_path_traversal() {
807 let temp_dir = TempDir::new().unwrap();
808 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
809
810 let input = ListDirectoryInput {
811 path: PathBuf::from("../../../etc"),
812 depth: 1,
813 max_lines: None,
814 };
815
816 let result = tool.execute(input).await;
817 assert!(result.is_err(), "Should reject path traversal");
818 }
819
820 #[tokio::test]
821 async fn test_rejects_non_directory() {
822 let temp_dir = TempDir::new().unwrap();
823 fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
824
825 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
826 let input = ListDirectoryInput {
827 path: PathBuf::from("file.txt"),
828 depth: 1,
829 max_lines: None,
830 };
831
832 let result = tool.execute(input).await;
833 assert!(result.is_err(), "Should reject non-directory path");
834 assert!(
835 result.unwrap_err().to_string().contains("not a directory"),
836 "Error should mention 'not a directory'"
837 );
838 }
839
840 #[tokio::test]
841 async fn test_rejects_max_lines_exceeding_hard_limit() {
842 let temp_dir = TempDir::new().unwrap();
843 fs::write(temp_dir.path().join("file.txt"), "x").unwrap();
844
845 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
846 let input = ListDirectoryInput {
847 path: PathBuf::from("."),
848 depth: 1,
849 max_lines: Some(50_000), };
851
852 let result = tool.execute(input).await;
853 assert!(result.is_err(), "Should reject max_lines > HARD_MAX_LINES");
854
855 let err_msg = result.unwrap_err().to_string();
856 assert!(
857 err_msg.contains("50000") && err_msg.contains("10000"),
858 "Error should mention both requested and max values: {}",
859 err_msg
860 );
861 }
862
863 #[tokio::test]
864 async fn test_zero_max_lines_returns_empty() {
865 let temp_dir = TempDir::new().unwrap();
866 fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
867
868 let tool = ListDirectoryTool::with_base_path(temp_dir.path().to_path_buf());
869 let input = ListDirectoryInput {
870 path: PathBuf::from("."),
871 depth: 1,
872 max_lines: Some(0),
873 };
874
875 let result = tool.execute(input).await.unwrap();
876 let output = result.as_text();
877
878 assert!(
880 output.contains("empty"),
881 "Zero max_lines should report directory as empty"
882 );
883 }
884}