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