返回 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 /// The model supplies the pattern, so it compiles through the bounded
61 /// regex cache: an over-long pattern is refused, not compiled.
62 #[tokio::test]
63 async fn grep_refuses_a_pattern_past_the_user_regex_length_cap() {
64 let tmp = tempdir().expect("tempdir");
65 fs::write(tmp.path().join("a.txt"), "needle\n").expect("write");
66 let ctx = ToolContext::new(tmp.path());
67 let long = "a".repeat(crate::regex_cache::MAX_USER_REGEX_PATTERN_BYTES + 1);
68 let err = GrepFilesTool
69 .execute(json!({ "pattern": long }), &ctx)
70 .await
71 .expect_err("an over-long pattern must be refused");
72 assert!(err.to_string().contains("limit"), "{err}");
73 }
74
75 #[test]
76 fn test_matches_glob_star() {
77 assert!(matches_glob("test.rs", "*.rs"));
78 assert!(matches_glob("foo.rs", "*.rs"));
79 assert!(!matches_glob("test.ts", "*.rs"));
80 assert!(!matches_glob("test.rs.bak", "*.rs"));
81 }
82
83 #[test]
84 fn test_matches_glob_question() {
85 assert!(matches_glob("test.rs", "test.??"));
86 assert!(!matches_glob("test.rs", "test.?"));
87 }
88
89 #[test]
90 fn test_matches_glob_double_star() {
91 assert!(matches_glob("src/main.rs", "src/**"));
92 assert!(matches_glob("src/lib/mod.rs", "src/**"));
93 assert!(matches_glob("node_modules/pkg/index.js", "node_modules/*"));
94 }
95
96 #[test]
97 fn test_matches_glob_path() {
98 assert!(matches_glob("src/main.rs", "src/*.rs"));
99 assert!(!matches_glob("lib/main.rs", "src/*.rs"));
100 }
101
102 /// Regression for #249: byte-index slicing panics on multi-byte
103 /// characters inside filenames like `dialogue_line__冰糖.mp3`.
104 #[test]
105 fn test_matches_glob_unicode_filename() {
106 let filename = "dialogue_line__冰糖.mp3";
107 // The filename should match *.mp3 without panicking.
108 assert!(matches_glob(filename, "*.mp3"));
109 // Asterisk matching against multi-byte characters must succeed.
110 assert!(matches_glob(filename, "dialogue_line__*"));
111 // Literal multi-byte characters inside the pattern must match.
112 assert!(matches_glob(filename, "*冰糖*"));
113 // Non-matching pattern must not panic either.
114 assert!(!matches_glob(filename, "nonexistent*"));
115 }
116
117 #[tokio::test]
118 async fn test_grep_files_basic() {
119 let tmp = tempdir().expect("tempdir");
120 let ctx = ToolContext::new(tmp.path().to_path_buf());
121
122 // Create test files
123 fs::write(
124 tmp.path().join("test.rs"),
125 "fn main() {\n println!(\"hello\");\n}\n",
126 )
127 .expect("write");
128 fs::write(
129 tmp.path().join("lib.rs"),
130 "pub fn hello() {}\npub fn world() {}\n",
131 )
132 .expect("write");
133
134 let tool = GrepFilesTool;
135 let result = tool
136 .execute(json!({"pattern": "fn"}), &ctx)
137 .await
138 .expect("execute");
139
140 assert!(result.success);
141 assert!(result.content.contains("main"));
142 assert!(result.content.contains("hello"));
143 }
144
145 #[tokio::test]
146 async fn test_grep_files_with_context() {
147 let tmp = tempdir().expect("tempdir");
148 let ctx = ToolContext::new(tmp.path().to_path_buf());
149
150 fs::write(
151 tmp.path().join("test.txt"),
152 "line1\nline2\nMATCH\nline4\nline5\n",
153 )
154 .expect("write");
155
156 let tool = GrepFilesTool;
157 let result = tool
158 .execute(json!({"pattern": "MATCH", "context_lines": 1}), &ctx)
159 .await
160 .expect("execute");
161
162 assert!(result.success);
163 assert!(result.content.contains("line2")); // context before
164 assert!(result.content.contains("line4")); // context after
165
166 let parsed: Value = serde_json::from_str(&result.content).unwrap();
167 let matches = parsed["matches"].as_array().unwrap();
168 assert_eq!(matches.len(), 1);
169 assert_eq!(matches[0]["context_before"], "line2");
170 assert_eq!(matches[0]["context_after"], "line4");
171 assert!(matches[0]["context_before"].is_string());
172 assert!(matches[0]["context_after"].is_string());
173 }
174
175 #[tokio::test]
176 async fn test_grep_files_multi_line_context_remains_arrays() {
177 let tmp = tempdir().expect("tempdir");
178 let ctx = ToolContext::new(tmp.path().to_path_buf());
179
180 fs::write(tmp.path().join("test.txt"), "a\nb\nMATCH\nd\ne\n").expect("write");
181
182 let tool = GrepFilesTool;
183 let result = tool
184 .execute(json!({"pattern": "MATCH", "context_lines": 2}), &ctx)
185 .await
186 .expect("execute");
187
188 let parsed: Value = serde_json::from_str(&result.content).unwrap();
189 let matches = parsed["matches"].as_array().unwrap();
190 assert_eq!(matches.len(), 1);
191 assert_eq!(matches[0]["context_before"], json!(["a", "b"]));
192 assert_eq!(matches[0]["context_after"], json!(["d", "e"]));
193 }
194
195 #[tokio::test]
196 async fn test_grep_files_case_insensitive() {
197 let tmp = tempdir().expect("tempdir");
198 let ctx = ToolContext::new(tmp.path().to_path_buf());
199
200 fs::write(
201 tmp.path().join("test.txt"),
202 "Hello World\nHELLO WORLD\nhello world\n",
203 )
204 .expect("write");
205
206 let tool = GrepFilesTool;
207 let result = tool
208 .execute(json!({"pattern": "hello", "case_insensitive": true}), &ctx)
209 .await
210 .expect("execute");
211
212 assert!(result.success);
213 // Should find all 3 lines
214 let parsed: Value = serde_json::from_str(&result.content).unwrap();
215 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 3);
216 }
217
218 #[tokio::test]
219 async fn test_grep_files_include_filter() {
220 let tmp = tempdir().expect("tempdir");
221 let ctx = ToolContext::new(tmp.path().to_path_buf());
222
223 fs::write(tmp.path().join("test.rs"), "fn test() {}\n").expect("write");
224 fs::write(tmp.path().join("test.js"), "function test() {}\n").expect("write");
225
226 let tool = GrepFilesTool;
227 let result = tool
228 .execute(json!({"pattern": "test", "include": ["*.rs"]}), &ctx)
229 .await
230 .expect("execute");
231
232 assert!(result.success);
233 // Should only match .rs file
234 let parsed: Value = serde_json::from_str(&result.content).unwrap();
235 let matches = parsed["matches"].as_array().unwrap();
236 assert_eq!(matches.len(), 1);
237 let file = matches[0]["file"].as_str().unwrap();
238 assert!(
239 file.rsplit('.')
240 .next()
241 .is_some_and(|ext| ext.eq_ignore_ascii_case("rs"))
242 );
243 }
244
245 #[tokio::test]
246 #[cfg(unix)]
247 async fn test_grep_files_does_not_follow_symlinked_files() {
248 let tmp = tempdir().expect("tempdir");
249 let root = tmp.path().join("workspace");
250 let outside = tmp.path().join("outside");
251 std::fs::create_dir_all(&root).expect("mkdir workspace");
252 std::fs::create_dir_all(&outside).expect("mkdir outside");
253 let outside_file = outside.join("secret.txt");
254 fs::write(&outside_file, "NEEDLE\n").expect("write outside");
255 std::os::unix::fs::symlink(&outside_file, root.join("secret.txt")).expect("symlink");
256
257 let ctx = ToolContext::new(root);
258 let tool = GrepFilesTool;
259 let result = tool
260 .execute(json!({"pattern": "NEEDLE"}), &ctx)
261 .await
262 .expect("execute");
263
264 assert!(result.success);
265 let parsed: Value = serde_json::from_str(&result.content).unwrap();
266 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 0);
267 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 0);
268 }
269
270 #[tokio::test]
271 #[cfg(unix)]
272 async fn test_grep_files_default_mode_skips_symlinked_directories_but_keeps_real_files() {
273 let tmp = tempdir().expect("tempdir");
274 let workspace = tmp.path().join("workspace");
275 let real_dir = workspace.join("real");
276 std::fs::create_dir_all(&real_dir).expect("mkdir workspace");
277 fs::write(real_dir.join("needle.txt"), "NEEDLE\n").expect("write real file");
278 std::os::unix::fs::symlink(&workspace, real_dir.join("loop")).expect("symlink loop");
279
280 let ctx = ToolContext::new(workspace);
281 let tool = GrepFilesTool;
282 let result = tool
283 .execute(json!({"pattern": "NEEDLE"}), &ctx)
284 .await
285 .expect("execute");
286
287 assert!(result.success);
288 let parsed: Value = serde_json::from_str(&result.content).unwrap();
289 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
290 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
291 let matches = parsed["matches"].as_array().unwrap();
292 assert_eq!(matches.len(), 1);
293 assert!(
294 matches[0]["file"]
295 .as_str()
296 .unwrap()
297 .ends_with("real/needle.txt")
298 );
299 }
300
301 #[tokio::test]
302 #[cfg(unix)]
303 async fn test_grep_files_follow_symlinks_avoids_directory_cycles() {
304 let tmp = tempdir().expect("tempdir");
305 let workspace = tmp.path().join("workspace");
306 let real_dir = workspace.join("real");
307 fs::create_dir_all(&real_dir).expect("mkdir");
308 fs::write(real_dir.join("needle.txt"), "NEEDLE\n").expect("write");
309 std::os::unix::fs::symlink(&workspace, real_dir.join("loop")).expect("symlink loop");
310
311 let ctx = ToolContext::new(workspace).with_follow_symlinks(true);
312 let tool = GrepFilesTool;
313 let result = tool
314 .execute(json!({"pattern": "NEEDLE"}), &ctx)
315 .await
316 .expect("execute");
317
318 assert!(result.success);
319 let parsed: Value = serde_json::from_str(&result.content).unwrap();
320 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
321 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
322 let matches = parsed["matches"].as_array().unwrap();
323 assert!(matches[0]["file"].as_str().unwrap().ends_with("needle.txt"));
324 }
325
326 #[tokio::test]
327 async fn test_grep_files_invalid_regex() {
328 let tmp = tempdir().expect("tempdir");
329 let ctx = ToolContext::new(tmp.path().to_path_buf());
330
331 let tool = GrepFilesTool;
332 let result = tool.execute(json!({"pattern": "[invalid"}), &ctx).await;
333
334 assert!(result.is_err());
335 }
336
337 #[tokio::test]
338 async fn test_grep_files_respects_cancel_token() {
339 let tmp = tempdir().expect("tempdir");
340 fs::write(tmp.path().join("test.txt"), "needle\n").expect("write");
341 let cancel_token = CancellationToken::new();
342 cancel_token.cancel();
343 let ctx = ToolContext::new(tmp.path().to_path_buf()).with_cancel_token(cancel_token);
344
345 let tool = GrepFilesTool;
346 let err = tool
347 .execute(json!({"pattern": "needle"}), &ctx)
348 .await
349 .expect_err("cancelled grep should return an error");
350
351 assert!(
352 format!("{err:?}").contains("cancelled"),
353 "unexpected error: {err:?}"
354 );
355 }
356
357 #[tokio::test]
358 async fn test_grep_files_streaming_stops_at_max_results() {
359 let tmp = tempdir().expect("tempdir");
360 let ctx = ToolContext::new(tmp.path().to_path_buf());
361
362 // Two files with many matches each; the walk must stop once the
363 // budget is exhausted without dropping context for the last match.
364 for name in ["a.txt", "b.txt"] {
365 let body: String = (1..=20).map(|n| format!("needle {n}\n")).collect();
366 fs::write(tmp.path().join(name), body).expect("write");
367 }
368
369 let tool = GrepFilesTool;
370 let result = tool
371 .execute(json!({"pattern": "needle", "max_results": 5}), &ctx)
372 .await
373 .expect("execute");
374
375 assert!(result.success);
376 let parsed: Value = serde_json::from_str(&result.content).unwrap();
377 let matches = parsed["matches"].as_array().unwrap();
378 assert_eq!(matches.len(), 5);
379 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 5);
380 // All five matches must come from the first file walked, in file
381 // order (streaming preserves walk order).
382 let first_file = matches[0]["file"].as_str().unwrap().to_string();
383 for m in matches {
384 assert_eq!(m["file"].as_str().unwrap(), first_file);
385 }
386 // The final in-budget match still gets its full after-context even
387 // though the match budget was exhausted on it.
388 assert_eq!(
389 matches[4]["context_after"],
390 json!(["needle 6", "needle 7"]),
391 "last match must keep after-context lines"
392 );
393 }
394
395 #[tokio::test]
396 async fn test_grep_files_ring_buffer_context_matches_full_read() {
397 let tmp = tempdir().expect("tempdir");
398 let ctx = ToolContext::new(tmp.path().to_path_buf());
399
400 // Matches at the start, middle, and end of the file exercise the
401 // partial before-context (ring not yet full) and truncated
402 // after-context (EOF) paths.
403 fs::write(
404 tmp.path().join("ctx.txt"),
405 "MATCH first\nb1\nb2\nb3\nMATCH mid\na1\na2\na3\nMATCH last\n",
406 )
407 .expect("write");
408
409 let tool = GrepFilesTool;
410 let result = tool
411 .execute(json!({"pattern": "MATCH", "context_lines": 2}), &ctx)
412 .await
413 .expect("execute");
414
415 let parsed: Value = serde_json::from_str(&result.content).unwrap();
416 let matches = parsed["matches"].as_array().unwrap();
417 assert_eq!(matches.len(), 3);
418 assert_eq!(matches[0]["context_before"], json!([]));
419 assert_eq!(matches[0]["context_after"], json!(["b1", "b2"]));
420 assert_eq!(matches[1]["context_before"], json!(["b2", "b3"]));
421 assert_eq!(matches[1]["context_after"], json!(["a1", "a2"]));
422 assert_eq!(matches[2]["context_before"], json!(["a2", "a3"]));
423 assert_eq!(matches[2]["context_after"], json!([]));
424 assert_eq!(matches[2]["line_number"].as_u64().unwrap(), 9);
425 }
426
427 #[tokio::test]
428 async fn test_grep_files_streaming_skips_invalid_utf8_files() {
429 let tmp = tempdir().expect("tempdir");
430 let ctx = ToolContext::new(tmp.path().to_path_buf());
431
432 // Invalid UTF-8 after a matching line: the whole file must be
433 // skipped, matching the historical read_to_string behavior.
434 fs::write(
435 tmp.path().join("binary.txt"),
436 [b"needle\n".as_slice(), &[0xFF, 0xFE, 0x00]].concat(),
437 )
438 .expect("write");
439 fs::write(tmp.path().join("clean.txt"), "needle\n").expect("write");
440
441 let tool = GrepFilesTool;
442 let result = tool
443 .execute(json!({"pattern": "needle"}), &ctx)
444 .await
445 .expect("execute");
446
447 let parsed: Value = serde_json::from_str(&result.content).unwrap();
448 assert_eq!(parsed["total_matches"].as_u64().unwrap(), 1);
449 assert_eq!(parsed["files_searched"].as_u64().unwrap(), 1);
450 let matches = parsed["matches"].as_array().unwrap();
451 assert!(matches[0]["file"].as_str().unwrap().ends_with("clean.txt"));
452 }
453
454 #[test]
455 fn test_grep_files_tool_properties() {
456 let tool = GrepFilesTool;
457 assert_eq!(tool.name(), "grep_files");
458 assert!(tool.is_read_only());
459 assert!(tool.is_sandboxable());
460 assert_eq!(tool.approval_requirement(), ApprovalRequirement::Auto);
461 }
462
463 #[test]
464 fn test_parallel_support_flags() {
465 let tool = GrepFilesTool;
466 assert!(tool.supports_parallel());
467 }
468
469 #[test]
470 fn double_star_exclude_prefix_stops_at_a_path_component() {
471 // Directory excludes still cover the directory and everything under it.
472 assert!(matches_glob("build", "build/**"));
473 assert!(matches_glob("build/out.o", "build/**"));
474 assert!(matches_glob(".git/config", ".git/**"));
475 // A same-prefix sibling file or directory is a different path.
476 assert!(!matches_glob("build.rs", "build/**"));
477 assert!(!matches_glob("build.gradle", "build/**"));
478 assert!(!matches_glob("builders/x.rs", "build/**"));
479 assert!(!matches_glob(".github/workflows/ci.yml", ".git/**"));
480 assert!(!matches_glob(".gitignore", ".git/**"));
481 assert!(!matches_glob("targets/a.rs", "target/**"));
482 }
483
484 #[tokio::test]
485 async fn grep_files_reports_truncation_when_matches_exceed_the_cap() {
486 let tmp = tempdir().expect("tempdir");
487 let ctx = ToolContext::new(tmp.path().to_path_buf());
488 // Three files, two matches each: six matches against a cap of 3.
489 for name in ["a.txt", "b.txt", "c.txt"] {
490 fs::write(tmp.path().join(name), "needle\nneedle\n").expect("write");
491 }
492
493 let capped = GrepFilesTool
494 .execute(json!({"pattern": "needle", "max_results": 3}), &ctx)
495 .await
496 .expect("execute");
497 let capped: Value = serde_json::from_str(&capped.content).unwrap();
498 assert_eq!(capped["matches"].as_array().unwrap().len(), 3);
499 assert_eq!(capped["truncated"], json!(true), "{capped}");
500
501 // Exactly at the cap with nothing more is complete, not truncated.
502 let exact = GrepFilesTool
503 .execute(json!({"pattern": "needle", "max_results": 6}), &ctx)
504 .await
505 .expect("execute");
506 let exact: Value = serde_json::from_str(&exact.content).unwrap();
507 assert_eq!(exact["matches"].as_array().unwrap().len(), 6);
508 assert_eq!(exact["truncated"], json!(false), "{exact}");
509 }
510
511 #[cfg(unix)]
512 #[tokio::test]
513 async fn grep_files_skips_an_unreadable_subdirectory() {
514 use std::os::unix::fs::PermissionsExt;
515
516 let tmp = tempdir().expect("tempdir");
517 let ctx = ToolContext::new(tmp.path().to_path_buf());
518 fs::write(tmp.path().join("found.txt"), "needle\n").expect("write");
519 let locked = tmp.path().join("locked");
520 fs::create_dir(&locked).expect("mkdir");
521 fs::write(locked.join("hidden.txt"), "needle\n").expect("write");
522 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("chmod");
523 if fs::read_dir(&locked).is_ok() {
524 // Running with privileges that ignore directory modes; nothing to prove.
525 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("chmod");
526 return;
527 }
528
529 let result = GrepFilesTool
530 .execute(json!({"pattern": "needle"}), &ctx)
531 .await;
532 fs::set_permissions(&locked, fs::Permissions::from_mode(0o755)).expect("chmod");
533
534 let result = result.expect("an unreadable subdirectory must not fail the search");
535 let parsed: Value = serde_json::from_str(&result.content).unwrap();
536 let matches = parsed["matches"].as_array().unwrap();
537 assert_eq!(matches.len(), 1, "{parsed}");
538 assert!(
539 matches[0]["file"]
540 .as_str()
541 .is_some_and(|file| file.ends_with("found.txt")),
542 "{parsed}"
543 );
544 assert_eq!(parsed["unreadable_paths_skipped"], json!(1), "{parsed}");
545 }
546
547 #[test]
548 fn double_star_without_a_directory_boundary_is_a_string_prefix() {
549 assert!(matches_glob("src/test_foo.rs", "src/test_**"));
550 assert!(matches_glob("src/test_dir/a.rs", "src/test_**"));
551 assert!(!matches_glob("src/other.rs", "src/test_**"));
552 }
553
554 #[tokio::test]
555 async fn grep_files_include_directory_glob_matches_nested_files() {
556 let tmp = tempdir().expect("tempdir");
557 let ctx = ToolContext::new(tmp.path().to_path_buf());
558 fs::create_dir_all(tmp.path().join("src").join("inner")).expect("mkdir");
559 fs::create_dir_all(tmp.path().join("srcs")).expect("mkdir");
560 fs::write(
561 tmp.path().join("src").join("inner").join("a.rs"),
562 "needle\n",
563 )
564 .expect("write");
565 fs::write(tmp.path().join("srcs").join("b.rs"), "needle\n").expect("write");
566
567 let result = GrepFilesTool
568 .execute(json!({"pattern": "needle", "include": ["src/**"]}), &ctx)
569 .await
570 .expect("execute");
571 let parsed: Value = serde_json::from_str(&result.content).unwrap();
572 let files: Vec<&str> = parsed["matches"]
573 .as_array()
574 .unwrap()
575 .iter()
576 .filter_map(|m| m["file"].as_str())
577 .collect();
578 assert_eq!(files.len(), 1, "{parsed}");
579 assert!(
580 files[0].replace('\\', "/").ends_with("src/inner/a.rs"),
581 "{parsed}"
582 );
583 }
584
585 #[tokio::test]
586 async fn grep_files_stops_at_the_cap_without_scanning_further_files() {
587 let tmp = tempdir().expect("tempdir");
588 let ctx = ToolContext::new(tmp.path().to_path_buf());
589 for name in ["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"] {
590 fs::write(tmp.path().join(name), "needle\n").expect("write");
591 }
592
593 let result = GrepFilesTool
594 .execute(json!({"pattern": "needle", "max_results": 2}), &ctx)
595 .await
596 .expect("execute");
597 let parsed: Value = serde_json::from_str(&result.content).unwrap();
598 assert_eq!(parsed["matches"].as_array().unwrap().len(), 2, "{parsed}");
599 // The budget fills exactly at the end of the second file; the walk
600 // must stop there rather than scan on looking for one more match.
601 assert_eq!(parsed["files_searched"], json!(2), "{parsed}");
602 assert_eq!(parsed["truncated"], json!(true), "{parsed}");
603 }
604
605 #[cfg(unix)]
606 #[tokio::test]
607 async fn grep_files_counts_an_unreadable_file() {
608 use std::os::unix::fs::PermissionsExt;
609
610 let tmp = tempdir().expect("tempdir");
611 let ctx = ToolContext::new(tmp.path().to_path_buf());
612 fs::write(tmp.path().join("found.txt"), "needle\n").expect("write");
613 let locked = tmp.path().join("locked.txt");
614 fs::write(&locked, "needle\n").expect("write");
615 fs::set_permissions(&locked, fs::Permissions::from_mode(0o000)).expect("chmod");
616 if fs::File::open(&locked).is_ok() {
617 // Running with privileges that ignore file modes; nothing to prove.
618 fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).expect("chmod");
619 return;
620 }
621
622 let result = GrepFilesTool
623 .execute(json!({"pattern": "needle"}), &ctx)
624 .await;
625 fs::set_permissions(&locked, fs::Permissions::from_mode(0o644)).expect("chmod");
626
627 let parsed: Value = serde_json::from_str(&result.expect("execute").content).unwrap();
628 assert_eq!(parsed["matches"].as_array().unwrap().len(), 1, "{parsed}");
629 assert_eq!(parsed["unreadable_paths_skipped"], json!(1), "{parsed}");
630 }
631
631 lines RUST