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 path_style = project.read(cx).path_style(cx);
90 let input = match serde_json::from_value::<ListDirectoryToolInput>(input) {
91 Ok(input) => input,
92 Err(err) => return Task::ready(Err(anyhow!(err))).into(),
93 };
94
95 // Sometimes models will return these even though we tell it to give a path and not a glob.
96 // When this happens, just list the root worktree directories.
97 if matches!(input.path.as_str(), "." | "" | "./" | "*") {
98 let output = project
99 .read(cx)
100 .worktrees(cx)
101 .filter_map(|worktree| {
102 worktree.read(cx).root_entry().and_then(|entry| {
103 if entry.is_dir() {
104 Some(entry.path.display(path_style))
105 } else {
106 None
107 }
108 })
109 })
110 .collect::<Vec<_>>()
111 .join("\n");
112
113 return Task::ready(Ok(output.into())).into();
114 }
115
116 let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
117 return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into();
118 };
119 let Some(worktree) = project
120 .read(cx)
121 .worktree_for_id(project_path.worktree_id, cx)
122 else {
123 return Task::ready(Err(anyhow!("Worktree not found"))).into();
124 };
125
126 // Check if the directory whose contents we're listing is itself excluded or private
127 let global_settings = WorktreeSettings::get_global(cx);
128 if global_settings.is_path_excluded(&project_path.path) {
129 return Task::ready(Err(anyhow!(
130 "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
131 &input.path
132 )))
133 .into();
134 }
135
136 if global_settings.is_path_private(&project_path.path) {
137 return Task::ready(Err(anyhow!(
138 "Cannot list directory because its path matches the user's global `private_files` setting: {}",
139 &input.path
140 )))
141 .into();
142 }
143
144 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
145 if worktree_settings.is_path_excluded(&project_path.path) {
146 return Task::ready(Err(anyhow!(
147 "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
148 &input.path
149 )))
150 .into();
151 }
152
153 if worktree_settings.is_path_private(&project_path.path) {
154 return Task::ready(Err(anyhow!(
155 "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
156 &input.path
157 )))
158 .into();
159 }
160
161 let worktree_snapshot = worktree.read(cx).snapshot();
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 let project_path = ProjectPath {
184 worktree_id: worktree_snapshot.id(),
185 path: entry.path.clone(),
186 };
187 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
188
189 if worktree_settings.is_path_excluded(&project_path.path)
190 || worktree_settings.is_path_private(&project_path.path)
191 {
192 continue;
193 }
194
195 let full_path = worktree_snapshot
196 .root_name()
197 .join(&entry.path)
198 .display(worktree_snapshot.path_style())
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};
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(cx, |settings| {
510 settings.project.worktree.file_scan_exclusions = Some(vec![
511 "**/.secretdir".to_string(),
512 "**/.mymetadata".to_string(),
513 "**/.hidden_subdir".to_string(),
514 ]);
515 settings.project.worktree.private_files = Some(
516 vec![
517 "**/.mysecrets".to_string(),
518 "**/*.privatekey".to_string(),
519 "**/*.mysensitive".to_string(),
520 ]
521 .into(),
522 );
523 });
524 });
525 });
526
527 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
528 let action_log = cx.new(|_| ActionLog::new(project.clone()));
529 let model = Arc::new(FakeLanguageModel::default());
530 let tool = Arc::new(ListDirectoryTool);
531
532 // Listing root directory should exclude private and excluded files
533 let input = json!({
534 "path": "project"
535 });
536
537 let result = cx
538 .update(|cx| {
539 tool.clone().run(
540 input,
541 Arc::default(),
542 project.clone(),
543 action_log.clone(),
544 model.clone(),
545 None,
546 cx,
547 )
548 })
549 .output
550 .await
551 .unwrap();
552
553 let content = result.content.as_str().unwrap();
554
555 // Should include normal directories
556 assert!(content.contains("normal_dir"), "Should list normal_dir");
557 assert!(content.contains("visible_dir"), "Should list visible_dir");
558
559 // Should NOT include excluded or private files
560 assert!(
561 !content.contains(".secretdir"),
562 "Should not list .secretdir (file_scan_exclusions)"
563 );
564 assert!(
565 !content.contains(".mymetadata"),
566 "Should not list .mymetadata (file_scan_exclusions)"
567 );
568 assert!(
569 !content.contains(".mysecrets"),
570 "Should not list .mysecrets (private_files)"
571 );
572
573 // Trying to list an excluded directory should fail
574 let input = json!({
575 "path": "project/.secretdir"
576 });
577
578 let result = cx
579 .update(|cx| {
580 tool.clone().run(
581 input,
582 Arc::default(),
583 project.clone(),
584 action_log.clone(),
585 model.clone(),
586 None,
587 cx,
588 )
589 })
590 .output
591 .await;
592
593 assert!(
594 result.is_err(),
595 "Should not be able to list excluded directory"
596 );
597 assert!(
598 result
599 .unwrap_err()
600 .to_string()
601 .contains("file_scan_exclusions"),
602 "Error should mention file_scan_exclusions"
603 );
604
605 // Listing a directory should exclude private files within it
606 let input = json!({
607 "path": "project/visible_dir"
608 });
609
610 let result = cx
611 .update(|cx| {
612 tool.clone().run(
613 input,
614 Arc::default(),
615 project.clone(),
616 action_log.clone(),
617 model.clone(),
618 None,
619 cx,
620 )
621 })
622 .output
623 .await
624 .unwrap();
625
626 let content = result.content.as_str().unwrap();
627
628 // Should include normal files
629 assert!(content.contains("normal.txt"), "Should list normal.txt");
630
631 // Should NOT include private files
632 assert!(
633 !content.contains("privatekey"),
634 "Should not list .privatekey files (private_files)"
635 );
636 assert!(
637 !content.contains("mysensitive"),
638 "Should not list .mysensitive files (private_files)"
639 );
640
641 // Should NOT include subdirectories that match exclusions
642 assert!(
643 !content.contains(".hidden_subdir"),
644 "Should not list .hidden_subdir (file_scan_exclusions)"
645 );
646 }
647
648 #[gpui::test]
649 async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
650 init_test(cx);
651
652 let fs = FakeFs::new(cx.executor());
653
654 // Create first worktree with its own private files
655 fs.insert_tree(
656 path!("/worktree1"),
657 json!({
658 ".zed": {
659 "settings.json": r#"{
660 "file_scan_exclusions": ["**/fixture.*"],
661 "private_files": ["**/secret.rs", "**/config.toml"]
662 }"#
663 },
664 "src": {
665 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
666 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
667 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
668 },
669 "tests": {
670 "test.rs": "mod tests { fn test_it() {} }",
671 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
672 }
673 }),
674 )
675 .await;
676
677 // Create second worktree with different private files
678 fs.insert_tree(
679 path!("/worktree2"),
680 json!({
681 ".zed": {
682 "settings.json": r#"{
683 "file_scan_exclusions": ["**/internal.*"],
684 "private_files": ["**/private.js", "**/data.json"]
685 }"#
686 },
687 "lib": {
688 "public.js": "export function greet() { return 'Hello from worktree2'; }",
689 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
690 "data.json": "{\"api_key\": \"json_secret_key\"}"
691 },
692 "docs": {
693 "README.md": "# Public Documentation",
694 "internal.md": "# Internal Secrets and Configuration"
695 }
696 }),
697 )
698 .await;
699
700 // Set global settings
701 cx.update(|cx| {
702 SettingsStore::update_global(cx, |store, cx| {
703 store.update_user_settings(cx, |settings| {
704 settings.project.worktree.file_scan_exclusions =
705 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
706 settings.project.worktree.private_files =
707 Some(vec!["**/.env".to_string()].into());
708 });
709 });
710 });
711
712 let project = Project::test(
713 fs.clone(),
714 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
715 cx,
716 )
717 .await;
718
719 // Wait for worktrees to be fully scanned
720 cx.executor().run_until_parked();
721
722 let action_log = cx.new(|_| ActionLog::new(project.clone()));
723 let model = Arc::new(FakeLanguageModel::default());
724 let tool = Arc::new(ListDirectoryTool);
725
726 // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
727 let input = json!({
728 "path": "worktree1/src"
729 });
730
731 let result = cx
732 .update(|cx| {
733 tool.clone().run(
734 input,
735 Arc::default(),
736 project.clone(),
737 action_log.clone(),
738 model.clone(),
739 None,
740 cx,
741 )
742 })
743 .output
744 .await
745 .unwrap();
746
747 let content = result.content.as_str().unwrap();
748 assert!(content.contains("main.rs"), "Should list main.rs");
749 assert!(
750 !content.contains("secret.rs"),
751 "Should not list secret.rs (local private_files)"
752 );
753 assert!(
754 !content.contains("config.toml"),
755 "Should not list config.toml (local private_files)"
756 );
757
758 // Test listing worktree1/tests - should exclude fixture.sql based on local settings
759 let input = json!({
760 "path": "worktree1/tests"
761 });
762
763 let result = cx
764 .update(|cx| {
765 tool.clone().run(
766 input,
767 Arc::default(),
768 project.clone(),
769 action_log.clone(),
770 model.clone(),
771 None,
772 cx,
773 )
774 })
775 .output
776 .await
777 .unwrap();
778
779 let content = result.content.as_str().unwrap();
780 assert!(content.contains("test.rs"), "Should list test.rs");
781 assert!(
782 !content.contains("fixture.sql"),
783 "Should not list fixture.sql (local file_scan_exclusions)"
784 );
785
786 // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
787 let input = json!({
788 "path": "worktree2/lib"
789 });
790
791 let result = cx
792 .update(|cx| {
793 tool.clone().run(
794 input,
795 Arc::default(),
796 project.clone(),
797 action_log.clone(),
798 model.clone(),
799 None,
800 cx,
801 )
802 })
803 .output
804 .await
805 .unwrap();
806
807 let content = result.content.as_str().unwrap();
808 assert!(content.contains("public.js"), "Should list public.js");
809 assert!(
810 !content.contains("private.js"),
811 "Should not list private.js (local private_files)"
812 );
813 assert!(
814 !content.contains("data.json"),
815 "Should not list data.json (local private_files)"
816 );
817
818 // Test listing worktree2/docs - should exclude internal.md based on local settings
819 let input = json!({
820 "path": "worktree2/docs"
821 });
822
823 let result = cx
824 .update(|cx| {
825 tool.clone().run(
826 input,
827 Arc::default(),
828 project.clone(),
829 action_log.clone(),
830 model.clone(),
831 None,
832 cx,
833 )
834 })
835 .output
836 .await
837 .unwrap();
838
839 let content = result.content.as_str().unwrap();
840 assert!(content.contains("README.md"), "Should list README.md");
841 assert!(
842 !content.contains("internal.md"),
843 "Should not list internal.md (local file_scan_exclusions)"
844 );
845
846 // Test trying to list an excluded directory directly
847 let input = json!({
848 "path": "worktree1/src/secret.rs"
849 });
850
851 let result = cx
852 .update(|cx| {
853 tool.clone().run(
854 input,
855 Arc::default(),
856 project.clone(),
857 action_log.clone(),
858 model.clone(),
859 None,
860 cx,
861 )
862 })
863 .output
864 .await;
865
866 // This should fail because we're trying to list a file, not a directory
867 assert!(result.is_err(), "Should fail when trying to list a file");
868 }
869}