Skip to main content

lash_tools/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, OptionalUsizeArg, StaticToolExecute, StaticToolProvider,
8    ToolDefinitionLashlangExt, TruncationMeta, default_glob_limit, default_path_dot,
9    execute_typed_tool, invalid_tool_args, non_empty_string, rg_file_list, run_blocking_value,
10};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13
14/// Find files by glob pattern.
15#[derive(Default)]
16pub struct Glob;
17
18/// Build the cached `glob` tool provider.
19pub fn glob_provider() -> StaticToolProvider<Glob> {
20    StaticToolProvider::new(vec![glob_tool_definition()], Glob)
21}
22
23#[derive(Clone, Debug, Deserialize, JsonSchema)]
24#[serde(deny_unknown_fields)]
25struct GlobArgs {
26    /// Glob pattern to match.
27    pattern: String,
28    /// Base directory to search in.
29    #[serde(default = "default_path_dot")]
30    path: String,
31    /// Maximum results to return. Use null or "none" for no cap.
32    #[serde(default = "default_glob_limit")]
33    limit: OptionalUsizeArg,
34}
35
36#[derive(Clone, Debug, Serialize, JsonSchema)]
37#[serde(deny_unknown_fields)]
38struct GlobOutput {
39    paths: Vec<String>,
40    truncated: Option<TruncationMeta>,
41}
42
43#[async_trait::async_trait]
44impl StaticToolExecute for Glob {
45    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
46        execute_typed_tool::<GlobArgs, GlobOutput, _, _>(call.args, |args| async move {
47            match run_blocking_value(move || execute_glob_sync(args)).await {
48                Ok(result) => result,
49                Err(err) => Err(ToolResult::err_fmt(format_args!("{err}"))),
50            }
51        })
52        .await
53    }
54}
55
56fn execute_glob_sync(args: GlobArgs) -> Result<GlobOutput, ToolResult> {
57    non_empty_string(&args.pattern, "pattern")?;
58    let limit = args.limit.into_option("limit", 1)?;
59    let base = PathBuf::from(args.path);
60    if !base.exists() {
61        return Err(ToolResult::err_fmt(format_args!(
62            "Path does not exist: {}",
63            base.display()
64        )));
65    }
66    if !base.is_dir() {
67        return Err(ToolResult::err_fmt(format_args!(
68            "{} is a file, not a directory. Pass the parent directory as path and use the pattern to match files.",
69            base.display()
70        )));
71    }
72
73    let glob = globset::GlobBuilder::new(&args.pattern)
74        .literal_separator(false)
75        .build()
76        .map_err(|err| invalid_tool_args(format!("Invalid glob pattern: {err}")))?;
77    let matcher = globset::GlobSetBuilder::new()
78        .add(glob)
79        .build()
80        .map_err(|err| ToolResult::err_fmt(format_args!("Failed to build glob matcher: {err}")))?;
81
82    let files = rg_file_list(&base, false, true, None, &[])?;
83
84    let mut matched_paths = BTreeSet::new();
85    for file in files {
86        let Ok(rel_path) = file.strip_prefix(&base) else {
87            continue;
88        };
89        if matcher.is_match(rel_path) {
90            matched_paths.insert(file.clone());
91        }
92        let components = rel_path.components().collect::<Vec<_>>();
93        if components.len() <= 1 {
94            continue;
95        }
96        let mut current = PathBuf::new();
97        for component in components.iter().take(components.len() - 1) {
98            current.push(component.as_os_str());
99            if matcher.is_match(&current) {
100                matched_paths.insert(base.join(&current));
101            }
102        }
103    }
104
105    let total_matches = matched_paths.len();
106    let mut paths = matched_paths
107        .into_iter()
108        .map(|path| path.to_string_lossy().to_string())
109        .collect::<Vec<_>>();
110    paths.sort();
111    if let Some(limit) = limit {
112        paths.truncate(limit);
113    }
114
115    let shown = paths.len();
116    let truncated = (total_matches > shown).then_some(TruncationMeta {
117        shown,
118        total: total_matches,
119        omitted: total_matches - shown,
120    });
121    Ok(GlobOutput { paths, truncated })
122}
123
124fn glob_tool_definition() -> ToolDefinition {
125    ToolDefinition::typed::<GlobArgs, GlobOutput>(
126                "tool:glob",
127                "glob",
128                [
129                    "Find filesystem paths by glob. ",
130                    FS_DEFAULTS_PREAMBLE,
131                    " Returns `paths` sorted lexicographically with truncation metadata. Defaults: path=\".\", limit=100.",
132                ]
133                .concat(),
134            )
135            .with_examples(vec![
136                r#"await files.glob({ pattern: "**/*.rs", path: "crates/lash/src", limit: 50 })?"#.into(),
137                r#"await files.glob({ pattern: "**/Cargo.toml", path: "." })?"#.into(),
138            ])
139            .with_lashlang_binding(lash_tool_support::lashlang_binding(
140                ["files"],
141                "glob",
142                &["find_files"],
143            ))
144            .with_scheduling(ToolScheduling::Parallel)
145            .with_retry_policy(ToolRetryPolicy::safe(2, 25, 100))
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use serde_json::json;
152    use tempfile::TempDir;
153
154    fn paths(result: &ToolResult) -> Vec<String> {
155        let value = result.value_for_projection();
156        value
157            .get("paths")
158            .and_then(|v| v.as_array())
159            .unwrap()
160            .iter()
161            .filter_map(|v| v.as_str().map(str::to_string))
162            .collect()
163    }
164
165    #[test]
166    fn glob_contract_documents_result_shape() {
167        let definition = glob_tool_definition();
168        assert_eq!(
169            definition.contract.output_schema.canonical["type"],
170            json!("object")
171        );
172        assert!(definition.contract.output_schema.canonical["properties"]["paths"].is_object());
173        assert!(
174            definition
175                .compact_contract()
176                .render_signature()
177                .contains("paths")
178        );
179    }
180
181    #[tokio::test]
182    async fn test_glob_matches() {
183        let dir = TempDir::new().unwrap();
184        std::fs::write(dir.path().join("a.rs"), "").unwrap();
185        std::fs::write(dir.path().join("b.rs"), "").unwrap();
186        std::fs::write(dir.path().join("c.txt"), "").unwrap();
187        let result = lash_core::testing::run_tool(
188            &glob_provider(),
189            "glob",
190            &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap()}),
191        )
192        .await;
193        assert!(result.is_success());
194        let paths = paths(&result);
195        assert!(paths.iter().any(|p| p.contains("a.rs")));
196        assert!(paths.iter().any(|p| p.contains("b.rs")));
197        assert!(!paths.iter().any(|p| p.contains("c.txt")));
198    }
199
200    #[tokio::test]
201    async fn test_glob_no_matches() {
202        let dir = TempDir::new().unwrap();
203        std::fs::write(dir.path().join("a.txt"), "").unwrap();
204        let result = lash_core::testing::run_tool(
205            &glob_provider(),
206            "glob",
207            &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap()}),
208        )
209        .await;
210        assert!(result.is_success());
211        assert!(paths(&result).is_empty());
212    }
213
214    #[tokio::test]
215    async fn test_glob_nested() {
216        let dir = TempDir::new().unwrap();
217        std::fs::create_dir_all(dir.path().join("sub/deep")).unwrap();
218        std::fs::write(dir.path().join("sub/deep/file.rs"), "").unwrap();
219        let result = lash_core::testing::run_tool(
220            &glob_provider(),
221            "glob",
222            &json!({"pattern": "**/*.rs", "path": dir.path().to_str().unwrap()}),
223        )
224        .await;
225        assert!(result.is_success());
226        let paths = paths(&result);
227        assert!(paths.iter().any(|p| p.contains("file.rs")));
228    }
229
230    #[tokio::test]
231    async fn test_glob_truncation_marker() {
232        let dir = TempDir::new().unwrap();
233        std::fs::write(dir.path().join("a.rs"), "").unwrap();
234        std::fs::write(dir.path().join("b.rs"), "").unwrap();
235        std::fs::write(dir.path().join("c.rs"), "").unwrap();
236        let result = lash_core::testing::run_tool(
237            &glob_provider(),
238            "glob",
239            &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap(), "limit": 2}),
240        )
241        .await;
242        assert!(result.is_success());
243        assert_eq!(paths(&result).len(), 2);
244        let value = result.value_for_projection();
245        let truncated = value.get("truncated").and_then(|v| v.as_object()).unwrap();
246        assert_eq!(truncated.get("shown").and_then(|v| v.as_u64()), Some(2));
247        assert_eq!(truncated.get("total").and_then(|v| v.as_u64()), Some(3));
248        assert_eq!(truncated.get("omitted").and_then(|v| v.as_u64()), Some(1));
249    }
250
251    #[tokio::test]
252    async fn test_glob_limit_none() {
253        let dir = TempDir::new().unwrap();
254        std::fs::write(dir.path().join("a.rs"), "").unwrap();
255        std::fs::write(dir.path().join("b.rs"), "").unwrap();
256        std::fs::write(dir.path().join("c.rs"), "").unwrap();
257        let result = lash_core::testing::run_tool(
258            &glob_provider(),
259            "glob",
260            &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap(), "limit": null}),
261        )
262        .await;
263        assert!(result.is_success());
264        assert_eq!(paths(&result).len(), 3);
265        assert!(
266            result
267                .value_for_projection()
268                .get("truncated")
269                .map(|v| v.is_null())
270                .unwrap_or(false)
271        );
272    }
273
274    #[tokio::test]
275    async fn test_glob_rejects_removed_list_like_options() {
276        let dir = TempDir::new().unwrap();
277        let result = lash_core::testing::run_tool(
278            &glob_provider(),
279            "glob",
280            &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap(), "with_lines": true}),
281        )
282        .await;
283        assert!(!result.is_success());
284    }
285
286    #[tokio::test]
287    async fn test_glob_excludes_hidden_by_default() {
288        let dir = TempDir::new().unwrap();
289        std::fs::write(dir.path().join(".hidden.rs"), "").unwrap();
290        std::fs::write(dir.path().join("shown.rs"), "").unwrap();
291        let result = lash_core::testing::run_tool(
292            &glob_provider(),
293            "glob",
294            &json!({"pattern": "*.rs", "path": dir.path().to_str().unwrap()}),
295        )
296        .await;
297        assert!(result.is_success());
298        let paths = paths(&result);
299        assert!(paths.iter().any(|p| p.ends_with("/shown.rs")));
300        assert!(!paths.iter().any(|p| p.ends_with("/.hidden.rs")));
301    }
302
303    #[tokio::test]
304    async fn test_glob_respects_repo_gitignore_by_default() {
305        let dir = TempDir::new().unwrap();
306        std::process::Command::new("git")
307            .args(["init", "-q"])
308            .current_dir(dir.path())
309            .status()
310            .unwrap();
311        std::fs::write(dir.path().join(".gitignore"), "ignored.rs\n").unwrap();
312        std::fs::write(dir.path().join("ignored.rs"), "").unwrap();
313        let result = lash_core::testing::run_tool(
314            &glob_provider(),
315            "glob",
316            &json!({
317                "pattern": "*.rs",
318                "path": dir.path().to_str().unwrap()
319            }),
320        )
321        .await;
322        assert!(result.is_success());
323        let paths = paths(&result);
324        assert!(!paths.iter().any(|p| p.ends_with("/ignored.rs")));
325    }
326
327    #[tokio::test]
328    async fn test_glob_excludes_dot_git_even_when_pattern_matches_it() {
329        let dir = TempDir::new().unwrap();
330        std::process::Command::new("git")
331            .args(["init", "-q"])
332            .current_dir(dir.path())
333            .status()
334            .unwrap();
335        let result = lash_core::testing::run_tool(
336            &glob_provider(),
337            "glob",
338            &json!({"pattern": ".git/**", "path": dir.path().to_str().unwrap()}),
339        )
340        .await;
341        assert!(result.is_success());
342        assert!(paths(&result).is_empty());
343    }
344
345    #[tokio::test]
346    async fn test_glob_excludes_node_modules_by_default() {
347        let dir = TempDir::new().unwrap();
348        std::fs::create_dir_all(dir.path().join("node_modules/pkg")).unwrap();
349        std::fs::write(dir.path().join("node_modules/pkg/index.js"), "").unwrap();
350        std::fs::write(dir.path().join("app.js"), "").unwrap();
351        let result = lash_core::testing::run_tool(
352            &glob_provider(),
353            "glob",
354            &json!({"pattern": "**/*.js", "path": dir.path().to_str().unwrap()}),
355        )
356        .await;
357        assert!(result.is_success());
358        let paths = paths(&result);
359        assert!(paths.iter().any(|p| p.ends_with("/app.js")));
360        assert!(!paths.iter().any(|p| p.contains("node_modules")));
361    }
362}