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