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, WorktreeSettings};
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::<WorktreeSettings>(cx, |settings| {
511                    settings.file_scan_exclusions = Some(vec![
512                        "**/.secretdir".to_string(),
513                        "**/.mymetadata".to_string(),
514                        "**/.hidden_subdir".to_string(),
515                    ]);
516                    settings.private_files = Some(vec![
517                        "**/.mysecrets".to_string(),
518                        "**/*.privatekey".to_string(),
519                        "**/*.mysensitive".to_string(),
520                    ]);
521                });
522            });
523        });
524
525        let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
526        let action_log = cx.new(|_| ActionLog::new(project.clone()));
527        let model = Arc::new(FakeLanguageModel::default());
528        let tool = Arc::new(ListDirectoryTool);
529
530        // Listing root directory should exclude private and excluded files
531        let input = json!({
532            "path": "project"
533        });
534
535        let result = cx
536            .update(|cx| {
537                tool.clone().run(
538                    input,
539                    Arc::default(),
540                    project.clone(),
541                    action_log.clone(),
542                    model.clone(),
543                    None,
544                    cx,
545                )
546            })
547            .output
548            .await
549            .unwrap();
550
551        let content = result.content.as_str().unwrap();
552
553        // Should include normal directories
554        assert!(content.contains("normal_dir"), "Should list normal_dir");
555        assert!(content.contains("visible_dir"), "Should list visible_dir");
556
557        // Should NOT include excluded or private files
558        assert!(
559            !content.contains(".secretdir"),
560            "Should not list .secretdir (file_scan_exclusions)"
561        );
562        assert!(
563            !content.contains(".mymetadata"),
564            "Should not list .mymetadata (file_scan_exclusions)"
565        );
566        assert!(
567            !content.contains(".mysecrets"),
568            "Should not list .mysecrets (private_files)"
569        );
570
571        // Trying to list an excluded directory should fail
572        let input = json!({
573            "path": "project/.secretdir"
574        });
575
576        let result = cx
577            .update(|cx| {
578                tool.clone().run(
579                    input,
580                    Arc::default(),
581                    project.clone(),
582                    action_log.clone(),
583                    model.clone(),
584                    None,
585                    cx,
586                )
587            })
588            .output
589            .await;
590
591        assert!(
592            result.is_err(),
593            "Should not be able to list excluded directory"
594        );
595        assert!(
596            result
597                .unwrap_err()
598                .to_string()
599                .contains("file_scan_exclusions"),
600            "Error should mention file_scan_exclusions"
601        );
602
603        // Listing a directory should exclude private files within it
604        let input = json!({
605            "path": "project/visible_dir"
606        });
607
608        let result = cx
609            .update(|cx| {
610                tool.clone().run(
611                    input,
612                    Arc::default(),
613                    project.clone(),
614                    action_log.clone(),
615                    model.clone(),
616                    None,
617                    cx,
618                )
619            })
620            .output
621            .await
622            .unwrap();
623
624        let content = result.content.as_str().unwrap();
625
626        // Should include normal files
627        assert!(content.contains("normal.txt"), "Should list normal.txt");
628
629        // Should NOT include private files
630        assert!(
631            !content.contains("privatekey"),
632            "Should not list .privatekey files (private_files)"
633        );
634        assert!(
635            !content.contains("mysensitive"),
636            "Should not list .mysensitive files (private_files)"
637        );
638
639        // Should NOT include subdirectories that match exclusions
640        assert!(
641            !content.contains(".hidden_subdir"),
642            "Should not list .hidden_subdir (file_scan_exclusions)"
643        );
644    }
645
646    #[gpui::test]
647    async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
648        init_test(cx);
649
650        let fs = FakeFs::new(cx.executor());
651
652        // Create first worktree with its own private files
653        fs.insert_tree(
654            path!("/worktree1"),
655            json!({
656                ".zed": {
657                    "settings.json": r#"{
658                        "file_scan_exclusions": ["**/fixture.*"],
659                        "private_files": ["**/secret.rs", "**/config.toml"]
660                    }"#
661                },
662                "src": {
663                    "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
664                    "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
665                    "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
666                },
667                "tests": {
668                    "test.rs": "mod tests { fn test_it() {} }",
669                    "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
670                }
671            }),
672        )
673        .await;
674
675        // Create second worktree with different private files
676        fs.insert_tree(
677            path!("/worktree2"),
678            json!({
679                ".zed": {
680                    "settings.json": r#"{
681                        "file_scan_exclusions": ["**/internal.*"],
682                        "private_files": ["**/private.js", "**/data.json"]
683                    }"#
684                },
685                "lib": {
686                    "public.js": "export function greet() { return 'Hello from worktree2'; }",
687                    "private.js": "const SECRET_TOKEN = \"private_token_2\";",
688                    "data.json": "{\"api_key\": \"json_secret_key\"}"
689                },
690                "docs": {
691                    "README.md": "# Public Documentation",
692                    "internal.md": "# Internal Secrets and Configuration"
693                }
694            }),
695        )
696        .await;
697
698        // Set global settings
699        cx.update(|cx| {
700            SettingsStore::update_global(cx, |store, cx| {
701                store.update_user_settings::<WorktreeSettings>(cx, |settings| {
702                    settings.file_scan_exclusions =
703                        Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
704                    settings.private_files = Some(vec!["**/.env".to_string()]);
705                });
706            });
707        });
708
709        let project = Project::test(
710            fs.clone(),
711            [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
712            cx,
713        )
714        .await;
715
716        // Wait for worktrees to be fully scanned
717        cx.executor().run_until_parked();
718
719        let action_log = cx.new(|_| ActionLog::new(project.clone()));
720        let model = Arc::new(FakeLanguageModel::default());
721        let tool = Arc::new(ListDirectoryTool);
722
723        // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
724        let input = json!({
725            "path": "worktree1/src"
726        });
727
728        let result = cx
729            .update(|cx| {
730                tool.clone().run(
731                    input,
732                    Arc::default(),
733                    project.clone(),
734                    action_log.clone(),
735                    model.clone(),
736                    None,
737                    cx,
738                )
739            })
740            .output
741            .await
742            .unwrap();
743
744        let content = result.content.as_str().unwrap();
745        assert!(content.contains("main.rs"), "Should list main.rs");
746        assert!(
747            !content.contains("secret.rs"),
748            "Should not list secret.rs (local private_files)"
749        );
750        assert!(
751            !content.contains("config.toml"),
752            "Should not list config.toml (local private_files)"
753        );
754
755        // Test listing worktree1/tests - should exclude fixture.sql based on local settings
756        let input = json!({
757            "path": "worktree1/tests"
758        });
759
760        let result = cx
761            .update(|cx| {
762                tool.clone().run(
763                    input,
764                    Arc::default(),
765                    project.clone(),
766                    action_log.clone(),
767                    model.clone(),
768                    None,
769                    cx,
770                )
771            })
772            .output
773            .await
774            .unwrap();
775
776        let content = result.content.as_str().unwrap();
777        assert!(content.contains("test.rs"), "Should list test.rs");
778        assert!(
779            !content.contains("fixture.sql"),
780            "Should not list fixture.sql (local file_scan_exclusions)"
781        );
782
783        // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
784        let input = json!({
785            "path": "worktree2/lib"
786        });
787
788        let result = cx
789            .update(|cx| {
790                tool.clone().run(
791                    input,
792                    Arc::default(),
793                    project.clone(),
794                    action_log.clone(),
795                    model.clone(),
796                    None,
797                    cx,
798                )
799            })
800            .output
801            .await
802            .unwrap();
803
804        let content = result.content.as_str().unwrap();
805        assert!(content.contains("public.js"), "Should list public.js");
806        assert!(
807            !content.contains("private.js"),
808            "Should not list private.js (local private_files)"
809        );
810        assert!(
811            !content.contains("data.json"),
812            "Should not list data.json (local private_files)"
813        );
814
815        // Test listing worktree2/docs - should exclude internal.md based on local settings
816        let input = json!({
817            "path": "worktree2/docs"
818        });
819
820        let result = cx
821            .update(|cx| {
822                tool.clone().run(
823                    input,
824                    Arc::default(),
825                    project.clone(),
826                    action_log.clone(),
827                    model.clone(),
828                    None,
829                    cx,
830                )
831            })
832            .output
833            .await
834            .unwrap();
835
836        let content = result.content.as_str().unwrap();
837        assert!(content.contains("README.md"), "Should list README.md");
838        assert!(
839            !content.contains("internal.md"),
840            "Should not list internal.md (local file_scan_exclusions)"
841        );
842
843        // Test trying to list an excluded directory directly
844        let input = json!({
845            "path": "worktree1/src/secret.rs"
846        });
847
848        let result = cx
849            .update(|cx| {
850                tool.clone().run(
851                    input,
852                    Arc::default(),
853                    project.clone(),
854                    action_log.clone(),
855                    model.clone(),
856                    None,
857                    cx,
858                )
859            })
860            .output
861            .await;
862
863        // This should fail because we're trying to list a file, not a directory
864        assert!(result.is_err(), "Should fail when trying to list a file");
865    }
866}