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#[derive(Default)]
16pub struct Ls;
17
18pub 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 #[serde(default = "default_path_dot")]
28 path: String,
29 #[serde(default)]
31 ignore: Vec<String>,
32 #[serde(default = "default_ls_depth")]
34 depth: OptionalUsizeArg,
35 #[serde(default = "default_ls_limit")]
37 limit: OptionalUsizeArg,
38 #[serde(default)]
40 with_lines: bool,
41 #[serde(default = "default_true")]
43 include_hidden: bool,
44 #[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(¤t));
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}