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    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                        // At max depth, count direct children
130                        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    /// Format entries with fair budget allocation (phase 2)
162    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        // Calculate fair share per entry
172        // Each entry needs at least 1 line, dirs with children need 2 (dir + "X more")
173        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                // Last entry gets remaining budget
186                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; // -1 for the dir line itself
198
199                    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                    // No budget for children, show count
205                    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        // Validate max_lines doesn't exceed hard limit
253        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        // Phase 1: Scan directory tree
264        let entries = self.scan_directory(path, 0, input.depth).await?;
265
266        // Phase 2: Format with fair budget allocation
267        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
430/// Colorize filename based on extension (eza-inspired)
431fn colorize_filename(name: &str) -> String {
432    let ext = name.rsplit('.').next().unwrap_or("");
433    match ext.to_lowercase().as_str() {
434        // Source code - green
435        "rs" | "py" | "js" | "ts" | "go" | "c" | "cpp" | "h" | "java" | "rb" | "php" => {
436            format!("\x1b[32m{}\x1b[0m", name)
437        }
438        // Config/data - yellow
439        "json" | "yaml" | "yml" | "toml" | "xml" | "ini" | "cfg" | "conf" => {
440            format!("\x1b[33m{}\x1b[0m", name)
441        }
442        // Docs - cyan
443        "md" | "txt" | "rst" | "doc" | "pdf" => {
444            format!("\x1b[36m{}\x1b[0m", name)
445        }
446        // Archives - red
447        "zip" | "tar" | "gz" | "bz2" | "xz" | "rar" | "7z" => {
448            format!("\x1b[31m{}\x1b[0m", name)
449        }
450        // Images - magenta
451        "png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => {
452            format!("\x1b[35m{}\x1b[0m", name)
453        }
454        // Executables - bold green
455        "sh" | "bash" | "zsh" | "exe" | "bin" => {
456            format!("\x1b[1;32m{}\x1b[0m", name)
457        }
458        // Lock files - dim
459        _ 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    // =========================================================================
473    // Smoke test - basic functionality
474    // =========================================================================
475
476    #[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    // =========================================================================
499    // Constructor and metadata tests
500    // =========================================================================
501
502    #[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    // =========================================================================
519    // Core functionality tests
520    // =========================================================================
521
522    #[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    // =========================================================================
610    // Size formatting tests (consolidated)
611    // =========================================================================
612
613    #[tokio::test]
614    async fn test_size_formatting() {
615        let temp_dir = TempDir::new().unwrap();
616
617        // Create files of different sizes
618        fs::write(temp_dir.path().join("tiny.txt"), "hi").unwrap(); // 2 bytes
619        fs::write(temp_dir.path().join("medium.txt"), "x".repeat(2048)).unwrap(); // 2 KB
620        fs::write(
621            temp_dir.path().join("large.txt"),
622            "x".repeat(1024 * 1024 + 1),
623        )
624        .unwrap(); // 1+ MB
625
626        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    // =========================================================================
642    // max_lines parameter tests
643    // =========================================================================
644
645    #[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, // No limit
679        };
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        // Exactly at limit - no truncation
702        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        // One under limit - truncates
714        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    // =========================================================================
727    // Fair budget allocation tests
728    // =========================================================================
729
730    #[tokio::test]
731    async fn test_fair_allocation_across_directories() {
732        let temp_dir = TempDir::new().unwrap();
733
734        // Create 5 directories, each with many files
735        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        // All 5 directories should be visible (fair allocation)
754        let dir_count = output.matches("[DIR]").count();
755        assert_eq!(dir_count, 5, "All directories should be visible");
756
757        // Each directory should show some files (not first-dir-takes-all)
758        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        // Big directory with 100 files
770        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        // Small directory with 2 files
777        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        // Both directories should be visible
793        assert!(output.contains("dir_big/"));
794        assert!(output.contains("dir_small/"));
795
796        // Small dir should show all its files
797        assert!(output.contains("a.txt"));
798        assert!(output.contains("b.txt"));
799    }
800
801    // =========================================================================
802    // Error handling tests
803    // =========================================================================
804
805    #[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), // Exceeds HARD_MAX_LINES (10,000)
850        };
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        // With zero budget, format_entries returns empty vec, so we get "empty" message
879        assert!(
880            output.contains("empty"),
881            "Zero max_lines should report directory as empty"
882        );
883    }
884}