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