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