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}