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