1use crate::schema::json_schema_for;
2use anyhow::{Result, anyhow};
3use assistant_tool::{ActionLog, Tool, ToolResult};
4use gpui::{AnyWindowHandle, App, Entity, Task};
5use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
6use project::{Project, WorktreeSettings};
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use settings::Settings;
10use std::{fmt::Write, path::Path, sync::Arc};
11use ui::IconName;
12use util::markdown::MarkdownInlineCode;
13
14#[derive(Debug, Serialize, Deserialize, JsonSchema)]
15pub struct ListDirectoryToolInput {
16 /// The fully-qualified path of the directory to list in the project.
17 ///
18 /// This path should never be absolute, and the first component
19 /// of the path should always be a root directory in a project.
20 ///
21 /// <example>
22 /// If the project has the following root directories:
23 ///
24 /// - directory1
25 /// - directory2
26 ///
27 /// You can list the contents of `directory1` by using the path `directory1`.
28 /// </example>
29 ///
30 /// <example>
31 /// If the project has the following root directories:
32 ///
33 /// - foo
34 /// - bar
35 ///
36 /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
37 /// </example>
38 pub path: String,
39}
40
41pub struct ListDirectoryTool;
42
43impl Tool for ListDirectoryTool {
44 type Input = ListDirectoryToolInput;
45
46 fn name(&self) -> String {
47 "list_directory".into()
48 }
49
50 fn needs_confirmation(&self, _: &Self::Input, _: &App) -> bool {
51 false
52 }
53
54 fn may_perform_edits(&self) -> bool {
55 false
56 }
57
58 fn description(&self) -> String {
59 include_str!("./list_directory_tool/description.md").into()
60 }
61
62 fn icon(&self) -> IconName {
63 IconName::Folder
64 }
65
66 fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result<serde_json::Value> {
67 json_schema_for::<ListDirectoryToolInput>(format)
68 }
69
70 fn ui_text(&self, input: &Self::Input) -> String {
71 let path = MarkdownInlineCode(&input.path);
72 format!("List the {path} directory's contents")
73 }
74
75 fn run(
76 self: Arc<Self>,
77 input: Self::Input,
78 _request: Arc<LanguageModelRequest>,
79 project: Entity<Project>,
80 _action_log: Entity<ActionLog>,
81 _model: Arc<dyn LanguageModel>,
82 _window: Option<AnyWindowHandle>,
83 cx: &mut App,
84 ) -> ToolResult {
85 // Sometimes models will return these even though we tell it to give a path and not a glob.
86 // When this happens, just list the root worktree directories.
87 if matches!(input.path.as_str(), "." | "" | "./" | "*") {
88 let output = project
89 .read(cx)
90 .worktrees(cx)
91 .filter_map(|worktree| {
92 worktree.read(cx).root_entry().and_then(|entry| {
93 if entry.is_dir() {
94 entry.path.to_str()
95 } else {
96 None
97 }
98 })
99 })
100 .collect::<Vec<_>>()
101 .join("\n");
102
103 return Task::ready(Ok(output.into())).into();
104 }
105
106 let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
107 return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into();
108 };
109 let Some(worktree) = project
110 .read(cx)
111 .worktree_for_id(project_path.worktree_id, cx)
112 else {
113 return Task::ready(Err(anyhow!("Worktree not found"))).into();
114 };
115
116 // Check if the directory whose contents we're listing is itself excluded or private
117 let global_settings = WorktreeSettings::get_global(cx);
118 if global_settings.is_path_excluded(&project_path.path) {
119 return Task::ready(Err(anyhow!(
120 "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
121 &input.path
122 )))
123 .into();
124 }
125
126 if global_settings.is_path_private(&project_path.path) {
127 return Task::ready(Err(anyhow!(
128 "Cannot list directory because its path matches the user's global `private_files` setting: {}",
129 &input.path
130 )))
131 .into();
132 }
133
134 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
135 if worktree_settings.is_path_excluded(&project_path.path) {
136 return Task::ready(Err(anyhow!(
137 "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
138 &input.path
139 )))
140 .into();
141 }
142
143 if worktree_settings.is_path_private(&project_path.path) {
144 return Task::ready(Err(anyhow!(
145 "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
146 &input.path
147 )))
148 .into();
149 }
150
151 let worktree_snapshot = worktree.read(cx).snapshot();
152 let worktree_root_name = worktree.read(cx).root_name().to_string();
153
154 let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
155 return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
156 };
157
158 if !entry.is_dir() {
159 return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
160 }
161 let worktree_snapshot = worktree.read(cx).snapshot();
162
163 let mut folders = Vec::new();
164 let mut files = Vec::new();
165
166 for entry in worktree_snapshot.child_entries(&project_path.path) {
167 // Skip private and excluded files and directories
168 if global_settings.is_path_private(&entry.path)
169 || global_settings.is_path_excluded(&entry.path)
170 {
171 continue;
172 }
173
174 if project
175 .read(cx)
176 .find_project_path(&entry.path, cx)
177 .map(|project_path| {
178 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
179
180 worktree_settings.is_path_excluded(&project_path.path)
181 || worktree_settings.is_path_private(&project_path.path)
182 })
183 .unwrap_or(false)
184 {
185 continue;
186 }
187
188 let full_path = Path::new(&worktree_root_name)
189 .join(&entry.path)
190 .display()
191 .to_string();
192 if entry.is_dir() {
193 folders.push(full_path);
194 } else {
195 files.push(full_path);
196 }
197 }
198
199 let mut output = String::new();
200
201 if !folders.is_empty() {
202 writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
203 }
204
205 if !files.is_empty() {
206 writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
207 }
208
209 if output.is_empty() {
210 writeln!(output, "{} is empty.", input.path).unwrap();
211 }
212
213 Task::ready(Ok(output.into())).into()
214 }
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220 use assistant_tool::Tool;
221 use gpui::{AppContext, TestAppContext, UpdateGlobal};
222 use indoc::indoc;
223 use language_model::fake_provider::FakeLanguageModel;
224 use project::{FakeFs, Project, WorktreeSettings};
225 use serde_json::json;
226 use settings::SettingsStore;
227 use util::path;
228
229 fn platform_paths(path_str: &str) -> String {
230 if cfg!(target_os = "windows") {
231 path_str.replace("/", "\\")
232 } else {
233 path_str.to_string()
234 }
235 }
236
237 fn init_test(cx: &mut TestAppContext) {
238 cx.update(|cx| {
239 let settings_store = SettingsStore::test(cx);
240 cx.set_global(settings_store);
241 language::init(cx);
242 Project::init_settings(cx);
243 });
244 }
245
246 #[gpui::test]
247 async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
248 init_test(cx);
249
250 let fs = FakeFs::new(cx.executor());
251 fs.insert_tree(
252 path!("/project"),
253 json!({
254 "src": {
255 "main.rs": "fn main() {}",
256 "lib.rs": "pub fn hello() {}",
257 "models": {
258 "user.rs": "struct User {}",
259 "post.rs": "struct Post {}"
260 },
261 "utils": {
262 "helper.rs": "pub fn help() {}"
263 }
264 },
265 "tests": {
266 "integration_test.rs": "#[test] fn test() {}"
267 },
268 "README.md": "# Project",
269 "Cargo.toml": "[package]"
270 }),
271 )
272 .await;
273
274 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
275 let action_log = cx.new(|_| ActionLog::new(project.clone()));
276 let model = Arc::new(FakeLanguageModel::default());
277 let tool = Arc::new(ListDirectoryTool);
278
279 // Test listing root directory
280 let input = ListDirectoryToolInput {
281 path: "project".to_string(),
282 };
283
284 let result = cx
285 .update(|cx| {
286 tool.clone().run(
287 input,
288 Arc::default(),
289 project.clone(),
290 action_log.clone(),
291 model.clone(),
292 None,
293 cx,
294 )
295 })
296 .output
297 .await
298 .unwrap();
299
300 let content = result.content.as_str().unwrap();
301 assert_eq!(
302 content,
303 platform_paths(indoc! {"
304 # Folders:
305 project/src
306 project/tests
307
308 # Files:
309 project/Cargo.toml
310 project/README.md
311 "})
312 );
313
314 // Test listing src directory
315 let input = ListDirectoryToolInput {
316 path: "project/src".to_string(),
317 };
318
319 let result = cx
320 .update(|cx| {
321 tool.clone().run(
322 input,
323 Arc::default(),
324 project.clone(),
325 action_log.clone(),
326 model.clone(),
327 None,
328 cx,
329 )
330 })
331 .output
332 .await
333 .unwrap();
334
335 let content = result.content.as_str().unwrap();
336 assert_eq!(
337 content,
338 platform_paths(indoc! {"
339 # Folders:
340 project/src/models
341 project/src/utils
342
343 # Files:
344 project/src/lib.rs
345 project/src/main.rs
346 "})
347 );
348
349 // Test listing directory with only files
350 let input = ListDirectoryToolInput {
351 path: "project/tests".to_string(),
352 };
353
354 let result = cx
355 .update(|cx| {
356 tool.clone().run(
357 input,
358 Arc::default(),
359 project.clone(),
360 action_log.clone(),
361 model.clone(),
362 None,
363 cx,
364 )
365 })
366 .output
367 .await
368 .unwrap();
369
370 let content = result.content.as_str().unwrap();
371 assert!(!content.contains("# Folders:"));
372 assert!(content.contains("# Files:"));
373 assert!(content.contains(&platform_paths("project/tests/integration_test.rs")));
374 }
375
376 #[gpui::test]
377 async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
378 init_test(cx);
379
380 let fs = FakeFs::new(cx.executor());
381 fs.insert_tree(
382 path!("/project"),
383 json!({
384 "empty_dir": {}
385 }),
386 )
387 .await;
388
389 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
390 let action_log = cx.new(|_| ActionLog::new(project.clone()));
391 let model = Arc::new(FakeLanguageModel::default());
392 let tool = Arc::new(ListDirectoryTool);
393
394 let input = ListDirectoryToolInput {
395 path: "project/empty_dir".to_string(),
396 };
397
398 let result = cx
399 .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
400 .output
401 .await
402 .unwrap();
403
404 let content = result.content.as_str().unwrap();
405 assert_eq!(content, "project/empty_dir is empty.\n");
406 }
407
408 #[gpui::test]
409 async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
410 init_test(cx);
411
412 let fs = FakeFs::new(cx.executor());
413 fs.insert_tree(
414 path!("/project"),
415 json!({
416 "file.txt": "content"
417 }),
418 )
419 .await;
420
421 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
422 let action_log = cx.new(|_| ActionLog::new(project.clone()));
423 let model = Arc::new(FakeLanguageModel::default());
424 let tool = Arc::new(ListDirectoryTool);
425
426 // Test non-existent path
427 let input = ListDirectoryToolInput {
428 path: "project/nonexistent".to_string(),
429 };
430
431 let result = cx
432 .update(|cx| {
433 tool.clone().run(
434 input,
435 Arc::default(),
436 project.clone(),
437 action_log.clone(),
438 model.clone(),
439 None,
440 cx,
441 )
442 })
443 .output
444 .await;
445
446 assert!(result.is_err());
447 assert!(result.unwrap_err().to_string().contains("Path not found"));
448
449 // Test trying to list a file instead of directory
450 let input = ListDirectoryToolInput {
451 path: "project/file.txt".to_string(),
452 };
453
454 let result = cx
455 .update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
456 .output
457 .await;
458
459 assert!(result.is_err());
460 assert!(
461 result
462 .unwrap_err()
463 .to_string()
464 .contains("is not a directory")
465 );
466 }
467
468 #[gpui::test]
469 async fn test_list_directory_security(cx: &mut TestAppContext) {
470 init_test(cx);
471
472 let fs = FakeFs::new(cx.executor());
473 fs.insert_tree(
474 path!("/project"),
475 json!({
476 "normal_dir": {
477 "file1.txt": "content",
478 "file2.txt": "content"
479 },
480 ".mysecrets": "SECRET_KEY=abc123",
481 ".secretdir": {
482 "config": "special configuration",
483 "secret.txt": "secret content"
484 },
485 ".mymetadata": "custom metadata",
486 "visible_dir": {
487 "normal.txt": "normal content",
488 "special.privatekey": "private key content",
489 "data.mysensitive": "sensitive data",
490 ".hidden_subdir": {
491 "hidden_file.txt": "hidden content"
492 }
493 }
494 }),
495 )
496 .await;
497
498 // Configure settings explicitly
499 cx.update(|cx| {
500 SettingsStore::update_global(cx, |store, cx| {
501 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
502 settings.file_scan_exclusions = Some(vec![
503 "**/.secretdir".to_string(),
504 "**/.mymetadata".to_string(),
505 "**/.hidden_subdir".to_string(),
506 ]);
507 settings.private_files = Some(vec![
508 "**/.mysecrets".to_string(),
509 "**/*.privatekey".to_string(),
510 "**/*.mysensitive".to_string(),
511 ]);
512 });
513 });
514 });
515
516 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
517 let action_log = cx.new(|_| ActionLog::new(project.clone()));
518 let model = Arc::new(FakeLanguageModel::default());
519 let tool = Arc::new(ListDirectoryTool);
520
521 // Listing root directory should exclude private and excluded files
522 let input = ListDirectoryToolInput {
523 path: "project".to_string(),
524 };
525
526 let result = cx
527 .update(|cx| {
528 tool.clone().run(
529 input,
530 Arc::default(),
531 project.clone(),
532 action_log.clone(),
533 model.clone(),
534 None,
535 cx,
536 )
537 })
538 .output
539 .await
540 .unwrap();
541
542 let content = result.content.as_str().unwrap();
543
544 // Should include normal directories
545 assert!(content.contains("normal_dir"), "Should list normal_dir");
546 assert!(content.contains("visible_dir"), "Should list visible_dir");
547
548 // Should NOT include excluded or private files
549 assert!(
550 !content.contains(".secretdir"),
551 "Should not list .secretdir (file_scan_exclusions)"
552 );
553 assert!(
554 !content.contains(".mymetadata"),
555 "Should not list .mymetadata (file_scan_exclusions)"
556 );
557 assert!(
558 !content.contains(".mysecrets"),
559 "Should not list .mysecrets (private_files)"
560 );
561
562 // Trying to list an excluded directory should fail
563 let input = ListDirectoryToolInput {
564 path: "project/.secretdir".to_string(),
565 };
566
567 let result = cx
568 .update(|cx| {
569 tool.clone().run(
570 input,
571 Arc::default(),
572 project.clone(),
573 action_log.clone(),
574 model.clone(),
575 None,
576 cx,
577 )
578 })
579 .output
580 .await;
581
582 assert!(
583 result.is_err(),
584 "Should not be able to list excluded directory"
585 );
586 assert!(
587 result
588 .unwrap_err()
589 .to_string()
590 .contains("file_scan_exclusions"),
591 "Error should mention file_scan_exclusions"
592 );
593
594 // Listing a directory should exclude private files within it
595 let input = ListDirectoryToolInput {
596 path: "project/visible_dir".to_string(),
597 };
598
599 let result = cx
600 .update(|cx| {
601 tool.clone().run(
602 input,
603 Arc::default(),
604 project.clone(),
605 action_log.clone(),
606 model.clone(),
607 None,
608 cx,
609 )
610 })
611 .output
612 .await
613 .unwrap();
614
615 let content = result.content.as_str().unwrap();
616
617 // Should include normal files
618 assert!(content.contains("normal.txt"), "Should list normal.txt");
619
620 // Should NOT include private files
621 assert!(
622 !content.contains("privatekey"),
623 "Should not list .privatekey files (private_files)"
624 );
625 assert!(
626 !content.contains("mysensitive"),
627 "Should not list .mysensitive files (private_files)"
628 );
629
630 // Should NOT include subdirectories that match exclusions
631 assert!(
632 !content.contains(".hidden_subdir"),
633 "Should not list .hidden_subdir (file_scan_exclusions)"
634 );
635 }
636
637 #[gpui::test]
638 async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
639 init_test(cx);
640
641 let fs = FakeFs::new(cx.executor());
642
643 // Create first worktree with its own private files
644 fs.insert_tree(
645 path!("/worktree1"),
646 json!({
647 ".zed": {
648 "settings.json": r#"{
649 "file_scan_exclusions": ["**/fixture.*"],
650 "private_files": ["**/secret.rs", "**/config.toml"]
651 }"#
652 },
653 "src": {
654 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
655 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
656 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
657 },
658 "tests": {
659 "test.rs": "mod tests { fn test_it() {} }",
660 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
661 }
662 }),
663 )
664 .await;
665
666 // Create second worktree with different private files
667 fs.insert_tree(
668 path!("/worktree2"),
669 json!({
670 ".zed": {
671 "settings.json": r#"{
672 "file_scan_exclusions": ["**/internal.*"],
673 "private_files": ["**/private.js", "**/data.json"]
674 }"#
675 },
676 "lib": {
677 "public.js": "export function greet() { return 'Hello from worktree2'; }",
678 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
679 "data.json": "{\"api_key\": \"json_secret_key\"}"
680 },
681 "docs": {
682 "README.md": "# Public Documentation",
683 "internal.md": "# Internal Secrets and Configuration"
684 }
685 }),
686 )
687 .await;
688
689 // Set global settings
690 cx.update(|cx| {
691 SettingsStore::update_global(cx, |store, cx| {
692 store.update_user_settings::<WorktreeSettings>(cx, |settings| {
693 settings.file_scan_exclusions =
694 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
695 settings.private_files = Some(vec!["**/.env".to_string()]);
696 });
697 });
698 });
699
700 let project = Project::test(
701 fs.clone(),
702 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
703 cx,
704 )
705 .await;
706
707 // Wait for worktrees to be fully scanned
708 cx.executor().run_until_parked();
709
710 let action_log = cx.new(|_| ActionLog::new(project.clone()));
711 let model = Arc::new(FakeLanguageModel::default());
712 let tool = Arc::new(ListDirectoryTool);
713
714 // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
715 let input = ListDirectoryToolInput {
716 path: "worktree1/src".to_string(),
717 };
718
719 let result = cx
720 .update(|cx| {
721 tool.clone().run(
722 input,
723 Arc::default(),
724 project.clone(),
725 action_log.clone(),
726 model.clone(),
727 None,
728 cx,
729 )
730 })
731 .output
732 .await
733 .unwrap();
734
735 let content = result.content.as_str().unwrap();
736 assert!(content.contains("main.rs"), "Should list main.rs");
737 assert!(
738 !content.contains("secret.rs"),
739 "Should not list secret.rs (local private_files)"
740 );
741 assert!(
742 !content.contains("config.toml"),
743 "Should not list config.toml (local private_files)"
744 );
745
746 // Test listing worktree1/tests - should exclude fixture.sql based on local settings
747 let input = ListDirectoryToolInput {
748 path: "worktree1/tests".to_string(),
749 };
750
751 let result = cx
752 .update(|cx| {
753 tool.clone().run(
754 input,
755 Arc::default(),
756 project.clone(),
757 action_log.clone(),
758 model.clone(),
759 None,
760 cx,
761 )
762 })
763 .output
764 .await
765 .unwrap();
766
767 let content = result.content.as_str().unwrap();
768 assert!(content.contains("test.rs"), "Should list test.rs");
769 assert!(
770 !content.contains("fixture.sql"),
771 "Should not list fixture.sql (local file_scan_exclusions)"
772 );
773
774 // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
775 let input = ListDirectoryToolInput {
776 path: "worktree2/lib".to_string(),
777 };
778
779 let result = cx
780 .update(|cx| {
781 tool.clone().run(
782 input,
783 Arc::default(),
784 project.clone(),
785 action_log.clone(),
786 model.clone(),
787 None,
788 cx,
789 )
790 })
791 .output
792 .await
793 .unwrap();
794
795 let content = result.content.as_str().unwrap();
796 assert!(content.contains("public.js"), "Should list public.js");
797 assert!(
798 !content.contains("private.js"),
799 "Should not list private.js (local private_files)"
800 );
801 assert!(
802 !content.contains("data.json"),
803 "Should not list data.json (local private_files)"
804 );
805
806 // Test listing worktree2/docs - should exclude internal.md based on local settings
807 let input = ListDirectoryToolInput {
808 path: "worktree2/docs".to_string(),
809 };
810
811 let result = cx
812 .update(|cx| {
813 tool.clone().run(
814 input,
815 Arc::default(),
816 project.clone(),
817 action_log.clone(),
818 model.clone(),
819 None,
820 cx,
821 )
822 })
823 .output
824 .await
825 .unwrap();
826
827 let content = result.content.as_str().unwrap();
828 assert!(content.contains("README.md"), "Should list README.md");
829 assert!(
830 !content.contains("internal.md"),
831 "Should not list internal.md (local file_scan_exclusions)"
832 );
833
834 // Test trying to list an excluded directory directly
835 let input = ListDirectoryToolInput {
836 path: "worktree1/src/secret.rs".to_string(),
837 };
838
839 let result = cx
840 .update(|cx| {
841 tool.clone().run(
842 input,
843 Arc::default(),
844 project.clone(),
845 action_log.clone(),
846 model.clone(),
847 None,
848 cx,
849 )
850 })
851 .output
852 .await;
853
854 // This should fail because we're trying to list a file, not a directory
855 assert!(result.is_err(), "Should fail when trying to list a file");
856 }
857}