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}