返回 CodeWhale
tests.rs
根目录 / crates / tui / src / tools / search / tests.rs
1 use std::fs;
2
3 use serde_json::{Value, json};
4 use tempfile::tempdir;
5 use tokio_util::sync::CancellationToken;
6
7 use crate::tools::spec::{ApprovalRequirement, ToolContext, ToolSpec};
8
9 use super::{GrepFilesTool, matches_glob};
10
11 #[test]
12 fn grep_description_matches_default_exclusion_behavior() {
13 let description = GrepFilesTool.description();
14
15 assert!(description.contains("skips common non-code directories"));
16 assert!(!description.contains("respects `.gitignore`"));
17 }
18
19 /// Representative of the ~150 shared `optional_*` call sites outside the
20 /// three that motivated the change: a wrong type is refused by name, an
21 /// absent or null field still takes its default.
22 #[tokio::test]
23 async fn grep_refuses_mistyped_optional_parameters_by_name() {
24 let tmp = tempdir().expect("tempdir");
25 fs::write(tmp.path().join("a.txt"), "needle\n").expect("write");
26 let ctx = ToolContext::new(tmp.path());
27
28 for (field, input) in [
29 (
30 "max_results",
31 json!({"pattern": "needle", "max_results": "10"}),
32 ),
33 (
34 "case_insensitive",
35 json!({"pattern": "needle", "case_insensitive": "true"}),
36 ),
37 (
38 "context_lines",
39 json!({"pattern": "needle", "context_lines": 2.5}),
40 ),
41 ("path", json!({"pattern": "needle", "path": ["."]})),
42 ] {
43 let err = GrepFilesTool
44 .execute(input, &ctx)
45 .await
46 .expect_err("a mistyped optional parameter must be refused");
47 let err = err.to_string();
48 assert!(err.contains(field), "error must name '{field}': {err}");
49 }
50
51 GrepFilesTool
52 .execute(
53 json!({"pattern": "needle", "max_results": Value::Null, "path": Value::Null}),
54 &ctx,
55 )
56 .await
57 .expect("explicit nulls read as absent");
58 }
59
60 #[test]
61 fn test_matches_glob_star() {
62 assert!(matches_glob("test.rs", "*.rs"));
63 assert!(matches_glob("foo.rs", "*.rs"));
64 assert!(!matches_glob("test.ts", "*.rs"));
65 assert!(!matches_glob("test.rs.bak", "*.rs"));
66 }
67
68 #[test]
69 fn test_matches_glob_question() {
70 assert!(matches_glob("test.rs", "test.??"));
71 assert!(!matches_glob("test.rs", "test.?"));
72 }
73
74 #[test]
75 fn test_matches_glob_double_star() {
76 assert!(matches_glob("src/main.rs", "src/**"));
77 assert!(matches_glob("src/lib/mod.rs", "src/**"));
78 assert!(matches_glob("node_modules/pkg/index.js", "node_modules/*"));
79 }
80
81 #[test]
82 fn test_matches_glob_path() {
83 assert!(matches_glob("src/main.rs", "src/*.rs"));
84 assert!(!matches_glob("lib/main.rs", "src/*.rs"));
85 }
86
87 /// Regression for #249: byte-index slicing panics on multi-byte
88 /// characters inside filenames like `dialogue_line__冰糖.mp3`.
89 #[test]
90 fn test_matches_glob_unicode_filename() {
91 let filename = "dialogue_line__冰糖.mp3";
92 // The filename should match *.mp3 without panicking.
93 assert!(matches_glob(filename, "*.mp3"));
94 // Asterisk matching against multi-byte characters must succeed.
95 assert!(matches_glob(filename, "dialogue_line__*"));
96 // Literal multi-byte characters inside the pattern must match.
97 assert!(matches_glob(filename, "*冰糖*"));
98 // Non-matching pattern must not panic either.
99 assert!(!matches_glob(filename, "nonexistent*"));
100 }
101
102 #[tokio::test]
103 async fn test_grep_files_basic() {
104 let tmp = tempdir().expect("tempdir");
105 let ctx = ToolContext::new(tmp.path().to_path_buf());
106
107 // Create test files
108 fs::write(
109 tmp.path().join("test.rs"),
110 "fn main() {\n println!(\"hello\");\n}\n",
111 )
112 .expect("write");
113 fs::write(
114 tmp.path().join("lib.rs"),
115 "pub fn hello() {}\npub fn world() {}\n",
116 )
117 .expect("write");
118
119 let tool = GrepFilesTool;
120 let result = tool
121 .execute(json!({"pattern": "fn"}), &ctx)
122 .await
123 .expect("execute");
124
125 assert!(result.success);
126 assert!(result.content.contains("main"));
127 assert!(result.content.contains("hello"));
128 }
129
130 #[tokio::test]
131 async fn test_grep_files_with_context() {
132 let tmp = tempdir().expect("tempdir");
133 let ctx = ToolContext::new(tmp.path().to_path_buf());
134
135 fs::write(
136 tmp.path().join("test.txt"),
137 "line1\nline2\nMATCH\nline4\nline5\n",
138 )
139 .expect("write");
140
141 let tool = GrepFilesTool;
142 let result = tool
143 .execute(json!({"pattern": "MATCH", "context_lines": 1}), &ctx)
144 .await
145 .expect("execute");
146
147 assert!(result.success);
148 assert!(result.content.contains("line2")); // context before
149 assert!(result.content.contains("line4")); // context after
150
151 let parsed: Value = serde_json::from_str(&result.content).unwrap();
152 let matches = parsed["matches"].as_array().unwrap();
153 assert_eq!(matches.len(), 1);
154 assert_eq!(matches[0]["context_before"], "line2");
155 assert_eq!(matches[0]["context_after"], "line4");
156 assert!(matches[0]["context_before"].is_string());
157 assert!(matches[0]["context_after"].is_string());
158 }
159
160 #[tokio::test]
161 async fn test_grep_files_multi_line_context_remains_arrays() {
162 let tmp = tempdir().expect("tempdir");
163 let ctx = ToolContext::new(tmp.path().to_path_buf());
164
165 fs::write(tmp.path().join("test.txt"), "a\nb\nMATCH\nd\ne\n").expect("write");
166
167 let tool = GrepFilesTool;
168 let result = tool
169 .execute(json!({"pattern": "MATCH", "context_lines": 2}), &ctx)
170 .await
171 .expect("execute");
172
173 let parsed: Value = serde_json::from_str(&result.content).unwrap();
174 let matches = parsed["matches"].as_array().unwrap();
175 assert_eq!(matches.len(), 1);
176 assert_eq!(matches[0]["context_before"], json!(["a", "b"]));
177 assert_eq!(matches[0]["context_after"], json!(["d", "e"]));
178 }
179
180 #[tokio::test]
181 async fn test_grep_files_case_insensitive() {
182 let tmp = tempdir().expect("tempdir");
183 let ctx = ToolContext::new(tmp.path().to_path_buf());
184
185 fs::write(
186 tmp.path().join("test.txt"),
187 "Hello World\nHELLO WORLD\nhello world\n",
188 )
189 .expect("write");
190
191 let tool = GrepFilesTool;
192 let result = tool
193 .execute(json!({"pattern": "hello", "case_insensitive": true}), &ctx)
194 .await
195 .expect("execute");
196
197 assert!(result.success);
198 // Should find all 3 lines
199 let parsed: Value = serde_json::from_str(&result.content).unwrap();
200 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 3);
201 }
202
203 #[tokio::test]
204 async fn test_grep_files_include_filter() {
205 let tmp = tempdir().expect("tempdir");
206 let ctx = ToolContext::new(tmp.path().to_path_buf());
207
208 fs::write(tmp.path().join("test.rs"), "fn test() {}\n").expect("write");
209 fs::write(tmp.path().join("test.js"), "function test() {}\n").expect("write");
210
211 let tool = GrepFilesTool;
212 let result = tool
213 .execute(json!({"pattern": "test", "include": ["*.rs"]}), &ctx)
214 .await
215 .expect("execute");
216
217 assert!(result.success);
218 // Should only match .rs file
219 let parsed: Value = serde_json::from_str(&result.content).unwrap();
220 let matches = parsed["matches"].as_array().unwrap();
221 assert_eq!(matches.len(), 1);
222 let file = matches[0]["file"].as_str().unwrap();
223 assert!(
224 file.rsplit('.')
225 .next()
226 .is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
227 );
228 }
229
230 #[tokio::test]
231 #[cfg(unix)]
232 async fn test_grep_files_does_not_follow_symlinked_files() {
233 let tmp = tempdir().expect("tempdir");
234 let root = tmp.path().join("workspace");
235 let outside = tmp.path().join("outside");
236 std::fs::create_dir_all(&root).expect("mkdir workspace");
237 std::fs::create_dir_all(&outside).expect("mkdir outside");
238 let outside_file = outside.join("secret.txt");
239 fs::write(&outside_file, "NEEDLE\n").expect("write outside");
240 std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink");
241
242 let ctx = ToolContext::new(root);
243 let tool = GrepFilesTool;
244 let result = tool
245 .execute(json!({"pattern": "NEEDLE"}), &ctx)
246 .await
247 .expect("execute");
248
249 assert!(result.success);
250 let parsed: Value = serde_json::from_str(&result.content).unwrap();
251 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 0);
252 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 0);
253 }
254
255 #[tokio::test]
256 #[cfg(unix)]
257 async fn test_grep_files_default_mode_skips_symlinked_directories_but_keeps_real_files() {
258 let tmp = tempdir().expect("tempdir");
259 let workspace = tmp.path().join("workspace");
260 let real_dir = workspace.join("real");
261 std::fs::create_dir_all(&real_dir).expect("mkdir workspace");
262 fs::write(real_dir.join("needle.txt"), "NEEDLE\n").expect("write real file");
263 std::os::unix::fs::symlink(&workspace, real_dir.join("loop")).expect("symlink loop");
264
265 let ctx = ToolContext::new(workspace);
266 let tool = GrepFilesTool;
267 let result = tool
268 .execute(json!({"pattern": "NEEDLE"}), &ctx)
269 .await
270 .expect("execute");
271
272 assert!(result.success);
273 let parsed: Value = serde_json::from_str(&result.content).unwrap();
274 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
275 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
276 let matches = parsed["matches"].as_array().unwrap();
277 assert_eq!(matches.len(), 1);
278 assert!(
279 matches[0]["file"]
280 .as_str()
281 .unwrap()
282 .ends_with("real/needle.txt")
283 );
284 }
285
286 #[tokio::test]
287 #[cfg(unix)]
288 async fn test_grep_files_follow_symlinks_avoids_directory_cycles() {
289 let tmp = tempdir().expect("tempdir");
290 let workspace = tmp.path().join("workspace");
291 let real_dir = workspace.join("real");
292 fs::create_dir_all(&real_dir).expect("mkdir");
293 fs::write(real_dir.join("needle.txt"), "NEEDLE\n").expect("write");
294 std::os::unix::fs::symlink(&workspace, real_dir.join("loop")).expect("symlink loop");
295
296 let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
297 let tool = GrepFilesTool;
298 let result = tool
299 .execute(json!({"pattern": "NEEDLE"}), &ctx)
300 .await
301 .expect("execute");
302
303 assert!(result.success);
304 let parsed: Value = serde_json::from_str(&result.content).unwrap();
305 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
306 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
307 let matches = parsed["matches"].as_array().unwrap();
308 assert!(matches[0]["file"].as_str().unwrap().ends_with("needle.txt"));
309 }
310
311 #[tokio::test]
312 async fn test_grep_files_invalid_regex() {
313 let tmp = tempdir().expect("tempdir");
314 let ctx = ToolContext::new(tmp.path().to_path_buf());
315
316 let tool = GrepFilesTool;
317 let result = tool.execute(json!({"pattern": "[invalid"}), &ctx).await;
318
319 assert!(result.is_err());
320 }
321
322 #[tokio::test]
323 async fn test_grep_files_respects_cancel_token() {
324 let tmp = tempdir().expect("tempdir");
325 fs::write(tmp.path().join("test.txt"), "needle\n").expect("write");
326 let cancel_token = CancellationToken::new();
327 cancel_token.cancel();
328 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
329
330 let tool = GrepFilesTool;
331 let err = tool
332 .execute(json!({"pattern": "needle"}), &ctx)
333 .await
334 .expect_err("cancelled grep should return an error");
335
336 assert!(
337 format!("{err:?}").contains("cancelled"),
338 "unexpected error: {err:?}"
339 );
340 }
341
342 #[tokio::test]
343 async fn test_grep_files_streaming_stops_at_max_results() {
344 let tmp = tempdir().expect("tempdir");
345 let ctx = ToolContext::new(tmp.path().to_path_buf());
346
347 // Two files with many matches each; the walk must stop once the
348 // budget is exhausted without dropping context for the last match.
349 for name in ["a.txt", "b.txt"] {
350 let body: String = (1..=20).map(|n| format!("needle {n}\n")).collect();
351 fs::write(tmp.path().join(name), body).expect("write");
352 }
353
354 let tool = GrepFilesTool;
355 let result = tool
356 .execute(json!({"pattern": "needle", "max_results": 5}), &ctx)
357 .await
358 .expect("execute");
359
360 assert!(result.success);
361 let parsed: Value = serde_json::from_str(&result.content).unwrap();
362 let matches = parsed["matches"].as_array().unwrap();
363 assert_eq!(matches.len(), 5);
364 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 5);
365 // All five matches must come from the first file walked, in file
366 // order (streaming preserves walk order).
367 let first_file = matches[0]["file"].as_str().unwrap().to_string();
368 for m in matches {
369 assert_eq!(m["file"].as_str().unwrap(), first_file);
370 }
371 // The final in-budget match still gets its full after-context even
372 // though the match budget was exhausted on it.
373 assert_eq!(
374 matches[4]["context_after"],
375 json!(["needle 6", "needle 7"]),
376 "last match must keep after-context lines"
377 );
378 }
379
380 #[tokio::test]
381 async fn test_grep_files_ring_buffer_context_matches_full_read() {
382 let tmp = tempdir().expect("tempdir");
383 let ctx = ToolContext::new(tmp.path().to_path_buf());
384
385 // Matches at the start, middle, and end of the file exercise the
386 // partial before-context (ring not yet full) and truncated
387 // after-context (EOF) paths.
388 fs::write(
389 tmp.path().join("ctx.txt"),
390 "MATCH first\nb1\nb2\nb3\nMATCH mid\na1\na2\na3\nMATCH last\n",
391 )
392 .expect("write");
393
394 let tool = GrepFilesTool;
395 let result = tool
396 .execute(json!({"pattern": "MATCH", "context_lines": 2}), &ctx)
397 .await
398 .expect("execute");
399
400 let parsed: Value = serde_json::from_str(&result.content).unwrap();
401 let matches = parsed["matches"].as_array().unwrap();
402 assert_eq!(matches.len(), 3);
403 assert_eq!(matches[0]["context_before"], json!([]));
404 assert_eq!(matches[0]["context_after"], json!(["b1", "b2"]));
405 assert_eq!(matches[1]["context_before"], json!(["b2", "b3"]));
406 assert_eq!(matches[1]["context_after"], json!(["a1", "a2"]));
407 assert_eq!(matches[2]["context_before"], json!(["a2", "a3"]));
408 assert_eq!(matches[2]["context_after"], json!([]));
409 assert_eq!(matches[2]["line_number"].as_u64().unwrap(), 9);
410 }
411
412 #[tokio::test]
413 async fn test_grep_files_streaming_skips_invalid_utf8_files() {
414 let tmp = tempdir().expect("tempdir");
415 let ctx = ToolContext::new(tmp.path().to_path_buf());
416
417 // Invalid UTF-8 after a matching line: the whole file must be
418 // skipped, matching the historical read_to_string behavior.
419 fs::write(
420 tmp.path().join("binary.txt"),
421 [b"needle\n".as_slice(), &[0xFF, 0xFE, 0x00]].concat(),
422 )
423 .expect("write");
424 fs::write(tmp.path().join("clean.txt"), "needle\n").expect("write");
425
426 let tool = GrepFilesTool;
427 let result = tool
428 .execute(json!({"pattern": "needle"}), &ctx)
429 .await
430 .expect("execute");
431
432 let parsed: Value = serde_json::from_str(&result.content).unwrap();
433 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
434 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
435 let matches = parsed["matches"].as_array().unwrap();
436 assert!(matches[0]["file"].as_str().unwrap().ends_with("clean.txt"));
437 }
438
439 #[test]
440 fn test_grep_files_tool_properties() {
441 let tool = GrepFilesTool;
442 assert_eq!(tool.name(), "grep_files");
443 assert!(tool.is_read_only());
444 assert!(tool.is_sandboxable());
445 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
446 }
447
448 #[test]
449 fn test_parallel_support_flags() {
450 let tool = GrepFilesTool;
451 assert!(tool.supports_parallel());
452 }
453
453 lines RUST