1use crate::{AgentTool, ToolCallEventStream};
2use agent_client_protocol as acp;
3use anyhow::{Result, anyhow};
4use futures::StreamExt;
5use gpui::{App, Entity, SharedString, Task};
6use language::{OffsetRangeExt, ParseStatus, Point};
7use project::{
8 Project, WorktreeSettings,
9 search::{SearchQuery, SearchResult},
10};
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use settings::Settings;
14use std::{cmp, fmt::Write, sync::Arc};
15use util::RangeExt;
16use util::markdown::MarkdownInlineCode;
17use util::paths::PathMatcher;
18
19/// Searches the contents of files in the project with a regular expression
20///
21/// - Prefer this tool to path search when searching for symbols in the project, because you won't need to guess what path it's in.
22/// - Supports full regex syntax (eg. "log.*Error", "function\\s+\\w+", etc.)
23/// - Pass an `include_pattern` if you know how to narrow your search on the files system
24/// - Never use this tool to search for paths. Only search file contents with this tool.
25/// - Use this tool when you need to find files containing specific patterns
26/// - Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.
27/// - DO NOT use HTML entities solely to escape characters in the tool parameters.
28#[derive(Debug, Serialize, Deserialize, JsonSchema)]
29pub struct GrepToolInput {
30 /// A regex pattern to search for in the entire project. Note that the regex will be parsed by the Rust `regex` crate.
31 ///
32 /// Do NOT specify a path here! This will only be matched against the code **content**.
33 pub regex: String,
34 /// A glob pattern for the paths of files to include in the search.
35 /// Supports standard glob patterns like "**/*.rs" or "frontend/src/**/*.ts".
36 /// If omitted, all files in the project will be searched.
37 ///
38 /// The glob pattern is matched against the full path including the project root directory.
39 ///
40 /// <example>
41 /// If the project has the following root directories:
42 ///
43 /// - /a/b/backend
44 /// - /c/d/frontend
45 ///
46 /// Use "backend/**/*.rs" to search only Rust files in the backend root directory.
47 /// Use "frontend/src/**/*.ts" to search TypeScript files only in the frontend root directory (sub-directory "src").
48 /// Use "**/*.rs" to search Rust files across all root directories.
49 /// </example>
50 pub include_pattern: Option<String>,
51 /// Optional starting position for paginated results (0-based).
52 /// When not provided, starts from the beginning.
53 #[serde(default)]
54 pub offset: u32,
55 /// Whether the regex is case-sensitive. Defaults to false (case-insensitive).
56 #[serde(default)]
57 pub case_sensitive: bool,
58}
59
60impl GrepToolInput {
61 /// Which page of search results this is.
62 pub fn page(&self) -> u32 {
63 1 + (self.offset / RESULTS_PER_PAGE)
64 }
65}
66
67const RESULTS_PER_PAGE: u32 = 20;
68
69pub struct GrepTool {
70 project: Entity<Project>,
71}
72
73impl GrepTool {
74 pub fn new(project: Entity<Project>) -> Self {
75 Self { project }
76 }
77}
78
79impl AgentTool for GrepTool {
80 type Input = GrepToolInput;
81 type Output = String;
82
83 fn name() -> &'static str {
84 "grep"
85 }
86
87 fn kind() -> acp::ToolKind {
88 acp::ToolKind::Search
89 }
90
91 fn initial_title(
92 &self,
93 input: Result<Self::Input, serde_json::Value>,
94 _cx: &mut App,
95 ) -> SharedString {
96 match input {
97 Ok(input) => {
98 let page = input.page();
99 let regex_str = MarkdownInlineCode(&input.regex);
100 let case_info = if input.case_sensitive {
101 " (case-sensitive)"
102 } else {
103 ""
104 };
105
106 if page > 1 {
107 format!("Get page {page} of search results for regex {regex_str}{case_info}")
108 } else {
109 format!("Search files for regex {regex_str}{case_info}")
110 }
111 }
112 Err(_) => "Search with regex".into(),
113 }
114 .into()
115 }
116
117 fn run(
118 self: Arc<Self>,
119 input: Self::Input,
120 _event_stream: ToolCallEventStream,
121 cx: &mut App,
122 ) -> Task<Result<Self::Output>> {
123 const CONTEXT_LINES: u32 = 2;
124 const MAX_ANCESTOR_LINES: u32 = 10;
125
126 let path_style = self.project.read(cx).path_style(cx);
127
128 let include_matcher = match PathMatcher::new(
129 input
130 .include_pattern
131 .as_ref()
132 .into_iter()
133 .collect::<Vec<_>>(),
134 path_style,
135 ) {
136 Ok(matcher) => matcher,
137 Err(error) => {
138 return Task::ready(Err(anyhow!("invalid include glob pattern: {error}")));
139 }
140 };
141
142 // Exclude global file_scan_exclusions and private_files settings
143 let exclude_matcher = {
144 let global_settings = WorktreeSettings::get_global(cx);
145 let exclude_patterns = global_settings
146 .file_scan_exclusions
147 .sources()
148 .chain(global_settings.private_files.sources());
149
150 match PathMatcher::new(exclude_patterns, path_style) {
151 Ok(matcher) => matcher,
152 Err(error) => {
153 return Task::ready(Err(anyhow!("invalid exclude pattern: {error}")));
154 }
155 }
156 };
157
158 let query = match SearchQuery::regex(
159 &input.regex,
160 false,
161 input.case_sensitive,
162 false,
163 false,
164 include_matcher,
165 exclude_matcher,
166 true, // Always match file include pattern against *full project paths* that start with a project root.
167 None,
168 ) {
169 Ok(query) => query,
170 Err(error) => return Task::ready(Err(error)),
171 };
172
173 let (results, search_task) = self
174 .project
175 .update(cx, |project, cx| project.search(query, cx));
176
177 let project = self.project.downgrade();
178 cx.spawn(async move |cx| {
179 // Keep the search alive for the duration of result iteration. Dropping this task is the
180 // cancellation mechanism; we intentionally do not detach it.
181 let _search_task = search_task;
182 futures::pin_mut!(results);
183
184 let mut output = String::new();
185 let mut skips_remaining = input.offset;
186 let mut matches_found = 0;
187 let mut has_more_matches = false;
188
189 'outer: while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await {
190 if ranges.is_empty() {
191 continue;
192 }
193
194 let Ok((Some(path), mut parse_status)) = buffer.read_with(cx, |buffer, cx| {
195 (buffer.file().map(|file| file.full_path(cx)), buffer.parse_status())
196 }) else {
197 continue;
198 };
199
200 // Check if this file should be excluded based on its worktree settings
201 if let Ok(Some(project_path)) = project.read_with(cx, |project, cx| {
202 project.find_project_path(&path, cx)
203 })
204 && cx.update(|cx| {
205 let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
206 worktree_settings.is_path_excluded(&project_path.path)
207 || worktree_settings.is_path_private(&project_path.path)
208 }).unwrap_or(false) {
209 continue;
210 }
211
212 while *parse_status.borrow() != ParseStatus::Idle {
213 parse_status.changed().await?;
214 }
215
216 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?;
217
218 let mut ranges = ranges
219 .into_iter()
220 .map(|range| {
221 let matched = range.to_point(&snapshot);
222 let matched_end_line_len = snapshot.line_len(matched.end.row);
223 let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len);
224 let symbols = snapshot.symbols_containing(matched.start, None);
225
226 if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) {
227 let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot);
228 let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES);
229 let end_col = snapshot.line_len(end_row);
230 let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col);
231
232 if capped_ancestor_range.contains_inclusive(&full_lines) {
233 return (capped_ancestor_range, Some(full_ancestor_range), symbols)
234 }
235 }
236
237 let mut matched = matched;
238 matched.start.column = 0;
239 matched.start.row =
240 matched.start.row.saturating_sub(CONTEXT_LINES);
241 matched.end.row = cmp::min(
242 snapshot.max_point().row,
243 matched.end.row + CONTEXT_LINES,
244 );
245 matched.end.column = snapshot.line_len(matched.end.row);
246
247 (matched, None, symbols)
248 })
249 .peekable();
250
251 let mut file_header_written = false;
252
253 while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){
254 if skips_remaining > 0 {
255 skips_remaining -= 1;
256 continue;
257 }
258
259 // We'd already found a full page of matches, and we just found one more.
260 if matches_found >= RESULTS_PER_PAGE {
261 has_more_matches = true;
262 break 'outer;
263 }
264
265 while let Some((next_range, _, _)) = ranges.peek() {
266 if range.end.row >= next_range.start.row {
267 range.end = next_range.end;
268 ranges.next();
269 } else {
270 break;
271 }
272 }
273
274 if !file_header_written {
275 writeln!(output, "\n## Matches in {}", path.display())?;
276 file_header_written = true;
277 }
278
279 let end_row = range.end.row;
280 output.push_str("\n### ");
281
282 for symbol in parent_symbols {
283 write!(output, "{} › ", symbol.text)?;
284 }
285
286 if range.start.row == end_row {
287 writeln!(output, "L{}", range.start.row + 1)?;
288 } else {
289 writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?;
290 }
291
292 output.push_str("```\n");
293 output.extend(snapshot.text_for_range(range));
294 output.push_str("\n```\n");
295
296 if let Some(ancestor_range) = ancestor_range
297 && end_row < ancestor_range.end.row {
298 let remaining_lines = ancestor_range.end.row - end_row;
299 writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
300 }
301
302 matches_found += 1;
303 }
304 }
305
306 if matches_found == 0 {
307 Ok("No matches found".into())
308 } else if has_more_matches {
309 Ok(format!(
310 "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
311 input.offset + 1,
312 input.offset + matches_found,
313 input.offset + RESULTS_PER_PAGE,
314 ))
315 } else {
316 Ok(format!("Found {matches_found} matches:\n{output}"))
317 }
318 })
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use crate::ToolCallEventStream;
325
326 use super::*;
327 use gpui::{TestAppContext, UpdateGlobal};
328 use project::{FakeFs, Project};
329 use serde_json::json;
330 use settings::SettingsStore;
331 use unindent::Unindent;
332 use util::path;
333
334 #[gpui::test]
335 async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
336 init_test(cx);
337 cx.executor().allow_parking();
338
339 let fs = FakeFs::new(cx.executor());
340 fs.insert_tree(
341 path!("/root"),
342 serde_json::json!({
343 "src": {
344 "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
345 "utils": {
346 "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}",
347 },
348 },
349 "tests": {
350 "test_main.rs": "fn test_main() {\n assert!(true);\n}",
351 }
352 }),
353 )
354 .await;
355
356 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
357
358 // Test with include pattern for Rust files inside the root of the project
359 let input = GrepToolInput {
360 regex: "println".to_string(),
361 include_pattern: Some("root/**/*.rs".to_string()),
362 offset: 0,
363 case_sensitive: false,
364 };
365
366 let result = run_grep_tool(input, project.clone(), cx).await;
367 assert!(result.contains("main.rs"), "Should find matches in main.rs");
368 assert!(
369 result.contains("helper.rs"),
370 "Should find matches in helper.rs"
371 );
372 assert!(
373 !result.contains("test_main.rs"),
374 "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
375 );
376
377 // Test with include pattern for src directory only
378 let input = GrepToolInput {
379 regex: "fn".to_string(),
380 include_pattern: Some("root/**/src/**".to_string()),
381 offset: 0,
382 case_sensitive: false,
383 };
384
385 let result = run_grep_tool(input, project.clone(), cx).await;
386 assert!(
387 result.contains("main.rs"),
388 "Should find matches in src/main.rs"
389 );
390 assert!(
391 result.contains("helper.rs"),
392 "Should find matches in src/utils/helper.rs"
393 );
394 assert!(
395 !result.contains("test_main.rs"),
396 "Should not include test_main.rs as it's not in src directory"
397 );
398
399 // Test with empty include pattern (should default to all files)
400 let input = GrepToolInput {
401 regex: "fn".to_string(),
402 include_pattern: None,
403 offset: 0,
404 case_sensitive: false,
405 };
406
407 let result = run_grep_tool(input, project.clone(), cx).await;
408 assert!(result.contains("main.rs"), "Should find matches in main.rs");
409 assert!(
410 result.contains("helper.rs"),
411 "Should find matches in helper.rs"
412 );
413 assert!(
414 result.contains("test_main.rs"),
415 "Should include test_main.rs"
416 );
417 }
418
419 #[gpui::test]
420 async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
421 init_test(cx);
422 cx.executor().allow_parking();
423
424 let fs = FakeFs::new(cx.executor());
425 fs.insert_tree(
426 path!("/root"),
427 serde_json::json!({
428 "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
429 }),
430 )
431 .await;
432
433 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
434
435 // Test case-insensitive search (default)
436 let input = GrepToolInput {
437 regex: "uppercase".to_string(),
438 include_pattern: Some("**/*.txt".to_string()),
439 offset: 0,
440 case_sensitive: false,
441 };
442
443 let result = run_grep_tool(input, project.clone(), cx).await;
444 assert!(
445 result.contains("UPPERCASE"),
446 "Case-insensitive search should match uppercase"
447 );
448
449 // Test case-sensitive search
450 let input = GrepToolInput {
451 regex: "uppercase".to_string(),
452 include_pattern: Some("**/*.txt".to_string()),
453 offset: 0,
454 case_sensitive: true,
455 };
456
457 let result = run_grep_tool(input, project.clone(), cx).await;
458 assert!(
459 !result.contains("UPPERCASE"),
460 "Case-sensitive search should not match uppercase"
461 );
462
463 // Test case-sensitive search
464 let input = GrepToolInput {
465 regex: "LOWERCASE".to_string(),
466 include_pattern: Some("**/*.txt".to_string()),
467 offset: 0,
468 case_sensitive: true,
469 };
470
471 let result = run_grep_tool(input, project.clone(), cx).await;
472
473 assert!(
474 !result.contains("lowercase"),
475 "Case-sensitive search should match lowercase"
476 );
477
478 // Test case-sensitive search for lowercase pattern
479 let input = GrepToolInput {
480 regex: "lowercase".to_string(),
481 include_pattern: Some("**/*.txt".to_string()),
482 offset: 0,
483 case_sensitive: true,
484 };
485
486 let result = run_grep_tool(input, project.clone(), cx).await;
487 assert!(
488 result.contains("lowercase"),
489 "Case-sensitive search should match lowercase text"
490 );
491 }
492
493 /// Helper function to set up a syntax test environment
494 async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
495 use unindent::Unindent;
496 init_test(cx);
497 cx.executor().allow_parking();
498
499 let fs = FakeFs::new(cx.executor());
500
501 // Create test file with syntax structures
502 fs.insert_tree(
503 path!("/root"),
504 serde_json::json!({
505 "test_syntax.rs": r#"
506 fn top_level_function() {
507 println!("This is at the top level");
508 }
509
510 mod feature_module {
511 pub mod nested_module {
512 pub fn nested_function(
513 first_arg: String,
514 second_arg: i32,
515 ) {
516 println!("Function in nested module");
517 println!("{first_arg}");
518 println!("{second_arg}");
519 }
520 }
521 }
522
523 struct MyStruct {
524 field1: String,
525 field2: i32,
526 }
527
528 impl MyStruct {
529 fn method_with_block() {
530 let condition = true;
531 if condition {
532 println!("Inside if block");
533 }
534 }
535
536 fn long_function() {
537 println!("Line 1");
538 println!("Line 2");
539 println!("Line 3");
540 println!("Line 4");
541 println!("Line 5");
542 println!("Line 6");
543 println!("Line 7");
544 println!("Line 8");
545 println!("Line 9");
546 println!("Line 10");
547 println!("Line 11");
548 println!("Line 12");
549 }
550 }
551
552 trait Processor {
553 fn process(&self, input: &str) -> String;
554 }
555
556 impl Processor for MyStruct {
557 fn process(&self, input: &str) -> String {
558 format!("Processed: {}", input)
559 }
560 }
561 "#.unindent().trim(),
562 }),
563 )
564 .await;
565
566 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
567
568 project.update(cx, |project, _cx| {
569 project.languages().add(language::rust_lang())
570 });
571
572 project
573 }
574
575 #[gpui::test]
576 async fn test_grep_top_level_function(cx: &mut TestAppContext) {
577 let project = setup_syntax_test(cx).await;
578
579 // Test: Line at the top level of the file
580 let input = GrepToolInput {
581 regex: "This is at the top level".to_string(),
582 include_pattern: Some("**/*.rs".to_string()),
583 offset: 0,
584 case_sensitive: false,
585 };
586
587 let result = run_grep_tool(input, project.clone(), cx).await;
588 let expected = r#"
589 Found 1 matches:
590
591 ## Matches in root/test_syntax.rs
592
593 ### fn top_level_function › L1-3
594 ```
595 fn top_level_function() {
596 println!("This is at the top level");
597 }
598 ```
599 "#
600 .unindent();
601 assert_eq!(result, expected);
602 }
603
604 #[gpui::test]
605 async fn test_grep_function_body(cx: &mut TestAppContext) {
606 let project = setup_syntax_test(cx).await;
607
608 // Test: Line inside a function body
609 let input = GrepToolInput {
610 regex: "Function in nested module".to_string(),
611 include_pattern: Some("**/*.rs".to_string()),
612 offset: 0,
613 case_sensitive: false,
614 };
615
616 let result = run_grep_tool(input, project.clone(), cx).await;
617 let expected = r#"
618 Found 1 matches:
619
620 ## Matches in root/test_syntax.rs
621
622 ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
623 ```
624 ) {
625 println!("Function in nested module");
626 println!("{first_arg}");
627 println!("{second_arg}");
628 }
629 ```
630 "#
631 .unindent();
632 assert_eq!(result, expected);
633 }
634
635 #[gpui::test]
636 async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
637 let project = setup_syntax_test(cx).await;
638
639 // Test: Line with a function argument
640 let input = GrepToolInput {
641 regex: "second_arg".to_string(),
642 include_pattern: Some("**/*.rs".to_string()),
643 offset: 0,
644 case_sensitive: false,
645 };
646
647 let result = run_grep_tool(input, project.clone(), cx).await;
648 let expected = r#"
649 Found 1 matches:
650
651 ## Matches in root/test_syntax.rs
652
653 ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
654 ```
655 pub fn nested_function(
656 first_arg: String,
657 second_arg: i32,
658 ) {
659 println!("Function in nested module");
660 println!("{first_arg}");
661 println!("{second_arg}");
662 }
663 ```
664 "#
665 .unindent();
666 assert_eq!(result, expected);
667 }
668
669 #[gpui::test]
670 async fn test_grep_if_block(cx: &mut TestAppContext) {
671 use unindent::Unindent;
672 let project = setup_syntax_test(cx).await;
673
674 // Test: Line inside an if block
675 let input = GrepToolInput {
676 regex: "Inside if block".to_string(),
677 include_pattern: Some("**/*.rs".to_string()),
678 offset: 0,
679 case_sensitive: false,
680 };
681
682 let result = run_grep_tool(input, project.clone(), cx).await;
683 let expected = r#"
684 Found 1 matches:
685
686 ## Matches in root/test_syntax.rs
687
688 ### impl MyStruct › fn method_with_block › L26-28
689 ```
690 if condition {
691 println!("Inside if block");
692 }
693 ```
694 "#
695 .unindent();
696 assert_eq!(result, expected);
697 }
698
699 #[gpui::test]
700 async fn test_grep_long_function_top(cx: &mut TestAppContext) {
701 use unindent::Unindent;
702 let project = setup_syntax_test(cx).await;
703
704 // Test: Line in the middle of a long function - should show message about remaining lines
705 let input = GrepToolInput {
706 regex: "Line 5".to_string(),
707 include_pattern: Some("**/*.rs".to_string()),
708 offset: 0,
709 case_sensitive: false,
710 };
711
712 let result = run_grep_tool(input, project.clone(), cx).await;
713 let expected = r#"
714 Found 1 matches:
715
716 ## Matches in root/test_syntax.rs
717
718 ### impl MyStruct › fn long_function › L31-41
719 ```
720 fn long_function() {
721 println!("Line 1");
722 println!("Line 2");
723 println!("Line 3");
724 println!("Line 4");
725 println!("Line 5");
726 println!("Line 6");
727 println!("Line 7");
728 println!("Line 8");
729 println!("Line 9");
730 println!("Line 10");
731 ```
732
733 3 lines remaining in ancestor node. Read the file to see all.
734 "#
735 .unindent();
736 assert_eq!(result, expected);
737 }
738
739 #[gpui::test]
740 async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
741 use unindent::Unindent;
742 let project = setup_syntax_test(cx).await;
743
744 // Test: Line in the long function
745 let input = GrepToolInput {
746 regex: "Line 12".to_string(),
747 include_pattern: Some("**/*.rs".to_string()),
748 offset: 0,
749 case_sensitive: false,
750 };
751
752 let result = run_grep_tool(input, project.clone(), cx).await;
753 let expected = r#"
754 Found 1 matches:
755
756 ## Matches in root/test_syntax.rs
757
758 ### impl MyStruct › fn long_function › L41-45
759 ```
760 println!("Line 10");
761 println!("Line 11");
762 println!("Line 12");
763 }
764 }
765 ```
766 "#
767 .unindent();
768 assert_eq!(result, expected);
769 }
770
771 async fn run_grep_tool(
772 input: GrepToolInput,
773 project: Entity<Project>,
774 cx: &mut TestAppContext,
775 ) -> String {
776 let tool = Arc::new(GrepTool { project });
777 let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx));
778
779 match task.await {
780 Ok(result) => {
781 if cfg!(windows) {
782 result.replace("root\\", "root/")
783 } else {
784 result
785 }
786 }
787 Err(e) => panic!("Failed to run grep tool: {}", e),
788 }
789 }
790
791 fn init_test(cx: &mut TestAppContext) {
792 cx.update(|cx| {
793 let settings_store = SettingsStore::test(cx);
794 cx.set_global(settings_store);
795 });
796 }
797
798 #[gpui::test]
799 async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
800 init_test(cx);
801
802 let fs = FakeFs::new(cx.executor());
803
804 fs.insert_tree(
805 path!("/"),
806 json!({
807 "project_root": {
808 "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
809 ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
810 ".secretdir": {
811 "config": "fn special_configuration() { /* excluded */ }"
812 },
813 ".mymetadata": "fn custom_metadata() { /* excluded */ }",
814 "subdir": {
815 "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
816 "special.privatekey": "fn private_key_content() { /* private */ }",
817 "data.mysensitive": "fn sensitive_data() { /* private */ }"
818 }
819 },
820 "outside_project": {
821 "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
822 }
823 }),
824 )
825 .await;
826
827 cx.update(|cx| {
828 use gpui::UpdateGlobal;
829 use settings::SettingsStore;
830 SettingsStore::update_global(cx, |store, cx| {
831 store.update_user_settings(cx, |settings| {
832 settings.project.worktree.file_scan_exclusions = Some(vec![
833 "**/.secretdir".to_string(),
834 "**/.mymetadata".to_string(),
835 ]);
836 settings.project.worktree.private_files = Some(
837 vec![
838 "**/.mysecrets".to_string(),
839 "**/*.privatekey".to_string(),
840 "**/*.mysensitive".to_string(),
841 ]
842 .into(),
843 );
844 });
845 });
846 });
847
848 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
849
850 // Searching for files outside the project worktree should return no results
851 let result = run_grep_tool(
852 GrepToolInput {
853 regex: "outside_function".to_string(),
854 include_pattern: None,
855 offset: 0,
856 case_sensitive: false,
857 },
858 project.clone(),
859 cx,
860 )
861 .await;
862 let paths = extract_paths_from_results(&result);
863 assert!(
864 paths.is_empty(),
865 "grep_tool should not find files outside the project worktree"
866 );
867
868 // Searching within the project should succeed
869 let result = run_grep_tool(
870 GrepToolInput {
871 regex: "main".to_string(),
872 include_pattern: None,
873 offset: 0,
874 case_sensitive: false,
875 },
876 project.clone(),
877 cx,
878 )
879 .await;
880 let paths = extract_paths_from_results(&result);
881 assert!(
882 paths.iter().any(|p| p.contains("allowed_file.rs")),
883 "grep_tool should be able to search files inside worktrees"
884 );
885
886 // Searching files that match file_scan_exclusions should return no results
887 let result = run_grep_tool(
888 GrepToolInput {
889 regex: "special_configuration".to_string(),
890 include_pattern: None,
891 offset: 0,
892 case_sensitive: false,
893 },
894 project.clone(),
895 cx,
896 )
897 .await;
898 let paths = extract_paths_from_results(&result);
899 assert!(
900 paths.is_empty(),
901 "grep_tool should not search files in .secretdir (file_scan_exclusions)"
902 );
903
904 let result = run_grep_tool(
905 GrepToolInput {
906 regex: "custom_metadata".to_string(),
907 include_pattern: None,
908 offset: 0,
909 case_sensitive: false,
910 },
911 project.clone(),
912 cx,
913 )
914 .await;
915 let paths = extract_paths_from_results(&result);
916 assert!(
917 paths.is_empty(),
918 "grep_tool should not search .mymetadata files (file_scan_exclusions)"
919 );
920
921 // Searching private files should return no results
922 let result = run_grep_tool(
923 GrepToolInput {
924 regex: "SECRET_KEY".to_string(),
925 include_pattern: None,
926 offset: 0,
927 case_sensitive: false,
928 },
929 project.clone(),
930 cx,
931 )
932 .await;
933 let paths = extract_paths_from_results(&result);
934 assert!(
935 paths.is_empty(),
936 "grep_tool should not search .mysecrets (private_files)"
937 );
938
939 let result = run_grep_tool(
940 GrepToolInput {
941 regex: "private_key_content".to_string(),
942 include_pattern: None,
943 offset: 0,
944 case_sensitive: false,
945 },
946 project.clone(),
947 cx,
948 )
949 .await;
950 let paths = extract_paths_from_results(&result);
951
952 assert!(
953 paths.is_empty(),
954 "grep_tool should not search .privatekey files (private_files)"
955 );
956
957 let result = run_grep_tool(
958 GrepToolInput {
959 regex: "sensitive_data".to_string(),
960 include_pattern: None,
961 offset: 0,
962 case_sensitive: false,
963 },
964 project.clone(),
965 cx,
966 )
967 .await;
968 let paths = extract_paths_from_results(&result);
969 assert!(
970 paths.is_empty(),
971 "grep_tool should not search .mysensitive files (private_files)"
972 );
973
974 // Searching a normal file should still work, even with private_files configured
975 let result = run_grep_tool(
976 GrepToolInput {
977 regex: "normal_file_content".to_string(),
978 include_pattern: None,
979 offset: 0,
980 case_sensitive: false,
981 },
982 project.clone(),
983 cx,
984 )
985 .await;
986 let paths = extract_paths_from_results(&result);
987 assert!(
988 paths.iter().any(|p| p.contains("normal_file.rs")),
989 "Should be able to search normal files"
990 );
991
992 // Path traversal attempts with .. in include_pattern should not escape project
993 let result = run_grep_tool(
994 GrepToolInput {
995 regex: "outside_function".to_string(),
996 include_pattern: Some("../outside_project/**/*.rs".to_string()),
997 offset: 0,
998 case_sensitive: false,
999 },
1000 project.clone(),
1001 cx,
1002 )
1003 .await;
1004 let paths = extract_paths_from_results(&result);
1005 assert!(
1006 paths.is_empty(),
1007 "grep_tool should not allow escaping project boundaries with relative paths"
1008 );
1009 }
1010
1011 #[gpui::test]
1012 async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1013 init_test(cx);
1014
1015 let fs = FakeFs::new(cx.executor());
1016
1017 // Create first worktree with its own private files
1018 fs.insert_tree(
1019 path!("/worktree1"),
1020 json!({
1021 ".zed": {
1022 "settings.json": r#"{
1023 "file_scan_exclusions": ["**/fixture.*"],
1024 "private_files": ["**/secret.rs"]
1025 }"#
1026 },
1027 "src": {
1028 "main.rs": "fn main() { let secret_key = \"hidden\"; }",
1029 "secret.rs": "const API_KEY: &str = \"secret_value\";",
1030 "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
1031 },
1032 "tests": {
1033 "test.rs": "fn test_secret() { assert!(true); }",
1034 "fixture.sql": "SELECT * FROM secret_table;"
1035 }
1036 }),
1037 )
1038 .await;
1039
1040 // Create second worktree with different private files
1041 fs.insert_tree(
1042 path!("/worktree2"),
1043 json!({
1044 ".zed": {
1045 "settings.json": r#"{
1046 "file_scan_exclusions": ["**/internal.*"],
1047 "private_files": ["**/private.js", "**/data.json"]
1048 }"#
1049 },
1050 "lib": {
1051 "public.js": "export function getSecret() { return 'public'; }",
1052 "private.js": "const SECRET_KEY = \"private_value\";",
1053 "data.json": "{\"secret_data\": \"hidden\"}"
1054 },
1055 "docs": {
1056 "README.md": "# Documentation with secret info",
1057 "internal.md": "Internal secret documentation"
1058 }
1059 }),
1060 )
1061 .await;
1062
1063 // Set global settings
1064 cx.update(|cx| {
1065 SettingsStore::update_global(cx, |store, cx| {
1066 store.update_user_settings(cx, |settings| {
1067 settings.project.worktree.file_scan_exclusions =
1068 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1069 settings.project.worktree.private_files =
1070 Some(vec!["**/.env".to_string()].into());
1071 });
1072 });
1073 });
1074
1075 let project = Project::test(
1076 fs.clone(),
1077 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1078 cx,
1079 )
1080 .await;
1081
1082 // Wait for worktrees to be fully scanned
1083 cx.executor().run_until_parked();
1084
1085 // Search for "secret" - should exclude files based on worktree-specific settings
1086 let result = run_grep_tool(
1087 GrepToolInput {
1088 regex: "secret".to_string(),
1089 include_pattern: None,
1090 offset: 0,
1091 case_sensitive: false,
1092 },
1093 project.clone(),
1094 cx,
1095 )
1096 .await;
1097 let paths = extract_paths_from_results(&result);
1098
1099 // Should find matches in non-private files
1100 assert!(
1101 paths.iter().any(|p| p.contains("main.rs")),
1102 "Should find 'secret' in worktree1/src/main.rs"
1103 );
1104 assert!(
1105 paths.iter().any(|p| p.contains("test.rs")),
1106 "Should find 'secret' in worktree1/tests/test.rs"
1107 );
1108 assert!(
1109 paths.iter().any(|p| p.contains("public.js")),
1110 "Should find 'secret' in worktree2/lib/public.js"
1111 );
1112 assert!(
1113 paths.iter().any(|p| p.contains("README.md")),
1114 "Should find 'secret' in worktree2/docs/README.md"
1115 );
1116
1117 // Should NOT find matches in private/excluded files based on worktree settings
1118 assert!(
1119 !paths.iter().any(|p| p.contains("secret.rs")),
1120 "Should not search in worktree1/src/secret.rs (local private_files)"
1121 );
1122 assert!(
1123 !paths.iter().any(|p| p.contains("fixture.sql")),
1124 "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
1125 );
1126 assert!(
1127 !paths.iter().any(|p| p.contains("private.js")),
1128 "Should not search in worktree2/lib/private.js (local private_files)"
1129 );
1130 assert!(
1131 !paths.iter().any(|p| p.contains("data.json")),
1132 "Should not search in worktree2/lib/data.json (local private_files)"
1133 );
1134 assert!(
1135 !paths.iter().any(|p| p.contains("internal.md")),
1136 "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
1137 );
1138
1139 // Test with `include_pattern` specific to one worktree
1140 let result = run_grep_tool(
1141 GrepToolInput {
1142 regex: "secret".to_string(),
1143 include_pattern: Some("worktree1/**/*.rs".to_string()),
1144 offset: 0,
1145 case_sensitive: false,
1146 },
1147 project.clone(),
1148 cx,
1149 )
1150 .await;
1151
1152 let paths = extract_paths_from_results(&result);
1153
1154 // Should only find matches in worktree1 *.rs files (excluding private ones)
1155 assert!(
1156 paths.iter().any(|p| p.contains("main.rs")),
1157 "Should find match in worktree1/src/main.rs"
1158 );
1159 assert!(
1160 paths.iter().any(|p| p.contains("test.rs")),
1161 "Should find match in worktree1/tests/test.rs"
1162 );
1163 assert!(
1164 !paths.iter().any(|p| p.contains("secret.rs")),
1165 "Should not find match in excluded worktree1/src/secret.rs"
1166 );
1167 assert!(
1168 paths.iter().all(|p| !p.contains("worktree2")),
1169 "Should not find any matches in worktree2"
1170 );
1171 }
1172
1173 // Helper function to extract file paths from grep results
1174 fn extract_paths_from_results(results: &str) -> Vec<String> {
1175 results
1176 .lines()
1177 .filter(|line| line.starts_with("## Matches in "))
1178 .map(|line| {
1179 line.strip_prefix("## Matches in ")
1180 .unwrap()
1181 .trim()
1182 .to_string()
1183 })
1184 .collect()
1185 }
1186}