list_directory_tool.rs

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