Skip to main content

mixtape_tools/filesystem/
list_directory.rs

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/// Input for listing directory contents
9#[derive(Debug, Deserialize, JsonSchema)]
10pub struct ListDirectoryInput {
11    /// Path to the directory to list (relative to base path)
12    pub path: PathBuf,
13
14    /// Maximum recursion depth (default: 2)
15    #[serde(default = "default_depth")]
16    pub depth: usize,
17
18    /// Maximum lines in output. If omitted, returns all entries (up to internal hard limit).
19    /// Use this to control output size for large directories.
20    #[serde(default)]
21    pub max_lines: Option<usize>,
22}
23
24fn default_depth() -> usize {
25    2
26}
27
28/// Hard limit on output lines to prevent runaway memory usage
29const HARD_MAX_LINES: usize = 10_000;
30
31/// Entry info collected during scan
32#[derive(Debug)]
33struct EntryInfo {
34    name: String,
35    is_dir: bool,
36    size: Option<u64>,
37    children: Vec<EntryInfo>,
38    child_count: usize, // Total count including nested
39}
40
41/// Tool for listing directory contents
42pub 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    /// Creates a new tool using the current working directory as the base path.
54    ///
55    /// Equivalent to `Default::default()`.
56    ///
57    /// # Panics
58    ///
59    /// Panics if the current working directory cannot be determined.
60    /// Use [`try_new`](Self::try_new) or [`with_base_path`](Self::with_base_path) instead.
61    pub fn new() -> Self {
62        Self {
63            base_path: std::env::current_dir().expect("Failed to get current working directory"),
64        }
65    }
66
67    /// Creates a new tool using the current working directory as the base path.
68    ///
69    /// Returns an error if the current working directory cannot be determined.
70    pub fn try_new() -> std::io::Result<Self> {
71        Ok(Self {
72            base_path: std::env::current_dir()?,
73        })
74    }
75
76    /// Creates a tool with a custom base directory.
77    ///
78    /// All file operations will be constrained to this directory.
79    pub fn with_base_path(base_path: PathBuf) -> Self {
80        Self { base_path }
81    }
82
83    /// Scan directory and collect entry info (phase 1)
84    #[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                        // At max depth, count direct children
131                        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    /// Format entries with fair budget allocation (phase 2)
163    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        // Calculate fair share per entry
173        // Each entry needs at least 1 line, dirs with children need 2 (dir + "X more")
174        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                // Last entry gets remaining budget
187                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; // -1 for the dir line itself
199
200                    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                    // No budget for children, show count
206                    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        // Validate max_lines doesn't exceed hard limit
254        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        // Phase 1: Scan directory tree
265        let entries = self.scan_directory(path, 0, input.depth).await?;
266
267        // Phase 2: Format with fair budget allocation
268        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
431/// Colorize filename based on extension (eza-inspired)
432fn colorize_filename(name: &str) -> String {
433    let ext = name.rsplit('.').next().unwrap_or("");
434    match ext.to_lowercase().as_str() {
435        // Source code - green
436        "rs" | "py" | "js" | "ts" | "go" | "c" | "cpp" | "h" | "java" | "rb" | "php" => {
437            format!("\x1b[32m{}\x1b[0m", name)
438        }
439        // Config/data - yellow
440        "json" | "yaml" | "yml" | "toml" | "xml" | "ini" | "cfg" | "conf" => {
441            format!("\x1b[33m{}\x1b[0m", name)
442        }
443        // Docs - cyan
444        "md" | "txt" | "rst" | "doc" | "pdf" => {
445            format!("\x1b[36m{}\x1b[0m", name)
446        }
447        // Archives - red
448        "zip" | "tar" | "gz" | "bz2" | "xz" | "rar" | "7z" => {
449            format!("\x1b[31m{}\x1b[0m", name)
450        }
451        // Images - magenta
452        "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => {
453            format!("\x1b[35m{}\x1b[0m", name)
454        }
455        // Executables - bold green
456        "sh" | "bash" | "zsh" | "exe" | "bin" => {
457            format!("\x1b[1;32m{}\x1b[0m", name)
458        }
459        // Lock files - dim
460        _ 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    // =========================================================================
474    // Smoke test - basic functionality
475    // =========================================================================
476
477    #[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    // =========================================================================
500    // Constructor and metadata tests
501    // =========================================================================
502
503    #[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    // =========================================================================
520    // Core functionality tests
521    // =========================================================================
522
523    #[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    // =========================================================================
611    // Size formatting tests (consolidated)
612    // =========================================================================
613
614    #[tokio::test]
615    async fn test_size_formatting() {
616        let temp_dir = TempDir::new().unwrap();
617
618        // Create files of different sizes
619        fs::write(temp_dir.path().join("tiny.txt"), "hi").unwrap(); // 2 bytes
620        fs::write(temp_dir.path().join("medium.txt"), "x".repeat(2048)).unwrap(); // 2 KB
621        fs::write(
622            temp_dir.path().join("large.txt"),
623            "x".repeat(1024 * 1024 + 1),
624        )
625        .unwrap(); // 1+ MB
626
627        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    // =========================================================================
643    // max_lines parameter tests
644    // =========================================================================
645
646    #[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, // No limit
680        };
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        // Exactly at limit - no truncation
703        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        // One under limit - truncates
715        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    // =========================================================================
728    // Fair budget allocation tests
729    // =========================================================================
730
731    #[tokio::test]
732    async fn test_fair_allocation_across_directories() {
733        let temp_dir = TempDir::new().unwrap();
734
735        // Create 5 directories, each with many files
736        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        // All 5 directories should be visible (fair allocation)
755        let dir_count = output.matches("[DIR]").count();
756        assert_eq!(dir_count, 5, "All directories should be visible");
757
758        // Each directory should show some files (not first-dir-takes-all)
759        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        // Big directory with 100 files
771        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        // Small directory with 2 files
778        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        // Both directories should be visible
794        assert!(output.contains("dir_big/"));
795        assert!(output.contains("dir_small/"));
796
797        // Small dir should show all its files
798        assert!(output.contains("a.txt"));
799        assert!(output.contains("b.txt"));
800    }
801
802    // =========================================================================
803    // Error handling tests
804    // =========================================================================
805
806    #[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), // Exceeds HARD_MAX_LINES (10,000)
851        };
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        // With zero budget, format_entries returns empty vec, so we get "empty" message
880        assert!(
881            output.contains("empty"),
882            "Zero max_lines should report directory as empty"
883        );
884    }
885}