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