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