list_directory_tool.rs

  1use crate::schema::json_schema_for;
  2use anyhow::{Result, anyhow};
  3use assistant_tool::{ActionLog, Tool, ToolResult};
  4use gpui::{AnyWindowHandle, App, Entity, Task};
  5use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
  6use project::{Project, WorktreeSettings};
  7use schemars::JsonSchema;
  8use serde::{Deserialize, Serialize};
  9use settings::Settings;
 10use std::{fmt::Write, path::Path, sync::Arc};
 11use ui::IconName;
 12use util::markdown::MarkdownInlineCode;
 13
 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
 19    /// of the path should always be a root directory in a project.
 20    ///
 21    /// <example>
 22    /// If the project has the following root directories:
 23    ///
 24    /// - directory1
 25    /// - directory2
 26    ///
 27    /// You can list the contents of `directory1` by using the path `directory1`.
 28    /// </example>
 29    ///
 30    /// <example>
 31    /// If the project has the following root directories:
 32    ///
 33    /// - foo
 34    /// - bar
 35    ///
 36    /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
 37    /// </example>
 38    pub path: String,
 39}
 40
 41pub struct ListDirectoryTool;
 42
 43impl Tool for ListDirectoryTool {
 44    fn name(&self) -> String {
 45        "list_directory".into()
 46    }
 47
 48    fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity<Project>, _: &App) -> bool {
 49        false
 50    }
 51
 52    fn may_perform_edits(&self) -> bool {
 53        false
 54    }
 55
 56    fn description(&self) -> String {
 57        include_str!("./list_directory_tool/description.md").into()
 58    }
 59
 60    fn icon(&self) -> IconName {
 61        IconName::ToolFolder
 62    }
 63
 64    fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
 65        json_schema_for::<ListDirectoryToolInput>(format)
 66    }
 67
 68    fn ui_text(&self, input: &serde_json::Value) -> String {
 69        match serde_json::from_value::<ListDirectoryToolInput>(input.clone()) {
 70            Ok(input) => {
 71                let path = MarkdownInlineCode(&input.path);
 72                format!("List the {path} directory's contents")
 73            }
 74            Err(_) => "List directory".to_string(),
 75        }
 76    }
 77
 78    fn run(
 79        self: Arc<Self>,
 80        input: serde_json::Value,
 81        _request: Arc<LanguageModelRequest>,
 82        project: Entity<Project>,
 83        _action_log: Entity<ActionLog>,
 84        _model: Arc<dyn LanguageModel>,
 85        _window: Option<AnyWindowHandle>,
 86        cx: &mut App,
 87    ) -> ToolResult {
 88        let input = match serde_json::from_value::<ListDirectoryToolInput>(input) {
 89            Ok(input) => input,
 90            Err(err) => return Task::ready(Err(anyhow!(err))).into(),
 91        };
 92
 93        // Sometimes models will return these even though we tell it to give a path and not a glob.
 94        // When this happens, just list the root worktree directories.
 95        if matches!(input.path.as_str(), "." | "" | "./" | "*") {
 96            let output = project
 97                .read(cx)
 98                .worktrees(cx)
 99                .filter_map(|worktree| {
100                    worktree.read(cx).root_entry().and_then(|entry| {
101                        if entry.is_dir() {
102                            entry.path.to_str()
103                        } else {
104                            None
105                        }
106                    })
107                })
108                .collect::<Vec<_>>()
109                .join("\n");
110
111            return Task::ready(Ok(output.into())).into();
112        }
113
114        let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
115            return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into();
116        };
117        let Some(worktree) = project
118            .read(cx)
119            .worktree_for_id(project_path.worktree_id, cx)
120        else {
121            return Task::ready(Err(anyhow!("Worktree not found"))).into();
122        };
123
124        // Check if the directory whose contents we're listing is itself excluded or private
125        let global_settings = WorktreeSettings::get_global(cx);
126        if global_settings.is_path_excluded(&project_path.path) {
127            return Task::ready(Err(anyhow!(
128                "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
129                &input.path
130            )))
131            .into();
132        }
133
134        if global_settings.is_path_private(&project_path.path) {
135            return Task::ready(Err(anyhow!(
136                "Cannot list directory because its path matches the user's global `private_files` setting: {}",
137                &input.path
138            )))
139            .into();
140        }
141
142        let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
143        if worktree_settings.is_path_excluded(&project_path.path) {
144            return Task::ready(Err(anyhow!(
145                "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
146                &input.path
147            )))
148            .into();
149        }
150
151        if worktree_settings.is_path_private(&project_path.path) {
152            return Task::ready(Err(anyhow!(
153                "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
154                &input.path
155            )))
156            .into();
157        }
158
159        let worktree_snapshot = worktree.read(cx).snapshot();
160        let worktree_root_name = worktree.read(cx).root_name().to_string();
161
162        let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
163            return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
164        };
165
166        if !entry.is_dir() {
167            return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
168        }
169        let worktree_snapshot = worktree.read(cx).snapshot();
170
171        let mut folders = Vec::new();
172        let mut files = Vec::new();
173
174        for entry in worktree_snapshot.child_entries(&project_path.path) {
175            // Skip private and excluded files and directories
176            if global_settings.is_path_private(&entry.path)
177                || global_settings.is_path_excluded(&entry.path)
178            {
179                continue;
180            }
181
182            if project
183                .read(cx)
184                .find_project_path(&entry.path, cx)
185                .map(|project_path| {
186                    let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
187
188                    worktree_settings.is_path_excluded(&project_path.path)
189                        || worktree_settings.is_path_private(&project_path.path)
190                })
191                .unwrap_or(false)
192            {
193                continue;
194            }
195
196            let full_path = Path::new(&worktree_root_name)
197                .join(&entry.path)
198                .display()
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, WorktreeSettings};
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::<WorktreeSettings>(cx, |settings| {
510                    settings.file_scan_exclusions = Some(vec![
511                        "**/.secretdir".to_string(),
512                        "**/.mymetadata".to_string(),
513                        "**/.hidden_subdir".to_string(),
514                    ]);
515                    settings.private_files = Some(vec![
516                        "**/.mysecrets".to_string(),
517                        "**/*.privatekey".to_string(),
518                        "**/*.mysensitive".to_string(),
519                    ]);
520                });
521            });
522        });
523
524        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
525        let action_log = cx.new(|_| ActionLog::new(project.clone()));
526        let model = Arc::new(FakeLanguageModel::default());
527        let tool = Arc::new(ListDirectoryTool);
528
529        // Listing root directory should exclude private and excluded files
530        let input = json!({
531            "path": "project"
532        });
533
534        let result = cx
535            .update(|cx| {
536                tool.clone().run(
537                    input,
538                    Arc::default(),
539                    project.clone(),
540                    action_log.clone(),
541                    model.clone(),
542                    None,
543                    cx,
544                )
545            })
546            .output
547            .await
548            .unwrap();
549
550        let content = result.content.as_str().unwrap();
551
552        // Should include normal directories
553        assert!(content.contains("normal_dir"), "Should list normal_dir");
554        assert!(content.contains("visible_dir"), "Should list visible_dir");
555
556        // Should NOT include excluded or private files
557        assert!(
558            !content.contains(".secretdir"),
559            "Should not list .secretdir (file_scan_exclusions)"
560        );
561        assert!(
562            !content.contains(".mymetadata"),
563            "Should not list .mymetadata (file_scan_exclusions)"
564        );
565        assert!(
566            !content.contains(".mysecrets"),
567            "Should not list .mysecrets (private_files)"
568        );
569
570        // Trying to list an excluded directory should fail
571        let input = json!({
572            "path": "project/.secretdir"
573        });
574
575        let result = cx
576            .update(|cx| {
577                tool.clone().run(
578                    input,
579                    Arc::default(),
580                    project.clone(),
581                    action_log.clone(),
582                    model.clone(),
583                    None,
584                    cx,
585                )
586            })
587            .output
588            .await;
589
590        assert!(
591            result.is_err(),
592            "Should not be able to list excluded directory"
593        );
594        assert!(
595            result
596                .unwrap_err()
597                .to_string()
598                .contains("file_scan_exclusions"),
599            "Error should mention file_scan_exclusions"
600        );
601
602        // Listing a directory should exclude private files within it
603        let input = json!({
604            "path": "project/visible_dir"
605        });
606
607        let result = cx
608            .update(|cx| {
609                tool.clone().run(
610                    input,
611                    Arc::default(),
612                    project.clone(),
613                    action_log.clone(),
614                    model.clone(),
615                    None,
616                    cx,
617                )
618            })
619            .output
620            .await
621            .unwrap();
622
623        let content = result.content.as_str().unwrap();
624
625        // Should include normal files
626        assert!(content.contains("normal.txt"), "Should list normal.txt");
627
628        // Should NOT include private files
629        assert!(
630            !content.contains("privatekey"),
631            "Should not list .privatekey files (private_files)"
632        );
633        assert!(
634            !content.contains("mysensitive"),
635            "Should not list .mysensitive files (private_files)"
636        );
637
638        // Should NOT include subdirectories that match exclusions
639        assert!(
640            !content.contains(".hidden_subdir"),
641            "Should not list .hidden_subdir (file_scan_exclusions)"
642        );
643    }
644
645    #[gpui::test]
646    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
647        init_test(cx);
648
649        let fs = FakeFs::new(cx.executor());
650
651        // Create first worktree with its own private files
652        fs.insert_tree(
653            path!("/worktree1"),
654            json!({
655                ".zed": {
656                    "settings.json": r#"{
657                        "file_scan_exclusions": ["**/fixture.*"],
658                        "private_files": ["**/secret.rs", "**/config.toml"]
659                    }"#
660                },
661                "src": {
662                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
663                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
664                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
665                },
666                "tests": {
667                    "test.rs": "mod tests { fn test_it() {} }",
668                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
669                }
670            }),
671        )
672        .await;
673
674        // Create second worktree with different private files
675        fs.insert_tree(
676            path!("/worktree2"),
677            json!({
678                ".zed": {
679                    "settings.json": r#"{
680                        "file_scan_exclusions": ["**/internal.*"],
681                        "private_files": ["**/private.js", "**/data.json"]
682                    }"#
683                },
684                "lib": {
685                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
686                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
687                    "data.json": "{\"api_key\": \"json_secret_key\"}"
688                },
689                "docs": {
690                    "README.md": "# Public Documentation",
691                    "internal.md": "# Internal Secrets and Configuration"
692                }
693            }),
694        )
695        .await;
696
697        // Set global settings
698        cx.update(|cx| {
699            SettingsStore::update_global(cx, |store, cx| {
700                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
701                    settings.file_scan_exclusions =
702                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
703                    settings.private_files = Some(vec!["**/.env".to_string()]);
704                });
705            });
706        });
707
708        let project = Project::test(
709            fs.clone(),
710            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
711            cx,
712        )
713        .await;
714
715        // Wait for worktrees to be fully scanned
716        cx.executor().run_until_parked();
717
718        let action_log = cx.new(|_| ActionLog::new(project.clone()));
719        let model = Arc::new(FakeLanguageModel::default());
720        let tool = Arc::new(ListDirectoryTool);
721
722        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
723        let input = json!({
724            "path": "worktree1/src"
725        });
726
727        let result = cx
728            .update(|cx| {
729                tool.clone().run(
730                    input,
731                    Arc::default(),
732                    project.clone(),
733                    action_log.clone(),
734                    model.clone(),
735                    None,
736                    cx,
737                )
738            })
739            .output
740            .await
741            .unwrap();
742
743        let content = result.content.as_str().unwrap();
744        assert!(content.contains("main.rs"), "Should list main.rs");
745        assert!(
746            !content.contains("secret.rs"),
747            "Should not list secret.rs (local private_files)"
748        );
749        assert!(
750            !content.contains("config.toml"),
751            "Should not list config.toml (local private_files)"
752        );
753
754        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
755        let input = json!({
756            "path": "worktree1/tests"
757        });
758
759        let result = cx
760            .update(|cx| {
761                tool.clone().run(
762                    input,
763                    Arc::default(),
764                    project.clone(),
765                    action_log.clone(),
766                    model.clone(),
767                    None,
768                    cx,
769                )
770            })
771            .output
772            .await
773            .unwrap();
774
775        let content = result.content.as_str().unwrap();
776        assert!(content.contains("test.rs"), "Should list test.rs");
777        assert!(
778            !content.contains("fixture.sql"),
779            "Should not list fixture.sql (local file_scan_exclusions)"
780        );
781
782        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
783        let input = json!({
784            "path": "worktree2/lib"
785        });
786
787        let result = cx
788            .update(|cx| {
789                tool.clone().run(
790                    input,
791                    Arc::default(),
792                    project.clone(),
793                    action_log.clone(),
794                    model.clone(),
795                    None,
796                    cx,
797                )
798            })
799            .output
800            .await
801            .unwrap();
802
803        let content = result.content.as_str().unwrap();
804        assert!(content.contains("public.js"), "Should list public.js");
805        assert!(
806            !content.contains("private.js"),
807            "Should not list private.js (local private_files)"
808        );
809        assert!(
810            !content.contains("data.json"),
811            "Should not list data.json (local private_files)"
812        );
813
814        // Test listing worktree2/docs - should exclude internal.md based on local settings
815        let input = json!({
816            "path": "worktree2/docs"
817        });
818
819        let result = cx
820            .update(|cx| {
821                tool.clone().run(
822                    input,
823                    Arc::default(),
824                    project.clone(),
825                    action_log.clone(),
826                    model.clone(),
827                    None,
828                    cx,
829                )
830            })
831            .output
832            .await
833            .unwrap();
834
835        let content = result.content.as_str().unwrap();
836        assert!(content.contains("README.md"), "Should list README.md");
837        assert!(
838            !content.contains("internal.md"),
839            "Should not list internal.md (local file_scan_exclusions)"
840        );
841
842        // Test trying to list an excluded directory directly
843        let input = json!({
844            "path": "worktree1/src/secret.rs"
845        });
846
847        let result = cx
848            .update(|cx| {
849                tool.clone().run(
850                    input,
851                    Arc::default(),
852                    project.clone(),
853                    action_log.clone(),
854                    model.clone(),
855                    None,
856                    cx,
857                )
858            })
859            .output
860            .await;
861
862        // This should fail because we're trying to list a file, not a directory
863        assert!(result.is_err(), "Should fail when trying to list a file");
864    }
865}