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}