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