1use crate::{AgentTool, ToolCallEventStream};
2use agent_client_protocol::ToolKind;
3use anyhow::{Result, anyhow};
4use gpui::{App, Entity, SharedString, Task};
5use project::{Project, WorktreeSettings};
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use settings::Settings;
9use std::fmt::Write;
10use std::{path::Path, sync::Arc};
11use util::markdown::MarkdownInlineCode;
12
13/// Lists files and directories in a given path. Prefer the `grep` or `find_path` tools when searching the codebase.
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 of the path should always be a root directory in a project.
19 ///
20 /// <example>
21 /// If the project has the following root directories:
22 ///
23 /// - directory1
24 /// - directory2
25 ///
26 /// You can list the contents of `directory1` by using the path `directory1`.
27 /// </example>
28 ///
29 /// <example>
30 /// If the project has the following root directories:
31 ///
32 /// - foo
33 /// - bar
34 ///
35 /// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
36 /// </example>
37 pub path: String,
38}
39
40pub struct ListDirectoryTool {
41 project: Entity<Project>,
42}
43
44impl ListDirectoryTool {
45 pub fn new(project: Entity<Project>) -> Self {
46 Self { project }
47 }
48}
49
50impl AgentTool for ListDirectoryTool {
51 type Input = ListDirectoryToolInput;
52 type Output = String;
53
54 fn name() -> &'static str {
55 "list_directory"
56 }
57
58 fn kind() -> ToolKind {
59 ToolKind::Read
60 }
61
62 fn initial_title(
63 &self,
64 input: Result<Self::Input, serde_json::Value>,
65 _cx: &mut App,
66 ) -> SharedString {
67 if let Ok(input) = input {
68 let path = MarkdownInlineCode(&input.path);
69 format!("List the {path} directory's contents").into()
70 } else {
71 "List directory".into()
72 }
73 }
74
75 fn run(
76 self: Arc<Self>,
77 input: Self::Input,
78 _event_stream: ToolCallEventStream,
79 cx: &mut App,
80 ) -> Task<Result<Self::Output>> {
81 // Sometimes models will return these even though we tell it to give a path and not a glob.
82 // When this happens, just list the root worktree directories.
83 if matches!(input.path.as_str(), "." | "" | "./" | "*") {
84 let output = self
85 .project
86 .read(cx)
87 .worktrees(cx)
88 .filter_map(|worktree| {
89 worktree.read(cx).root_entry().and_then(|entry| {
90 if entry.is_dir() {
91 entry.path.to_str()
92 } else {
93 None
94 }
95 })
96 })
97 .collect::<Vec<_>>()
98 .join("\n");
99
100 return Task::ready(Ok(output));
101 }
102
103 let Some(project_path) = self.project.read(cx).find_project_path(&input.path, cx) else {
104 return Task::ready(Err(anyhow!("Path {} not found in project", input.path)));
105 };
106 let Some(worktree) = self
107 .project
108 .read(cx)
109 .worktree_for_id(project_path.worktree_id, cx)
110 else {
111 return Task::ready(Err(anyhow!("Worktree not found")));
112 };
113
114 // Check if the directory whose contents we're listing is itself excluded or private
115 let global_settings = WorktreeSettings::get_global(cx);
116 if global_settings.is_path_excluded(&project_path.path) {
117 return Task::ready(Err(anyhow!(
118 "Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
119 &input.path
120 )));
121 }
122
123 if global_settings.is_path_private(&project_path.path) {
124 return Task::ready(Err(anyhow!(
125 "Cannot list directory because its path matches the user's global `private_files` setting: {}",
126 &input.path
127 )));
128 }
129
130 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
131 if worktree_settings.is_path_excluded(&project_path.path) {
132 return Task::ready(Err(anyhow!(
133 "Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
134 &input.path
135 )));
136 }
137
138 if worktree_settings.is_path_private(&project_path.path) {
139 return Task::ready(Err(anyhow!(
140 "Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
141 &input.path
142 )));
143 }
144
145 let worktree_snapshot = worktree.read(cx).snapshot();
146 let worktree_root_name = worktree.read(cx).root_name().to_string();
147
148 let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
149 return Task::ready(Err(anyhow!("Path not found: {}", input.path)));
150 };
151
152 if !entry.is_dir() {
153 return Task::ready(Err(anyhow!("{} is not a directory.", input.path)));
154 }
155 let worktree_snapshot = worktree.read(cx).snapshot();
156
157 let mut folders = Vec::new();
158 let mut files = Vec::new();
159
160 for entry in worktree_snapshot.child_entries(&project_path.path) {
161 // Skip private and excluded files and directories
162 if global_settings.is_path_private(&entry.path)
163 || global_settings.is_path_excluded(&entry.path)
164 {
165 continue;
166 }
167
168 if self
169 .project
170 .read(cx)
171 .find_project_path(&entry.path, cx)
172 .map(|project_path| {
173 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
174
175 worktree_settings.is_path_excluded(&project_path.path)
176 || worktree_settings.is_path_private(&project_path.path)
177 })
178 .unwrap_or(false)
179 {
180 continue;
181 }
182
183 let full_path = Path::new(&worktree_root_name)
184 .join(&entry.path)
185 .display()
186 .to_string();
187 if entry.is_dir() {
188 folders.push(full_path);
189 } else {
190 files.push(full_path);
191 }
192 }
193
194 let mut output = String::new();
195
196 if !folders.is_empty() {
197 writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
198 }
199
200 if !files.is_empty() {
201 writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
202 }
203
204 if output.is_empty() {
205 writeln!(output, "{} is empty.", input.path).unwrap();
206 }
207
208 Task::ready(Ok(output))
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215 use gpui::{TestAppContext, UpdateGlobal};
216 use indoc::indoc;
217 use project::{FakeFs, Project};
218 use serde_json::json;
219 use settings::SettingsStore;
220 use util::path;
221
222 fn platform_paths(path_str: &str) -> String {
223 if cfg!(target_os = "windows") {
224 path_str.replace("/", "\\")
225 } else {
226 path_str.to_string()
227 }
228 }
229
230 fn init_test(cx: &mut TestAppContext) {
231 cx.update(|cx| {
232 let settings_store = SettingsStore::test(cx);
233 cx.set_global(settings_store);
234 language::init(cx);
235 Project::init_settings(cx);
236 });
237 }
238
239 #[gpui::test]
240 async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
241 init_test(cx);
242
243 let fs = FakeFs::new(cx.executor());
244 fs.insert_tree(
245 path!("/project"),
246 json!({
247 "src": {
248 "main.rs": "fn main() {}",
249 "lib.rs": "pub fn hello() {}",
250 "models": {
251 "user.rs": "struct User {}",
252 "post.rs": "struct Post {}"
253 },
254 "utils": {
255 "helper.rs": "pub fn help() {}"
256 }
257 },
258 "tests": {
259 "integration_test.rs": "#[test] fn test() {}"
260 },
261 "README.md": "# Project",
262 "Cargo.toml": "[package]"
263 }),
264 )
265 .await;
266
267 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
268 let tool = Arc::new(ListDirectoryTool::new(project));
269
270 // Test listing root directory
271 let input = ListDirectoryToolInput {
272 path: "project".into(),
273 };
274 let output = cx
275 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
276 .await
277 .unwrap();
278 assert_eq!(
279 output,
280 platform_paths(indoc! {"
281 # Folders:
282 project/src
283 project/tests
284
285 # Files:
286 project/Cargo.toml
287 project/README.md
288 "})
289 );
290
291 // Test listing src directory
292 let input = ListDirectoryToolInput {
293 path: "project/src".into(),
294 };
295 let output = cx
296 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
297 .await
298 .unwrap();
299 assert_eq!(
300 output,
301 platform_paths(indoc! {"
302 # Folders:
303 project/src/models
304 project/src/utils
305
306 # Files:
307 project/src/lib.rs
308 project/src/main.rs
309 "})
310 );
311
312 // Test listing directory with only files
313 let input = ListDirectoryToolInput {
314 path: "project/tests".into(),
315 };
316 let output = cx
317 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
318 .await
319 .unwrap();
320 assert!(!output.contains("# Folders:"));
321 assert!(output.contains("# Files:"));
322 assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
323 }
324
325 #[gpui::test]
326 async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
327 init_test(cx);
328
329 let fs = FakeFs::new(cx.executor());
330 fs.insert_tree(
331 path!("/project"),
332 json!({
333 "empty_dir": {}
334 }),
335 )
336 .await;
337
338 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
339 let tool = Arc::new(ListDirectoryTool::new(project));
340
341 let input = ListDirectoryToolInput {
342 path: "project/empty_dir".into(),
343 };
344 let output = cx
345 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
346 .await
347 .unwrap();
348 assert_eq!(output, "project/empty_dir is empty.\n");
349 }
350
351 #[gpui::test]
352 async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
353 init_test(cx);
354
355 let fs = FakeFs::new(cx.executor());
356 fs.insert_tree(
357 path!("/project"),
358 json!({
359 "file.txt": "content"
360 }),
361 )
362 .await;
363
364 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
365 let tool = Arc::new(ListDirectoryTool::new(project));
366
367 // Test non-existent path
368 let input = ListDirectoryToolInput {
369 path: "project/nonexistent".into(),
370 };
371 let output = cx
372 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
373 .await;
374 assert!(output.unwrap_err().to_string().contains("Path not found"));
375
376 // Test trying to list a file instead of directory
377 let input = ListDirectoryToolInput {
378 path: "project/file.txt".into(),
379 };
380 let output = cx
381 .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
382 .await;
383 assert!(
384 output
385 .unwrap_err()
386 .to_string()
387 .contains("is not a directory")
388 );
389 }
390
391 #[gpui::test]
392 async fn test_list_directory_security(cx: &mut TestAppContext) {
393 init_test(cx);
394
395 let fs = FakeFs::new(cx.executor());
396 fs.insert_tree(
397 path!("/project"),
398 json!({
399 "normal_dir": {
400 "file1.txt": "content",
401 "file2.txt": "content"
402 },
403 ".mysecrets": "SECRET_KEY=abc123",
404 ".secretdir": {
405 "config": "special configuration",
406 "secret.txt": "secret content"
407 },
408 ".mymetadata": "custom metadata",
409 "visible_dir": {
410 "normal.txt": "normal content",
411 "special.privatekey": "private key content",
412 "data.mysensitive": "sensitive data",
413 ".hidden_subdir": {
414 "hidden_file.txt": "hidden content"
415 }
416 }
417 }),
418 )
419 .await;
420
421 // Configure settings explicitly
422 cx.update(|cx| {
423 SettingsStore::update_global(cx, |store, cx| {
424 store.update_user_settings(cx, |settings| {
425 settings.project.worktree.file_scan_exclusions = Some(vec![
426 "**/.secretdir".to_string(),
427 "**/.mymetadata".to_string(),
428 "**/.hidden_subdir".to_string(),
429 ]);
430 settings.project.worktree.private_files = Some(
431 vec![
432 "**/.mysecrets".to_string(),
433 "**/*.privatekey".to_string(),
434 "**/*.mysensitive".to_string(),
435 ]
436 .into(),
437 );
438 });
439 });
440 });
441
442 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
443 let tool = Arc::new(ListDirectoryTool::new(project));
444
445 // Listing root directory should exclude private and excluded files
446 let input = ListDirectoryToolInput {
447 path: "project".into(),
448 };
449 let output = cx
450 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
451 .await
452 .unwrap();
453
454 // Should include normal directories
455 assert!(output.contains("normal_dir"), "Should list normal_dir");
456 assert!(output.contains("visible_dir"), "Should list visible_dir");
457
458 // Should NOT include excluded or private files
459 assert!(
460 !output.contains(".secretdir"),
461 "Should not list .secretdir (file_scan_exclusions)"
462 );
463 assert!(
464 !output.contains(".mymetadata"),
465 "Should not list .mymetadata (file_scan_exclusions)"
466 );
467 assert!(
468 !output.contains(".mysecrets"),
469 "Should not list .mysecrets (private_files)"
470 );
471
472 // Trying to list an excluded directory should fail
473 let input = ListDirectoryToolInput {
474 path: "project/.secretdir".into(),
475 };
476 let output = cx
477 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
478 .await;
479 assert!(
480 output
481 .unwrap_err()
482 .to_string()
483 .contains("file_scan_exclusions"),
484 "Error should mention file_scan_exclusions"
485 );
486
487 // Listing a directory should exclude private files within it
488 let input = ListDirectoryToolInput {
489 path: "project/visible_dir".into(),
490 };
491 let output = cx
492 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
493 .await
494 .unwrap();
495
496 // Should include normal files
497 assert!(output.contains("normal.txt"), "Should list normal.txt");
498
499 // Should NOT include private files
500 assert!(
501 !output.contains("privatekey"),
502 "Should not list .privatekey files (private_files)"
503 );
504 assert!(
505 !output.contains("mysensitive"),
506 "Should not list .mysensitive files (private_files)"
507 );
508
509 // Should NOT include subdirectories that match exclusions
510 assert!(
511 !output.contains(".hidden_subdir"),
512 "Should not list .hidden_subdir (file_scan_exclusions)"
513 );
514 }
515
516 #[gpui::test]
517 async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
518 init_test(cx);
519
520 let fs = FakeFs::new(cx.executor());
521
522 // Create first worktree with its own private files
523 fs.insert_tree(
524 path!("/worktree1"),
525 json!({
526 ".zed": {
527 "settings.json": r#"{
528 "file_scan_exclusions": ["**/fixture.*"],
529 "private_files": ["**/secret.rs", "**/config.toml"]
530 }"#
531 },
532 "src": {
533 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
534 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
535 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
536 },
537 "tests": {
538 "test.rs": "mod tests { fn test_it() {} }",
539 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
540 }
541 }),
542 )
543 .await;
544
545 // Create second worktree with different private files
546 fs.insert_tree(
547 path!("/worktree2"),
548 json!({
549 ".zed": {
550 "settings.json": r#"{
551 "file_scan_exclusions": ["**/internal.*"],
552 "private_files": ["**/private.js", "**/data.json"]
553 }"#
554 },
555 "lib": {
556 "public.js": "export function greet() { return 'Hello from worktree2'; }",
557 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
558 "data.json": "{\"api_key\": \"json_secret_key\"}"
559 },
560 "docs": {
561 "README.md": "# Public Documentation",
562 "internal.md": "# Internal Secrets and Configuration"
563 }
564 }),
565 )
566 .await;
567
568 // Set global settings
569 cx.update(|cx| {
570 SettingsStore::update_global(cx, |store, cx| {
571 store.update_user_settings(cx, |settings| {
572 settings.project.worktree.file_scan_exclusions =
573 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
574 settings.project.worktree.private_files =
575 Some(vec!["**/.env".to_string()].into());
576 });
577 });
578 });
579
580 let project = Project::test(
581 fs.clone(),
582 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
583 cx,
584 )
585 .await;
586
587 // Wait for worktrees to be fully scanned
588 cx.executor().run_until_parked();
589
590 let tool = Arc::new(ListDirectoryTool::new(project));
591
592 // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
593 let input = ListDirectoryToolInput {
594 path: "worktree1/src".into(),
595 };
596 let output = cx
597 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
598 .await
599 .unwrap();
600 assert!(output.contains("main.rs"), "Should list main.rs");
601 assert!(
602 !output.contains("secret.rs"),
603 "Should not list secret.rs (local private_files)"
604 );
605 assert!(
606 !output.contains("config.toml"),
607 "Should not list config.toml (local private_files)"
608 );
609
610 // Test listing worktree1/tests - should exclude fixture.sql based on local settings
611 let input = ListDirectoryToolInput {
612 path: "worktree1/tests".into(),
613 };
614 let output = cx
615 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
616 .await
617 .unwrap();
618 assert!(output.contains("test.rs"), "Should list test.rs");
619 assert!(
620 !output.contains("fixture.sql"),
621 "Should not list fixture.sql (local file_scan_exclusions)"
622 );
623
624 // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
625 let input = ListDirectoryToolInput {
626 path: "worktree2/lib".into(),
627 };
628 let output = cx
629 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
630 .await
631 .unwrap();
632 assert!(output.contains("public.js"), "Should list public.js");
633 assert!(
634 !output.contains("private.js"),
635 "Should not list private.js (local private_files)"
636 );
637 assert!(
638 !output.contains("data.json"),
639 "Should not list data.json (local private_files)"
640 );
641
642 // Test listing worktree2/docs - should exclude internal.md based on local settings
643 let input = ListDirectoryToolInput {
644 path: "worktree2/docs".into(),
645 };
646 let output = cx
647 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
648 .await
649 .unwrap();
650 assert!(output.contains("README.md"), "Should list README.md");
651 assert!(
652 !output.contains("internal.md"),
653 "Should not list internal.md (local file_scan_exclusions)"
654 );
655
656 // Test trying to list an excluded directory directly
657 let input = ListDirectoryToolInput {
658 path: "worktree1/src/secret.rs".into(),
659 };
660 let output = cx
661 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
662 .await;
663 assert!(
664 output
665 .unwrap_err()
666 .to_string()
667 .contains("Cannot list directory"),
668 );
669 }
670}