use anyhow::Result;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::json;
use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
pub struct ListDirTool;
#[derive(Deserialize)]
struct ListDirArgs {
path: Option<String>,
#[serde(default = "default_depth")]
depth: usize,
}
fn default_depth() -> usize {
2
}
#[async_trait]
impl Tool for ListDirTool {
fn definition(&self) -> ToolDef {
ToolDef {
name: "list_directory",
description: "List files and directories as a tree structure. Skips noise directories (node_modules, .git, target, __pycache__, etc.).\n\
Use this to understand project structure or explore unfamiliar directories.\n\
For finding specific files by name/extension, prefer glob instead.\n\
The depth parameter controls how deep to recurse (default 2, max 5).".to_string(),
parameters: json!({
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to list (default: working directory)" },
"depth": { "type": "integer", "description": "Max depth to recurse (default 2)" }
},
"required": []
}),
}
}
fn approval(&self, _args: &str) -> ApprovalRequirement {
ApprovalRequirement::AutoApprove
}
fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
let parsed = match serde_json::from_str::<ListDirArgs>(args) {
Ok(parsed) => parsed,
Err(_) => return self.approval(args),
};
let working_dir = match ctx.working_dir.try_read() {
Ok(wd) => wd.clone(),
Err(_) => return self.approval(args),
};
let raw_path = parsed.path.as_deref().unwrap_or(".");
match super::approval_for_path(raw_path, &working_dir, super::ExternalPathAction::Enumerate)
{
Ok(approval) => approval,
Err(_) => self.approval(args),
}
}
async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
let parsed: ListDirArgs = serde_json::from_str(args)?;
let working_dir = ctx.working_dir.read().await.clone();
let path = parsed.path.as_deref().unwrap_or(".");
let depth = parsed.depth.min(5);
let dir = match super::inspect_path_access(path, &working_dir) {
Ok(access) => access.path,
Err(err) => {
return Ok(ToolResult {
call_id: String::new(),
output: err.to_string(),
success: false,
});
}
};
if !dir.exists() {
return Ok(ToolResult {
call_id: String::new(),
output: format!("Directory not found: {}", dir.display()),
success: false,
});
}
if !dir.is_dir() {
return Ok(ToolResult {
call_id: String::new(),
output: format!("Not a directory: {}", dir.display()),
success: false,
});
}
let mut lines = Vec::new();
scan_dir(&mut lines, &dir, 0, depth);
if lines.len() > 200 {
lines.truncate(200);
lines.push(" ... (truncated at 200 entries)".to_string());
}
Ok(ToolResult {
call_id: String::new(),
output: lines.join("\n"),
success: true,
})
}
}
fn scan_dir(lines: &mut Vec<String>, dir: &std::path::Path, depth: usize, max_depth: usize) {
if depth > max_depth {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
let mut items: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| !super::should_skip_dir(&e.file_name().to_string_lossy()))
.collect();
items.sort_by_key(|e| e.file_name());
for entry in &items {
let name = entry.file_name().to_string_lossy().to_string();
let indent = " ".repeat(depth);
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
lines.push(format!("{}{}/", indent, name));
scan_dir(lines, &entry.path(), depth + 1, max_depth);
} else {
lines.push(format!("{}{}", indent, name));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn rejects_file_path_instead_of_returning_empty_listing() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("README.md");
std::fs::write(&file, "# notes\n").unwrap();
let ctx = ToolContext::new(dir.path().to_path_buf());
let tool = ListDirTool;
let args = r#"{"path":"README.md"}"#;
let result = tool.execute(args, &ctx).await.unwrap();
assert!(!result.success);
assert!(
result.output.contains("Not a directory:"),
"unexpected output: {}",
result.output
);
assert!(
result.output.contains("README.md"),
"output should name the file path: {}",
result.output
);
}
}