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}