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#[derive(Default)]
16pub struct Glob;
17
18pub 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 pattern: String,
28 #[serde(default = "default_path_dot")]
30 path: String,
31 #[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(¤t) {
100 matched_paths.insert(base.join(¤t));
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}