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