1use std::collections::BTreeSet;
2use std::path::PathBuf;
3
4use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolRetryPolicy, ToolScheduling};
5
6use lash_tool_support::{
7 FS_DEFAULTS_PREAMBLE, StaticToolExecute, StaticToolProvider, build_path_entry,
8 filesystem_entries_output_schema, filesystem_entries_result, object_schema,
9 parse_optional_bool, parse_optional_usize_arg, require_str, rg_file_list, run_blocking,
10};
11
12#[derive(Default)]
14pub struct Glob;
15
16pub fn glob_provider() -> StaticToolProvider<Glob> {
18 StaticToolProvider::new(vec![glob_tool_definition()], Glob)
19}
20
21const MAX_RESULTS: usize = 100;
22
23#[async_trait::async_trait]
24impl StaticToolExecute for Glob {
25 async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
26 let args = call.args;
27 let pattern = match require_str(args, "pattern") {
28 Ok(s) => s,
29 Err(e) => return e,
30 };
31
32 let base_dir = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
33 let limit = match parse_limit(args) {
34 Ok(limit) => limit,
35 Err(e) => return e,
36 };
37 let with_lines = match parse_optional_bool(args, "with_lines", false) {
38 Ok(v) => v,
39 Err(e) => return e,
40 };
41 let include_hidden = match parse_optional_bool(args, "include_hidden", true) {
42 Ok(v) => v,
43 Err(e) => return e,
44 };
45 let respect_gitignore = match parse_optional_bool(args, "respect_gitignore", true) {
46 Ok(v) => v,
47 Err(e) => return e,
48 };
49 let base = PathBuf::from(base_dir);
50 let pattern = pattern.to_string();
51
52 run_blocking(move || {
53 if !base.exists() {
54 return ToolResult::err_fmt(format_args!("Path does not exist: {}", base.display()));
55 }
56 if !base.is_dir() {
57 return ToolResult::err_fmt(format_args!(
58 "{} is a file, not a directory. Pass the parent directory as path and use the pattern to match files.",
59 base.display()
60 ));
61 }
62
63 let glob = match globset::GlobBuilder::new(&pattern)
64 .literal_separator(false)
65 .build()
66 {
67 Ok(glob) => glob,
68 Err(err) => return ToolResult::err_fmt(format_args!("Invalid glob pattern: {err}")),
69 };
70 let matcher = match globset::GlobSetBuilder::new().add(glob).build() {
71 Ok(matcher) => matcher,
72 Err(err) => {
73 return ToolResult::err_fmt(format_args!("Failed to build glob matcher: {err}"));
74 }
75 };
76
77 let files = match rg_file_list(&base, include_hidden, respect_gitignore, None, &[]) {
78 Ok(files) => files,
79 Err(err) => return err,
80 };
81
82 let mut matched_paths = BTreeSet::new();
83 for file in files {
84 let Ok(rel_path) = file.strip_prefix(&base) else {
85 continue;
86 };
87 if matcher.is_match(rel_path) {
88 matched_paths.insert(file.clone());
89 }
90 let components = rel_path.components().collect::<Vec<_>>();
91 if components.len() <= 1 {
92 continue;
93 }
94 let mut current = PathBuf::new();
95 for component in components.iter().take(components.len() - 1) {
96 current.push(component.as_os_str());
97 if matcher.is_match(¤t) {
98 matched_paths.insert(base.join(¤t));
99 }
100 }
101 }
102
103 let mut matches = matched_paths
104 .into_iter()
105 .map(|path| build_path_entry(&path, with_lines))
106 .collect::<Vec<_>>();
107 matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.path.cmp(&b.0.path)));
108 let total_matches = matches.len();
109 if let Some(limit) = limit {
110 matches.truncate(limit);
111 }
112
113 let items = matches.into_iter().map(|(entry, _)| entry).collect();
114 ToolResult::ok(filesystem_entries_result(items, total_matches))
115 })
116 .await
117 }
118}
119
120fn glob_tool_definition() -> ToolDefinition {
121 ToolDefinition::raw(
122 "tool:glob",
123 "glob",
124 [
125 "Find filesystem entries by glob. ",
126 FS_DEFAULTS_PREAMBLE,
127 " Returns a record with `items` sorted by `modified_at` (newest first). Each item has `path`, `kind`, `size_bytes`, `lines`, and `modified_at`. Defaults: limit=100, with_lines=false, include_hidden=true, respect_gitignore=true.",
128 ]
129 .concat(),
130 object_schema(
131 serde_json::json!({
132 "pattern": { "type": "string" },
133 "path": {
134 "type": "string",
135 "default": ".",
136 "description": "Base directory to search in (default: current directory)"
137 },
138 "limit": {
139 "type": ["integer", "null", "string"],
140 "minimum": 1,
141 "default": MAX_RESULTS,
142 "description": "Maximum results to return (default: 100). Use null or \"none\" for no cap."
143 },
144 "with_lines": {
145 "type": "boolean",
146 "default": false,
147 "description": "Count text lines for file entries (`lines`). Default: false."
148 },
149 "include_hidden": {
150 "type": "boolean",
151 "default": true,
152 "description": "Include dotfiles and dot-directories. Default: true."
153 },
154 "respect_gitignore": {
155 "type": "boolean",
156 "default": true,
157 "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."
158 }
159 }),
160 &["pattern"],
161 ),
162 filesystem_entries_output_schema(),
163 )
164 .with_examples(vec![
165 r#"await files.glob({ pattern: "**/*.rs", path: "crates/lash/src", limit: 50 })?"#.into(),
166 r#"await files.glob({ pattern: "**/Cargo.toml", path: "." })?"#.into(),
167 ])
168 .with_agent_surface(lash_tool_support::agent_surface(
169 ["files"],
170 "glob",
171 &["find_files"],
172 ))
173 .with_scheduling(ToolScheduling::Parallel)
174 .with_retry_policy(ToolRetryPolicy::safe(2, 25, 100))
175}
176
177fn parse_limit(args: &serde_json::Value) -> Result<Option<usize>, ToolResult> {
178 parse_optional_usize_arg(args, "limit", Some(MAX_RESULTS), true, 1)
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use serde_json::json;
185 use tempfile::TempDir;
186
187 fn items(result: &ToolResult) -> Vec<serde_json::Value> {
188 let value = result.value_for_projection();
189 value
190 .get("items")
191 .and_then(|v| v.as_array())
192 .unwrap()
193 .clone()
194 }
195
196 #[test]
197 fn glob_contract_documents_result_shape() {
198 let definition = glob_tool_definition();
199 assert_eq!(definition.contract.output_schema["type"], json!("object"));
200 assert!(definition.contract.output_schema["properties"]["items"].is_object());
201 assert!(
202 definition
203 .compact_contract()
204 .render_signature()
205 .contains("items")
206 );
207 }
208
209 #[tokio::test]
210 async fn test_glob_matches() {
211 let dir = TempDir::new().unwrap();
212 std::fs::write(dir.path().join("a.rs"), "").unwrap();
213 std::fs::write(dir.path().join("b.rs"), "").unwrap();
214 std::fs::write(dir.path().join("c.txt"), "").unwrap();
215 let result = lash_core::testing::run_tool(
216 &glob_provider(),
217 "glob",
218 &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap()}),
219 )
220 .await;
221 assert!(result.is_success());
222 let arr = items(&result);
223 let paths: Vec<&str> = arr
224 .iter()
225 .filter_map(|v| v.get("path").and_then(|x| x.as_str()))
226 .collect();
227 assert!(paths.iter().any(|p| p.contains("a.rs")));
228 assert!(paths.iter().any(|p| p.contains("b.rs")));
229 assert!(!paths.iter().any(|p| p.contains("c.txt")));
230 assert!(
231 arr.iter()
232 .all(|v| v.get("size_bytes").and_then(|x| x.as_u64()).is_some())
233 );
234 assert!(
235 arr.iter()
236 .all(|v| v.get("modified_at").and_then(|x| x.as_str()).is_some())
237 );
238 assert!(
239 arr.iter()
240 .all(|v| v.get("lines").map(|x| x.is_null()).unwrap_or(false))
241 );
242 }
243
244 #[tokio::test]
245 async fn test_glob_no_matches() {
246 let dir = TempDir::new().unwrap();
247 std::fs::write(dir.path().join("a.txt"), "").unwrap();
248 let result = lash_core::testing::run_tool(
249 &glob_provider(),
250 "glob",
251 &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap()}),
252 )
253 .await;
254 assert!(result.is_success());
255 assert!(items(&result).is_empty());
256 }
257
258 #[tokio::test]
259 async fn test_glob_nested() {
260 let dir = TempDir::new().unwrap();
261 std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
262 std::fs::write(dir.path().join("sub/deep/file.rs"), "").unwrap();
263 let result = lash_core::testing::run_tool(
264 &glob_provider(),
265 "glob",
266 &json!({"pattern": "**/*.rs", "path": dir.path().to_str().unwrap()}),
267 )
268 .await;
269 assert!(result.is_success());
270 let arr = items(&result);
271 let paths: Vec<&str> = arr
272 .iter()
273 .filter_map(|v| v.get("path").and_then(|x| x.as_str()))
274 .collect();
275 assert!(paths.iter().any(|p| p.contains("file.rs")));
276 }
277
278 #[tokio::test]
279 async fn test_glob_truncation_marker() {
280 let dir = TempDir::new().unwrap();
281 std::fs::write(dir.path().join("a.rs"), "").unwrap();
282 std::fs::write(dir.path().join("b.rs"), "").unwrap();
283 std::fs::write(dir.path().join("c.rs"), "").unwrap();
284 let result = lash_core::testing::run_tool(
285 &glob_provider(),
286 "glob",
287 &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap(), "limit": 2}),
288 )
289 .await;
290 assert!(result.is_success());
291 let arr = items(&result);
292 assert_eq!(arr.len(), 2);
293 let value = result.value_for_projection();
294 let truncated = value.get("truncated").and_then(|v| v.as_object()).unwrap();
295 assert_eq!(truncated.get("shown").and_then(|v| v.as_u64()), Some(2));
296 assert_eq!(truncated.get("total").and_then(|v| v.as_u64()), Some(3));
297 assert_eq!(truncated.get("omitted").and_then(|v| v.as_u64()), Some(1));
298 }
299
300 #[tokio::test]
301 async fn test_glob_limit_none() {
302 let dir = TempDir::new().unwrap();
303 std::fs::write(dir.path().join("a.rs"), "").unwrap();
304 std::fs::write(dir.path().join("b.rs"), "").unwrap();
305 std::fs::write(dir.path().join("c.rs"), "").unwrap();
306 let result = lash_core::testing::run_tool(
307 &glob_provider(),
308 "glob",
309 &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap(), "limit": null}),
310 )
311 .await;
312 assert!(result.is_success());
313 let arr = items(&result);
314 assert_eq!(arr.len(), 3);
315 assert!(
316 result
317 .value_for_projection()
318 .get("truncated")
319 .map(|v| v.is_null())
320 .unwrap_or(false)
321 );
322 }
323
324 #[tokio::test]
325 async fn test_glob_with_lines() {
326 let dir = TempDir::new().unwrap();
327 std::fs::write(dir.path().join("a.rs"), "line1\nline2\nline3\n").unwrap();
328 let result = lash_core::testing::run_tool(
329 &glob_provider(),
330 "glob",
331 &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap(), "with_lines": true}),
332 )
333 .await;
334 assert!(result.is_success());
335 let arr = items(&result);
336 assert_eq!(arr.len(), 1);
337 assert_eq!(arr[0].get("lines").and_then(|v| v.as_u64()), Some(3));
338 }
339
340 #[tokio::test]
341 async fn test_glob_includes_hidden_by_default() {
342 let dir = TempDir::new().unwrap();
343 std::fs::write(dir.path().join(".hidden.rs"), "").unwrap();
344 let result = lash_core::testing::run_tool(
345 &glob_provider(),
346 "glob",
347 &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap()}),
348 )
349 .await;
350 assert!(result.is_success());
351 let paths: Vec<String> = items(&result)
352 .iter()
353 .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
354 .collect();
355 assert!(paths.iter().any(|p| p.ends_with("/.hidden.rs")));
356 }
357
358 #[tokio::test]
359 async fn test_glob_respect_gitignore_false_disables_repo_gitignore() {
360 let dir = TempDir::new().unwrap();
361 std::process::Command::new("git")
362 .args(["init", "-q"])
363 .current_dir(dir.path())
364 .status()
365 .unwrap();
366 std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
367 std::fs::write(dir.path().join("ignored.rs"), "").unwrap();
368 let result = lash_core::testing::run_tool(
369 &glob_provider(),
370 "glob",
371 &json!({
372 "pattern": "*.rs",
373 "path": dir.path().to_str().unwrap(),
374 "respect_gitignore": false
375 }),
376 )
377 .await;
378 assert!(result.is_success());
379 let paths: Vec<String> = items(&result)
380 .iter()
381 .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
382 .collect();
383 assert!(paths.iter().any(|p| p.ends_with("/ignored.rs")));
384 }
385
386 #[tokio::test]
387 async fn test_glob_respect_gitignore_true_hides_repo_ignored_files() {
388 let dir = TempDir::new().unwrap();
389 std::process::Command::new("git")
390 .args(["init", "-q"])
391 .current_dir(dir.path())
392 .status()
393 .unwrap();
394 std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
395 std::fs::write(dir.path().join("ignored.rs"), "").unwrap();
396 let result = lash_core::testing::run_tool(
397 &glob_provider(),
398 "glob",
399 &json!({
400 "pattern": "*.rs",
401 "path": dir.path().to_str().unwrap()
402 }),
403 )
404 .await;
405 assert!(result.is_success());
406 let paths: Vec<String> = items(&result)
407 .iter()
408 .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
409 .collect();
410 assert!(!paths.iter().any(|p| p.ends_with("/ignored.rs")));
411 }
412
413 #[tokio::test]
414 async fn test_glob_no_longer_hides_dot_git_entries_by_default() {
415 let dir = TempDir::new().unwrap();
416 std::process::Command::new("git")
417 .args(["init", "-q"])
418 .current_dir(dir.path())
419 .status()
420 .unwrap();
421 let result = lash_core::testing::run_tool(
422 &glob_provider(),
423 "glob",
424 &json!({"pattern": ".git/**", "path": dir.path().to_str().unwrap()}),
425 )
426 .await;
427 assert!(result.is_success());
428 let paths: Vec<String> = items(&result)
429 .iter()
430 .filter_map(|v| v.get("path").and_then(|x| x.as_str()).map(str::to_string))
431 .collect();
432 assert!(paths.iter().any(|p| p.contains("/.git/")));
433 }
434}