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