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