Skip to main content

lash_tool_files/
ls.rs

1use std::collections::BTreeSet;
2use std::path::{Path, PathBuf};
3
4use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolRetryPolicy, ToolScheduling};
5use lash_tool_support::{
6    FS_DEFAULTS_PREAMBLE, StaticToolExecute, StaticToolProvider, build_path_entry,
7    filesystem_entries_output_schema, filesystem_entries_result, object_schema,
8    parse_optional_bool, parse_optional_usize_arg, rg_file_list, run_blocking,
9};
10
11/// List filesystem entries in a directory tree.
12#[derive(Default)]
13pub struct Ls;
14
15/// Build the cached `ls` tool provider.
16pub fn ls_provider() -> StaticToolProvider<Ls> {
17    StaticToolProvider::new(vec![ls_tool_definition()], Ls)
18}
19
20const DEFAULT_DEPTH: usize = 3;
21const MAX_ENTRIES: usize = 500;
22
23#[async_trait::async_trait]
24impl StaticToolExecute for Ls {
25    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
26        let args = call.args;
27        let base_dir = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
28
29        let ignore_patterns: Vec<&str> = args
30            .get("ignore")
31            .and_then(|v| v.as_array())
32            .map(|values| values.iter().filter_map(|value| value.as_str()).collect())
33            .unwrap_or_default();
34
35        let max_depth = match parse_depth(args) {
36            Ok(d) => d,
37            Err(e) => return e,
38        };
39        let limit = match parse_limit(args) {
40            Ok(d) => d,
41            Err(e) => return e,
42        };
43        let with_lines = match parse_optional_bool(args, "with_lines", false) {
44            Ok(v) => v,
45            Err(e) => return e,
46        };
47        let include_hidden = match parse_optional_bool(args, "include_hidden", true) {
48            Ok(v) => v,
49            Err(e) => return e,
50        };
51        let respect_gitignore = match parse_optional_bool(args, "respect_gitignore", true) {
52            Ok(v) => v,
53            Err(e) => return e,
54        };
55        let base = PathBuf::from(base_dir);
56        let ignore_patterns = ignore_patterns
57            .into_iter()
58            .map(|pattern| pattern.to_string())
59            .collect::<Vec<_>>();
60        run_blocking(move || {
61            if !base.is_dir() {
62                return ToolResult::err_fmt(format_args!("Not a directory: {}", base.display()));
63            }
64
65            let globs = ignore_patterns
66                .into_iter()
67                .map(|pattern| format!("!{pattern}"))
68                .collect::<Vec<_>>();
69
70            let files = match rg_file_list(&base, include_hidden, respect_gitignore, None, &globs) {
71                Ok(files) => files,
72                Err(err) => return err,
73            };
74
75            let all_paths = collect_ls_paths(&base, &files, max_depth);
76            let total_entries = all_paths.len();
77            let shown_paths = match limit {
78                Some(limit) => all_paths.into_iter().take(limit).collect::<Vec<_>>(),
79                None => all_paths.into_iter().collect::<Vec<_>>(),
80            };
81            let items = shown_paths
82                .into_iter()
83                .map(|path| build_path_entry(&path, with_lines).0)
84                .collect();
85            ToolResult::ok(filesystem_entries_result(items, total_entries))
86        })
87        .await
88    }
89}
90
91fn ls_tool_definition() -> ToolDefinition {
92    ToolDefinition::raw(
93                "tool:ls",
94                "ls",
95                [
96                    "List filesystem entries. ",
97                    FS_DEFAULTS_PREAMBLE,
98                    " Returns a record with `items` sorted by path. Each item has `path`, `kind`, `size_bytes`, `lines`, and `modified_at`. Defaults: depth=3, limit=500, with_lines=false, include_hidden=true, respect_gitignore=true.",
99                ]
100                .concat(),
101                object_schema(
102                    serde_json::json!({
103                        "path": {
104                            "type": "string",
105                            "default": ".",
106                            "description": "Directory to list (default: current directory)"
107                        },
108                        "ignore": {
109                            "type": "array",
110                            "items": { "type": "string" },
111                            "description": "Additional glob patterns to ignore."
112                        },
113                        "depth": {
114                            "type": ["integer", "null", "string"],
115                            "minimum": 1,
116                            "default": DEFAULT_DEPTH,
117                            "description": "Maximum directory depth to traverse (default: 3). Use null or \"none\" for no depth cap."
118                        },
119                        "limit": {
120                            "type": ["integer", "null", "string"],
121                            "minimum": 1,
122                            "default": MAX_ENTRIES,
123                            "description": "Maximum entries to return (default: 500). Use null or \"none\" for no cap."
124                        },
125                        "with_lines": {
126                            "type": "boolean",
127                            "default": false,
128                            "description": "Count text lines for file entries (`lines`). Default: false."
129                        },
130                        "include_hidden": {
131                            "type": "boolean",
132                            "default": true,
133                            "description": "Include dotfiles and dot-directories. Default: true."
134                        },
135                        "respect_gitignore": {
136                            "type": "boolean",
137                            "default": true,
138                            "description": "Respect `.gitignore` and related ignore files. When true (default), `.gitignore` is honored only inside Git repos. When false, ignore-file processing is fully disabled."
139                        }
140                    }),
141                    &[],
142                ),
143                filesystem_entries_output_schema(),
144            )
145            .with_examples(vec![
146                r#"await files.list({ path: ".", depth: 1, limit: 100 })?"#.into(),
147                r#"await files.list({ path: "crates/lash/src/tools", with_lines: true })?"#.into(),
148            ])
149            .with_agent_surface(lash_tool_support::agent_surface(
150                ["files"],
151                "list",
152                &["list_files", "list_directory"],
153            ))
154            .with_scheduling(ToolScheduling::Parallel)
155            .with_retry_policy(ToolRetryPolicy::safe(2, 25, 100))
156}
157
158fn parse_depth(args: &serde_json::Value) -> Result<Option<usize>, ToolResult> {
159    parse_optional_usize_arg(args, "depth", Some(DEFAULT_DEPTH), true, 1)
160}
161
162fn parse_limit(args: &serde_json::Value) -> Result<Option<usize>, ToolResult> {
163    parse_optional_usize_arg(args, "limit", Some(MAX_ENTRIES), true, 1)
164}
165
166fn collect_ls_paths(base: &Path, files: &[PathBuf], max_depth: Option<usize>) -> BTreeSet<PathBuf> {
167    let mut entries = BTreeSet::new();
168    for file in files {
169        let Ok(rel_path) = file.strip_prefix(base) else {
170            continue;
171        };
172        let components = rel_path.components().collect::<Vec<_>>();
173        if components.is_empty() {
174            continue;
175        }
176
177        let max_file_depth = max_depth.unwrap_or(usize::MAX);
178        if components.len() <= max_file_depth {
179            entries.insert(file.clone());
180        }
181
182        let dir_depth = components.len().saturating_sub(1);
183        let dirs_to_include = max_depth.map_or(dir_depth, |depth| depth.min(dir_depth));
184        let mut current = PathBuf::new();
185        for component in components.iter().take(dirs_to_include) {
186            current.push(component.as_os_str());
187            entries.insert(base.join(&current));
188        }
189    }
190    entries
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde_json::json;
197    use tempfile::TempDir;
198
199    fn items(result: &ToolResult) -> Vec<serde_json::Value> {
200        let value = result.value_for_projection();
201        value
202            .get("items")
203            .and_then(|v| v.as_array())
204            .unwrap()
205            .clone()
206    }
207
208    #[test]
209    fn ls_contract_documents_result_shape() {
210        let definition = ls_tool_definition();
211        assert_eq!(definition.contract.output_schema["type"], json!("object"));
212        assert!(definition.contract.output_schema["properties"]["items"].is_object());
213        assert!(
214            definition
215                .compact_contract()
216                .render_signature()
217                .contains("items")
218        );
219    }
220
221    #[tokio::test]
222    async fn test_ls_files_and_dirs() {
223        let dir = TempDir::new().unwrap();
224        std::fs::write(dir.path().join("file.txt"), "").unwrap();
225        std::fs::create_dir(dir.path().join("subdir")).unwrap();
226        std::fs::write(dir.path().join("subdir/nested.rs"), "").unwrap();
227        let result = lash_core::testing::run_tool(
228            &ls_provider(),
229            "ls",
230            &json!({"path": dir.path().to_str().unwrap()}),
231        )
232        .await;
233        assert!(result.is_success());
234        let arr = items(&result);
235        let paths: Vec<&str> = arr
236            .iter()
237            .filter_map(|v| v.get("path").and_then(|x| x.as_str()))
238            .collect();
239        assert!(paths.iter().any(|p| p.contains("file.txt")));
240        assert!(paths.iter().any(|p| p.contains("subdir")));
241        assert!(paths.iter().any(|p| p.contains("nested.rs")));
242    }
243
244    #[tokio::test]
245    async fn test_ls_empty_dir() {
246        let dir = TempDir::new().unwrap();
247        let result = lash_core::testing::run_tool(
248            &ls_provider(),
249            "ls",
250            &json!({"path": dir.path().to_str().unwrap()}),
251        )
252        .await;
253        assert!(result.is_success());
254        assert!(items(&result).is_empty());
255    }
256
257    #[tokio::test]
258    async fn test_ls_not_a_dir() {
259        let dir = TempDir::new().unwrap();
260        let path = dir.path().join("file.txt");
261        std::fs::write(&path, "").unwrap();
262        let result = lash_core::testing::run_tool(
263            &ls_provider(),
264            "ls",
265            &json!({"path": path.to_str().unwrap()}),
266        )
267        .await;
268        assert!(!result.is_success());
269    }
270
271    #[tokio::test]
272    async fn test_ls_depth_limit() {
273        let dir = TempDir::new().unwrap();
274        std::fs::create_dir_all(dir.path().join("a/b/c")).unwrap();
275        std::fs::write(dir.path().join("a/b/c/file.txt"), "").unwrap();
276        let result = lash_core::testing::run_tool(
277            &ls_provider(),
278            "ls",
279            &json!({"path": dir.path().to_str().unwrap(), "depth": 1}),
280        )
281        .await;
282        assert!(result.is_success());
283        let arr = items(&result);
284        let paths: Vec<&str> = arr
285            .iter()
286            .filter_map(|v| v.get("path").and_then(|x| x.as_str()))
287            .collect();
288        assert!(paths.iter().any(|p| p.ends_with("/a")));
289        assert!(!paths.iter().any(|p| p.ends_with("/b")));
290        assert!(!paths.iter().any(|p| p.ends_with("/file.txt")));
291    }
292
293    #[tokio::test]
294    async fn test_ls_limit_truncation_metadata() {
295        let dir = TempDir::new().unwrap();
296        std::fs::write(dir.path().join("a.txt"), "").unwrap();
297        std::fs::write(dir.path().join("b.txt"), "").unwrap();
298        std::fs::write(dir.path().join("c.txt"), "").unwrap();
299        let result = lash_core::testing::run_tool(
300            &ls_provider(),
301            "ls",
302            &json!({"path": dir.path().to_str().unwrap(), "limit": 2}),
303        )
304        .await;
305        assert!(result.is_success());
306        assert_eq!(items(&result).len(), 2);
307        let value = result.value_for_projection();
308        let truncated = value.get("truncated").and_then(|v| v.as_object()).unwrap();
309        assert_eq!(truncated.get("shown").and_then(|v| v.as_u64()), Some(2));
310        assert_eq!(truncated.get("total").and_then(|v| v.as_u64()), Some(3));
311        assert_eq!(truncated.get("omitted").and_then(|v| v.as_u64()), Some(1));
312    }
313
314    #[tokio::test]
315    async fn test_ls_with_lines() {
316        let dir = TempDir::new().unwrap();
317        std::fs::write(dir.path().join("a.txt"), "line1\nline2\n").unwrap();
318        let result = lash_core::testing::run_tool(
319            &ls_provider(),
320            "ls",
321            &json!({"path": dir.path().to_str().unwrap(), "with_lines": true}),
322        )
323        .await;
324        assert!(result.is_success());
325        let arr = items(&result);
326        assert_eq!(arr.len(), 1);
327        assert_eq!(arr[0].get("lines").and_then(|v| v.as_u64()), Some(2));
328    }
329
330    #[tokio::test]
331    async fn test_ls_includes_hidden_by_default() {
332        let dir = TempDir::new().unwrap();
333        std::fs::write(dir.path().join(".env"), "KEY=value\n").unwrap();
334        let result = lash_core::testing::run_tool(
335            &ls_provider(),
336            "ls",
337            &json!({"path": dir.path().to_str().unwrap()}),
338        )
339        .await;
340        assert!(result.is_success());
341        let paths: Vec<String> = items(&result)
342            .iter()
343            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
344            .collect();
345        assert!(paths.iter().any(|p| p.ends_with("/.env")));
346    }
347
348    #[tokio::test]
349    async fn test_ls_respect_gitignore_default_does_not_apply_gitignore_outside_repo() {
350        let dir = TempDir::new().unwrap();
351        std::fs::write(dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
352        std::fs::write(dir.path().join("ignored.txt"), "").unwrap();
353        let result = lash_core::testing::run_tool(
354            &ls_provider(),
355            "ls",
356            &json!({"path": dir.path().to_str().unwrap()}),
357        )
358        .await;
359        assert!(result.is_success());
360        let paths: Vec<String> = items(&result)
361            .iter()
362            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
363            .collect();
364        assert!(paths.iter().any(|p| p.ends_with("/ignored.txt")));
365    }
366
367    #[tokio::test]
368    async fn test_ls_respect_gitignore_false_disables_repo_gitignore() {
369        let dir = TempDir::new().unwrap();
370        std::process::Command::new("git")
371            .args(["init", "-q"])
372            .current_dir(dir.path())
373            .status()
374            .unwrap();
375        std::fs::write(dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
376        std::fs::write(dir.path().join("ignored.txt"), "").unwrap();
377        let result = lash_core::testing::run_tool(
378            &ls_provider(),
379            "ls",
380            &json!({
381                "path": dir.path().to_str().unwrap(),
382                "respect_gitignore": false
383            }),
384        )
385        .await;
386        assert!(result.is_success());
387        let paths: Vec<String> = items(&result)
388            .iter()
389            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
390            .collect();
391        assert!(paths.iter().any(|p| p.ends_with("/ignored.txt")));
392    }
393
394    #[tokio::test]
395    async fn test_ls_respect_gitignore_true_hides_repo_ignored_files() {
396        let dir = TempDir::new().unwrap();
397        std::process::Command::new("git")
398            .args(["init", "-q"])
399            .current_dir(dir.path())
400            .status()
401            .unwrap();
402        std::fs::write(dir.path().join(".gitignore"), "ignored.txt\n").unwrap();
403        std::fs::write(dir.path().join("ignored.txt"), "").unwrap();
404        let result = lash_core::testing::run_tool(
405            &ls_provider(),
406            "ls",
407            &json!({
408                "path": dir.path().to_str().unwrap()
409            }),
410        )
411        .await;
412        assert!(result.is_success());
413        let paths: Vec<String> = items(&result)
414            .iter()
415            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
416            .collect();
417        assert!(!paths.iter().any(|p| p.ends_with("/ignored.txt")));
418    }
419
420    #[tokio::test]
421    async fn test_ls_no_longer_hides_dot_git_entries_by_default() {
422        let dir = TempDir::new().unwrap();
423        std::process::Command::new("git")
424            .args(["init", "-q"])
425            .current_dir(dir.path())
426            .status()
427            .unwrap();
428        let result = lash_core::testing::run_tool(
429            &ls_provider(),
430            "ls",
431            &json!({"path": dir.path().to_str().unwrap()}),
432        )
433        .await;
434        assert!(result.is_success());
435        let paths: Vec<String> = items(&result)
436            .iter()
437            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
438            .collect();
439        assert!(paths.iter().any(|p| p.ends_with("/.git")));
440        assert!(paths.iter().any(|p| p.contains("/.git/")));
441    }
442
443    #[tokio::test]
444    async fn test_ls_does_not_hide_node_modules_by_default() {
445        let dir = TempDir::new().unwrap();
446        std::fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
447        std::fs::write(dir.path().join("node_modules/pkg/index.js"), "").unwrap();
448        let result = lash_core::testing::run_tool(
449            &ls_provider(),
450            "ls",
451            &json!({"path": dir.path().to_str().unwrap()}),
452        )
453        .await;
454        assert!(result.is_success());
455        let paths: Vec<String> = items(&result)
456            .iter()
457            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
458            .collect();
459        assert!(paths.iter().any(|p| p.contains("node_modules")));
460    }
461
462    #[tokio::test]
463    async fn test_ls_ignore_parameter_excludes_matching_paths() {
464        let dir = TempDir::new().unwrap();
465        std::fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
466        std::fs::write(dir.path().join("node_modules/pkg/index.js"), "").unwrap();
467        let result = lash_core::testing::run_tool(
468            &ls_provider(),
469            "ls",
470            &json!({
471                "path": dir.path().to_str().unwrap(),
472                "ignore": ["**/node_modules/**", "**/node_modules"]
473            }),
474        )
475        .await;
476        assert!(result.is_success());
477        let paths: Vec<String> = items(&result)
478            .iter()
479            .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
480            .collect();
481        assert!(!paths.iter().any(|p| p.contains("node_modules")));
482    }
483}