list_directory_tool.rs

  1use crate::schema::json_schema_for;
  2use action_log::ActionLog;
  3use anyhow::{Result, anyhow};
  4use assistant_tool::{Tool, ToolResult};
  5use gpui::{AnyWindowHandle, App, Entity, Task};
  6use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
  7use project::{Project, ProjectPath, WorktreeSettings};
  8use schemars::JsonSchema;
  9use serde::{Deserialize, Serialize};
 10use settings::Settings;
 11use std::{fmt::Write, sync::Arc};
 12use ui::IconName;
 13use util::markdown::MarkdownInlineCode;
 14
 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
 44impl Tool for ListDirectoryTool {
 45    fn name(&self) -> String {
 46        "list_directory".into()
 47    }
 48
 49    fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
 50        false
 51    }
 52
 53    fn may_perform_edits(&self) -> bool {
 54        false
 55    }
 56
 57    fn description(&self) -> String {
 58        include_str!("./list_directory_tool/description.md").into()
 59    }
 60
 61    fn icon(&self) -> IconName {
 62        IconName::ToolFolder
 63    }
 64
 65    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 66        json_schema_for::<ListDirectoryToolInput>(format)
 67    }
 68
 69    fn ui_text(&self, input: &serde_json::Value) -> String {
 70        match serde_json::from_value::<ListDirectoryToolInput>(input.clone()) {
 71            Ok(input) => {
 72                let path = MarkdownInlineCode(&input.path);
 73                format!("List the {path} directory's contents")
 74            }
 75            Err(_) => "List directory".to_string(),
 76        }
 77    }
 78
 79    fn run(
 80        self: Arc<Self>,
 81        input: serde_json::Value,
 82        _request: Arc<LanguageModelRequest>,
 83        project: Entity<Project>,
 84        _action_log: Entity<ActionLog>,
 85        _model: Arc<dyn LanguageModel>,
 86        _window: Option<AnyWindowHandle>,
 87        cx: &mut App,
 88    ) -> ToolResult {
 89        let path_style = project.read(cx).path_style(cx);
 90        let input = match serde_json::from_value::<ListDirectoryToolInput>(input) {
 91            Ok(input) => input,
 92            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 93        };
 94
 95        // Sometimes models will return these even though we tell it to give a path and not a glob.
 96        // When this happens, just list the root worktree directories.
 97        if matches!(input.path.as_str(), "." | "" | "./" | "*") {
 98            let output = project
 99                .read(cx)
100                .worktrees(cx)
101                .filter_map(|worktree| {
102                    worktree.read(cx).root_entry().and_then(|entry| {
103                        if entry.is_dir() {
104                            Some(entry.path.display(path_style))
105                        } else {
106                            None
107                        }
108                    })
109                })
110                .collect::<Vec<_>>()
111                .join("\n");
112
113            return Task::ready(Ok(output.into())).into();
114        }
115
116        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
117            return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into();
118        };
119        let Some(worktree) = project
120            .read(cx)
121            .worktree_for_id(project_path.worktree_id, cx)
122        else {
123            return Task::ready(Err(anyhow!("Worktree not found"))).into();
124        };
125
126        // Check if the directory whose contents we're listing is itself excluded or private
127        let global_settings = WorktreeSettings::get_global(cx);
128        if global_settings.is_path_excluded(&project_path.path) {
129            return Task::ready(Err(anyhow!(
130                "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
131                &input.path
132            )))
133            .into();
134        }
135
136        if global_settings.is_path_private(&project_path.path) {
137            return Task::ready(Err(anyhow!(
138                "Cannot list directory because its path matches the user's global `private_files` setting: {}",
139                &input.path
140            )))
141            .into();
142        }
143
144        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
145        if worktree_settings.is_path_excluded(&project_path.path) {
146            return Task::ready(Err(anyhow!(
147                "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
148                &input.path
149            )))
150            .into();
151        }
152
153        if worktree_settings.is_path_private(&project_path.path) {
154            return Task::ready(Err(anyhow!(
155                "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
156                &input.path
157            )))
158            .into();
159        }
160
161        let worktree_snapshot = worktree.read(cx).snapshot();
162
163        let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
164            return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
165        };
166
167        if !entry.is_dir() {
168            return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
169        }
170        let worktree_snapshot = worktree.read(cx).snapshot();
171
172        let mut folders = Vec::new();
173        let mut files = Vec::new();
174
175        for entry in worktree_snapshot.child_entries(&project_path.path) {
176            // Skip private and excluded files and directories
177            if global_settings.is_path_private(&entry.path)
178                || global_settings.is_path_excluded(&entry.path)
179            {
180                continue;
181            }
182
183            let project_path = ProjectPath {
184                worktree_id: worktree_snapshot.id(),
185                path: entry.path.clone(),
186            };
187            let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
188
189            if worktree_settings.is_path_excluded(&project_path.path)
190                || worktree_settings.is_path_private(&project_path.path)
191            {
192                continue;
193            }
194
195            let full_path = worktree_snapshot
196                .root_name()
197                .join(&entry.path)
198                .display(worktree_snapshot.path_style())
199                .to_string();
200            if entry.is_dir() {
201                folders.push(full_path);
202            } else {
203                files.push(full_path);
204            }
205        }
206
207        let mut output = String::new();
208
209        if !folders.is_empty() {
210            writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
211        }
212
213        if !files.is_empty() {
214            writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
215        }
216
217        if output.is_empty() {
218            writeln!(output, "{} is empty.", input.path).unwrap();
219        }
220
221        Task::ready(Ok(output.into())).into()
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use assistant_tool::Tool;
229    use gpui::{AppContext, TestAppContext, UpdateGlobal};
230    use indoc::indoc;
231    use language_model::fake_provider::FakeLanguageModel;
232    use project::{FakeFs, Project};
233    use serde_json::json;
234    use settings::SettingsStore;
235    use util::path;
236
237    fn platform_paths(path_str: &str) -> String {
238        if cfg!(target_os = "windows") {
239            path_str.replace("/", "\\")
240        } else {
241            path_str.to_string()
242        }
243    }
244
245    fn init_test(cx: &mut TestAppContext) {
246        cx.update(|cx| {
247            let settings_store = SettingsStore::test(cx);
248            cx.set_global(settings_store);
249            language::init(cx);
250            Project::init_settings(cx);
251        });
252    }
253
254    #[gpui::test]
255    async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
256        init_test(cx);
257
258        let fs = FakeFs::new(cx.executor());
259        fs.insert_tree(
260            path!("/project"),
261            json!({
262                "src": {
263                    "main.rs": "fn main() {}",
264                    "lib.rs": "pub fn hello() {}",
265                    "models": {
266                        "user.rs": "struct User {}",
267                        "post.rs": "struct Post {}"
268                    },
269                    "utils": {
270                        "helper.rs": "pub fn help() {}"
271                    }
272                },
273                "tests": {
274                    "integration_test.rs": "#[test] fn test() {}"
275                },
276                "README.md": "# Project",
277                "Cargo.toml": "[package]"
278            }),
279        )
280        .await;
281
282        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
283        let action_log = cx.new(|_| ActionLog::new(project.clone()));
284        let model = Arc::new(FakeLanguageModel::default());
285        let tool = Arc::new(ListDirectoryTool);
286
287        // Test listing root directory
288        let input = json!({
289            "path": "project"
290        });
291
292        let result = cx
293            .update(|cx| {
294                tool.clone().run(
295                    input,
296                    Arc::default(),
297                    project.clone(),
298                    action_log.clone(),
299                    model.clone(),
300                    None,
301                    cx,
302                )
303            })
304            .output
305            .await
306            .unwrap();
307
308        let content = result.content.as_str().unwrap();
309        assert_eq!(
310            content,
311            platform_paths(indoc! {"
312                # Folders:
313                project/src
314                project/tests
315
316                # Files:
317                project/Cargo.toml
318                project/README.md
319            "})
320        );
321
322        // Test listing src directory
323        let input = json!({
324            "path": "project/src"
325        });
326
327        let result = cx
328            .update(|cx| {
329                tool.clone().run(
330                    input,
331                    Arc::default(),
332                    project.clone(),
333                    action_log.clone(),
334                    model.clone(),
335                    None,
336                    cx,
337                )
338            })
339            .output
340            .await
341            .unwrap();
342
343        let content = result.content.as_str().unwrap();
344        assert_eq!(
345            content,
346            platform_paths(indoc! {"
347                # Folders:
348                project/src/models
349                project/src/utils
350
351                # Files:
352                project/src/lib.rs
353                project/src/main.rs
354            "})
355        );
356
357        // Test listing directory with only files
358        let input = json!({
359            "path": "project/tests"
360        });
361
362        let result = cx
363            .update(|cx| {
364                tool.clone().run(
365                    input,
366                    Arc::default(),
367                    project.clone(),
368                    action_log.clone(),
369                    model.clone(),
370                    None,
371                    cx,
372                )
373            })
374            .output
375            .await
376            .unwrap();
377
378        let content = result.content.as_str().unwrap();
379        assert!(!content.contains("# Folders:"));
380        assert!(content.contains("# Files:"));
381        assert!(content.contains(&platform_paths("project/tests/integration_test.rs")));
382    }
383
384    #[gpui::test]
385    async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
386        init_test(cx);
387
388        let fs = FakeFs::new(cx.executor());
389        fs.insert_tree(
390            path!("/project"),
391            json!({
392                "empty_dir": {}
393            }),
394        )
395        .await;
396
397        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
398        let action_log = cx.new(|_| ActionLog::new(project.clone()));
399        let model = Arc::new(FakeLanguageModel::default());
400        let tool = Arc::new(ListDirectoryTool);
401
402        let input = json!({
403            "path": "project/empty_dir"
404        });
405
406        let result = cx
407            .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
408            .output
409            .await
410            .unwrap();
411
412        let content = result.content.as_str().unwrap();
413        assert_eq!(content, "project/empty_dir is empty.\n");
414    }
415
416    #[gpui::test]
417    async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
418        init_test(cx);
419
420        let fs = FakeFs::new(cx.executor());
421        fs.insert_tree(
422            path!("/project"),
423            json!({
424                "file.txt": "content"
425            }),
426        )
427        .await;
428
429        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
430        let action_log = cx.new(|_| ActionLog::new(project.clone()));
431        let model = Arc::new(FakeLanguageModel::default());
432        let tool = Arc::new(ListDirectoryTool);
433
434        // Test non-existent path
435        let input = json!({
436            "path": "project/nonexistent"
437        });
438
439        let result = cx
440            .update(|cx| {
441                tool.clone().run(
442                    input,
443                    Arc::default(),
444                    project.clone(),
445                    action_log.clone(),
446                    model.clone(),
447                    None,
448                    cx,
449                )
450            })
451            .output
452            .await;
453
454        assert!(result.is_err());
455        assert!(result.unwrap_err().to_string().contains("Path not found"));
456
457        // Test trying to list a file instead of directory
458        let input = json!({
459            "path": "project/file.txt"
460        });
461
462        let result = cx
463            .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
464            .output
465            .await;
466
467        assert!(result.is_err());
468        assert!(
469            result
470                .unwrap_err()
471                .to_string()
472                .contains("is not a directory")
473        );
474    }
475
476    #[gpui::test]
477    async fn test_list_directory_security(cx: &mut TestAppContext) {
478        init_test(cx);
479
480        let fs = FakeFs::new(cx.executor());
481        fs.insert_tree(
482            path!("/project"),
483            json!({
484                "normal_dir": {
485                    "file1.txt": "content",
486                    "file2.txt": "content"
487                },
488                ".mysecrets": "SECRET_KEY=abc123",
489                ".secretdir": {
490                    "config": "special configuration",
491                    "secret.txt": "secret content"
492                },
493                ".mymetadata": "custom metadata",
494                "visible_dir": {
495                    "normal.txt": "normal content",
496                    "special.privatekey": "private key content",
497                    "data.mysensitive": "sensitive data",
498                    ".hidden_subdir": {
499                        "hidden_file.txt": "hidden content"
500                    }
501                }
502            }),
503        )
504        .await;
505
506        // Configure settings explicitly
507        cx.update(|cx| {
508            SettingsStore::update_global(cx, |store, cx| {
509                store.update_user_settings(cx, |settings| {
510                    settings.project.worktree.file_scan_exclusions = Some(vec![
511                        "**/.secretdir".to_string(),
512                        "**/.mymetadata".to_string(),
513                        "**/.hidden_subdir".to_string(),
514                    ]);
515                    settings.project.worktree.private_files = Some(
516                        vec![
517                            "**/.mysecrets".to_string(),
518                            "**/*.privatekey".to_string(),
519                            "**/*.mysensitive".to_string(),
520                        ]
521                        .into(),
522                    );
523                });
524            });
525        });
526
527        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
528        let action_log = cx.new(|_| ActionLog::new(project.clone()));
529        let model = Arc::new(FakeLanguageModel::default());
530        let tool = Arc::new(ListDirectoryTool);
531
532        // Listing root directory should exclude private and excluded files
533        let input = json!({
534            "path": "project"
535        });
536
537        let result = cx
538            .update(|cx| {
539                tool.clone().run(
540                    input,
541                    Arc::default(),
542                    project.clone(),
543                    action_log.clone(),
544                    model.clone(),
545                    None,
546                    cx,
547                )
548            })
549            .output
550            .await
551            .unwrap();
552
553        let content = result.content.as_str().unwrap();
554
555        // Should include normal directories
556        assert!(content.contains("normal_dir"), "Should list normal_dir");
557        assert!(content.contains("visible_dir"), "Should list visible_dir");
558
559        // Should NOT include excluded or private files
560        assert!(
561            !content.contains(".secretdir"),
562            "Should not list .secretdir (file_scan_exclusions)"
563        );
564        assert!(
565            !content.contains(".mymetadata"),
566            "Should not list .mymetadata (file_scan_exclusions)"
567        );
568        assert!(
569            !content.contains(".mysecrets"),
570            "Should not list .mysecrets (private_files)"
571        );
572
573        // Trying to list an excluded directory should fail
574        let input = json!({
575            "path": "project/.secretdir"
576        });
577
578        let result = cx
579            .update(|cx| {
580                tool.clone().run(
581                    input,
582                    Arc::default(),
583                    project.clone(),
584                    action_log.clone(),
585                    model.clone(),
586                    None,
587                    cx,
588                )
589            })
590            .output
591            .await;
592
593        assert!(
594            result.is_err(),
595            "Should not be able to list excluded directory"
596        );
597        assert!(
598            result
599                .unwrap_err()
600                .to_string()
601                .contains("file_scan_exclusions"),
602            "Error should mention file_scan_exclusions"
603        );
604
605        // Listing a directory should exclude private files within it
606        let input = json!({
607            "path": "project/visible_dir"
608        });
609
610        let result = cx
611            .update(|cx| {
612                tool.clone().run(
613                    input,
614                    Arc::default(),
615                    project.clone(),
616                    action_log.clone(),
617                    model.clone(),
618                    None,
619                    cx,
620                )
621            })
622            .output
623            .await
624            .unwrap();
625
626        let content = result.content.as_str().unwrap();
627
628        // Should include normal files
629        assert!(content.contains("normal.txt"), "Should list normal.txt");
630
631        // Should NOT include private files
632        assert!(
633            !content.contains("privatekey"),
634            "Should not list .privatekey files (private_files)"
635        );
636        assert!(
637            !content.contains("mysensitive"),
638            "Should not list .mysensitive files (private_files)"
639        );
640
641        // Should NOT include subdirectories that match exclusions
642        assert!(
643            !content.contains(".hidden_subdir"),
644            "Should not list .hidden_subdir (file_scan_exclusions)"
645        );
646    }
647
648    #[gpui::test]
649    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
650        init_test(cx);
651
652        let fs = FakeFs::new(cx.executor());
653
654        // Create first worktree with its own private files
655        fs.insert_tree(
656            path!("/worktree1"),
657            json!({
658                ".zed": {
659                    "settings.json": r#"{
660                        "file_scan_exclusions": ["**/fixture.*"],
661                        "private_files": ["**/secret.rs", "**/config.toml"]
662                    }"#
663                },
664                "src": {
665                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
666                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
667                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
668                },
669                "tests": {
670                    "test.rs": "mod tests { fn test_it() {} }",
671                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
672                }
673            }),
674        )
675        .await;
676
677        // Create second worktree with different private files
678        fs.insert_tree(
679            path!("/worktree2"),
680            json!({
681                ".zed": {
682                    "settings.json": r#"{
683                        "file_scan_exclusions": ["**/internal.*"],
684                        "private_files": ["**/private.js", "**/data.json"]
685                    }"#
686                },
687                "lib": {
688                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
689                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
690                    "data.json": "{\"api_key\": \"json_secret_key\"}"
691                },
692                "docs": {
693                    "README.md": "# Public Documentation",
694                    "internal.md": "# Internal Secrets and Configuration"
695                }
696            }),
697        )
698        .await;
699
700        // Set global settings
701        cx.update(|cx| {
702            SettingsStore::update_global(cx, |store, cx| {
703                store.update_user_settings(cx, |settings| {
704                    settings.project.worktree.file_scan_exclusions =
705                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
706                    settings.project.worktree.private_files =
707                        Some(vec!["**/.env".to_string()].into());
708                });
709            });
710        });
711
712        let project = Project::test(
713            fs.clone(),
714            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
715            cx,
716        )
717        .await;
718
719        // Wait for worktrees to be fully scanned
720        cx.executor().run_until_parked();
721
722        let action_log = cx.new(|_| ActionLog::new(project.clone()));
723        let model = Arc::new(FakeLanguageModel::default());
724        let tool = Arc::new(ListDirectoryTool);
725
726        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
727        let input = json!({
728            "path": "worktree1/src"
729        });
730
731        let result = cx
732            .update(|cx| {
733                tool.clone().run(
734                    input,
735                    Arc::default(),
736                    project.clone(),
737                    action_log.clone(),
738                    model.clone(),
739                    None,
740                    cx,
741                )
742            })
743            .output
744            .await
745            .unwrap();
746
747        let content = result.content.as_str().unwrap();
748        assert!(content.contains("main.rs"), "Should list main.rs");
749        assert!(
750            !content.contains("secret.rs"),
751            "Should not list secret.rs (local private_files)"
752        );
753        assert!(
754            !content.contains("config.toml"),
755            "Should not list config.toml (local private_files)"
756        );
757
758        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
759        let input = json!({
760            "path": "worktree1/tests"
761        });
762
763        let result = cx
764            .update(|cx| {
765                tool.clone().run(
766                    input,
767                    Arc::default(),
768                    project.clone(),
769                    action_log.clone(),
770                    model.clone(),
771                    None,
772                    cx,
773                )
774            })
775            .output
776            .await
777            .unwrap();
778
779        let content = result.content.as_str().unwrap();
780        assert!(content.contains("test.rs"), "Should list test.rs");
781        assert!(
782            !content.contains("fixture.sql"),
783            "Should not list fixture.sql (local file_scan_exclusions)"
784        );
785
786        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
787        let input = json!({
788            "path": "worktree2/lib"
789        });
790
791        let result = cx
792            .update(|cx| {
793                tool.clone().run(
794                    input,
795                    Arc::default(),
796                    project.clone(),
797                    action_log.clone(),
798                    model.clone(),
799                    None,
800                    cx,
801                )
802            })
803            .output
804            .await
805            .unwrap();
806
807        let content = result.content.as_str().unwrap();
808        assert!(content.contains("public.js"), "Should list public.js");
809        assert!(
810            !content.contains("private.js"),
811            "Should not list private.js (local private_files)"
812        );
813        assert!(
814            !content.contains("data.json"),
815            "Should not list data.json (local private_files)"
816        );
817
818        // Test listing worktree2/docs - should exclude internal.md based on local settings
819        let input = json!({
820            "path": "worktree2/docs"
821        });
822
823        let result = cx
824            .update(|cx| {
825                tool.clone().run(
826                    input,
827                    Arc::default(),
828                    project.clone(),
829                    action_log.clone(),
830                    model.clone(),
831                    None,
832                    cx,
833                )
834            })
835            .output
836            .await
837            .unwrap();
838
839        let content = result.content.as_str().unwrap();
840        assert!(content.contains("README.md"), "Should list README.md");
841        assert!(
842            !content.contains("internal.md"),
843            "Should not list internal.md (local file_scan_exclusions)"
844        );
845
846        // Test trying to list an excluded directory directly
847        let input = json!({
848            "path": "worktree1/src/secret.rs"
849        });
850
851        let result = cx
852            .update(|cx| {
853                tool.clone().run(
854                    input,
855                    Arc::default(),
856                    project.clone(),
857                    action_log.clone(),
858                    model.clone(),
859                    None,
860                    cx,
861                )
862            })
863            .output
864            .await;
865
866        // This should fail because we're trying to list a file, not a directory
867        assert!(result.is_err(), "Should fail when trying to list a file");
868    }
869}