Skip to main content

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