Skip to main content

atomcode_core/tool/
list_dir.rs

1use 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); // Cap at 5
67
68        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}