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 SettingsStore::load_registered_settings(cx);
235
236 language::init(cx);
237 Project::init_settings(cx);
238 });
239 }
240
241 #[gpui::test]
242 async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
243 init_test(cx);
244
245 let fs = FakeFs::new(cx.executor());
246 fs.insert_tree(
247 path!("/project"),
248 json!({
249 "src": {
250 "main.rs": "fn main() {}",
251 "lib.rs": "pub fn hello() {}",
252 "models": {
253 "user.rs": "struct User {}",
254 "post.rs": "struct Post {}"
255 },
256 "utils": {
257 "helper.rs": "pub fn help() {}"
258 }
259 },
260 "tests": {
261 "integration_test.rs": "#[test] fn test() {}"
262 },
263 "README.md": "# Project",
264 "Cargo.toml": "[package]"
265 }),
266 )
267 .await;
268
269 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
270 let tool = Arc::new(ListDirectoryTool::new(project));
271
272 // Test listing root directory
273 let input = ListDirectoryToolInput {
274 path: "project".into(),
275 };
276 let output = cx
277 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
278 .await
279 .unwrap();
280 assert_eq!(
281 output,
282 platform_paths(indoc! {"
283 # Folders:
284 project/src
285 project/tests
286
287 # Files:
288 project/Cargo.toml
289 project/README.md
290 "})
291 );
292
293 // Test listing src directory
294 let input = ListDirectoryToolInput {
295 path: "project/src".into(),
296 };
297 let output = cx
298 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
299 .await
300 .unwrap();
301 assert_eq!(
302 output,
303 platform_paths(indoc! {"
304 # Folders:
305 project/src/models
306 project/src/utils
307
308 # Files:
309 project/src/lib.rs
310 project/src/main.rs
311 "})
312 );
313
314 // Test listing directory with only files
315 let input = ListDirectoryToolInput {
316 path: "project/tests".into(),
317 };
318 let output = cx
319 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
320 .await
321 .unwrap();
322 assert!(!output.contains("# Folders:"));
323 assert!(output.contains("# Files:"));
324 assert!(output.contains(&platform_paths("project/tests/integration_test.rs")));
325 }
326
327 #[gpui::test]
328 async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
329 init_test(cx);
330
331 let fs = FakeFs::new(cx.executor());
332 fs.insert_tree(
333 path!("/project"),
334 json!({
335 "empty_dir": {}
336 }),
337 )
338 .await;
339
340 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
341 let tool = Arc::new(ListDirectoryTool::new(project));
342
343 let input = ListDirectoryToolInput {
344 path: "project/empty_dir".into(),
345 };
346 let output = cx
347 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
348 .await
349 .unwrap();
350 assert_eq!(output, "project/empty_dir is empty.\n");
351 }
352
353 #[gpui::test]
354 async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
355 init_test(cx);
356
357 let fs = FakeFs::new(cx.executor());
358 fs.insert_tree(
359 path!("/project"),
360 json!({
361 "file.txt": "content"
362 }),
363 )
364 .await;
365
366 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
367 let tool = Arc::new(ListDirectoryTool::new(project));
368
369 // Test non-existent path
370 let input = ListDirectoryToolInput {
371 path: "project/nonexistent".into(),
372 };
373 let output = cx
374 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
375 .await;
376 assert!(output.unwrap_err().to_string().contains("Path not found"));
377
378 // Test trying to list a file instead of directory
379 let input = ListDirectoryToolInput {
380 path: "project/file.txt".into(),
381 };
382 let output = cx
383 .update(|cx| tool.run(input, ToolCallEventStream::test().0, cx))
384 .await;
385 assert!(
386 output
387 .unwrap_err()
388 .to_string()
389 .contains("is not a directory")
390 );
391 }
392
393 #[gpui::test]
394 async fn test_list_directory_security(cx: &mut TestAppContext) {
395 init_test(cx);
396
397 let fs = FakeFs::new(cx.executor());
398 fs.insert_tree(
399 path!("/project"),
400 json!({
401 "normal_dir": {
402 "file1.txt": "content",
403 "file2.txt": "content"
404 },
405 ".mysecrets": "SECRET_KEY=abc123",
406 ".secretdir": {
407 "config": "special configuration",
408 "secret.txt": "secret content"
409 },
410 ".mymetadata": "custom metadata",
411 "visible_dir": {
412 "normal.txt": "normal content",
413 "special.privatekey": "private key content",
414 "data.mysensitive": "sensitive data",
415 ".hidden_subdir": {
416 "hidden_file.txt": "hidden content"
417 }
418 }
419 }),
420 )
421 .await;
422
423 // Configure settings explicitly
424 cx.update(|cx| {
425 SettingsStore::update_global(cx, |store, cx| {
426 store.update_user_settings(cx, |settings| {
427 settings.project.worktree.file_scan_exclusions = Some(vec![
428 "**/.secretdir".to_string(),
429 "**/.mymetadata".to_string(),
430 "**/.hidden_subdir".to_string(),
431 ]);
432 settings.project.worktree.private_files = Some(
433 vec![
434 "**/.mysecrets".to_string(),
435 "**/*.privatekey".to_string(),
436 "**/*.mysensitive".to_string(),
437 ]
438 .into(),
439 );
440 });
441 });
442 });
443
444 let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
445 let tool = Arc::new(ListDirectoryTool::new(project));
446
447 // Listing root directory should exclude private and excluded files
448 let input = ListDirectoryToolInput {
449 path: "project".into(),
450 };
451 let output = cx
452 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
453 .await
454 .unwrap();
455
456 // Should include normal directories
457 assert!(output.contains("normal_dir"), "Should list normal_dir");
458 assert!(output.contains("visible_dir"), "Should list visible_dir");
459
460 // Should NOT include excluded or private files
461 assert!(
462 !output.contains(".secretdir"),
463 "Should not list .secretdir (file_scan_exclusions)"
464 );
465 assert!(
466 !output.contains(".mymetadata"),
467 "Should not list .mymetadata (file_scan_exclusions)"
468 );
469 assert!(
470 !output.contains(".mysecrets"),
471 "Should not list .mysecrets (private_files)"
472 );
473
474 // Trying to list an excluded directory should fail
475 let input = ListDirectoryToolInput {
476 path: "project/.secretdir".into(),
477 };
478 let output = cx
479 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
480 .await;
481 assert!(
482 output
483 .unwrap_err()
484 .to_string()
485 .contains("file_scan_exclusions"),
486 "Error should mention file_scan_exclusions"
487 );
488
489 // Listing a directory should exclude private files within it
490 let input = ListDirectoryToolInput {
491 path: "project/visible_dir".into(),
492 };
493 let output = cx
494 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
495 .await
496 .unwrap();
497
498 // Should include normal files
499 assert!(output.contains("normal.txt"), "Should list normal.txt");
500
501 // Should NOT include private files
502 assert!(
503 !output.contains("privatekey"),
504 "Should not list .privatekey files (private_files)"
505 );
506 assert!(
507 !output.contains("mysensitive"),
508 "Should not list .mysensitive files (private_files)"
509 );
510
511 // Should NOT include subdirectories that match exclusions
512 assert!(
513 !output.contains(".hidden_subdir"),
514 "Should not list .hidden_subdir (file_scan_exclusions)"
515 );
516 }
517
518 #[gpui::test]
519 async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
520 init_test(cx);
521
522 let fs = FakeFs::new(cx.executor());
523
524 // Create first worktree with its own private files
525 fs.insert_tree(
526 path!("/worktree1"),
527 json!({
528 ".zed": {
529 "settings.json": r#"{
530 "file_scan_exclusions": ["**/fixture.*"],
531 "private_files": ["**/secret.rs", "**/config.toml"]
532 }"#
533 },
534 "src": {
535 "main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
536 "secret.rs": "const API_KEY: &str = \"secret_key_1\";",
537 "config.toml": "[database]\nurl = \"postgres://localhost/db1\""
538 },
539 "tests": {
540 "test.rs": "mod tests { fn test_it() {} }",
541 "fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
542 }
543 }),
544 )
545 .await;
546
547 // Create second worktree with different private files
548 fs.insert_tree(
549 path!("/worktree2"),
550 json!({
551 ".zed": {
552 "settings.json": r#"{
553 "file_scan_exclusions": ["**/internal.*"],
554 "private_files": ["**/private.js", "**/data.json"]
555 }"#
556 },
557 "lib": {
558 "public.js": "export function greet() { return 'Hello from worktree2'; }",
559 "private.js": "const SECRET_TOKEN = \"private_token_2\";",
560 "data.json": "{\"api_key\": \"json_secret_key\"}"
561 },
562 "docs": {
563 "README.md": "# Public Documentation",
564 "internal.md": "# Internal Secrets and Configuration"
565 }
566 }),
567 )
568 .await;
569
570 // Set global settings
571 cx.update(|cx| {
572 SettingsStore::update_global(cx, |store, cx| {
573 store.update_user_settings(cx, |settings| {
574 settings.project.worktree.file_scan_exclusions =
575 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
576 settings.project.worktree.private_files =
577 Some(vec!["**/.env".to_string()].into());
578 });
579 });
580 });
581
582 let project = Project::test(
583 fs.clone(),
584 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
585 cx,
586 )
587 .await;
588
589 // Wait for worktrees to be fully scanned
590 cx.executor().run_until_parked();
591
592 let tool = Arc::new(ListDirectoryTool::new(project));
593
594 // Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
595 let input = ListDirectoryToolInput {
596 path: "worktree1/src".into(),
597 };
598 let output = cx
599 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
600 .await
601 .unwrap();
602 assert!(output.contains("main.rs"), "Should list main.rs");
603 assert!(
604 !output.contains("secret.rs"),
605 "Should not list secret.rs (local private_files)"
606 );
607 assert!(
608 !output.contains("config.toml"),
609 "Should not list config.toml (local private_files)"
610 );
611
612 // Test listing worktree1/tests - should exclude fixture.sql based on local settings
613 let input = ListDirectoryToolInput {
614 path: "worktree1/tests".into(),
615 };
616 let output = cx
617 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
618 .await
619 .unwrap();
620 assert!(output.contains("test.rs"), "Should list test.rs");
621 assert!(
622 !output.contains("fixture.sql"),
623 "Should not list fixture.sql (local file_scan_exclusions)"
624 );
625
626 // Test listing worktree2/lib - should exclude private.js and data.json based on local settings
627 let input = ListDirectoryToolInput {
628 path: "worktree2/lib".into(),
629 };
630 let output = cx
631 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
632 .await
633 .unwrap();
634 assert!(output.contains("public.js"), "Should list public.js");
635 assert!(
636 !output.contains("private.js"),
637 "Should not list private.js (local private_files)"
638 );
639 assert!(
640 !output.contains("data.json"),
641 "Should not list data.json (local private_files)"
642 );
643
644 // Test listing worktree2/docs - should exclude internal.md based on local settings
645 let input = ListDirectoryToolInput {
646 path: "worktree2/docs".into(),
647 };
648 let output = cx
649 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
650 .await
651 .unwrap();
652 assert!(output.contains("README.md"), "Should list README.md");
653 assert!(
654 !output.contains("internal.md"),
655 "Should not list internal.md (local file_scan_exclusions)"
656 );
657
658 // Test trying to list an excluded directory directly
659 let input = ListDirectoryToolInput {
660 path: "worktree1/src/secret.rs".into(),
661 };
662 let output = cx
663 .update(|cx| tool.clone().run(input, ToolCallEventStream::test().0, cx))
664 .await;
665 assert!(
666 output
667 .unwrap_err()
668 .to_string()
669 .contains("Cannot list directory"),
670 );
671 }
672}