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#[derive(Default)]
13pub struct Ls;
14
15pub 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(¤t));
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}