atomcode_core/tool/
list_dir.rs1use anyhow::Result;
2use async_trait::async_trait;
3use serde::Deserialize;
4use serde_json::json;
5
6use super::{ApprovalRequirement, Tool, ToolContext, ToolDef, ToolResult};
7
8pub struct ListDirTool;
9
10#[derive(Deserialize)]
11struct ListDirArgs {
12 path: Option<String>,
13 #[serde(default = "default_depth")]
14 depth: usize,
15}
16
17fn default_depth() -> usize {
18 2
19}
20
21#[async_trait]
22impl Tool for ListDirTool {
23 fn definition(&self) -> ToolDef {
24 ToolDef {
25 name: "list_directory",
26 description: "List files and directories as a tree structure. Skips noise directories (node_modules, .git, target, __pycache__, etc.).\n\
27 Use this to understand project structure or explore unfamiliar directories.\n\
28 For finding specific files by name/extension, prefer glob instead.\n\
29 The depth parameter controls how deep to recurse (default 2, max 5).".to_string(),
30 parameters: json!({
31 "type": "object",
32 "properties": {
33 "path": { "type": "string", "description": "Directory to list (default: working directory)" },
34 "depth": { "type": "integer", "description": "Max depth to recurse (default 2)" }
35 },
36 "required": []
37 }),
38 }
39 }
40
41 fn approval(&self, _args: &str) -> ApprovalRequirement {
42 ApprovalRequirement::AutoApprove
43 }
44
45 fn approval_with_context(&self, args: &str, ctx: &ToolContext) -> ApprovalRequirement {
46 let parsed = match serde_json::from_str::<ListDirArgs>(args) {
47 Ok(parsed) => parsed,
48 Err(_) => return self.approval(args),
49 };
50 let working_dir = match ctx.working_dir.try_read() {
51 Ok(wd) => wd.clone(),
52 Err(_) => return self.approval(args),
53 };
54 let raw_path = parsed.path.as_deref().unwrap_or(".");
55 match super::approval_for_path(raw_path, &working_dir, super::ExternalPathAction::Enumerate)
56 {
57 Ok(approval) => approval,
58 Err(_) => self.approval(args),
59 }
60 }
61
62 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult> {
63 let parsed: ListDirArgs = serde_json::from_str(args)?;
64 let working_dir = ctx.working_dir.read().await.clone();
65 let path = parsed.path.as_deref().unwrap_or(".");
66 let depth = parsed.depth.min(5); let dir = match super::inspect_path_access(path, &working_dir) {
69 Ok(access) => access.path,
70 Err(err) => {
71 return Ok(ToolResult {
72 call_id: String::new(),
73 output: err.to_string(),
74 success: false,
75 });
76 }
77 };
78 if !dir.exists() {
79 return Ok(ToolResult {
80 call_id: String::new(),
81 output: format!("Directory not found: {}", dir.display()),
82 success: false,
83 });
84 }
85 if !dir.is_dir() {
86 return Ok(ToolResult {
87 call_id: String::new(),
88 output: format!("Not a directory: {}", dir.display()),
89 success: false,
90 });
91 }
92
93 let mut lines = Vec::new();
94 scan_dir(&mut lines, &dir, 0, depth);
95
96 if lines.len() > 200 {
97 lines.truncate(200);
98 lines.push(" ... (truncated at 200 entries)".to_string());
99 }
100
101 Ok(ToolResult {
102 call_id: String::new(),
103 output: lines.join("\n"),
104 success: true,
105 })
106 }
107}
108
109fn scan_dir(lines: &mut Vec<String>, dir: &std::path::Path, depth: usize, max_depth: usize) {
110 if depth > max_depth {
111 return;
112 }
113
114 let entries = match std::fs::read_dir(dir) {
115 Ok(e) => e,
116 Err(_) => return,
117 };
118
119 let mut items: Vec<_> = entries
120 .filter_map(|e| e.ok())
121 .filter(|e| !super::should_skip_dir(&e.file_name().to_string_lossy()))
122 .collect();
123 items.sort_by_key(|e| e.file_name());
124
125 for entry in &items {
126 let name = entry.file_name().to_string_lossy().to_string();
127 let indent = " ".repeat(depth);
128 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
129 if is_dir {
130 lines.push(format!("{}{}/", indent, name));
131 scan_dir(lines, &entry.path(), depth + 1, max_depth);
132 } else {
133 lines.push(format!("{}{}", indent, name));
134 }
135 }
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use tempfile::TempDir;
142
143 #[tokio::test]
144 async fn rejects_file_path_instead_of_returning_empty_listing() {
145 let dir = TempDir::new().unwrap();
146 let file = dir.path().join("README.md");
147 std::fs::write(&file, "# notes\n").unwrap();
148
149 let ctx = ToolContext::new(dir.path().to_path_buf());
150 let tool = ListDirTool;
151 let args = r#"{"path":"README.md"}"#;
152
153 let result = tool.execute(args, &ctx).await.unwrap();
154 assert!(!result.success);
155 assert!(
156 result.output.contains("Not a directory:"),
157 "unexpected output: {}",
158 result.output
159 );
160 assert!(
161 result.output.contains("README.md"),
162 "output should name the file path: {}",
163 result.output
164 );
165 }
166}