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 const 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            language::init(cx);
227            Project::init_settings(cx);
228        });
229    }
230
231    #[gpui::test]
232    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
233        init_test(cx);
234
235        let fs = FakeFs::new(cx.executor());
236        fs.insert_tree(
237            path!("/project"),
238            json!({
239                "src": {
240                    "main.rs": "fn main() {}",
241                    "lib.rs": "pub fn hello() {}",
242                    "models": {
243                        "user.rs": "struct User {}",
244                        "post.rs": "struct Post {}"
245                    },
246                    "utils": {
247                        "helper.rs": "pub fn help() {}"
248                    }
249                },
250                "tests": {
251                    "integration_test.rs": "#[test] fn test() {}"
252                },
253                "README.md": "# Project",
254                "Cargo.toml": "[package]"
255            }),
256        )
257        .await;
258
259        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
260        let tool = Arc::new(ListDirectoryTool::new(project));
261
262        // Test listing root directory
263        let input = ListDirectoryToolInput {
264            path: "project".into(),
265        };
266        let output = cx
267            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
268            .await
269            .unwrap();
270        assert_eq!(
271            output,
272            platform_paths(indoc! {"
273                # Folders:
274                project/src
275                project/tests
276
277                # Files:
278                project/Cargo.toml
279                project/README.md
280            "})
281        );
282
283        // Test listing src directory
284        let input = ListDirectoryToolInput {
285            path: "project/src".into(),
286        };
287        let output = cx
288            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
289            .await
290            .unwrap();
291        assert_eq!(
292            output,
293            platform_paths(indoc! {"
294                # Folders:
295                project/src/models
296                project/src/utils
297
298                # Files:
299                project/src/lib.rs
300                project/src/main.rs
301            "})
302        );
303
304        // Test listing directory with only files
305        let input = ListDirectoryToolInput {
306            path: "project/tests".into(),
307        };
308        let output = cx
309            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
310            .await
311            .unwrap();
312        assert!(!output.contains("# Folders:"));
313        assert!(output.contains("# Files:"));
314        assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
315    }
316
317    #[gpui::test]
318    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
319        init_test(cx);
320
321        let fs = FakeFs::new(cx.executor());
322        fs.insert_tree(
323            path!("/project"),
324            json!({
325                "empty_dir": {}
326            }),
327        )
328        .await;
329
330        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
331        let tool = Arc::new(ListDirectoryTool::new(project));
332
333        let input = ListDirectoryToolInput {
334            path: "project/empty_dir".into(),
335        };
336        let output = cx
337            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
338            .await
339            .unwrap();
340        assert_eq!(output, "project/empty_dir is empty.\n");
341    }
342
343    #[gpui::test]
344    async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
345        init_test(cx);
346
347        let fs = FakeFs::new(cx.executor());
348        fs.insert_tree(
349            path!("/project"),
350            json!({
351                "file.txt": "content"
352            }),
353        )
354        .await;
355
356        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
357        let tool = Arc::new(ListDirectoryTool::new(project));
358
359        // Test non-existent path
360        let input = ListDirectoryToolInput {
361            path: "project/nonexistent".into(),
362        };
363        let output = cx
364            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
365            .await;
366        assert!(output.unwrap_err().to_string().contains("Path not found"));
367
368        // Test trying to list a file instead of directory
369        let input = ListDirectoryToolInput {
370            path: "project/file.txt".into(),
371        };
372        let output = cx
373            .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
374            .await;
375        assert!(
376            output
377                .unwrap_err()
378                .to_string()
379                .contains("is not a directory")
380        );
381    }
382
383    #[gpui::test]
384    async fn test_list_directory_security(cx: &mut TestAppContext) {
385        init_test(cx);
386
387        let fs = FakeFs::new(cx.executor());
388        fs.insert_tree(
389            path!("/project"),
390            json!({
391                "normal_dir": {
392                    "file1.txt": "content",
393                    "file2.txt": "content"
394                },
395                ".mysecrets": "SECRET_KEY=abc123",
396                ".secretdir": {
397                    "config": "special configuration",
398                    "secret.txt": "secret content"
399                },
400                ".mymetadata": "custom metadata",
401                "visible_dir": {
402                    "normal.txt": "normal content",
403                    "special.privatekey": "private key content",
404                    "data.mysensitive": "sensitive data",
405                    ".hidden_subdir": {
406                        "hidden_file.txt": "hidden content"
407                    }
408                }
409            }),
410        )
411        .await;
412
413        // Configure settings explicitly
414        cx.update(|cx| {
415            SettingsStore::update_global(cx, |store, cx| {
416                store.update_user_settings(cx, |settings| {
417                    settings.project.worktree.file_scan_exclusions = Some(vec![
418                        "**/.secretdir".to_string(),
419                        "**/.mymetadata".to_string(),
420                        "**/.hidden_subdir".to_string(),
421                    ]);
422                    settings.project.worktree.private_files = Some(
423                        vec![
424                            "**/.mysecrets".to_string(),
425                            "**/*.privatekey".to_string(),
426                            "**/*.mysensitive".to_string(),
427                        ]
428                        .into(),
429                    );
430                });
431            });
432        });
433
434        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
435        let tool = Arc::new(ListDirectoryTool::new(project));
436
437        // Listing root directory should exclude private and excluded files
438        let input = ListDirectoryToolInput {
439            path: "project".into(),
440        };
441        let output = cx
442            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
443            .await
444            .unwrap();
445
446        // Should include normal directories
447        assert!(output.contains("normal_dir"), "Should list normal_dir");
448        assert!(output.contains("visible_dir"), "Should list visible_dir");
449
450        // Should NOT include excluded or private files
451        assert!(
452            !output.contains(".secretdir"),
453            "Should not list .secretdir (file_scan_exclusions)"
454        );
455        assert!(
456            !output.contains(".mymetadata"),
457            "Should not list .mymetadata (file_scan_exclusions)"
458        );
459        assert!(
460            !output.contains(".mysecrets"),
461            "Should not list .mysecrets (private_files)"
462        );
463
464        // Trying to list an excluded directory should fail
465        let input = ListDirectoryToolInput {
466            path: "project/.secretdir".into(),
467        };
468        let output = cx
469            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
470            .await;
471        assert!(
472            output
473                .unwrap_err()
474                .to_string()
475                .contains("file_scan_exclusions"),
476            "Error should mention file_scan_exclusions"
477        );
478
479        // Listing a directory should exclude private files within it
480        let input = ListDirectoryToolInput {
481            path: "project/visible_dir".into(),
482        };
483        let output = cx
484            .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
485            .await
486            .unwrap();
487
488        // Should include normal files
489        assert!(output.contains("normal.txt"), "Should list normal.txt");
490
491        // Should NOT include private files
492        assert!(
493            !output.contains("privatekey"),
494            "Should not list .privatekey files (private_files)"
495        );
496        assert!(
497            !output.contains("mysensitive"),
498            "Should not list .mysensitive files (private_files)"
499        );
500
501        // Should NOT include subdirectories that match exclusions
502        assert!(
503            !output.contains(".hidden_subdir"),
504            "Should not list .hidden_subdir (file_scan_exclusions)"
505        );
506    }
507
508    #[gpui::test]
509    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
510        init_test(cx);
511
512        let fs = FakeFs::new(cx.executor());
513
514        // Create first worktree with its own private files
515        fs.insert_tree(
516            path!("/worktree1"),
517            json!({
518                ".zed": {
519                    "settings.json": r#"{
520                        "file_scan_exclusions": ["**/fixture.*"],
521                        "private_files": ["**/secret.rs", "**/config.toml"]
522                    }"#
523                },
524                "src": {
525                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
526                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
527                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
528                },
529                "tests": {
530                    "test.rs": "mod tests { fn test_it() {} }",
531                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
532                }
533            }),
534        )
535        .await;
536
537        // Create second worktree with different private files
538        fs.insert_tree(
539            path!("/worktree2"),
540            json!({
541                ".zed": {
542                    "settings.json": r#"{
543                        "file_scan_exclusions": ["**/internal.*"],
544                        "private_files": ["**/private.js", "**/data.json"]
545                    }"#
546                },
547                "lib": {
548                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
549                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
550                    "data.json": "{\"api_key\": \"json_secret_key\"}"
551                },
552                "docs": {
553                    "README.md": "# Public Documentation",
554                    "internal.md": "# Internal Secrets and Configuration"
555                }
556            }),
557        )
558        .await;
559
560        // Set global settings
561        cx.update(|cx| {
562            SettingsStore::update_global(cx, |store, cx| {
563                store.update_user_settings(cx, |settings| {
564                    settings.project.worktree.file_scan_exclusions =
565                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
566                    settings.project.worktree.private_files =
567                        Some(vec!["**/.env".to_string()].into());
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}