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