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, SearchResults, 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 = 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 SearchResults {rx, _task_handle} = results;
182 futures::pin_mut!(rx);
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 }) = rx.next().await {
190 if ranges.is_empty() {
191 continue;
192 }
193
194 let (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 if 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 }) {
209 continue;
210 }
211 }
212
213 while *parse_status.borrow() != ParseStatus::Idle {
214 parse_status.changed().await?;
215 }
216
217 let snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot());
218
219 let mut ranges = ranges
220 .into_iter()
221 .map(|range| {
222 let matched = range.to_point(&snapshot);
223 let matched_end_line_len = snapshot.line_len(matched.end.row);
224 let full_lines = Point::new(matched.start.row, 0)..Point::new(matched.end.row, matched_end_line_len);
225 let symbols = snapshot.symbols_containing(matched.start, None);
226
227 if let Some(ancestor_node) = snapshot.syntax_ancestor(full_lines.clone()) {
228 let full_ancestor_range = ancestor_node.byte_range().to_point(&snapshot);
229 let end_row = full_ancestor_range.end.row.min(full_ancestor_range.start.row + MAX_ANCESTOR_LINES);
230 let end_col = snapshot.line_len(end_row);
231 let capped_ancestor_range = Point::new(full_ancestor_range.start.row, 0)..Point::new(end_row, end_col);
232
233 if capped_ancestor_range.contains_inclusive(&full_lines) {
234 return (capped_ancestor_range, Some(full_ancestor_range), symbols)
235 }
236 }
237
238 let mut matched = matched;
239 matched.start.column = 0;
240 matched.start.row =
241 matched.start.row.saturating_sub(CONTEXT_LINES);
242 matched.end.row = cmp::min(
243 snapshot.max_point().row,
244 matched.end.row + CONTEXT_LINES,
245 );
246 matched.end.column = snapshot.line_len(matched.end.row);
247
248 (matched, None, symbols)
249 })
250 .peekable();
251
252 let mut file_header_written = false;
253
254 while let Some((mut range, ancestor_range, parent_symbols)) = ranges.next(){
255 if skips_remaining > 0 {
256 skips_remaining -= 1;
257 continue;
258 }
259
260 // We'd already found a full page of matches, and we just found one more.
261 if matches_found >= RESULTS_PER_PAGE {
262 has_more_matches = true;
263 break 'outer;
264 }
265
266 while let Some((next_range, _, _)) = ranges.peek() {
267 if range.end.row >= next_range.start.row {
268 range.end = next_range.end;
269 ranges.next();
270 } else {
271 break;
272 }
273 }
274
275 if !file_header_written {
276 writeln!(output, "\n## Matches in {}", path.display())?;
277 file_header_written = true;
278 }
279
280 let end_row = range.end.row;
281 output.push_str("\n### ");
282
283 for symbol in parent_symbols {
284 write!(output, "{} › ", symbol.text)?;
285 }
286
287 if range.start.row == end_row {
288 writeln!(output, "L{}", range.start.row + 1)?;
289 } else {
290 writeln!(output, "L{}-{}", range.start.row + 1, end_row + 1)?;
291 }
292
293 output.push_str("```\n");
294 output.extend(snapshot.text_for_range(range));
295 output.push_str("\n```\n");
296
297 if let Some(ancestor_range) = ancestor_range
298 && end_row < ancestor_range.end.row {
299 let remaining_lines = ancestor_range.end.row - end_row;
300 writeln!(output, "\n{} lines remaining in ancestor node. Read the file to see all.", remaining_lines)?;
301 }
302
303 matches_found += 1;
304 }
305 }
306
307 if matches_found == 0 {
308 Ok("No matches found".into())
309 } else if has_more_matches {
310 Ok(format!(
311 "Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
312 input.offset + 1,
313 input.offset + matches_found,
314 input.offset + RESULTS_PER_PAGE,
315 ))
316 } else {
317 Ok(format!("Found {matches_found} matches:\n{output}"))
318 }
319 })
320 }
321}
322
323#[cfg(test)]
324mod tests {
325 use crate::ToolCallEventStream;
326
327 use super::*;
328 use gpui::{TestAppContext, UpdateGlobal};
329 use project::{FakeFs, Project};
330 use serde_json::json;
331 use settings::SettingsStore;
332 use unindent::Unindent;
333 use util::path;
334
335 #[gpui::test]
336 async fn test_grep_tool_with_include_pattern(cx: &mut TestAppContext) {
337 init_test(cx);
338 cx.executor().allow_parking();
339
340 let fs = FakeFs::new(cx.executor());
341 fs.insert_tree(
342 path!("/root"),
343 serde_json::json!({
344 "src": {
345 "main.rs": "fn main() {\n println!(\"Hello, world!\");\n}",
346 "utils": {
347 "helper.rs": "fn helper() {\n println!(\"I'm a helper!\");\n}",
348 },
349 },
350 "tests": {
351 "test_main.rs": "fn test_main() {\n assert!(true);\n}",
352 }
353 }),
354 )
355 .await;
356
357 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
358
359 // Test with include pattern for Rust files inside the root of the project
360 let input = GrepToolInput {
361 regex: "println".to_string(),
362 include_pattern: Some("root/**/*.rs".to_string()),
363 offset: 0,
364 case_sensitive: false,
365 };
366
367 let result = run_grep_tool(input, project.clone(), cx).await;
368 assert!(result.contains("main.rs"), "Should find matches in main.rs");
369 assert!(
370 result.contains("helper.rs"),
371 "Should find matches in helper.rs"
372 );
373 assert!(
374 !result.contains("test_main.rs"),
375 "Should not include test_main.rs even though it's a .rs file (because it doesn't have the pattern)"
376 );
377
378 // Test with include pattern for src directory only
379 let input = GrepToolInput {
380 regex: "fn".to_string(),
381 include_pattern: Some("root/**/src/**".to_string()),
382 offset: 0,
383 case_sensitive: false,
384 };
385
386 let result = run_grep_tool(input, project.clone(), cx).await;
387 assert!(
388 result.contains("main.rs"),
389 "Should find matches in src/main.rs"
390 );
391 assert!(
392 result.contains("helper.rs"),
393 "Should find matches in src/utils/helper.rs"
394 );
395 assert!(
396 !result.contains("test_main.rs"),
397 "Should not include test_main.rs as it's not in src directory"
398 );
399
400 // Test with empty include pattern (should default to all files)
401 let input = GrepToolInput {
402 regex: "fn".to_string(),
403 include_pattern: None,
404 offset: 0,
405 case_sensitive: false,
406 };
407
408 let result = run_grep_tool(input, project.clone(), cx).await;
409 assert!(result.contains("main.rs"), "Should find matches in main.rs");
410 assert!(
411 result.contains("helper.rs"),
412 "Should find matches in helper.rs"
413 );
414 assert!(
415 result.contains("test_main.rs"),
416 "Should include test_main.rs"
417 );
418 }
419
420 #[gpui::test]
421 async fn test_grep_tool_with_case_sensitivity(cx: &mut TestAppContext) {
422 init_test(cx);
423 cx.executor().allow_parking();
424
425 let fs = FakeFs::new(cx.executor());
426 fs.insert_tree(
427 path!("/root"),
428 serde_json::json!({
429 "case_test.txt": "This file has UPPERCASE and lowercase text.\nUPPERCASE patterns should match only with case_sensitive: true",
430 }),
431 )
432 .await;
433
434 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
435
436 // Test case-insensitive search (default)
437 let input = GrepToolInput {
438 regex: "uppercase".to_string(),
439 include_pattern: Some("**/*.txt".to_string()),
440 offset: 0,
441 case_sensitive: false,
442 };
443
444 let result = run_grep_tool(input, project.clone(), cx).await;
445 assert!(
446 result.contains("UPPERCASE"),
447 "Case-insensitive search should match uppercase"
448 );
449
450 // Test case-sensitive search
451 let input = GrepToolInput {
452 regex: "uppercase".to_string(),
453 include_pattern: Some("**/*.txt".to_string()),
454 offset: 0,
455 case_sensitive: true,
456 };
457
458 let result = run_grep_tool(input, project.clone(), cx).await;
459 assert!(
460 !result.contains("UPPERCASE"),
461 "Case-sensitive search should not match uppercase"
462 );
463
464 // Test case-sensitive search
465 let input = GrepToolInput {
466 regex: "LOWERCASE".to_string(),
467 include_pattern: Some("**/*.txt".to_string()),
468 offset: 0,
469 case_sensitive: true,
470 };
471
472 let result = run_grep_tool(input, project.clone(), cx).await;
473
474 assert!(
475 !result.contains("lowercase"),
476 "Case-sensitive search should match lowercase"
477 );
478
479 // Test case-sensitive search for lowercase pattern
480 let input = GrepToolInput {
481 regex: "lowercase".to_string(),
482 include_pattern: Some("**/*.txt".to_string()),
483 offset: 0,
484 case_sensitive: true,
485 };
486
487 let result = run_grep_tool(input, project.clone(), cx).await;
488 assert!(
489 result.contains("lowercase"),
490 "Case-sensitive search should match lowercase text"
491 );
492 }
493
494 /// Helper function to set up a syntax test environment
495 async fn setup_syntax_test(cx: &mut TestAppContext) -> Entity<Project> {
496 use unindent::Unindent;
497 init_test(cx);
498 cx.executor().allow_parking();
499
500 let fs = FakeFs::new(cx.executor());
501
502 // Create test file with syntax structures
503 fs.insert_tree(
504 path!("/root"),
505 serde_json::json!({
506 "test_syntax.rs": r#"
507 fn top_level_function() {
508 println!("This is at the top level");
509 }
510
511 mod feature_module {
512 pub mod nested_module {
513 pub fn nested_function(
514 first_arg: String,
515 second_arg: i32,
516 ) {
517 println!("Function in nested module");
518 println!("{first_arg}");
519 println!("{second_arg}");
520 }
521 }
522 }
523
524 struct MyStruct {
525 field1: String,
526 field2: i32,
527 }
528
529 impl MyStruct {
530 fn method_with_block() {
531 let condition = true;
532 if condition {
533 println!("Inside if block");
534 }
535 }
536
537 fn long_function() {
538 println!("Line 1");
539 println!("Line 2");
540 println!("Line 3");
541 println!("Line 4");
542 println!("Line 5");
543 println!("Line 6");
544 println!("Line 7");
545 println!("Line 8");
546 println!("Line 9");
547 println!("Line 10");
548 println!("Line 11");
549 println!("Line 12");
550 }
551 }
552
553 trait Processor {
554 fn process(&self, input: &str) -> String;
555 }
556
557 impl Processor for MyStruct {
558 fn process(&self, input: &str) -> String {
559 format!("Processed: {}", input)
560 }
561 }
562 "#.unindent().trim(),
563 }),
564 )
565 .await;
566
567 let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
568
569 project.update(cx, |project, _cx| {
570 project.languages().add(language::rust_lang())
571 });
572
573 project
574 }
575
576 #[gpui::test]
577 async fn test_grep_top_level_function(cx: &mut TestAppContext) {
578 let project = setup_syntax_test(cx).await;
579
580 // Test: Line at the top level of the file
581 let input = GrepToolInput {
582 regex: "This is at the top level".to_string(),
583 include_pattern: Some("**/*.rs".to_string()),
584 offset: 0,
585 case_sensitive: false,
586 };
587
588 let result = run_grep_tool(input, project.clone(), cx).await;
589 let expected = r#"
590 Found 1 matches:
591
592 ## Matches in root/test_syntax.rs
593
594 ### fn top_level_function › L1-3
595 ```
596 fn top_level_function() {
597 println!("This is at the top level");
598 }
599 ```
600 "#
601 .unindent();
602 assert_eq!(result, expected);
603 }
604
605 #[gpui::test]
606 async fn test_grep_function_body(cx: &mut TestAppContext) {
607 let project = setup_syntax_test(cx).await;
608
609 // Test: Line inside a function body
610 let input = GrepToolInput {
611 regex: "Function in nested module".to_string(),
612 include_pattern: Some("**/*.rs".to_string()),
613 offset: 0,
614 case_sensitive: false,
615 };
616
617 let result = run_grep_tool(input, project.clone(), cx).await;
618 let expected = r#"
619 Found 1 matches:
620
621 ## Matches in root/test_syntax.rs
622
623 ### mod feature_module › pub mod nested_module › pub fn nested_function › L10-14
624 ```
625 ) {
626 println!("Function in nested module");
627 println!("{first_arg}");
628 println!("{second_arg}");
629 }
630 ```
631 "#
632 .unindent();
633 assert_eq!(result, expected);
634 }
635
636 #[gpui::test]
637 async fn test_grep_function_args_and_body(cx: &mut TestAppContext) {
638 let project = setup_syntax_test(cx).await;
639
640 // Test: Line with a function argument
641 let input = GrepToolInput {
642 regex: "second_arg".to_string(),
643 include_pattern: Some("**/*.rs".to_string()),
644 offset: 0,
645 case_sensitive: false,
646 };
647
648 let result = run_grep_tool(input, project.clone(), cx).await;
649 let expected = r#"
650 Found 1 matches:
651
652 ## Matches in root/test_syntax.rs
653
654 ### mod feature_module › pub mod nested_module › pub fn nested_function › L7-14
655 ```
656 pub fn nested_function(
657 first_arg: String,
658 second_arg: i32,
659 ) {
660 println!("Function in nested module");
661 println!("{first_arg}");
662 println!("{second_arg}");
663 }
664 ```
665 "#
666 .unindent();
667 assert_eq!(result, expected);
668 }
669
670 #[gpui::test]
671 async fn test_grep_if_block(cx: &mut TestAppContext) {
672 use unindent::Unindent;
673 let project = setup_syntax_test(cx).await;
674
675 // Test: Line inside an if block
676 let input = GrepToolInput {
677 regex: "Inside if block".to_string(),
678 include_pattern: Some("**/*.rs".to_string()),
679 offset: 0,
680 case_sensitive: false,
681 };
682
683 let result = run_grep_tool(input, project.clone(), cx).await;
684 let expected = r#"
685 Found 1 matches:
686
687 ## Matches in root/test_syntax.rs
688
689 ### impl MyStruct › fn method_with_block › L26-28
690 ```
691 if condition {
692 println!("Inside if block");
693 }
694 ```
695 "#
696 .unindent();
697 assert_eq!(result, expected);
698 }
699
700 #[gpui::test]
701 async fn test_grep_long_function_top(cx: &mut TestAppContext) {
702 use unindent::Unindent;
703 let project = setup_syntax_test(cx).await;
704
705 // Test: Line in the middle of a long function - should show message about remaining lines
706 let input = GrepToolInput {
707 regex: "Line 5".to_string(),
708 include_pattern: Some("**/*.rs".to_string()),
709 offset: 0,
710 case_sensitive: false,
711 };
712
713 let result = run_grep_tool(input, project.clone(), cx).await;
714 let expected = r#"
715 Found 1 matches:
716
717 ## Matches in root/test_syntax.rs
718
719 ### impl MyStruct › fn long_function › L31-41
720 ```
721 fn long_function() {
722 println!("Line 1");
723 println!("Line 2");
724 println!("Line 3");
725 println!("Line 4");
726 println!("Line 5");
727 println!("Line 6");
728 println!("Line 7");
729 println!("Line 8");
730 println!("Line 9");
731 println!("Line 10");
732 ```
733
734 3 lines remaining in ancestor node. Read the file to see all.
735 "#
736 .unindent();
737 assert_eq!(result, expected);
738 }
739
740 #[gpui::test]
741 async fn test_grep_long_function_bottom(cx: &mut TestAppContext) {
742 use unindent::Unindent;
743 let project = setup_syntax_test(cx).await;
744
745 // Test: Line in the long function
746 let input = GrepToolInput {
747 regex: "Line 12".to_string(),
748 include_pattern: Some("**/*.rs".to_string()),
749 offset: 0,
750 case_sensitive: false,
751 };
752
753 let result = run_grep_tool(input, project.clone(), cx).await;
754 let expected = r#"
755 Found 1 matches:
756
757 ## Matches in root/test_syntax.rs
758
759 ### impl MyStruct › fn long_function › L41-45
760 ```
761 println!("Line 10");
762 println!("Line 11");
763 println!("Line 12");
764 }
765 }
766 ```
767 "#
768 .unindent();
769 assert_eq!(result, expected);
770 }
771
772 async fn run_grep_tool(
773 input: GrepToolInput,
774 project: Entity<Project>,
775 cx: &mut TestAppContext,
776 ) -> String {
777 let tool = Arc::new(GrepTool { project });
778 let task = cx.update(|cx| tool.run(input, ToolCallEventStream::test().0, cx));
779
780 match task.await {
781 Ok(result) => {
782 if cfg!(windows) {
783 result.replace("root\\", "root/")
784 } else {
785 result
786 }
787 }
788 Err(e) => panic!("Failed to run grep tool: {}", e),
789 }
790 }
791
792 fn init_test(cx: &mut TestAppContext) {
793 cx.update(|cx| {
794 let settings_store = SettingsStore::test(cx);
795 cx.set_global(settings_store);
796 });
797 }
798
799 #[gpui::test]
800 async fn test_grep_security_boundaries(cx: &mut TestAppContext) {
801 init_test(cx);
802
803 let fs = FakeFs::new(cx.executor());
804
805 fs.insert_tree(
806 path!("/"),
807 json!({
808 "project_root": {
809 "allowed_file.rs": "fn main() { println!(\"This file is in the project\"); }",
810 ".mysecrets": "SECRET_KEY=abc123\nfn secret() { /* private */ }",
811 ".secretdir": {
812 "config": "fn special_configuration() { /* excluded */ }"
813 },
814 ".mymetadata": "fn custom_metadata() { /* excluded */ }",
815 "subdir": {
816 "normal_file.rs": "fn normal_file_content() { /* Normal */ }",
817 "special.privatekey": "fn private_key_content() { /* private */ }",
818 "data.mysensitive": "fn sensitive_data() { /* private */ }"
819 }
820 },
821 "outside_project": {
822 "sensitive_file.rs": "fn outside_function() { /* This file is outside the project */ }"
823 }
824 }),
825 )
826 .await;
827
828 cx.update(|cx| {
829 use gpui::UpdateGlobal;
830 use settings::SettingsStore;
831 SettingsStore::update_global(cx, |store, cx| {
832 store.update_user_settings(cx, |settings| {
833 settings.project.worktree.file_scan_exclusions = Some(vec![
834 "**/.secretdir".to_string(),
835 "**/.mymetadata".to_string(),
836 ]);
837 settings.project.worktree.private_files = Some(
838 vec![
839 "**/.mysecrets".to_string(),
840 "**/*.privatekey".to_string(),
841 "**/*.mysensitive".to_string(),
842 ]
843 .into(),
844 );
845 });
846 });
847 });
848
849 let project = Project::test(fs.clone(), [path!("/project_root").as_ref()], cx).await;
850
851 // Searching for files outside the project worktree should return no results
852 let result = run_grep_tool(
853 GrepToolInput {
854 regex: "outside_function".to_string(),
855 include_pattern: None,
856 offset: 0,
857 case_sensitive: false,
858 },
859 project.clone(),
860 cx,
861 )
862 .await;
863 let paths = extract_paths_from_results(&result);
864 assert!(
865 paths.is_empty(),
866 "grep_tool should not find files outside the project worktree"
867 );
868
869 // Searching within the project should succeed
870 let result = run_grep_tool(
871 GrepToolInput {
872 regex: "main".to_string(),
873 include_pattern: None,
874 offset: 0,
875 case_sensitive: false,
876 },
877 project.clone(),
878 cx,
879 )
880 .await;
881 let paths = extract_paths_from_results(&result);
882 assert!(
883 paths.iter().any(|p| p.contains("allowed_file.rs")),
884 "grep_tool should be able to search files inside worktrees"
885 );
886
887 // Searching files that match file_scan_exclusions should return no results
888 let result = run_grep_tool(
889 GrepToolInput {
890 regex: "special_configuration".to_string(),
891 include_pattern: None,
892 offset: 0,
893 case_sensitive: false,
894 },
895 project.clone(),
896 cx,
897 )
898 .await;
899 let paths = extract_paths_from_results(&result);
900 assert!(
901 paths.is_empty(),
902 "grep_tool should not search files in .secretdir (file_scan_exclusions)"
903 );
904
905 let result = run_grep_tool(
906 GrepToolInput {
907 regex: "custom_metadata".to_string(),
908 include_pattern: None,
909 offset: 0,
910 case_sensitive: false,
911 },
912 project.clone(),
913 cx,
914 )
915 .await;
916 let paths = extract_paths_from_results(&result);
917 assert!(
918 paths.is_empty(),
919 "grep_tool should not search .mymetadata files (file_scan_exclusions)"
920 );
921
922 // Searching private files should return no results
923 let result = run_grep_tool(
924 GrepToolInput {
925 regex: "SECRET_KEY".to_string(),
926 include_pattern: None,
927 offset: 0,
928 case_sensitive: false,
929 },
930 project.clone(),
931 cx,
932 )
933 .await;
934 let paths = extract_paths_from_results(&result);
935 assert!(
936 paths.is_empty(),
937 "grep_tool should not search .mysecrets (private_files)"
938 );
939
940 let result = run_grep_tool(
941 GrepToolInput {
942 regex: "private_key_content".to_string(),
943 include_pattern: None,
944 offset: 0,
945 case_sensitive: false,
946 },
947 project.clone(),
948 cx,
949 )
950 .await;
951 let paths = extract_paths_from_results(&result);
952
953 assert!(
954 paths.is_empty(),
955 "grep_tool should not search .privatekey files (private_files)"
956 );
957
958 let result = run_grep_tool(
959 GrepToolInput {
960 regex: "sensitive_data".to_string(),
961 include_pattern: None,
962 offset: 0,
963 case_sensitive: false,
964 },
965 project.clone(),
966 cx,
967 )
968 .await;
969 let paths = extract_paths_from_results(&result);
970 assert!(
971 paths.is_empty(),
972 "grep_tool should not search .mysensitive files (private_files)"
973 );
974
975 // Searching a normal file should still work, even with private_files configured
976 let result = run_grep_tool(
977 GrepToolInput {
978 regex: "normal_file_content".to_string(),
979 include_pattern: None,
980 offset: 0,
981 case_sensitive: false,
982 },
983 project.clone(),
984 cx,
985 )
986 .await;
987 let paths = extract_paths_from_results(&result);
988 assert!(
989 paths.iter().any(|p| p.contains("normal_file.rs")),
990 "Should be able to search normal files"
991 );
992
993 // Path traversal attempts with .. in include_pattern should not escape project
994 let result = run_grep_tool(
995 GrepToolInput {
996 regex: "outside_function".to_string(),
997 include_pattern: Some("../outside_project/**/*.rs".to_string()),
998 offset: 0,
999 case_sensitive: false,
1000 },
1001 project.clone(),
1002 cx,
1003 )
1004 .await;
1005 let paths = extract_paths_from_results(&result);
1006 assert!(
1007 paths.is_empty(),
1008 "grep_tool should not allow escaping project boundaries with relative paths"
1009 );
1010 }
1011
1012 #[gpui::test]
1013 async fn test_grep_with_multiple_worktree_settings(cx: &mut TestAppContext) {
1014 init_test(cx);
1015
1016 let fs = FakeFs::new(cx.executor());
1017
1018 // Create first worktree with its own private files
1019 fs.insert_tree(
1020 path!("/worktree1"),
1021 json!({
1022 ".zed": {
1023 "settings.json": r#"{
1024 "file_scan_exclusions": ["**/fixture.*"],
1025 "private_files": ["**/secret.rs"]
1026 }"#
1027 },
1028 "src": {
1029 "main.rs": "fn main() { let secret_key = \"hidden\"; }",
1030 "secret.rs": "const API_KEY: &str = \"secret_value\";",
1031 "utils.rs": "pub fn get_config() -> String { \"config\".to_string() }"
1032 },
1033 "tests": {
1034 "test.rs": "fn test_secret() { assert!(true); }",
1035 "fixture.sql": "SELECT * FROM secret_table;"
1036 }
1037 }),
1038 )
1039 .await;
1040
1041 // Create second worktree with different private files
1042 fs.insert_tree(
1043 path!("/worktree2"),
1044 json!({
1045 ".zed": {
1046 "settings.json": r#"{
1047 "file_scan_exclusions": ["**/internal.*"],
1048 "private_files": ["**/private.js", "**/data.json"]
1049 }"#
1050 },
1051 "lib": {
1052 "public.js": "export function getSecret() { return 'public'; }",
1053 "private.js": "const SECRET_KEY = \"private_value\";",
1054 "data.json": "{\"secret_data\": \"hidden\"}"
1055 },
1056 "docs": {
1057 "README.md": "# Documentation with secret info",
1058 "internal.md": "Internal secret documentation"
1059 }
1060 }),
1061 )
1062 .await;
1063
1064 // Set global settings
1065 cx.update(|cx| {
1066 SettingsStore::update_global(cx, |store, cx| {
1067 store.update_user_settings(cx, |settings| {
1068 settings.project.worktree.file_scan_exclusions =
1069 Some(vec!["**/.git".to_string(), "**/node_modules".to_string()]);
1070 settings.project.worktree.private_files =
1071 Some(vec!["**/.env".to_string()].into());
1072 });
1073 });
1074 });
1075
1076 let project = Project::test(
1077 fs.clone(),
1078 [path!("/worktree1").as_ref(), path!("/worktree2").as_ref()],
1079 cx,
1080 )
1081 .await;
1082
1083 // Wait for worktrees to be fully scanned
1084 cx.executor().run_until_parked();
1085
1086 // Search for "secret" - should exclude files based on worktree-specific settings
1087 let result = run_grep_tool(
1088 GrepToolInput {
1089 regex: "secret".to_string(),
1090 include_pattern: None,
1091 offset: 0,
1092 case_sensitive: false,
1093 },
1094 project.clone(),
1095 cx,
1096 )
1097 .await;
1098 let paths = extract_paths_from_results(&result);
1099
1100 // Should find matches in non-private files
1101 assert!(
1102 paths.iter().any(|p| p.contains("main.rs")),
1103 "Should find 'secret' in worktree1/src/main.rs"
1104 );
1105 assert!(
1106 paths.iter().any(|p| p.contains("test.rs")),
1107 "Should find 'secret' in worktree1/tests/test.rs"
1108 );
1109 assert!(
1110 paths.iter().any(|p| p.contains("public.js")),
1111 "Should find 'secret' in worktree2/lib/public.js"
1112 );
1113 assert!(
1114 paths.iter().any(|p| p.contains("README.md")),
1115 "Should find 'secret' in worktree2/docs/README.md"
1116 );
1117
1118 // Should NOT find matches in private/excluded files based on worktree settings
1119 assert!(
1120 !paths.iter().any(|p| p.contains("secret.rs")),
1121 "Should not search in worktree1/src/secret.rs (local private_files)"
1122 );
1123 assert!(
1124 !paths.iter().any(|p| p.contains("fixture.sql")),
1125 "Should not search in worktree1/tests/fixture.sql (local file_scan_exclusions)"
1126 );
1127 assert!(
1128 !paths.iter().any(|p| p.contains("private.js")),
1129 "Should not search in worktree2/lib/private.js (local private_files)"
1130 );
1131 assert!(
1132 !paths.iter().any(|p| p.contains("data.json")),
1133 "Should not search in worktree2/lib/data.json (local private_files)"
1134 );
1135 assert!(
1136 !paths.iter().any(|p| p.contains("internal.md")),
1137 "Should not search in worktree2/docs/internal.md (local file_scan_exclusions)"
1138 );
1139
1140 // Test with `include_pattern` specific to one worktree
1141 let result = run_grep_tool(
1142 GrepToolInput {
1143 regex: "secret".to_string(),
1144 include_pattern: Some("worktree1/**/*.rs".to_string()),
1145 offset: 0,
1146 case_sensitive: false,
1147 },
1148 project.clone(),
1149 cx,
1150 )
1151 .await;
1152
1153 let paths = extract_paths_from_results(&result);
1154
1155 // Should only find matches in worktree1 *.rs files (excluding private ones)
1156 assert!(
1157 paths.iter().any(|p| p.contains("main.rs")),
1158 "Should find match in worktree1/src/main.rs"
1159 );
1160 assert!(
1161 paths.iter().any(|p| p.contains("test.rs")),
1162 "Should find match in worktree1/tests/test.rs"
1163 );
1164 assert!(
1165 !paths.iter().any(|p| p.contains("secret.rs")),
1166 "Should not find match in excluded worktree1/src/secret.rs"
1167 );
1168 assert!(
1169 paths.iter().all(|p| !p.contains("worktree2")),
1170 "Should not find any matches in worktree2"
1171 );
1172 }
1173
1174 // Helper function to extract file paths from grep results
1175 fn extract_paths_from_results(results: &str) -> Vec<String> {
1176 results
1177 .lines()
1178 .filter(|line| line.starts_with("## Matches in "))
1179 .map(|line| {
1180 line.strip_prefix("## Matches in ")
1181 .unwrap()
1182 .trim()
1183 .to_string()
1184 })
1185 .collect()
1186 }
1187}