Skip to main content

lash_tool_files/
glob.rs

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/// Find files by glob pattern.
13#[derive(Default)]
14pub struct Glob;
15
16/// Build the cached `glob` tool provider.
17pub 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(&current) {
98                        matched_paths.insert(base.join(&current));
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}