use crate::schema::json_schema_for;
use anyhow::{Result, anyhow};
use assistant_tool::{ActionLog, Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::{Project, WorktreeSettings};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::{fmt::Write, path::Path, sync::Arc};
use ui::IconName;
use util::markdown::MarkdownInlineCode;
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct ListDirectoryToolInput {
/// The fully-qualified path of the directory to list in the project.
///
/// This path should never be absolute, and the first component
/// of the path should always be a root directory in a project.
///
///
/// If the project has the following root directories:
///
/// - directory1
/// - directory2
///
/// You can list the contents of `directory1` by using the path `directory1`.
///
///
///
/// If the project has the following root directories:
///
/// - foo
/// - bar
///
/// If you wanna list contents in the directory `foo/baz`, you should use the path `foo/baz`.
///
pub path: String,
}
pub struct ListDirectoryTool;
impl Tool for ListDirectoryTool {
fn name(&self) -> String {
"list_directory".into()
}
fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool {
false
}
fn may_perform_edits(&self) -> bool {
false
}
fn description(&self) -> String {
include_str!("./list_directory_tool/description.md").into()
}
fn icon(&self) -> IconName {
IconName::Folder
}
fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result {
json_schema_for::(format)
}
fn ui_text(&self, input: &serde_json::Value) -> String {
match serde_json::from_value::(input.clone()) {
Ok(input) => {
let path = MarkdownInlineCode(&input.path);
format!("List the {path} directory's contents")
}
Err(_) => "List directory".to_string(),
}
}
fn run(
self: Arc,
input: serde_json::Value,
_request: Arc,
project: Entity,
_action_log: Entity,
_model: Arc,
_window: Option,
cx: &mut App,
) -> ToolResult {
let input = match serde_json::from_value::(input) {
Ok(input) => input,
Err(err) => return Task::ready(Err(anyhow!(err))).into(),
};
// Sometimes models will return these even though we tell it to give a path and not a glob.
// When this happens, just list the root worktree directories.
if matches!(input.path.as_str(), "." | "" | "./" | "*") {
let output = project
.read(cx)
.worktrees(cx)
.filter_map(|worktree| {
worktree.read(cx).root_entry().and_then(|entry| {
if entry.is_dir() {
entry.path.to_str()
} else {
None
}
})
})
.collect::>()
.join("\n");
return Task::ready(Ok(output.into())).into();
}
let Some(project_path) = project.read(cx).find_project_path(&input.path, cx) else {
return Task::ready(Err(anyhow!("Path {} not found in project", input.path))).into();
};
let Some(worktree) = project
.read(cx)
.worktree_for_id(project_path.worktree_id, cx)
else {
return Task::ready(Err(anyhow!("Worktree not found"))).into();
};
// Check if the directory whose contents we're listing is itself excluded or private
let global_settings = WorktreeSettings::get_global(cx);
if global_settings.is_path_excluded(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot list directory because its path matches the user's global `file_scan_exclusions` setting: {}",
&input.path
)))
.into();
}
if global_settings.is_path_private(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot list directory because its path matches the user's global `private_files` setting: {}",
&input.path
)))
.into();
}
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
if worktree_settings.is_path_excluded(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot list directory because its path matches the user's worktree`file_scan_exclusions` setting: {}",
&input.path
)))
.into();
}
if worktree_settings.is_path_private(&project_path.path) {
return Task::ready(Err(anyhow!(
"Cannot list directory because its path matches the user's worktree `private_paths` setting: {}",
&input.path
)))
.into();
}
let worktree_snapshot = worktree.read(cx).snapshot();
let worktree_root_name = worktree.read(cx).root_name().to_string();
let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
};
if !entry.is_dir() {
return Task::ready(Err(anyhow!("{} is not a directory.", input.path))).into();
}
let worktree_snapshot = worktree.read(cx).snapshot();
let mut folders = Vec::new();
let mut files = Vec::new();
for entry in worktree_snapshot.child_entries(&project_path.path) {
// Skip private and excluded files and directories
if global_settings.is_path_private(&entry.path)
|| global_settings.is_path_excluded(&entry.path)
{
continue;
}
if project
.read(cx)
.find_project_path(&entry.path, cx)
.map(|project_path| {
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
worktree_settings.is_path_excluded(&project_path.path)
|| worktree_settings.is_path_private(&project_path.path)
})
.unwrap_or(false)
{
continue;
}
let full_path = Path::new(&worktree_root_name)
.join(&entry.path)
.display()
.to_string();
if entry.is_dir() {
folders.push(full_path);
} else {
files.push(full_path);
}
}
let mut output = String::new();
if !folders.is_empty() {
writeln!(output, "# Folders:\n{}", folders.join("\n")).unwrap();
}
if !files.is_empty() {
writeln!(output, "\n# Files:\n{}", files.join("\n")).unwrap();
}
if output.is_empty() {
writeln!(output, "{} is empty.", input.path).unwrap();
}
Task::ready(Ok(output.into())).into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use assistant_tool::Tool;
use gpui::{AppContext, TestAppContext, UpdateGlobal};
use indoc::indoc;
use language_model::fake_provider::FakeLanguageModel;
use project::{FakeFs, Project, WorktreeSettings};
use serde_json::json;
use settings::SettingsStore;
use util::path;
fn platform_paths(path_str: &str) -> String {
if cfg!(target_os = "windows") {
path_str.replace("/", "\\")
} else {
path_str.to_string()
}
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
language::init(cx);
Project::init_settings(cx);
});
}
#[gpui::test]
async fn test_list_directory_separates_files_and_dirs(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"src": {
"main.rs": "fn main() {}",
"lib.rs": "pub fn hello() {}",
"models": {
"user.rs": "struct User {}",
"post.rs": "struct Post {}"
},
"utils": {
"helper.rs": "pub fn help() {}"
}
},
"tests": {
"integration_test.rs": "#[test] fn test() {}"
},
"README.md": "# Project",
"Cargo.toml": "[package]"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
let tool = Arc::new(ListDirectoryTool);
// Test listing root directory
let input = json!({
"path": "project"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert_eq!(
content,
platform_paths(indoc! {"
# Folders:
project/src
project/tests
# Files:
project/Cargo.toml
project/README.md
"})
);
// Test listing src directory
let input = json!({
"path": "project/src"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert_eq!(
content,
platform_paths(indoc! {"
# Folders:
project/src/models
project/src/utils
# Files:
project/src/lib.rs
project/src/main.rs
"})
);
// Test listing directory with only files
let input = json!({
"path": "project/tests"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert!(!content.contains("# Folders:"));
assert!(content.contains("# Files:"));
assert!(content.contains(&platform_paths("project/tests/integration_test.rs")));
}
#[gpui::test]
async fn test_list_directory_empty_directory(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"empty_dir": {}
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
let tool = Arc::new(ListDirectoryTool);
let input = json!({
"path": "project/empty_dir"
});
let result = cx
.update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert_eq!(content, "project/empty_dir is empty.\n");
}
#[gpui::test]
async fn test_list_directory_error_cases(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"file.txt": "content"
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
let tool = Arc::new(ListDirectoryTool);
// Test non-existent path
let input = json!({
"path": "project/nonexistent"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Path not found"));
// Test trying to list a file instead of directory
let input = json!({
"path": "project/file.txt"
});
let result = cx
.update(|cx| tool.run(input, Arc::default(), project, action_log, model, None, cx))
.output
.await;
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("is not a directory")
);
}
#[gpui::test]
async fn test_list_directory_security(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/project"),
json!({
"normal_dir": {
"file1.txt": "content",
"file2.txt": "content"
},
".mysecrets": "SECRET_KEY=abc123",
".secretdir": {
"config": "special configuration",
"secret.txt": "secret content"
},
".mymetadata": "custom metadata",
"visible_dir": {
"normal.txt": "normal content",
"special.privatekey": "private key content",
"data.mysensitive": "sensitive data",
".hidden_subdir": {
"hidden_file.txt": "hidden content"
}
}
}),
)
.await;
// Configure settings explicitly
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::(cx, |settings| {
settings.file_scan_exclusions = Some(vec![
"**/.secretdir".to_string(),
"**/.mymetadata".to_string(),
"**/.hidden_subdir".to_string(),
]);
settings.private_files = Some(vec![
"**/.mysecrets".to_string(),
"**/*.privatekey".to_string(),
"**/*.mysensitive".to_string(),
]);
});
});
});
let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await;
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
let tool = Arc::new(ListDirectoryTool);
// Listing root directory should exclude private and excluded files
let input = json!({
"path": "project"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
// Should include normal directories
assert!(content.contains("normal_dir"), "Should list normal_dir");
assert!(content.contains("visible_dir"), "Should list visible_dir");
// Should NOT include excluded or private files
assert!(
!content.contains(".secretdir"),
"Should not list .secretdir (file_scan_exclusions)"
);
assert!(
!content.contains(".mymetadata"),
"Should not list .mymetadata (file_scan_exclusions)"
);
assert!(
!content.contains(".mysecrets"),
"Should not list .mysecrets (private_files)"
);
// Trying to list an excluded directory should fail
let input = json!({
"path": "project/.secretdir"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await;
assert!(
result.is_err(),
"Should not be able to list excluded directory"
);
assert!(
result
.unwrap_err()
.to_string()
.contains("file_scan_exclusions"),
"Error should mention file_scan_exclusions"
);
// Listing a directory should exclude private files within it
let input = json!({
"path": "project/visible_dir"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
// Should include normal files
assert!(content.contains("normal.txt"), "Should list normal.txt");
// Should NOT include private files
assert!(
!content.contains("privatekey"),
"Should not list .privatekey files (private_files)"
);
assert!(
!content.contains("mysensitive"),
"Should not list .mysensitive files (private_files)"
);
// Should NOT include subdirectories that match exclusions
assert!(
!content.contains(".hidden_subdir"),
"Should not list .hidden_subdir (file_scan_exclusions)"
);
}
#[gpui::test]
async fn test_list_directory_with_multiple_worktree_settings(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
// Create first worktree with its own private files
fs.insert_tree(
path!("/worktree1"),
json!({
".zed": {
"settings.json": r#"{
"file_scan_exclusions": ["**/fixture.*"],
"private_files": ["**/secret.rs", "**/config.toml"]
}"#
},
"src": {
"main.rs": "fn main() { println!(\"Hello from worktree1\"); }",
"secret.rs": "const API_KEY: &str = \"secret_key_1\";",
"config.toml": "[database]\nurl = \"postgres://localhost/db1\""
},
"tests": {
"test.rs": "mod tests { fn test_it() {} }",
"fixture.sql": "CREATE TABLE users (id INT, name VARCHAR(255));"
}
}),
)
.await;
// Create second worktree with different private files
fs.insert_tree(
path!("/worktree2"),
json!({
".zed": {
"settings.json": r#"{
"file_scan_exclusions": ["**/internal.*"],
"private_files": ["**/private.js", "**/data.json"]
}"#
},
"lib": {
"public.js": "export function greet() { return 'Hello from worktree2'; }",
"private.js": "const SECRET_TOKEN = \"private_token_2\";",
"data.json": "{\"api_key\": \"json_secret_key\"}"
},
"docs": {
"README.md": "# Public Documentation",
"internal.md": "# Internal Secrets and Configuration"
}
}),
)
.await;
// Set global settings
cx.update(|cx| {
SettingsStore::update_global(cx, |store, cx| {
store.update_user_settings::(cx, |settings| {
settings.file_scan_exclusions =
Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
settings.private_files = Some(vec!["**/.env".to_string()]);
});
});
});
let project = Project::test(
fs.clone(),
[path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
cx,
)
.await;
// Wait for worktrees to be fully scanned
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let model = Arc::new(FakeLanguageModel::default());
let tool = Arc::new(ListDirectoryTool);
// Test listing worktree1/src - should exclude secret.rs and config.toml based on local settings
let input = json!({
"path": "worktree1/src"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert!(content.contains("main.rs"), "Should list main.rs");
assert!(
!content.contains("secret.rs"),
"Should not list secret.rs (local private_files)"
);
assert!(
!content.contains("config.toml"),
"Should not list config.toml (local private_files)"
);
// Test listing worktree1/tests - should exclude fixture.sql based on local settings
let input = json!({
"path": "worktree1/tests"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert!(content.contains("test.rs"), "Should list test.rs");
assert!(
!content.contains("fixture.sql"),
"Should not list fixture.sql (local file_scan_exclusions)"
);
// Test listing worktree2/lib - should exclude private.js and data.json based on local settings
let input = json!({
"path": "worktree2/lib"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert!(content.contains("public.js"), "Should list public.js");
assert!(
!content.contains("private.js"),
"Should not list private.js (local private_files)"
);
assert!(
!content.contains("data.json"),
"Should not list data.json (local private_files)"
);
// Test listing worktree2/docs - should exclude internal.md based on local settings
let input = json!({
"path": "worktree2/docs"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await
.unwrap();
let content = result.content.as_str().unwrap();
assert!(content.contains("README.md"), "Should list README.md");
assert!(
!content.contains("internal.md"),
"Should not list internal.md (local file_scan_exclusions)"
);
// Test trying to list an excluded directory directly
let input = json!({
"path": "worktree1/src/secret.rs"
});
let result = cx
.update(|cx| {
tool.clone().run(
input,
Arc::default(),
project.clone(),
action_log.clone(),
model.clone(),
None,
cx,
)
})
.output
.await;
// This should fail because we're trying to list a file, not a directory
assert!(result.is_err(), "Should fail when trying to list a file");
}
}