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