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