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