list_directory_tool.rs

  1use crate::{AgentTool, ToolCallEventStream};
  2use agent_client_protocol::ToolKind;
  3use anyhow::{Result, anyhow};
  4use gpui::{App, Entity, SharedString, Task};
  5use project::{Project, WorktreeSettings};
  6use schemars::JsonSchema;
  7use serde::{Deserialize, Serialize};
  8use settings::Settings;
  9use std::fmt::Write;
 10use std::{path::Path, sync::Arc};
 11use util::markdown::MarkdownInlineCode;
 12
 13/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.
 14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 15pub struct ListDirectoryToolInput {
 16    /// The fully-qualified path of the directory to list in the project.
 17    ///
 18    /// This path should never be absolute, and the first component of the path should always be a root directory in a project.
 19    ///
 20    /// <example>
 21    /// If the project has the following root directories:
 22    ///
 23    /// - directory1
 24    /// - directory2
 25    ///
 26    /// You can list the contents of `directory1` by using the path `directory1`.
 27    /// </example>
 28    ///
 29    /// <example>
 30    /// If the project has the following root directories:
 31    ///
 32    /// - foo
 33    /// - bar
 34    ///
 35    /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
 36    /// </example>
 37    pub path: String,
 38}
 39
 40pub struct ListDirectoryTool {
 41    project: Entity<Project>,
 42}
 43
 44impl ListDirectoryTool {
 45    pub fn new(project: Entity<Project>) -> Self {
 46        Self { project }
 47    }
 48}
 49
 50impl AgentTool for ListDirectoryTool {
 51    type Input = ListDirectoryToolInput;
 52    type Output = String;
 53
 54    fn name() -> &'static str {
 55        "list_directory"
 56    }
 57
 58    fn kind() -> ToolKind {
 59        ToolKind::Read
 60    }
 61
 62    fn initial_title(&self, input: Result<Self::Input, serde_json::Value>) -> SharedString {
 63        if let Ok(input) = input {
 64            let path = MarkdownInlineCode(&input.path);
 65            format!("List the {path} directory's contents").into()
 66        } else {
 67            "List directory".into()
 68        }
 69    }
 70
 71    fn run(
 72        self: Arc<Self>,
 73        input: Self::Input,
 74        _event_stream: ToolCallEventStream,
 75        cx: &mut App,
 76    ) -> Task<Result<Self::Output>> {
 77        // Sometimes models will return these even though we tell it to give a path and not a glob.
 78        // When this happens, just list the root worktree directories.
 79        if matches!(input.path.as_str(), "." | "" | "./" | "*") {
 80            let output = self
 81                .project
 82                .read(cx)
 83                .worktrees(cx)
 84                .filter_map(|worktree| {
 85                    worktree.read(cx).root_entry().and_then(|entry| {
 86                        if entry.is_dir() {
 87                            entry.path.to_str()
 88                        } else {
 89                            None
 90                        }
 91                    })
 92                })
 93                .collect::<Vec<_>>()
 94                .join("\n");
 95
 96            return Task::ready(Ok(output));
 97        }
 98
 99        let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
100            return Task::ready(Err(anyhow!("Path {} not found in project", input.path)));
101        };
102        let Some(worktree) = self
103            .project
104            .read(cx)
105            .worktree_for_id(project_path.worktree_id, cx)
106        else {
107            return Task::ready(Err(anyhow!("Worktree not found")));
108        };
109
110        // Check if the directory whose contents we're listing is itself excluded or private
111        let global_settings = WorktreeSettings::get_global(cx);
112        if global_settings.is_path_excluded(&project_path.path) {
113            return Task::ready(Err(anyhow!(
114                "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
115                &input.path
116            )));
117        }
118
119        if global_settings.is_path_private(&project_path.path) {
120            return Task::ready(Err(anyhow!(
121                "Cannot list directory because its path matches the user's global `private_files` setting: {}",
122                &input.path
123            )));
124        }
125
126        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
127        if worktree_settings.is_path_excluded(&project_path.path) {
128            return Task::ready(Err(anyhow!(
129                "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
130                &input.path
131            )));
132        }
133
134        if worktree_settings.is_path_private(&project_path.path) {
135            return Task::ready(Err(anyhow!(
136                "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
137                &input.path
138            )));
139        }
140
141        let worktree_snapshot = worktree.read(cx).snapshot();
142        let worktree_root_name = worktree.read(cx).root_name().to_string();
143
144        let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
145            return Task::ready(Err(anyhow!("Path not found: {}", input.path)));
146        };
147
148        if !entry.is_dir() {
149            return Task::ready(Err(anyhow!("{} is not a directory.", input.path)));
150        }
151        let worktree_snapshot = worktree.read(cx).snapshot();
152
153        let mut folders = Vec::new();
154        let mut files = Vec::new();
155
156        for entry in worktree_snapshot.child_entries(&project_path.path) {
157            // Skip private and excluded files and directories
158            if global_settings.is_path_private(&entry.path)
159                || global_settings.is_path_excluded(&entry.path)
160            {
161                continue;
162            }
163
164            if self
165                .project
166                .read(cx)
167                .find_project_path(&entry.path, cx)
168                .map(|project_path| {
169                    let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
170
171                    worktree_settings.is_path_excluded(&project_path.path)
172                        || worktree_settings.is_path_private(&project_path.path)
173                })
174                .unwrap_or(false)
175            {
176                continue;
177            }
178
179            let full_path = Path::new(&worktree_root_name)
180                .join(&entry.path)
181                .display()
182                .to_string();
183            if entry.is_dir() {
184                folders.push(full_path);
185            } else {
186                files.push(full_path);
187            }
188        }
189
190        let mut output = String::new();
191
192        if !folders.is_empty() {
193            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
194        }
195
196        if !files.is_empty() {
197            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
198        }
199
200        if output.is_empty() {
201            writeln!(output, "{} is empty.", input.path).unwrap();
202        }
203
204        Task::ready(Ok(output))
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use gpui::{TestAppContext, UpdateGlobal};
212    use indoc::indoc;
213    use project::{FakeFs, Project, WorktreeSettings};
214    use serde_json::json;
215    use settings::SettingsStore;
216    use util::path;
217
218    fn platform_paths(path_str: &str) -> String {
219        if cfg!(target_os = "windows") {
220            path_str.replace("/", "\\")
221        } else {
222            path_str.to_string()
223        }
224    }
225
226    fn init_test(cx: &mut TestAppContext) {
227        cx.update(|cx| {
228            let settings_store = SettingsStore::test(cx);
229            cx.set_global(settings_store);
230            language::init(cx);
231            Project::init_settings(cx);
232        });
233    }
234
235    #[gpui::test]
236    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
237        init_test(cx);
238
239        let fs = FakeFs::new(cx.executor());
240        fs.insert_tree(
241            path!("/project"),
242            json!({
243                "src": {
244                    "main.rs": "fn main() {}",
245                    "lib.rs": "pub fn hello() {}",
246                    "models": {
247                        "user.rs": "struct User {}",
248                        "post.rs": "struct Post {}"
249                    },
250                    "utils": {
251                        "helper.rs": "pub fn help() {}"
252                    }
253                },
254                "tests": {
255                    "integration_test.rs": "#[test] fn test() {}"
256                },
257                "README.md": "# Project",
258                "Cargo.toml": "[package]"
259            }),
260        )
261        .await;
262
263        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
264        let tool = Arc::new(ListDirectoryTool::new(project));
265
266        // Test listing root directory
267        let input = ListDirectoryToolInput {
268            path: "project".into(),
269        };
270        let output = cx
271            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
272            .await
273            .unwrap();
274        assert_eq!(
275            output,
276            platform_paths(indoc! {"
277                # Folders:
278                project/src
279                project/tests
280
281                # Files:
282                project/Cargo.toml
283                project/README.md
284            "})
285        );
286
287        // Test listing src directory
288        let input = ListDirectoryToolInput {
289            path: "project/src".into(),
290        };
291        let output = cx
292            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
293            .await
294            .unwrap();
295        assert_eq!(
296            output,
297            platform_paths(indoc! {"
298                # Folders:
299                project/src/models
300                project/src/utils
301
302                # Files:
303                project/src/lib.rs
304                project/src/main.rs
305            "})
306        );
307
308        // Test listing directory with only files
309        let input = ListDirectoryToolInput {
310            path: "project/tests".into(),
311        };
312        let output = cx
313            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
314            .await
315            .unwrap();
316        assert!(!output.contains("# Folders:"));
317        assert!(output.contains("# Files:"));
318        assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
319    }
320
321    #[gpui::test]
322    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
323        init_test(cx);
324
325        let fs = FakeFs::new(cx.executor());
326        fs.insert_tree(
327            path!("/project"),
328            json!({
329                "empty_dir": {}
330            }),
331        )
332        .await;
333
334        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
335        let tool = Arc::new(ListDirectoryTool::new(project));
336
337        let input = ListDirectoryToolInput {
338            path: "project/empty_dir".into(),
339        };
340        let output = cx
341            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
342            .await
343            .unwrap();
344        assert_eq!(output, "project/empty_dir is empty.\n");
345    }
346
347    #[gpui::test]
348    async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
349        init_test(cx);
350
351        let fs = FakeFs::new(cx.executor());
352        fs.insert_tree(
353            path!("/project"),
354            json!({
355                "file.txt": "content"
356            }),
357        )
358        .await;
359
360        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
361        let tool = Arc::new(ListDirectoryTool::new(project));
362
363        // Test non-existent path
364        let input = ListDirectoryToolInput {
365            path: "project/nonexistent".into(),
366        };
367        let output = cx
368            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
369            .await;
370        assert!(output.unwrap_err().to_string().contains("Path not found"));
371
372        // Test trying to list a file instead of directory
373        let input = ListDirectoryToolInput {
374            path: "project/file.txt".into(),
375        };
376        let output = cx
377            .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
378            .await;
379        assert!(
380            output
381                .unwrap_err()
382                .to_string()
383                .contains("is not a directory")
384        );
385    }
386
387    #[gpui::test]
388    async fn test_list_directory_security(cx: &mut TestAppContext) {
389        init_test(cx);
390
391        let fs = FakeFs::new(cx.executor());
392        fs.insert_tree(
393            path!("/project"),
394            json!({
395                "normal_dir": {
396                    "file1.txt": "content",
397                    "file2.txt": "content"
398                },
399                ".mysecrets": "SECRET_KEY=abc123",
400                ".secretdir": {
401                    "config": "special configuration",
402                    "secret.txt": "secret content"
403                },
404                ".mymetadata": "custom metadata",
405                "visible_dir": {
406                    "normal.txt": "normal content",
407                    "special.privatekey": "private key content",
408                    "data.mysensitive": "sensitive data",
409                    ".hidden_subdir": {
410                        "hidden_file.txt": "hidden content"
411                    }
412                }
413            }),
414        )
415        .await;
416
417        // Configure settings explicitly
418        cx.update(|cx| {
419            SettingsStore::update_global(cx, |store, cx| {
420                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
421                    settings.file_scan_exclusions = Some(vec![
422                        "**/.secretdir".to_string(),
423                        "**/.mymetadata".to_string(),
424                        "**/.hidden_subdir".to_string(),
425                    ]);
426                    settings.private_files = Some(vec![
427                        "**/.mysecrets".to_string(),
428                        "**/*.privatekey".to_string(),
429                        "**/*.mysensitive".to_string(),
430                    ]);
431                });
432            });
433        });
434
435        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
436        let tool = Arc::new(ListDirectoryTool::new(project));
437
438        // Listing root directory should exclude private and excluded files
439        let input = ListDirectoryToolInput {
440            path: "project".into(),
441        };
442        let output = cx
443            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
444            .await
445            .unwrap();
446
447        // Should include normal directories
448        assert!(output.contains("normal_dir"), "Should list normal_dir");
449        assert!(output.contains("visible_dir"), "Should list visible_dir");
450
451        // Should NOT include excluded or private files
452        assert!(
453            !output.contains(".secretdir"),
454            "Should not list .secretdir (file_scan_exclusions)"
455        );
456        assert!(
457            !output.contains(".mymetadata"),
458            "Should not list .mymetadata (file_scan_exclusions)"
459        );
460        assert!(
461            !output.contains(".mysecrets"),
462            "Should not list .mysecrets (private_files)"
463        );
464
465        // Trying to list an excluded directory should fail
466        let input = ListDirectoryToolInput {
467            path: "project/.secretdir".into(),
468        };
469        let output = cx
470            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
471            .await;
472        assert!(
473            output
474                .unwrap_err()
475                .to_string()
476                .contains("file_scan_exclusions"),
477            "Error should mention file_scan_exclusions"
478        );
479
480        // Listing a directory should exclude private files within it
481        let input = ListDirectoryToolInput {
482            path: "project/visible_dir".into(),
483        };
484        let output = cx
485            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
486            .await
487            .unwrap();
488
489        // Should include normal files
490        assert!(output.contains("normal.txt"), "Should list normal.txt");
491
492        // Should NOT include private files
493        assert!(
494            !output.contains("privatekey"),
495            "Should not list .privatekey files (private_files)"
496        );
497        assert!(
498            !output.contains("mysensitive"),
499            "Should not list .mysensitive files (private_files)"
500        );
501
502        // Should NOT include subdirectories that match exclusions
503        assert!(
504            !output.contains(".hidden_subdir"),
505            "Should not list .hidden_subdir (file_scan_exclusions)"
506        );
507    }
508
509    #[gpui::test]
510    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
511        init_test(cx);
512
513        let fs = FakeFs::new(cx.executor());
514
515        // Create first worktree with its own private files
516        fs.insert_tree(
517            path!("/worktree1"),
518            json!({
519                ".zed": {
520                    "settings.json": r#"{
521                        "file_scan_exclusions": ["**/fixture.*"],
522                        "private_files": ["**/secret.rs", "**/config.toml"]
523                    }"#
524                },
525                "src": {
526                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
527                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
528                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
529                },
530                "tests": {
531                    "test.rs": "mod tests { fn test_it() {} }",
532                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
533                }
534            }),
535        )
536        .await;
537
538        // Create second worktree with different private files
539        fs.insert_tree(
540            path!("/worktree2"),
541            json!({
542                ".zed": {
543                    "settings.json": r#"{
544                        "file_scan_exclusions": ["**/internal.*"],
545                        "private_files": ["**/private.js", "**/data.json"]
546                    }"#
547                },
548                "lib": {
549                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
550                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
551                    "data.json": "{\"api_key\": \"json_secret_key\"}"
552                },
553                "docs": {
554                    "README.md": "# Public Documentation",
555                    "internal.md": "# Internal Secrets and Configuration"
556                }
557            }),
558        )
559        .await;
560
561        // Set global settings
562        cx.update(|cx| {
563            SettingsStore::update_global(cx, |store, cx| {
564                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
565                    settings.file_scan_exclusions =
566                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
567                    settings.private_files = Some(vec!["**/.env".to_string()]);
568                });
569            });
570        });
571
572        let project = Project::test(
573            fs.clone(),
574            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
575            cx,
576        )
577        .await;
578
579        // Wait for worktrees to be fully scanned
580        cx.executor().run_until_parked();
581
582        let tool = Arc::new(ListDirectoryTool::new(project));
583
584        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
585        let input = ListDirectoryToolInput {
586            path: "worktree1/src".into(),
587        };
588        let output = cx
589            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
590            .await
591            .unwrap();
592        assert!(output.contains("main.rs"), "Should list main.rs");
593        assert!(
594            !output.contains("secret.rs"),
595            "Should not list secret.rs (local private_files)"
596        );
597        assert!(
598            !output.contains("config.toml"),
599            "Should not list config.toml (local private_files)"
600        );
601
602        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
603        let input = ListDirectoryToolInput {
604            path: "worktree1/tests".into(),
605        };
606        let output = cx
607            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
608            .await
609            .unwrap();
610        assert!(output.contains("test.rs"), "Should list test.rs");
611        assert!(
612            !output.contains("fixture.sql"),
613            "Should not list fixture.sql (local file_scan_exclusions)"
614        );
615
616        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
617        let input = ListDirectoryToolInput {
618            path: "worktree2/lib".into(),
619        };
620        let output = cx
621            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
622            .await
623            .unwrap();
624        assert!(output.contains("public.js"), "Should list public.js");
625        assert!(
626            !output.contains("private.js"),
627            "Should not list private.js (local private_files)"
628        );
629        assert!(
630            !output.contains("data.json"),
631            "Should not list data.json (local private_files)"
632        );
633
634        // Test listing worktree2/docs - should exclude internal.md based on local settings
635        let input = ListDirectoryToolInput {
636            path: "worktree2/docs".into(),
637        };
638        let output = cx
639            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
640            .await
641            .unwrap();
642        assert!(output.contains("README.md"), "Should list README.md");
643        assert!(
644            !output.contains("internal.md"),
645            "Should not list internal.md (local file_scan_exclusions)"
646        );
647
648        // Test trying to list an excluded directory directly
649        let input = ListDirectoryToolInput {
650            path: "worktree1/src/secret.rs".into(),
651        };
652        let output = cx
653            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
654            .await;
655        assert!(
656            output
657                .unwrap_err()
658                .to_string()
659                .contains("Cannot list directory"),
660        );
661    }
662}